mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
1108 lines
36 KiB
TypeScript
1108 lines
36 KiB
TypeScript
/**
|
|
* Context Gauge Extension
|
|
*
|
|
* Custom footer with context gauge + subscription usage bars.
|
|
* Auto-detects provider from current model and shows relevant usage.
|
|
*
|
|
* Supports: Claude Max, Codex, Copilot, Gemini, MiniMax Token Plan, Kimi Coding
|
|
*/
|
|
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import { buildSessionContext } from "@earendil-works/pi-coding-agent";
|
|
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
|
|
import { execSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { collectFooterStatusItems } from "./status.ts";
|
|
|
|
// ============ Types ============
|
|
|
|
interface RateWindow {
|
|
label: string;
|
|
usedPercent: number;
|
|
resetsIn?: string; // human readable "2h38m"
|
|
}
|
|
|
|
interface UsageSnapshot {
|
|
provider: string;
|
|
windows: RateWindow[];
|
|
error?: string;
|
|
fetchedAt: number;
|
|
}
|
|
|
|
interface GitCache {
|
|
branch: string | null;
|
|
dirty: boolean;
|
|
ahead: number;
|
|
behind: number;
|
|
}
|
|
|
|
// ============ Usage Cache ============
|
|
|
|
const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes
|
|
const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider
|
|
|
|
// ============ Env Flags ============
|
|
|
|
function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean {
|
|
if (value === undefined) return fallback;
|
|
|
|
const normalized = value.trim().toLowerCase();
|
|
if (!normalized) return fallback;
|
|
|
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
return fallback;
|
|
}
|
|
|
|
// ============ Identity Cache ============
|
|
|
|
let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved
|
|
|
|
// ============ Git Cache ============
|
|
|
|
let gitCache: GitCache | null = null;
|
|
|
|
function parseGitStatus(output: string): GitCache {
|
|
let branch: string | null = null;
|
|
let dirty = false;
|
|
let ahead = 0;
|
|
let behind = 0;
|
|
|
|
for (const line of output.split("\n")) {
|
|
if (!line) continue;
|
|
|
|
if (line.startsWith("# branch.head ")) {
|
|
const head = line.slice("# branch.head ".length).trim();
|
|
branch = head && head !== "(detached)" ? head : null;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith("# branch.ab ")) {
|
|
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/);
|
|
if (match) {
|
|
ahead = parseInt(match[1], 10) || 0;
|
|
behind = parseInt(match[2], 10) || 0;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!line.startsWith("# ")) dirty = true;
|
|
}
|
|
|
|
return { branch, dirty, ahead, behind };
|
|
}
|
|
|
|
function sameGitCache(a: GitCache | null, b: GitCache | null): boolean {
|
|
if (a === b) return true;
|
|
if (!a || !b) return false;
|
|
return a.branch === b.branch && a.dirty === b.dirty && a.ahead === b.ahead && a.behind === b.behind;
|
|
}
|
|
|
|
function refreshGitCache(): boolean {
|
|
let next: GitCache | null = null;
|
|
|
|
try {
|
|
const status = execSync("git status --porcelain=v2 --branch 2>/dev/null", {
|
|
encoding: "utf8",
|
|
timeout: 1000,
|
|
});
|
|
next = parseGitStatus(status.trimEnd());
|
|
} catch {
|
|
next = null;
|
|
}
|
|
|
|
const changed = !sameGitCache(gitCache, next);
|
|
gitCache = next;
|
|
return changed;
|
|
}
|
|
|
|
// ============ JWT Helpers ============
|
|
|
|
function decodeJwtPayload(token: string): Record<string, any> | null {
|
|
try {
|
|
const parts = token.split(".");
|
|
if (parts.length < 2) return null;
|
|
let payload = parts[1];
|
|
// Add base64 padding
|
|
payload += "=".repeat((4 - (payload.length % 4)) % 4);
|
|
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
|
return JSON.parse(decoded);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getEmailPrefixFromJwt(token: string): string | null {
|
|
const payload = decodeJwtPayload(token);
|
|
const email = payload?.["https://api.openai.com/profile"]?.email;
|
|
if (!email || typeof email !== "string") return null;
|
|
const prefix = email.split("@")[0];
|
|
return prefix || null;
|
|
}
|
|
|
|
// ============ Auth Loading ============
|
|
|
|
function loadAuthJson(): Record<string, any> {
|
|
const authPath = join(homedir(), ".pi", "agent", "auth.json");
|
|
try {
|
|
if (existsSync(authPath)) {
|
|
return JSON.parse(readFileSync(authPath, "utf-8"));
|
|
}
|
|
} catch {}
|
|
return {};
|
|
}
|
|
|
|
function resolveAuthValue(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
|
|
if (trimmed.startsWith("!")) {
|
|
try {
|
|
const output = execSync(trimmed.slice(1), {
|
|
encoding: "utf-8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
timeout: 2000,
|
|
}).trim();
|
|
return output || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
if (/^[A-Z][A-Z0-9_]*$/.test(trimmed) && process.env[trimmed]) {
|
|
return process.env[trimmed];
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
function getApiKey(providerKey: string, envVar: string): string | undefined {
|
|
if (process.env[envVar]) return process.env[envVar];
|
|
|
|
const auth = loadAuthJson();
|
|
const entry = auth[providerKey];
|
|
if (!entry) return undefined;
|
|
|
|
if (typeof entry === "string") {
|
|
return resolveAuthValue(entry);
|
|
}
|
|
|
|
return resolveAuthValue(entry.key ?? entry.access ?? entry.refresh);
|
|
}
|
|
|
|
function getClaudeToken(): string | undefined {
|
|
const auth = loadAuthJson();
|
|
if (auth.anthropic?.access) return auth.anthropic.access;
|
|
|
|
// Fallback: Claude CLI keychain (macOS)
|
|
try {
|
|
const keychainData = execSync(
|
|
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
|
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
).trim();
|
|
if (keychainData) {
|
|
const parsed = JSON.parse(keychainData);
|
|
if (parsed.claudeAiOauth?.accessToken) {
|
|
return parsed.claudeAiOauth.accessToken;
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function getCopilotToken(): string | undefined {
|
|
const auth = loadAuthJson();
|
|
return auth["github-copilot"]?.refresh;
|
|
}
|
|
|
|
function getCodexToken(): { token: string; accountId?: string } | undefined {
|
|
const auth = loadAuthJson();
|
|
if (auth["openai-codex"]?.access) {
|
|
return { token: auth["openai-codex"].access, accountId: auth["openai-codex"]?.accountId };
|
|
}
|
|
|
|
// Fallback: ~/.codex/auth.json
|
|
const codexPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
|
|
try {
|
|
if (existsSync(codexPath)) {
|
|
const data = JSON.parse(readFileSync(codexPath, "utf-8"));
|
|
if (data.OPENAI_API_KEY) {
|
|
return { token: data.OPENAI_API_KEY };
|
|
}
|
|
if (data.tokens?.access_token) {
|
|
return { token: data.tokens.access_token, accountId: data.tokens.account_id };
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function getGeminiToken(): string | undefined {
|
|
const auth = loadAuthJson();
|
|
if (auth["google-gemini-cli"]?.access) return auth["google-gemini-cli"].access;
|
|
|
|
// Fallback: ~/.gemini/oauth_creds.json
|
|
const geminiPath = join(homedir(), ".gemini", "oauth_creds.json");
|
|
try {
|
|
if (existsSync(geminiPath)) {
|
|
const data = JSON.parse(readFileSync(geminiPath, "utf-8"));
|
|
return data.access_token;
|
|
}
|
|
} catch {}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function getMinimaxToken(provider: "minimax" | "minimax-cn"): string | undefined {
|
|
return provider === "minimax"
|
|
? getApiKey("minimax", "MINIMAX_API_KEY")
|
|
: getApiKey("minimax-cn", "MINIMAX_CN_API_KEY");
|
|
}
|
|
|
|
function getKimiToken(): string | undefined {
|
|
return getApiKey("kimi-coding", "KIMI_API_KEY");
|
|
}
|
|
|
|
// ============ Time Formatting ============
|
|
|
|
function formatResetTime(date: Date): string {
|
|
const diffMs = date.getTime() - Date.now();
|
|
if (diffMs < 0) return "now";
|
|
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
if (diffMins < 60) return `${diffMins}m`;
|
|
|
|
const hours = Math.floor(diffMins / 60);
|
|
const mins = diffMins % 60;
|
|
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
|
|
|
const days = Math.floor(hours / 24);
|
|
const remainingHours = hours % 24;
|
|
return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`;
|
|
}
|
|
|
|
/** Clamp a percentage to [0, 100]. Does NOT auto-normalize 0-1 fractions. */
|
|
function clampPercent(value: number): number {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(0, Math.min(100, value));
|
|
}
|
|
|
|
/** Normalize a value that might be 0-1 fraction OR 0-100 percent, then clamp. */
|
|
function normalizePercent(value: number): number {
|
|
if (!Number.isFinite(value)) return 0;
|
|
const normalized = value <= 1 && value >= 0 ? value * 100 : value;
|
|
return Math.max(0, Math.min(100, normalized));
|
|
}
|
|
|
|
function getWindowLabel(durationMs: number | undefined, fallback: string): string {
|
|
if (!durationMs || !Number.isFinite(durationMs) || durationMs <= 0) return fallback;
|
|
|
|
const hourMs = 60 * 60 * 1000;
|
|
const dayMs = 24 * hourMs;
|
|
const weekMs = 7 * dayMs;
|
|
|
|
// Check if duration is close to a standard window and use the fallback label.
|
|
// This preserves "5h" / "Week" / "Day" labels even when the actual
|
|
// rolling window start/end times don't align perfectly.
|
|
const isCloseToWeek = Math.abs(durationMs - weekMs) <= hourMs * 2;
|
|
const isCloseToDay = Math.abs(durationMs - dayMs) <= hourMs * 2;
|
|
const isCloseTo5h = Math.abs(durationMs - 5 * hourMs) <= hourMs * 2;
|
|
|
|
if (isCloseToWeek || fallback === "Week") return "Week";
|
|
if (isCloseToDay || fallback === "Day") return "Day";
|
|
if (isCloseTo5h || fallback === "5h") return fallback;
|
|
|
|
const hours = Math.round(durationMs / hourMs);
|
|
if (hours >= 1 && hours < 48) return `${hours}h`;
|
|
|
|
const days = Math.round(durationMs / dayMs);
|
|
if (days >= 1) return `${days}d`;
|
|
|
|
const mins = Math.max(1, Math.round(durationMs / 60000));
|
|
return `${mins}m`;
|
|
}
|
|
|
|
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 5000): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
return await fetch(url, { ...init, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
// ============ Usage Fetchers ============
|
|
|
|
async function fetchClaudeUsage(): Promise<UsageSnapshot> {
|
|
const token = getClaudeToken();
|
|
if (!token) {
|
|
return { provider: "Claude", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
}
|
|
|
|
try {
|
|
const res = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"anthropic-beta": "oauth-2025-04-20",
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return { provider: "Claude", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
}
|
|
|
|
const data = (await res.json()) as any;
|
|
const windows: RateWindow[] = [];
|
|
|
|
if (data.five_hour?.utilization !== undefined) {
|
|
windows.push({
|
|
label: "5h",
|
|
usedPercent: normalizePercent(data.five_hour.utilization),
|
|
resetsIn: data.five_hour.resets_at ? formatResetTime(new Date(data.five_hour.resets_at)) : undefined,
|
|
});
|
|
}
|
|
|
|
if (data.seven_day?.utilization !== undefined) {
|
|
windows.push({
|
|
label: "Week",
|
|
usedPercent: normalizePercent(data.seven_day.utilization),
|
|
resetsIn: data.seven_day.resets_at ? formatResetTime(new Date(data.seven_day.resets_at)) : undefined,
|
|
});
|
|
}
|
|
|
|
return { provider: "Claude", windows, fetchedAt: Date.now() };
|
|
} catch (e) {
|
|
return { provider: "Claude", windows: [], error: String(e), fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
async function fetchCopilotUsage(): Promise<UsageSnapshot> {
|
|
const token = getCopilotToken();
|
|
if (!token) {
|
|
return { provider: "Copilot", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
}
|
|
|
|
try {
|
|
const res = await fetchWithTimeout("https://api.github.com/copilot_internal/user", {
|
|
headers: {
|
|
"Editor-Version": "vscode/1.96.2",
|
|
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
"X-Github-Api-Version": "2025-04-01",
|
|
Accept: "application/json",
|
|
Authorization: `token ${token}`,
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return { provider: "Copilot", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
}
|
|
|
|
const data = (await res.json()) as any;
|
|
const windows: RateWindow[] = [];
|
|
|
|
const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
|
|
const resetsIn = resetDate ? formatResetTime(resetDate) : undefined;
|
|
|
|
if (data.quota_snapshots?.premium_interactions) {
|
|
const pi = data.quota_snapshots.premium_interactions;
|
|
const usedPercent = clampPercent(100 - (pi.percent_remaining || 0));
|
|
windows.push({ label: "Premium", usedPercent, resetsIn });
|
|
}
|
|
|
|
if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
|
|
const chat = data.quota_snapshots.chat;
|
|
windows.push({
|
|
label: "Chat",
|
|
usedPercent: clampPercent(100 - (chat.percent_remaining || 0)),
|
|
resetsIn,
|
|
});
|
|
}
|
|
|
|
return { provider: "Copilot", windows, fetchedAt: Date.now() };
|
|
} catch (e) {
|
|
return { provider: "Copilot", windows: [], error: String(e), fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
async function fetchCodexUsage(): Promise<UsageSnapshot> {
|
|
const creds = getCodexToken();
|
|
if (!creds) {
|
|
return { provider: "Codex", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
}
|
|
|
|
const providerLabel = "Codex";
|
|
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${creds.token}`,
|
|
"User-Agent": "pi-agent",
|
|
Accept: "application/json",
|
|
};
|
|
|
|
if (creds.accountId) {
|
|
headers["ChatGPT-Account-Id"] = creds.accountId;
|
|
}
|
|
|
|
const res = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", {
|
|
method: "GET",
|
|
headers,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
}
|
|
|
|
const data = (await res.json()) as any;
|
|
const windows: RateWindow[] = [];
|
|
|
|
if (data.rate_limit?.primary_window) {
|
|
const pw = data.rate_limit.primary_window;
|
|
const resetDate = pw.reset_at ? new Date(pw.reset_at * 1000) : undefined;
|
|
const durationMs = typeof pw.limit_window_seconds === "number" ? pw.limit_window_seconds * 1000 : undefined;
|
|
windows.push({
|
|
label: getWindowLabel(durationMs, "5h"),
|
|
usedPercent: clampPercent(pw.used_percent || 0),
|
|
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
});
|
|
}
|
|
|
|
if (data.rate_limit?.secondary_window) {
|
|
const sw = data.rate_limit.secondary_window;
|
|
const resetDate = sw.reset_at ? new Date(sw.reset_at * 1000) : undefined;
|
|
const durationMs = typeof sw.limit_window_seconds === "number" ? sw.limit_window_seconds * 1000 : undefined;
|
|
windows.push({
|
|
label: getWindowLabel(durationMs, "Week"),
|
|
usedPercent: clampPercent(sw.used_percent || 0),
|
|
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
});
|
|
}
|
|
|
|
return { provider: providerLabel, windows, fetchedAt: Date.now() };
|
|
} catch (e) {
|
|
return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
async function fetchGeminiUsage(): Promise<UsageSnapshot> {
|
|
const token = getGeminiToken();
|
|
if (!token) {
|
|
return { provider: "Gemini", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
}
|
|
|
|
try {
|
|
const res = await fetchWithTimeout("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: "{}",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return { provider: "Gemini", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
}
|
|
|
|
const data = (await res.json()) as any;
|
|
const quotas: Record<string, number> = {};
|
|
|
|
for (const bucket of data.buckets || []) {
|
|
const model = bucket.modelId || "unknown";
|
|
const frac = bucket.remainingFraction ?? 1;
|
|
if (!quotas[model] || frac < quotas[model]) quotas[model] = frac;
|
|
}
|
|
|
|
const windows: RateWindow[] = [];
|
|
let proMin = 1,
|
|
flashMin = 1;
|
|
let hasProModel = false,
|
|
hasFlashModel = false;
|
|
|
|
for (const [model, frac] of Object.entries(quotas)) {
|
|
if (model.toLowerCase().includes("pro")) {
|
|
hasProModel = true;
|
|
if (frac < proMin) proMin = frac;
|
|
}
|
|
if (model.toLowerCase().includes("flash")) {
|
|
hasFlashModel = true;
|
|
if (frac < flashMin) flashMin = frac;
|
|
}
|
|
}
|
|
|
|
if (hasProModel) windows.push({ label: "Pro", usedPercent: clampPercent((1 - proMin) * 100) });
|
|
if (hasFlashModel) windows.push({ label: "Flash", usedPercent: clampPercent((1 - flashMin) * 100) });
|
|
|
|
return { provider: "Gemini", windows, fetchedAt: Date.now() };
|
|
} catch (e) {
|
|
return { provider: "Gemini", windows: [], error: String(e), fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
async function fetchMinimaxUsage(provider: "minimax" | "minimax-cn"): Promise<UsageSnapshot> {
|
|
const token = getMinimaxToken(provider);
|
|
const providerLabel = provider === "minimax-cn" ? "MiniMax CN" : "MiniMax";
|
|
// Docs-recommended Token Plan endpoint. The legacy /coding_plan/remains path
|
|
// still works but the response field names differ from the current UI.
|
|
const endpoint =
|
|
provider === "minimax-cn"
|
|
? "https://api.minimaxi.com/v1/token_plan/remains"
|
|
: "https://api.minimax.io/v1/token_plan/remains";
|
|
|
|
if (!token) {
|
|
return { provider: providerLabel, windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
}
|
|
|
|
try {
|
|
const res = await fetchWithTimeout(endpoint, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return { provider: providerLabel, windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
}
|
|
|
|
const data = (await res.json()) as any;
|
|
const baseResp = data?.base_resp;
|
|
if (baseResp?.status_code && baseResp.status_code !== 0) {
|
|
return {
|
|
provider: providerLabel,
|
|
windows: [],
|
|
error: baseResp.status_msg || `API ${baseResp.status_code}`,
|
|
fetchedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
const remains = Array.isArray(data?.model_remains) ? data.model_remains : [];
|
|
// Token Plan returns one bucket per capability (general = text/code, video, etc.).
|
|
// Prefer the active "general" bucket since M-series chat models land there.
|
|
// status === 1 = window is active/limiting, 3 = inactive (no usage). Fall back
|
|
// to the first active bucket, then the first bucket of any kind.
|
|
const textBucket =
|
|
remains.find(
|
|
(entry: any) => entry?.model_name === "general" && Number(entry?.current_interval_status) === 1
|
|
) ||
|
|
remains.find((entry: any) => entry?.model_name === "general") ||
|
|
remains.find((entry: any) => Number(entry?.current_interval_status) === 1) ||
|
|
remains[0];
|
|
|
|
if (!textBucket) {
|
|
return { provider: providerLabel, windows: [], error: "no-usage-data", fetchedAt: Date.now() };
|
|
}
|
|
|
|
const windows: RateWindow[] = [];
|
|
|
|
// Source of truth: *_remaining_percent (the *_total_count / *_usage_count
|
|
// fields are zeroed in the credit-based model and cannot be used to compute
|
|
// a fraction). The UI usage bar maps 100 - remainingPercent -> used.
|
|
const intervalRemaining = Number(textBucket.current_interval_remaining_percent);
|
|
if (Number.isFinite(intervalRemaining)) {
|
|
const usedPercent = clampPercent(100 - intervalRemaining);
|
|
const resetDate = textBucket.end_time ? new Date(Number(textBucket.end_time)) : undefined;
|
|
const durationMs =
|
|
textBucket.start_time && textBucket.end_time
|
|
? Number(textBucket.end_time) - Number(textBucket.start_time)
|
|
: undefined;
|
|
windows.push({
|
|
label: getWindowLabel(durationMs, "5h"),
|
|
usedPercent,
|
|
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
});
|
|
}
|
|
|
|
const weeklyRemaining = Number(textBucket.current_weekly_remaining_percent);
|
|
if (Number.isFinite(weeklyRemaining)) {
|
|
const usedPercent = clampPercent(100 - weeklyRemaining);
|
|
const resetDate = textBucket.weekly_end_time
|
|
? new Date(Number(textBucket.weekly_end_time))
|
|
: undefined;
|
|
const durationMs =
|
|
textBucket.weekly_start_time && textBucket.weekly_end_time
|
|
? Number(textBucket.weekly_end_time) - Number(textBucket.weekly_start_time)
|
|
: undefined;
|
|
windows.push({
|
|
label: getWindowLabel(durationMs, "Week"),
|
|
usedPercent,
|
|
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
});
|
|
}
|
|
|
|
if (windows.length === 0) {
|
|
return { provider: providerLabel, windows: [], error: "no-usage-data", fetchedAt: Date.now() };
|
|
}
|
|
|
|
return { provider: providerLabel, windows, fetchedAt: Date.now() };
|
|
} catch (e) {
|
|
return { provider: providerLabel, windows: [], error: String(e), fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
async function fetchKimiUsage(): Promise<UsageSnapshot> {
|
|
const token = getKimiToken();
|
|
const endpoint = "https://api.kimi.com/coding/v1/usages";
|
|
if (!token) {
|
|
return { provider: "Kimi Coding", windows: [], error: "no-auth", fetchedAt: Date.now() };
|
|
}
|
|
|
|
try {
|
|
const res = await fetchWithTimeout(endpoint, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
return { provider: "Kimi Coding", windows: [], error: `HTTP ${res.status}`, fetchedAt: Date.now() };
|
|
}
|
|
|
|
const data = (await res.json()) as any;
|
|
const windows: RateWindow[] = [];
|
|
|
|
for (const limit of data.limits || []) {
|
|
const windowLimit = Number(limit.detail?.limit) || 0;
|
|
const windowRemaining = Number(limit.detail?.remaining) || 0;
|
|
if (windowLimit > 0) {
|
|
const used = windowLimit - windowRemaining;
|
|
const usedPercent = clampPercent((used / windowLimit) * 100);
|
|
const resetDate = limit.detail?.resetTime ? new Date(limit.detail.resetTime) : undefined;
|
|
const durationMs =
|
|
limit.window?.duration && limit.window?.timeUnit === "TIME_UNIT_MINUTE"
|
|
? limit.window.duration * 60 * 1000
|
|
: undefined;
|
|
|
|
windows.push({
|
|
label: getWindowLabel(durationMs, "5h"),
|
|
usedPercent,
|
|
resetsIn: resetDate ? formatResetTime(resetDate) : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
const weeklyLimit = Number(data.usage?.limit) || 0;
|
|
const weeklyRemaining = Number(data.usage?.remaining) || 0;
|
|
const weeklyResetTime = data.usage?.resetTime;
|
|
|
|
if (weeklyLimit > 0) {
|
|
const used = weeklyLimit - weeklyRemaining;
|
|
const usedPercent = clampPercent((used / weeklyLimit) * 100);
|
|
windows.push({
|
|
label: "Weekly",
|
|
usedPercent,
|
|
resetsIn: weeklyResetTime ? formatResetTime(new Date(weeklyResetTime)) : undefined,
|
|
});
|
|
}
|
|
|
|
return { provider: "Kimi Coding", windows, fetchedAt: Date.now() };
|
|
} catch (e) {
|
|
return { provider: "Kimi Coding", windows: [], error: String(e), fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
// ============ Provider Detection ============
|
|
|
|
// Map pi provider names to our internal usage provider keys
|
|
const PROVIDER_MAP: Record<string, string> = {
|
|
anthropic: "claude", // Claude Max subscription
|
|
"openai-codex": "codex", // Codex subscription
|
|
"github-copilot": "copilot", // Copilot subscription
|
|
"google-gemini-cli": "gemini", // Gemini CLI subscription
|
|
minimax: "minimax", // MiniMax Token Plan / Coding Plan
|
|
"minimax-cn": "minimax-cn", // MiniMax China plan
|
|
"kimi-coding": "kimi-coding", // Kimi plan
|
|
};
|
|
|
|
function detectProvider(modelProvider: string): string | null {
|
|
return PROVIDER_MAP[modelProvider] || null;
|
|
}
|
|
|
|
async function fetchUsageForProvider(provider: string): Promise<UsageSnapshot> {
|
|
switch (provider) {
|
|
case "claude":
|
|
return fetchClaudeUsage();
|
|
case "codex":
|
|
return fetchCodexUsage();
|
|
case "copilot":
|
|
return fetchCopilotUsage();
|
|
case "gemini":
|
|
return fetchGeminiUsage();
|
|
case "minimax":
|
|
return fetchMinimaxUsage("minimax");
|
|
case "minimax-cn":
|
|
return fetchMinimaxUsage("minimax-cn");
|
|
case "kimi-coding":
|
|
return fetchKimiUsage();
|
|
default:
|
|
return { provider: "Unknown", windows: [], error: "unknown-provider", fetchedAt: Date.now() };
|
|
}
|
|
}
|
|
|
|
// ============ Extension ============
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
const CTX_GAUGE_WIDTH = 12;
|
|
|
|
// Thin bar characters (same style for both context and usage)
|
|
const BAR_FILLED = "━";
|
|
const BAR_EMPTY = "─";
|
|
|
|
// Optional visibility toggles (default: enabled)
|
|
const showCwd = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_CWD, true);
|
|
const showBranch = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_BRANCH, true);
|
|
|
|
function formatTokenCount(tokens: number): string {
|
|
if (tokens >= 1_000_000) {
|
|
const m = tokens / 1_000_000;
|
|
return m % 1 === 0 ? `${m}M` : `${m.toFixed(1).replace(/\.0$/, "")}M`;
|
|
}
|
|
if (tokens >= 1_000) {
|
|
return `${Math.round(tokens / 1_000)}k`;
|
|
}
|
|
return `${tokens}`;
|
|
}
|
|
|
|
function fitFooterSegment(width: number, variants: string[]): string {
|
|
const safeWidth = Math.max(1, width);
|
|
|
|
for (const variant of variants) {
|
|
if (visibleWidth(variant) <= safeWidth) return variant;
|
|
}
|
|
|
|
return truncateToWidth(variants[variants.length - 1] || "", safeWidth);
|
|
}
|
|
|
|
function wrapFooterSegments(segments: string[], width: number, sep: string): string[] {
|
|
const safeWidth = Math.max(1, width);
|
|
const lines: string[] = [];
|
|
let current = "";
|
|
|
|
for (const segment of segments.filter(Boolean)) {
|
|
const fitted = truncateToWidth(segment, safeWidth);
|
|
|
|
if (!current) {
|
|
current = fitted;
|
|
continue;
|
|
}
|
|
|
|
const candidate = current + sep + fitted;
|
|
if (visibleWidth(candidate) <= safeWidth) {
|
|
current = candidate;
|
|
continue;
|
|
}
|
|
|
|
lines.push(truncateToWidth(current, safeWidth));
|
|
current = fitted;
|
|
}
|
|
|
|
if (current) lines.push(truncateToWidth(current, safeWidth));
|
|
return lines;
|
|
}
|
|
|
|
function renderContextGauge(
|
|
percentage: number,
|
|
theme: any,
|
|
used?: number,
|
|
total?: number,
|
|
options?: { barWidth?: number; includeCounts?: boolean }
|
|
): string {
|
|
const barWidth = Math.max(4, options?.barWidth ?? CTX_GAUGE_WIDTH);
|
|
const clamped = Math.max(0, Math.min(100, percentage));
|
|
const filled = Math.round((clamped / 100) * barWidth);
|
|
const empty = barWidth - filled;
|
|
|
|
let color: string;
|
|
if (clamped >= 90) color = "error";
|
|
else if (clamped >= 70) color = "warning";
|
|
else if (clamped >= 50) color = "accent";
|
|
else color = "success";
|
|
|
|
const bar = theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
|
|
const pct = `${Math.round(clamped)}%`;
|
|
const counts =
|
|
options?.includeCounts === false || used === undefined || !total
|
|
? ""
|
|
: ` ${formatTokenCount(used)}/${formatTokenCount(total)}`;
|
|
|
|
return theme.fg("dim", "ctx ") + bar + " " + theme.fg("dim", pct + counts);
|
|
}
|
|
|
|
function renderUsageBar(usedPercent: number, barWidth: number, theme: any): string {
|
|
const clamped = Math.max(0, Math.min(100, usedPercent));
|
|
const filled = Math.round((clamped / 100) * barWidth);
|
|
const empty = barWidth - filled;
|
|
|
|
let color: string;
|
|
if (clamped >= 92) color = "error";
|
|
else if (clamped >= 85) color = "warning";
|
|
else color = "success";
|
|
|
|
return theme.fg(color, BAR_FILLED.repeat(filled)) + theme.fg("dim", BAR_EMPTY.repeat(empty));
|
|
}
|
|
|
|
function renderUsageWindow(
|
|
window: RateWindow,
|
|
theme: any,
|
|
options?: { barWidth?: number; includeReset?: boolean }
|
|
): string {
|
|
const dim = (s: string) => theme.fg("dim", s);
|
|
const bar = renderUsageBar(window.usedPercent, Math.max(4, options?.barWidth ?? 10), theme);
|
|
const pct = dim(`${Math.round(window.usedPercent)}%`);
|
|
const timeStr = options?.includeReset === false || !window.resetsIn ? "" : " " + dim(window.resetsIn);
|
|
return `${dim(window.label)} ${bar} ${pct}${timeStr}`;
|
|
}
|
|
|
|
function renderUsageLine(usage: UsageSnapshot, width: number, theme: any): string[] {
|
|
if (!usage.windows.length) return [];
|
|
|
|
const dim = (s: string) => theme.fg("dim", s);
|
|
const sep = " " + dim(">") + " ";
|
|
const segments: string[] = [theme.fg("accent", usage.provider)];
|
|
|
|
for (const w of usage.windows) {
|
|
segments.push(
|
|
fitFooterSegment(width, [
|
|
renderUsageWindow(w, theme, { barWidth: 10, includeReset: true }),
|
|
renderUsageWindow(w, theme, { barWidth: 8, includeReset: true }),
|
|
renderUsageWindow(w, theme, { barWidth: 8, includeReset: false }),
|
|
renderUsageWindow(w, theme, { barWidth: 6, includeReset: false }),
|
|
renderUsageWindow(w, theme, { barWidth: 4, includeReset: false }),
|
|
])
|
|
);
|
|
}
|
|
|
|
return wrapFooterSegments(segments, width, sep);
|
|
}
|
|
|
|
function getThinkingLevel(ctx: any): string {
|
|
const entries = ctx.sessionManager.getEntries();
|
|
const leafId = ctx.sessionManager.getLeafId();
|
|
const context = buildSessionContext(entries, leafId);
|
|
return context.thinkingLevel || "off";
|
|
}
|
|
|
|
function getContextInfo(ctx: any): { percentage: number; used: number; total: number } {
|
|
const model = ctx.model;
|
|
const contextWindow = model?.contextWindow ?? 0;
|
|
if (contextWindow === 0) return { percentage: 0, used: 0, total: 0 };
|
|
|
|
const entries = ctx.sessionManager.getEntries();
|
|
const leafId = ctx.sessionManager.getLeafId();
|
|
const context = buildSessionContext(entries, leafId);
|
|
const messages = context.messages;
|
|
|
|
const lastAssistant = messages
|
|
.slice()
|
|
.reverse()
|
|
.find((m: any) => m.role === "assistant" && m.stopReason !== "aborted") as any;
|
|
|
|
const usage = lastAssistant?.usage;
|
|
if (!usage) return { percentage: 0, used: 0, total: contextWindow };
|
|
const contextTokens = (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
|
|
return { percentage: (contextTokens / contextWindow) * 100, used: contextTokens, total: contextWindow };
|
|
}
|
|
|
|
// Track usage state for rendering
|
|
let latestUsage: UsageSnapshot | null = null;
|
|
let activeProvider: string | null = null; // internal provider key for the current model
|
|
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Store tui reference for triggering re-renders from event handlers
|
|
let tuiRef: { requestRender: () => void } | null = null;
|
|
|
|
function refreshGitFooter(): void {
|
|
if (refreshGitCache()) tuiRef?.requestRender();
|
|
}
|
|
|
|
/** Fetch usage for the active provider. Shows cached data immediately,
|
|
* then fetches fresh in the background. Discards results if provider
|
|
* changed while the fetch was in flight. */
|
|
function fetchUsage(modelProvider: string): void {
|
|
const provider = detectProvider(modelProvider);
|
|
if (!provider) {
|
|
activeProvider = null;
|
|
latestUsage = null;
|
|
stopRefreshTimer();
|
|
tuiRef?.requestRender();
|
|
return;
|
|
}
|
|
|
|
activeProvider = provider;
|
|
|
|
// Show cached data immediately if available
|
|
const cached = usageCache.get(provider);
|
|
if (cached && cached.windows.length > 0) {
|
|
latestUsage = cached;
|
|
tuiRef?.requestRender();
|
|
}
|
|
|
|
// Fetch fresh in background — keep cached data on transient errors
|
|
fetchUsageForProvider(provider)
|
|
.then((u) => {
|
|
if (!u || activeProvider !== provider) return;
|
|
if (u.windows.length === 0 && u.error && cached?.windows.length) return;
|
|
usageCache.set(provider, u);
|
|
latestUsage = u;
|
|
tuiRef?.requestRender();
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
/** Start (or restart) the periodic refresh timer. */
|
|
function startRefreshTimer(): void {
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = setInterval(() => {
|
|
if (activeProvider) {
|
|
const provider = activeProvider;
|
|
const cached = usageCache.get(provider);
|
|
fetchUsageForProvider(provider)
|
|
.then((u) => {
|
|
if (!u || activeProvider !== provider) return;
|
|
if (u.windows.length === 0 && u.error && cached?.windows.length) return;
|
|
usageCache.set(provider, u);
|
|
latestUsage = u;
|
|
tuiRef?.requestRender();
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}, USAGE_REFRESH_INTERVAL);
|
|
}
|
|
|
|
function stopRefreshTimer(): void {
|
|
if (refreshTimer) {
|
|
clearInterval(refreshTimer);
|
|
refreshTimer = null;
|
|
}
|
|
}
|
|
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
refreshGitCache();
|
|
|
|
if (!ctx.hasUI) return;
|
|
|
|
ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
|
|
tuiRef = tui;
|
|
|
|
const unsub = footerData.onBranchChange(() => {
|
|
refreshGitFooter();
|
|
});
|
|
|
|
// Initial fetch inside factory — tui is guaranteed available here,
|
|
// so requestRender() will work when the async fetch completes.
|
|
if (ctx.model?.provider) {
|
|
fetchUsage(ctx.model.provider);
|
|
startRefreshTimer();
|
|
}
|
|
|
|
return {
|
|
dispose: () => {
|
|
unsub();
|
|
tuiRef = null;
|
|
stopRefreshTimer();
|
|
},
|
|
invalidate() {},
|
|
render(width: number): string[] {
|
|
const { percentage, used: ctxUsed, total: ctxTotal } = getContextInfo(ctx);
|
|
|
|
// Build parts for status line
|
|
let pwd = ctx.cwd;
|
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
if (home && pwd.startsWith(home)) {
|
|
pwd = `~${pwd.slice(home.length)}`;
|
|
}
|
|
|
|
let branchStr = "";
|
|
if (showBranch && gitCache?.branch) {
|
|
const branchColor = gitCache.dirty ? "warning" : "success";
|
|
branchStr = theme.fg(branchColor, gitCache.branch);
|
|
if (gitCache.dirty) branchStr += theme.fg("warning", " *");
|
|
if (gitCache.ahead) branchStr += theme.fg("success", ` ↑${gitCache.ahead}`);
|
|
if (gitCache.behind) branchStr += theme.fg("error", ` ↓${gitCache.behind}`);
|
|
}
|
|
|
|
// Model + thinking
|
|
const modelName = ctx.model?.id?.split("/").pop() || "no-model";
|
|
const plainModelStr = theme.fg("muted", modelName);
|
|
let modelStr = plainModelStr;
|
|
if (ctx.model?.reasoning) {
|
|
const thinkingLevel = getThinkingLevel(ctx);
|
|
if (thinkingLevel !== "off") {
|
|
modelStr += " " + theme.fg("dim", ">") + " " + theme.fg("accent", thinkingLevel);
|
|
}
|
|
}
|
|
|
|
const sep = " " + theme.fg("dim", ">") + " ";
|
|
const lines: string[] = [];
|
|
|
|
const pwdStr = showCwd ? theme.fg("accent", pwd) : "";
|
|
const locationVariants: string[] = [];
|
|
if (pwdStr && branchStr) locationVariants.push(pwdStr + sep + branchStr);
|
|
if (pwdStr) locationVariants.push(pwdStr);
|
|
if (branchStr) locationVariants.push(branchStr);
|
|
const locationBlock = locationVariants.length > 0 ? fitFooterSegment(width, locationVariants) : "";
|
|
|
|
const extensionStatusBlocks = collectFooterStatusItems(footerData.getExtensionStatuses()).map((status) =>
|
|
status.accent ? theme.fg("accent", status.text) : status.text
|
|
);
|
|
|
|
const statusBlocks = [
|
|
locationBlock,
|
|
...extensionStatusBlocks,
|
|
fitFooterSegment(width, modelStr === plainModelStr ? [plainModelStr] : [modelStr, plainModelStr]),
|
|
fitFooterSegment(width, [
|
|
renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
|
|
barWidth: CTX_GAUGE_WIDTH,
|
|
includeCounts: true,
|
|
}),
|
|
renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
|
|
barWidth: 10,
|
|
includeCounts: false,
|
|
}),
|
|
renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
|
|
barWidth: 8,
|
|
includeCounts: false,
|
|
}),
|
|
renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
|
|
barWidth: 6,
|
|
includeCounts: false,
|
|
}),
|
|
renderContextGauge(percentage, theme, ctxUsed, ctxTotal, {
|
|
barWidth: 4,
|
|
includeCounts: false,
|
|
}),
|
|
]),
|
|
];
|
|
|
|
lines.push(...wrapFooterSegments(statusBlocks, width, sep));
|
|
|
|
if (latestUsage && latestUsage.windows.length > 0) {
|
|
lines.push(...renderUsageLine(latestUsage, width, theme));
|
|
}
|
|
|
|
return lines.map((line) => truncateToWidth(line, width));
|
|
},
|
|
};
|
|
});
|
|
|
|
});
|
|
|
|
pi.on("turn_end", async () => {
|
|
refreshGitFooter();
|
|
});
|
|
|
|
// Refresh when model changes — fetch immediately, restart timer
|
|
pi.on("model_select", (event, _ctx) => {
|
|
if (!event.model?.provider) return;
|
|
fetchUsage(event.model.provider);
|
|
startRefreshTimer(); // reset the 5min countdown since we just fetched
|
|
});
|
|
}
|