import { existsSync, readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { fetchOpenAICodexUsage, formatUsageStatus, formatUsageSummary, getOpenAICodexAccountId, isOpenAICodexProvider, isOpenAIProvider, type UsageAvailability, type UsageSnapshot, } from "./openai-usage.ts"; import { DEFAULT_GH_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS, DEFAULT_REFRESH_INTERVAL_MS, GH_PR_VIEW_ARGS, GIT_STATUS_ARGS, GitFooterCache, formatGitFooterStatus, formatGitStatusFooterSegment, formatPullRequestFooterSegment, parseGitStatusPorcelainV2, parsePullRequestJson, type TimerHandle, } from "./git-status.ts"; const DEFAULT_CONFIG: MinimalFooterConfig = { context: { showPercent: true, dumbZone: { enabled: true, thresholdTokens: 350_000, label: "DUMB ZONE", color: "error", }, }, codexUsage: { enabled: true, cacheTtlMs: 5 * 60 * 1000, requestTimeoutMs: 10 * 1000, windows: { primary: { enabled: true, label: "auto", }, secondary: { enabled: true, label: "auto", }, }, }, experimentalMarker: { enabled: true, label: "xp", color: "warning", }, gitStatus: { enabled: true, refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS, gitTimeoutMs: DEFAULT_GIT_TIMEOUT_MS, ghTimeoutMs: DEFAULT_GH_TIMEOUT_MS, }, }; const DUMB_ZONE_COLORS = new Set(["error", "warning", "accent", "text", "dim"]); // Node treats delays above this value as 1ms. Clamp only the identity-check // schedule; the configured cache TTL remains the source of truth for lifecycle // usage-result caching. const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647; const ZERO_CACHE_IDENTITY_CHECK_INTERVAL_MS = 5 * 60 * 1000; type RecursivePartial = { [P in keyof T]?: T[P] extends object ? RecursivePartial : T[P]; }; type DumbZoneColor = "error" | "warning" | "accent" | "text" | "dim"; interface MinimalFooterConfig { context: { showPercent: boolean; dumbZone: { enabled: boolean; thresholdTokens: number; label: string; color: DumbZoneColor; }; }; codexUsage: { enabled: boolean; cacheTtlMs: number; requestTimeoutMs: number; windows: { primary: { enabled: boolean; label: string; }; secondary: { enabled: boolean; label: string; }; }; }; experimentalMarker: { enabled: boolean; label: string; color: DumbZoneColor; }; gitStatus: { enabled: boolean; refreshIntervalMs: number; gitTimeoutMs: number; ghTimeoutMs: number; }; } type UsageIdentity = { renderKey: string; usageKey: string; oauth: boolean; accountId?: string; }; type UsageSessionState = { config: MinimalFooterConfig; snapshot?: UsageSnapshot; lastFetchedAt?: number; loading: boolean; availability?: UsageAvailability; usageIdentity?: UsageIdentity; requestVersion: number; disposed: boolean; inflight?: Promise; requestRender?: () => void; usageRefreshTimer?: TimerHandle; gitCache?: GitFooterCache; }; type ProjectConfigContext = { cwd: string; isProjectTrusted?: () => boolean; }; function readConfigFile(path: string): RecursivePartial { if (!existsSync(path)) return {}; try { const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("Expected a non-null, non-array object"); } return parsed as RecursivePartial; } catch (error) { console.error(`Warning: Could not parse ${path}: ${error}`); return {}; } } function mergeConfig( base: MinimalFooterConfig, overrides: RecursivePartial, ): MinimalFooterConfig { const context = overrides.context; const dumbZone = context?.dumbZone; const codexUsage = overrides.codexUsage; const primaryWindow = codexUsage?.windows?.primary; const secondaryWindow = codexUsage?.windows?.secondary; const experimentalMarker = overrides.experimentalMarker; const gitStatus = overrides.gitStatus; return { context: { showPercent: normalizeBoolean(context?.showPercent, base.context.showPercent), dumbZone: { enabled: normalizeBoolean(dumbZone?.enabled, base.context.dumbZone.enabled), thresholdTokens: normalizeNonNegativeNumber( dumbZone?.thresholdTokens, base.context.dumbZone.thresholdTokens, ), label: normalizeLabel(dumbZone?.label, base.context.dumbZone.label), color: normalizeDumbZoneColor(dumbZone?.color, base.context.dumbZone.color), }, }, codexUsage: { enabled: normalizeBoolean(codexUsage?.enabled, base.codexUsage.enabled), cacheTtlMs: normalizeNonNegativeNumber(codexUsage?.cacheTtlMs, base.codexUsage.cacheTtlMs), requestTimeoutMs: normalizePositiveNumber( codexUsage?.requestTimeoutMs, base.codexUsage.requestTimeoutMs, ), windows: { primary: { enabled: normalizeBoolean( primaryWindow?.enabled, base.codexUsage.windows.primary.enabled, ), label: normalizeLabel(primaryWindow?.label, base.codexUsage.windows.primary.label), }, secondary: { enabled: normalizeBoolean( secondaryWindow?.enabled, base.codexUsage.windows.secondary.enabled, ), label: normalizeLabel(secondaryWindow?.label, base.codexUsage.windows.secondary.label), }, }, }, experimentalMarker: { enabled: normalizeBoolean(experimentalMarker?.enabled, base.experimentalMarker.enabled), label: normalizeLabel(experimentalMarker?.label, base.experimentalMarker.label), color: normalizeDumbZoneColor(experimentalMarker?.color, base.experimentalMarker.color), }, gitStatus: { enabled: normalizeBoolean(gitStatus?.enabled, base.gitStatus.enabled), refreshIntervalMs: normalizePositiveNumber( gitStatus?.refreshIntervalMs, base.gitStatus.refreshIntervalMs, ), gitTimeoutMs: normalizePositiveNumber(gitStatus?.gitTimeoutMs, base.gitStatus.gitTimeoutMs), ghTimeoutMs: normalizePositiveNumber(gitStatus?.ghTimeoutMs, base.gitStatus.ghTimeoutMs), }, }; } function normalizeBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } function normalizeNonNegativeNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback; } function normalizePositiveNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } function normalizeLabel(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value.trim() : fallback; } function normalizeDumbZoneColor(value: unknown, fallback: DumbZoneColor): DumbZoneColor { return DUMB_ZONE_COLORS.has(value as DumbZoneColor) ? (value as DumbZoneColor) : fallback; } function canReadProjectConfig(ctx: ProjectConfigContext): boolean { return typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted(); } function findProjectConfigPath(cwd: string): string { let current = cwd; while (true) { const candidate = join(current, CONFIG_DIR_NAME, "minimal-footer.json"); if (existsSync(candidate)) return candidate; const parent = dirname(current); if (parent === current) return join(cwd, CONFIG_DIR_NAME, "minimal-footer.json"); current = parent; } } function loadConfig(ctx: ProjectConfigContext): MinimalFooterConfig { const globalConfig = readConfigFile(join(getAgentDir(), "extensions", "minimal-footer.json")); const projectConfig = canReadProjectConfig(ctx) ? readConfigFile(findProjectConfigPath(ctx.cwd)) : {}; return mergeConfig(mergeConfig(DEFAULT_CONFIG, globalConfig), projectConfig); } function shouldShowCodexUsage(config: MinimalFooterConfig): boolean { return ( config.codexUsage.enabled && (config.codexUsage.windows.primary.enabled || config.codexUsage.windows.secondary.enabled) ); } function shouldShowExperimentalMarker(config: MinimalFooterConfig): boolean { return config.experimentalMarker.enabled && process.env.PI_EXPERIMENTAL === "1"; } function shouldShowGitStatus(config: MinimalFooterConfig): boolean { return config.gitStatus.enabled; } function canRunProjectGit(ctx: ProjectConfigContext): boolean { return canReadProjectConfig(ctx); } function renderFooterLines(options: { width: number; cwd: string; config: MinimalFooterConfig; branch: string; gitStatus?: string; contextUsage?: { percent?: number | null; tokens?: number | null }; modelId?: string; modelProvider?: string; thinkingLevel: string; theme: { fg(color: DumbZoneColor | "dim", text: string): string }; usageSnapshot?: UsageSnapshot; usageStatus?: UsageAvailability; }): string[] { const repo = basename(options.cwd); const branchText = options.gitStatus ? [options.branch, options.gitStatus].filter(Boolean).join(" · ") : options.branch; const context = options.contextUsage?.percent == null ? "?" : `${options.contextUsage.percent.toFixed(1)}%`; const dumbZone = options.config.context.dumbZone; const inDumbZone = dumbZone.enabled && (options.contextUsage?.tokens ?? 0) > dumbZone.thresholdTokens; const usageEnabled = shouldShowCodexUsage(options.config); const usageSummary = usageEnabled && isOpenAICodexProvider(options.modelProvider) ? formatUsageSummary(options.usageSnapshot, options.config.codexUsage.windows) : undefined; const usageStatus = usageEnabled && (isOpenAICodexProvider(options.modelProvider) || isOpenAIProvider(options.modelProvider)) ? formatUsageStatus(options.usageStatus) : undefined; const model = options.modelId ?? "no-model"; const modelText = options.thinkingLevel === "off" ? model : `${model} ${options.thinkingLevel}`; const branchStyled = options.theme.fg("dim", branchText); const repoStyled = options.theme.fg("dim", repo); const contextParts: string[] = []; if (options.config.context.showPercent) contextParts.push(options.theme.fg("dim", context)); if (inDumbZone) contextParts.push(options.theme.fg(dumbZone.color, dumbZone.label)); if (usageSummary) contextParts.push(options.theme.fg("dim", usageSummary)); else if (usageStatus) contextParts.push(options.theme.fg("dim", usageStatus)); if (shouldShowExperimentalMarker(options.config)) { const marker = options.config.experimentalMarker; contextParts.push(options.theme.fg(marker.color, marker.label)); } const contextStyled = contextParts.join(options.theme.fg("dim", " · ")); const modelStyled = options.theme.fg("dim", modelText); const renderSplitLine = (left: string, right: string): string => { const gap = " ".repeat(Math.max(2, options.width - visibleWidth(left) - visibleWidth(right))); return truncateToWidth(left + gap + right, options.width); }; const line1Fits = visibleWidth(branchStyled) + visibleWidth(repoStyled) + 2 <= options.width; const line2Fits = visibleWidth(contextStyled) + visibleWidth(modelStyled) + 2 <= options.width; if (line1Fits && line2Fits) { return [renderSplitLine(branchStyled, repoStyled), renderSplitLine(contextStyled, modelStyled)]; } return [ truncateToWidth(branchStyled, options.width), truncateToWidth(repoStyled, options.width), truncateToWidth(contextStyled, options.width), truncateToWidth(modelStyled, options.width), ]; } function clearUsageState(state: UsageSessionState): void { state.snapshot = undefined; state.lastFetchedAt = undefined; state.loading = false; state.availability = undefined; } function isModelUsingOAuth(ctx: ExtensionContext): boolean { if (!ctx.model) return false; try { return ctx.modelRegistry.isUsingOAuth(ctx.model); } catch { return false; } } function buildUsageRenderKey( model: NonNullable, oauth: boolean, ): string { return [model.provider, model.api, model.id, oauth ? "oauth" : "api-key"].join("/"); } function getUsageRenderKey(ctx: ExtensionContext): string | undefined { const model = ctx.model; if (!model) return undefined; return buildUsageRenderKey(model, isModelUsingOAuth(ctx)); } /** * Resolve account-bound identity only at lifecycle refresh boundaries. Footer * renders use getUsageRenderKey instead so a streaming repaint never reads * auth.json or otherwise reloads account metadata. */ function getUsageIdentity(ctx: ExtensionContext): UsageIdentity | undefined { const model = ctx.model; if (!model) return undefined; const oauth = isModelUsingOAuth(ctx); const renderKey = buildUsageRenderKey(model, oauth); const accountId = isOpenAICodexProvider(model.provider) && oauth ? getOpenAICodexAccountId(ctx.modelRegistry) : undefined; return { renderKey, usageKey: [renderKey, accountId ?? "account-unknown"].join("/"), oauth, accountId, }; } type UsageMode = "codex" | UsageAvailability; function getUsageMode( ctx: ExtensionContext, config: MinimalFooterConfig, identity?: UsageIdentity, ): UsageMode | undefined { if (!shouldShowCodexUsage(config)) return undefined; const oauth = identity?.oauth ?? isModelUsingOAuth(ctx); if (isOpenAICodexProvider(ctx.model?.provider)) { return oauth ? "codex" : "unavailable"; } if (isOpenAIProvider(ctx.model?.provider) && oauth) { // Pi's new OpenAI ChatGPT OAuth flow targets api.openai.com. There is // no documented account-usage contract for that token, so never reuse // the legacy openai-codex WHAM request here. return "unsupported"; } return undefined; } function invalidateUsageState(state: UsageSessionState, usageIdentity?: UsageIdentity): void { state.requestVersion += 1; state.snapshot = undefined; state.lastFetchedAt = undefined; state.loading = false; state.availability = undefined; state.usageIdentity = usageIdentity; // The previous promise may still be resolving. Clearing the reference lets // a newly selected provider/account start its own request; requestVersion // prevents the old promise from repainting this state when it settles. state.inflight = undefined; } function usageRequestStillCurrent( ctx: ExtensionContext, state: UsageSessionState, requestVersion: number, requestUsageKey: string | undefined, ): boolean { if ( state.disposed || state.requestVersion !== requestVersion || state.usageIdentity?.usageKey !== requestUsageKey ) { return false; } // Account-only login changes do not emit an extension event in Pi 0.99. // Re-read the lifecycle identity before accepting a response, but never // perform this check from render(). const currentIdentity = getUsageIdentity(ctx); if (currentIdentity?.usageKey === requestUsageKey) return true; invalidateUsageState(state, currentIdentity); state.requestRender?.(); void refreshUsageIfNeeded(ctx, state, true, currentIdentity); return false; } async function refreshUsageIfNeeded( ctx: ExtensionContext, state: UsageSessionState, force = false, usageIdentity?: UsageIdentity, ): Promise { if (state.disposed) return; const resolvedUsageIdentity = usageIdentity ?? getUsageIdentity(ctx); const config = state.config; if (state.usageIdentity?.usageKey !== resolvedUsageIdentity?.usageKey) { invalidateUsageState(state, resolvedUsageIdentity); state.requestRender?.(); } const mode = getUsageMode(ctx, config, resolvedUsageIdentity); if (!mode) { if (state.snapshot || state.availability || state.loading) { clearUsageState(state); state.requestRender?.(); } return; } if (mode !== "codex") { const status: UsageAvailability = mode; if (!force && state.availability === status && state.lastFetchedAt !== undefined) return; clearUsageState(state); state.availability = status; state.lastFetchedAt = Date.now(); state.requestRender?.(); return; } const now = Date.now(); if ( !force && state.lastFetchedAt !== undefined && now - state.lastFetchedAt < config.codexUsage.cacheTtlMs ) { return; } if (state.inflight) return state.inflight; const requestVersion = state.requestVersion; const requestUsageKey = resolvedUsageIdentity?.usageKey; state.loading = true; state.availability = undefined; state.requestRender?.(); const request = (async () => { try { const snapshot = await fetchOpenAICodexUsage(ctx.modelRegistry, { timeoutMs: config.codexUsage.requestTimeoutMs, // Passing the lifecycle-resolved identity avoids a second account // lookup and binds the response to the account that was requested. accountId: resolvedUsageIdentity?.accountId, }); if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return; if (snapshot) { state.snapshot = snapshot; state.lastFetchedAt = snapshot.fetchedAt; } else { state.snapshot = undefined; state.lastFetchedAt = Date.now(); state.availability = "unavailable"; } } catch { if (!usageRequestStillCurrent(ctx, state, requestVersion, requestUsageKey)) return; state.snapshot = undefined; state.lastFetchedAt = Date.now(); state.availability = "unavailable"; } finally { if ( state.disposed || state.requestVersion !== requestVersion || state.usageIdentity?.usageKey !== requestUsageKey ) return; state.loading = false; state.inflight = undefined; state.requestRender?.(); } })(); state.inflight = request; return request; } function checkUsageIdentity(ctx: ExtensionContext, state: UsageSessionState): void { if (state.disposed) return; const currentIdentity = getUsageIdentity(ctx); if (state.usageIdentity?.usageKey === currentIdentity?.usageKey) return; // The timer is deliberately identity-only. It performs no WHAM request for // an unchanged account; an identity change uses the existing account-bound // refresh path so token/header validation and late-result isolation remain in // one place. invalidateUsageState(state, currentIdentity); state.requestRender?.(); void refreshUsageIfNeeded(ctx, state, true, currentIdentity); } function startUsageRefreshTimer(ctx: ExtensionContext, state: UsageSessionState): void { const configuredIntervalMs = state.config.codexUsage.cacheTtlMs; if (state.usageRefreshTimer !== undefined || !shouldShowCodexUsage(state.config)) { return; } const requestedIntervalMs = configuredIntervalMs === 0 ? ZERO_CACHE_IDENTITY_CHECK_INTERVAL_MS : configuredIntervalMs; const intervalMs = Math.min(requestedIntervalMs, MAX_NODE_TIMER_DELAY_MS); const handle = setInterval(() => checkUsageIdentity(ctx, state), intervalMs); (handle as { unref?: () => void }).unref?.(); state.usageRefreshTimer = handle; } function disposeUsageRefreshTimer(state: UsageSessionState): void { if (state.usageRefreshTimer === undefined) return; clearInterval(state.usageRefreshTimer as ReturnType); state.usageRefreshTimer = undefined; } export default function (pi: ExtensionAPI) { const states = new WeakMap(); function disposeGitCache(state: UsageSessionState): void { state.gitCache?.dispose(); state.gitCache = undefined; } function disposeState(state: UsageSessionState): void { state.disposed = true; state.requestVersion += 1; state.snapshot = undefined; state.lastFetchedAt = undefined; state.loading = false; state.availability = undefined; state.usageIdentity = undefined; state.inflight = undefined; state.requestRender = undefined; disposeUsageRefreshTimer(state); disposeGitCache(state); } function ensureGitCache(ctx: ExtensionContext, state: UsageSessionState): void { if (!shouldShowGitStatus(state.config) || !canRunProjectGit(ctx)) { disposeGitCache(state); return; } if (state.gitCache) return; state.gitCache = new GitFooterCache({ cwd: () => ctx.cwd, canRun: () => canRunProjectGit(ctx), refreshIntervalMs: state.config.gitStatus.refreshIntervalMs, gitTimeoutMs: state.config.gitStatus.gitTimeoutMs, ghTimeoutMs: state.config.gitStatus.ghTimeoutMs, onChange: () => state.requestRender?.(), }); } pi.on("session_start", (_event, ctx) => { const previous = states.get(ctx.sessionManager); if (previous) disposeState(previous); const state: UsageSessionState = { config: loadConfig(ctx), loading: false, requestVersion: 0, disposed: false, }; states.set(ctx.sessionManager, state); ensureGitCache(ctx, state); ctx.ui.setFooter((tui, theme, footerData) => { const unsub = footerData.onBranchChange(() => tui.requestRender()); state.requestRender = () => tui.requestRender(); void refreshUsageIfNeeded(ctx, state); return { dispose() { disposeState(state); unsub(); }, invalidate() {}, render(width: number): string[] { // Keep repainting cheap: account-bound identity is resolved only // by lifecycle refreshes, never while streaming output renders. const usageRenderKey = getUsageRenderKey(ctx); const usageStateMatchesModel = state.usageIdentity?.renderKey === usageRenderKey; return renderFooterLines({ width, cwd: ctx.cwd, config: state.config, branch: footerData.getGitBranch() ?? state.gitCache?.getStatusSnapshot()?.branch ?? "", gitStatus: shouldShowGitStatus(state.config) ? formatGitFooterStatus( state.gitCache?.getStatusSnapshot(), state.gitCache?.getPullRequestSnapshot(), ) : undefined, contextUsage: ctx.getContextUsage(), modelId: ctx.model?.id, modelProvider: ctx.model?.provider, thinkingLevel: pi.getThinkingLevel(), theme, usageSnapshot: usageStateMatchesModel ? state.snapshot : undefined, usageStatus: usageStateMatchesModel ? state.availability : undefined, }); }, }; }); startUsageRefreshTimer(ctx, state); }); pi.on("model_select", (_event, ctx) => { const state = states.get(ctx.sessionManager); if (!state) return; // A model-select event is also the boundary for an account reload. Do // not let a prior provider/account's snapshot or promise survive it. const usageIdentity = getUsageIdentity(ctx); invalidateUsageState(state, usageIdentity); state.requestRender?.(); void refreshUsageIfNeeded(ctx, state, true, usageIdentity); }); pi.on("turn_start", (_event, ctx) => { const state = states.get(ctx.sessionManager); if (!state) return; // Prompt boundaries provide an additional account-change check without // forcing a usage request when the current cache is still fresh. void refreshUsageIfNeeded(ctx, state); }); pi.on("turn_end", (_event, ctx) => { const state = states.get(ctx.sessionManager); if (!state) return; void refreshUsageIfNeeded(ctx, state); ensureGitCache(ctx, state); void state.gitCache?.refresh(); }); pi.on("session_shutdown", (_event, ctx) => { const state = states.get(ctx.sessionManager); if (!state) return; disposeState(state); states.delete(ctx.sessionManager); }); } export const __testing = { GitFooterCache, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_GIT_TIMEOUT_MS, DEFAULT_GH_TIMEOUT_MS, GIT_STATUS_ARGS, GH_PR_VIEW_ARGS, loadConfig, renderFooterLines, parseGitStatusPorcelainV2, parsePullRequestJson, formatGitStatusFooterSegment, formatPullRequestFooterSegment, formatGitFooterStatus, };