import { readFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent"; import { getCapabilities, hyperlink, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { configuredOpenUri } from "./open.ts"; const THINKING_COLORS = { minimal: 46, low: 82, medium: 118, high: 220, xhigh: 208, max: 196, } as const; const QUOTA_STATUS_KEY = "pi-multi-codex"; const AGENT_TIME_ENTRY = "pi-footer:agent-work"; const SUBAGENT_BACKGROUND_RESULT = "subagent-background-result"; /** Pi's VIRTUAL_MODEL_API: catalog entries that route each request to a physical model. */ const VIRTUAL_MODEL_API = "pi-virtual"; type CountedUsage = { input: number; output: number; cost: { total: number } }; type GitSummary = { badges: string; detachedOid?: string }; const EMPTY_GIT_SUMMARY: GitSummary = { badges: "" }; function isNonNegativeNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } function subagentBackgroundUsage(details: unknown): CountedUsage | undefined { if (!details || typeof details !== "object" || Array.isArray(details)) return; const usage = (details as Record).usage; if (!usage || typeof usage !== "object" || Array.isArray(usage)) return; const record = usage as Record; const cost = record.cost; if (!cost || typeof cost !== "object" || Array.isArray(cost)) return; const total = (cost as Record).total; if (!isNonNegativeNumber(record.input) || !isNonNegativeNumber(record.output) || !isNonNegativeNumber(total)) return; return { input: record.input, output: record.output, cost: { total } }; } function formatTokens(count: number): string { if (count < 1_000) return `${count}`; if (count < 1_000_000) return `${(count / 1_000).toFixed(1)}k`; return `${(count / 1_000_000).toFixed(1)}M`; } function formatDuration(milliseconds: number): string { const totalSeconds = Math.floor(milliseconds / 1_000); const hours = Math.floor(totalSeconds / 3_600); const minutes = Math.floor(totalSeconds % 3_600 / 60); const seconds = totalSeconds % 60; return hours ? `${hours}h ${minutes}m ${seconds}s` : minutes ? `${minutes}m ${seconds}s` : `${seconds}s`; } function sanitizeStatus(text: string): string { return text.replace(/[\r\n]+/g, " ").trim(); } function summarizeGitStatus(output: string, operation?: string): GitSummary { let staged = 0; let unstaged = 0; let untracked = 0; let conflicts = 0; let ahead = 0; let behind = 0; let detachedOid: string | undefined; for (const line of output.split(/\r?\n/)) { if (line.startsWith("# branch.oid ")) { const oid = line.slice(13).trim(); if (oid !== "(initial)") detachedOid = oid.slice(0, 7); } else if (line.startsWith("# branch.ab ")) { const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line); if (match) { ahead = Number(match[1]); behind = Number(match[2]); } } else if (line.startsWith("? ")) { untracked++; } else if (line.startsWith("u ")) { conflicts++; } else if (line.startsWith("1 ") || line.startsWith("2 ")) { const xy = line.slice(2, 4); if (xy[0] !== ".") staged++; if (xy[1] !== ".") unstaged++; } } const badges = [ operation, conflicts ? `!${conflicts}` : undefined, staged ? `+${staged}` : undefined, unstaged ? `~${unstaged}` : undefined, untracked ? `?${untracked}` : undefined, ahead ? `↑${ahead}` : undefined, behind ? `↓${behind}` : undefined, ].filter(Boolean).join(" "); return { badges: badges ? `[${badges}]` : "", detachedOid }; } async function readOptional(path: string): Promise { try { return await readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } } function operationProgress(current: string | undefined, total: string | undefined): string { const currentStep = Number.parseInt(current ?? "", 10); const totalSteps = Number.parseInt(total ?? "", 10); return currentStep > 0 && totalSteps > 0 ? ` ${currentStep}/${totalSteps}` : ""; } async function readGitOperation(gitDir: string): Promise { const [ rebaseMergeCurrent, rebaseMergeTotal, rebaseApplyCurrent, rebaseApplyTotal, applying, mergeHead, cherryPickHead, revertHead, bisectLog, ] = await Promise.all([ readOptional(join(gitDir, "rebase-merge", "msgnum")), readOptional(join(gitDir, "rebase-merge", "end")), readOptional(join(gitDir, "rebase-apply", "next")), readOptional(join(gitDir, "rebase-apply", "last")), readOptional(join(gitDir, "rebase-apply", "applying")), readOptional(join(gitDir, "MERGE_HEAD")), readOptional(join(gitDir, "CHERRY_PICK_HEAD")), readOptional(join(gitDir, "REVERT_HEAD")), readOptional(join(gitDir, "BISECT_LOG")), ]); if (rebaseMergeCurrent !== undefined) return `REBASE${operationProgress(rebaseMergeCurrent, rebaseMergeTotal)}`; if (rebaseApplyCurrent !== undefined) { return `${applying === undefined ? "REBASE" : "AM"}${operationProgress(rebaseApplyCurrent, rebaseApplyTotal)}`; } if (mergeHead !== undefined) return "MERGING"; if (cherryPickHead !== undefined) return "CHERRY-PICKING"; if (revertHead !== undefined) return "REVERTING"; if (bisectLog !== undefined) return "BISECTING"; } function align(left: string, right: string, width: number, ellipsis: string): string { const available = width - visibleWidth(left) - 2; if (available <= 0) return truncateToWidth(left, width, ellipsis); const clippedRight = truncateToWidth(right, available, ""); return left + " ".repeat(width - visibleWidth(left) - visibleWidth(clippedRight)) + clippedRight; } // Mirrors align but reserves the right side (runtime) and truncates the left status first. function alignRightReserved(left: string, right: string, width: number, ellipsis: string): string { const available = Math.max(width - visibleWidth(right) - 2, 0); const clippedLeft = truncateToWidth(left, available, ellipsis); return clippedLeft + " ".repeat(Math.max(width - visibleWidth(clippedLeft) - visibleWidth(right), 0)) + truncateToWidth(right, width, ""); } function color(text: string, ansi256: number): string { return `\x1b[38;5;${ansi256}m${text}\x1b[39m`; } function rainbow(text: string): string { const colors = [196, 220, 46, 39, 201]; return [...text].map((character, index) => color(character, colors[index % colors.length]!)).join(""); } /** Physical model and thinking level of the latest successful response on the branch; failed and aborted requests are skipped. */ function latestResponse(ctx: ExtensionContext): { model: string; thinkingLevel?: string } | undefined { const branch = ctx.sessionManager.getBranch(); for (let index = branch.length - 1; index >= 0; index--) { const entry = branch[index]!; if (entry.type !== "message" || entry.message.role !== "assistant") continue; const { stopReason, model, thinkingLevel } = entry.message; if (stopReason !== "error" && stopReason !== "aborted") return { model, thinkingLevel }; } } export default function footerExtension(pi: ExtensionAPI): void { let activeMilliseconds = 0; let activeStartedAt: number | undefined; let promptPaused = false; let runtimeTimer: ReturnType | undefined; let requestRuntimeRender: (() => void) | undefined; let refreshGitStatus: (() => Promise) | undefined; const activeCodegraphCalls = new Set(); pi.on("tool_execution_start", (event) => { if (event.toolName !== "codegraph_explore") return; activeCodegraphCalls.add(event.toolCallId); requestRuntimeRender?.(); }); pi.on("tool_execution_end", (event) => { if (activeCodegraphCalls.delete(event.toolCallId)) requestRuntimeRender?.(); }); const stopRuntimeTimer = () => { if (runtimeTimer === undefined) return; clearInterval(runtimeTimer); runtimeTimer = undefined; }; const startActive = () => { if (activeStartedAt !== undefined || promptPaused) return; activeStartedAt = performance.now(); if (requestRuntimeRender) runtimeTimer = setInterval(requestRuntimeRender, 1_000); requestRuntimeRender?.(); }; const pauseActive = () => { if (activeStartedAt === undefined) return; activeMilliseconds += performance.now() - activeStartedAt; activeStartedAt = undefined; promptPaused = true; stopRuntimeTimer(); requestRuntimeRender?.(); }; const resumeActive = () => { if (!promptPaused) return; promptPaused = false; startActive(); }; const finalizeActive = (): boolean => { if (activeStartedAt === undefined && !promptPaused) return false; if (activeStartedAt !== undefined) activeMilliseconds += performance.now() - activeStartedAt; activeStartedAt = undefined; promptPaused = false; stopRuntimeTimer(); requestRuntimeRender?.(); return true; }; pi.on("agent_start", startActive); pi.on("ui_prompt_start", pauseActive); pi.on("ui_prompt_end", resumeActive); pi.on("agent_settled", async (_event, ctx) => { if (!ctx.isIdle()) return; if (finalizeActive()) pi.appendEntry(AGENT_TIME_ENTRY, activeMilliseconds); await refreshGitStatus?.(); }); pi.on("session_shutdown", () => { stopRuntimeTimer(); activeStartedAt = undefined; promptPaused = false; refreshGitStatus = undefined; activeCodegraphCalls.clear(); }); pi.on("session_start", async (_event, ctx) => { stopRuntimeTimer(); activeStartedAt = undefined; promptPaused = false; requestRuntimeRender = undefined; refreshGitStatus = undefined; activeCodegraphCalls.clear(); // Latest valid entry wins; stored data is untrusted. activeMilliseconds = 0; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === AGENT_TIME_ENTRY && isNonNegativeNumber(entry.data)) { activeMilliseconds = entry.data; } } if (ctx.mode !== "tui") return; const git = await pi.exec( "git", ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir", "--git-dir"], { cwd: ctx.cwd }, ); const [root, commonDir, gitDir] = git.stdout.trim().split(/\r?\n/); const rootName = basename(root || ctx.cwd); const commonName = commonDir && basename(commonDir) === ".git" ? basename(dirname(commonDir)) : undefined; const repo = git.code === 0 ? commonName && commonName !== rootName ? commonName : rootName : basename(ctx.cwd); let gitSummary = EMPTY_GIT_SUMMARY; let gitRefreshGeneration = 0; if (git.code === 0 && gitDir) { refreshGitStatus = async () => { const generation = ++gitRefreshGeneration; const [status, operation] = await Promise.all([ pi.exec("git", ["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], { cwd: ctx.cwd }), readGitOperation(gitDir), ]); if (generation !== gitRefreshGeneration) return; gitSummary = status.code === 0 ? summarizeGitStatus(status.stdout, operation) : EMPTY_GIT_SUMMARY; requestRuntimeRender?.(); }; await refreshGitStatus(); } let tps: number | undefined; // Measure first to last streamed update: providers emit message_start before the first token, // and some (Claude bridge) emit message_end only after post-stream teardown. let assistantStartedAt: number | undefined; let assistantLastUpdateAt: number | undefined; pi.on("message_update", (event) => { if (event.message.role !== "assistant") return; assistantLastUpdateAt = performance.now(); assistantStartedAt ??= assistantLastUpdateAt; }); pi.on("message_end", (event) => { if (event.message.role !== "assistant") return; const output = event.message.usage?.output ?? 0; const seconds = assistantStartedAt === undefined ? 0 : (assistantLastUpdateAt! - assistantStartedAt) / 1000; assistantStartedAt = assistantLastUpdateAt = undefined; tps = seconds > 0 ? output / seconds : undefined; }); // Session entries are append-only; the leaf also changes on tree navigation. let usageKey: string | undefined; let routed: ReturnType; let input = 0; let output = 0; let cost = 0; let cacheRate: number | undefined; const computeUsage = (entries: SessionEntry[]) => { input = 0; output = 0; cost = 0; cacheRate = undefined; const add = (usage: CountedUsage | undefined) => { if (!usage) return; input += usage.input; output += usage.output; cost += usage.cost.total; }; for (const entry of entries) { if (entry.type === "message" && entry.message.role === "assistant") { const usage = entry.message.usage; const prompt = usage.input + usage.cacheRead + usage.cacheWrite; cacheRate = prompt ? usage.cacheRead / prompt * 100 : 0; add(usage); } else if (entry.type === "message" && entry.message.role === "toolResult") { add(entry.message.usage); } else if (entry.type === "custom_message" && entry.customType === SUBAGENT_BACKGROUND_RESULT) { add(subagentBackgroundUsage(entry.details)); } else if (entry.type === "branch_summary" || entry.type === "compaction") { add(entry.usage); } } }; ctx.ui.setFooter((tui, theme, data) => { requestRuntimeRender = () => tui.requestRender(); const unsubscribe = data.onBranchChange(() => { void refreshGitStatus?.(); requestRuntimeRender?.(); }); return { dispose() { unsubscribe(); requestRuntimeRender = undefined; stopRuntimeTimer(); }, invalidate() { }, render(width: number): string[] { const entries = ctx.sessionManager.getEntries(); const key = `${entries.length}:${ctx.sessionManager.getLeafId()}`; if (key !== usageKey) { usageKey = key; computeUsage(entries); routed = latestResponse(ctx); } const reportedBranch = data.getGitBranch()?.replace(/^worktree\//, ""); const branch = reportedBranch === "detached" && gitSummary.detachedOid ? `@${gitSummary.detachedOid}` : reportedBranch; const context = ctx.getContextUsage()?.percent; const openUri = configuredOpenUri(ctx.cwd); const extensionStatuses = data.getExtensionStatuses(); const prStatus = sanitizeStatus(extensionStatuses.get("pi-pr") ?? ""); const codegraph = activeCodegraphCalls.size > 0 ? `${theme.fg("accent", "●")} CG` : sanitizeStatus(extensionStatuses.get("pi-codegraph") ?? ""); const quotaStatus = sanitizeStatus(extensionStatuses.get(QUOTA_STATUS_KEY) ?? ""); const externalStatuses: string[] = []; for (const [key, value] of [...extensionStatuses].sort(([a], [b]) => a.localeCompare(b))) { if (key === "pi-pr" || key === "pi-codegraph" || key === QUOTA_STATUS_KEY) continue; const text = sanitizeStatus(value); if (text) externalStatuses.push(text); } const ellipsis = theme.fg("dim", "…"); const usage = theme.fg("dim", [ `↑ ${formatTokens(input)}`, `↓ ${formatTokens(output)}`, `↺ ${cacheRate === undefined ? "—" : `${cacheRate.toFixed(1)}%`}`, `⚡ ${tps === undefined ? "—" : `${tps.toFixed(1)} t/s`}`, `$ ${cost.toFixed(3)}`, `◔ ${context == null ? "—" : `${context.toFixed(1)}%`}`, ].join(" · ")); const thinkingText = (level: string) => { const thinkingColor = THINKING_COLORS[level as keyof typeof THINKING_COLORS]; return level === "ultra" ? rainbow(level) : thinkingColor === undefined ? theme.fg("dim", level) : color(level, thinkingColor); }; const selected = theme.fg("dim", `${ctx.model?.id ?? "no-model"} • `) + thinkingText(String(ctx.thinkingLevel ?? "off")); // Under a virtual selection, show where the latest request went, as Pi's own footer does. const model = ctx.model?.api === VIRTUAL_MODEL_API && routed ? selected + theme.fg("dim", ` → ${routed.model}`) + (routed.thinkingLevel ? theme.fg("dim", " • ") + thinkingText(routed.thinkingLevel) : "") : selected; const elapsed = activeMilliseconds + (activeStartedAt === undefined ? 0 : performance.now() - activeStartedAt); const runtime = theme.fg("dim", `◷ ${formatDuration(elapsed)}`); const identity = branch ? theme.fg("dim", `${repo} · `) : ""; const checkout = branch ?? repo; const checkoutLink = openUri && getCapabilities().hyperlinks ? hyperlink(theme.fg("accent", checkout), openUri) : theme.fg("dim", checkout); const checkoutStatus = gitSummary.badges ? `${checkoutLink} ${theme.fg("dim", gitSummary.badges)}` : checkoutLink; const identityLine = prStatus ? `${identity}${checkoutStatus} · ${prStatus}` : `${identity}${checkoutStatus}`; const firstLine = quotaStatus ? align(identityLine, quotaStatus, width, ellipsis) : identityLine; const lines = [ firstLine, align(usage, model, width, ellipsis), alignRightReserved([codegraph, ...externalStatuses].filter(Boolean).join(" · "), runtime, width, ellipsis), ]; return lines.map((line) => truncateToWidth(line, width, ellipsis)); }, }; }); }); }