Files
my-pi/pi-minimal-footer/index.ts
T

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
});
}