/** * mechanicus-footer — replaces pi's default footer with a mechanicum-flavored * version. Changes from the default: * * - Context usage rendered as "45k/131k (34%)" instead of "34%/131k" so you * don't have to multiply the percentage in your head. * - Compaction counter: appends "C" when one or more compactions have * occurred during the session. Walks sessionManager entries for the * current count; no state tracking needed. * - Everything else (↑in, ↓out, R cache, cost, model, thinking level, * extension statuses on line 3+) is preserved in the same slots and * formatting. * * The context percentage threshold colors still apply: * > 90% → error (rust) * > 70% → warning (gold) * else → dim (default) */ import type { ExtensionAPI, ExtensionContext, Theme, } from "@mariozechner/pi-coding-agent"; import { formatTokens } from "../shared/format.js"; import { normalizePiEntries } from "../shared/token-stats.js"; export default function (pi: ExtensionAPI) { let cachedCtx: ExtensionContext | null = null; let lastTurnStartMs: number | null = null; let lastTurnDurationMs: number | null = null; const install = (ctx: ExtensionContext) => { if (!ctx.hasUI) return; cachedCtx = ctx; ctx.ui.setFooter((_tui, theme, footerData) => ({ render(width: number): string[] { if (!cachedCtx) return []; try { return renderInner(cachedCtx, theme, footerData, width); } catch { // Any late render after the runner went stale: swallow and // return nothing rather than crashing node. return []; } }, invalidate() {}, })); }; const renderInner = ( c: ExtensionContext, theme: Theme, footerData: any, width: number, ): string[] => { const dim = (s: string) => theme.fg("dim", s); const readExtensionStatuses = (): Array<[string, string]> => { const statuses = footerData?.getExtensionStatuses?.(); if (!statuses) { return []; } if (Array.isArray(statuses)) { return statuses as Array<[string, string]>; } if (typeof statuses === "object") { const candidate = statuses as { [Symbol.iterator]?: unknown; }; if (typeof candidate[Symbol.iterator] === "function") { try { return Array.from(candidate as Iterable<[string, string]>); } catch { return []; } } return Object.entries(statuses as Record); } return []; }; // ── Walk session entries for token totals + compactions ─ let totalIn = 0; let totalOut = 0; let totalCache = 0; let totalCost = 0; let compactions = 0; let lastAssistantOutput = 0; const entries = normalizePiEntries(c.sessionManager.getEntries()); for (const entry of entries) { if (entry.type === "compaction") compactions++; if ( entry.type === "message" && entry.message.role === "assistant" ) { totalIn += entry.message.usage.input; totalOut += entry.message.usage.output; totalCache += entry.message.usage.cacheRead; totalCost += entry.message.usage.cost.total; lastAssistantOutput = entry.message.usage.output; } } // ── Context usage (current) ───────────────────────────── const usage = c.getContextUsage(); const curTokens = usage?.tokens; const maxTokens = usage?.contextWindow ?? c.model?.contextWindow ?? 0; const pct = usage?.percent ?? null; // ── Line 1: cwd + git branch + session name ───────────── let pwd = c.sessionManager.getCwd(); const home = process.env.HOME; if (home && pwd.startsWith(home)) pwd = `~${pwd.slice(home.length)}`; const branch = footerData.getGitBranch(); if (branch) pwd = `${pwd} (${branch})`; const sessionName = c.sessionManager.getSessionName(); if (sessionName) pwd = `${pwd} • ${sessionName}`; const pwdLine = dim(pwd); // ── Line 2: stats + right-aligned model ───────────────── const leftParts: string[] = []; if (totalIn) leftParts.push(`↑${formatTokens(totalIn)}`); if (totalOut) leftParts.push(`↓${formatTokens(totalOut)}`); if (totalCache) leftParts.push(`R${formatTokens(totalCache)}`); if (totalCost > 0) leftParts.push(`$${totalCost.toFixed(3)}`); // Generation speed (last turn): output tokens / duration if ( lastTurnDurationMs !== null && lastTurnDurationMs > 500 && lastAssistantOutput > 0 ) { const tokPerSec = lastAssistantOutput / (lastTurnDurationMs / 1000); leftParts.push(`${tokPerSec.toFixed(1)}tok/s`); } // Context chunk — format: "45k/131k (34%)" let ctxRaw: string; let ctxColored: string; if (curTokens !== null && curTokens !== undefined && maxTokens > 0) { ctxRaw = `${formatTokens(curTokens)}/${formatTokens(maxTokens)} (${ pct === null ? "?" : pct.toFixed(0) }%)`; } else { ctxRaw = `?/${formatTokens(maxTokens)}`; } if (pct !== null && pct > 90) ctxColored = theme.fg("error", ctxRaw); else if (pct !== null && pct > 70) ctxColored = theme.fg("warning", ctxRaw); else ctxColored = dim(ctxRaw); // Build left-side string. Non-context parts are dim; context keeps // its threshold color. We interleave raw lengths for padding math. const dimmedLeft = leftParts.map(dim).join(" "); const dimmedLeftRaw = leftParts.join(" "); const compactionChunk = compactions > 0 ? ` ${dim(`C${compactions}`)}` : ""; const compactionRaw = compactions > 0 ? ` C${compactions}` : ""; const leftFull = (dimmedLeft.length ? dimmedLeft + " " : "") + ctxColored + compactionChunk; const leftRawWidth = (dimmedLeftRaw.length ? dimmedLeftRaw.length + 1 : 0) + ctxRaw.length + compactionRaw.length; // Right side: model + thinking level const modelName = c.model?.id ?? "no-model"; let rightRaw = modelName; if (c.model?.reasoning) { const level = pi.getThinkingLevel?.() ?? "off"; rightRaw = `${modelName} • ${level === "off" ? "thinking off" : level}`; } const rightFull = dim(rightRaw); // Pad between const minSpace = 2; let statsLine: string; if (leftRawWidth + minSpace + rightRaw.length <= width) { const pad = " ".repeat(width - leftRawWidth - rightRaw.length); statsLine = leftFull + pad + rightFull; } else { statsLine = leftFull; } const lines = [pwdLine, statsLine]; // ── Line 3+: extension statuses (alphabetical) ────────── const extStatuses = readExtensionStatuses(); if (extStatuses.length > 0) { const sorted = extStatuses.sort(([a], [b]) => a.localeCompare(b), ); const statusLine = sorted.map(([, text]) => text).join(" "); lines.push(statusLine); } return lines; }; pi.on("session_start", async (_event, ctx) => { install(ctx); }); pi.on("turn_start", async () => { lastTurnStartMs = Date.now(); }); // Keep ctx fresh on every turn so token sums and context usage are current. pi.on("turn_end", async (_event, ctx) => { if (lastTurnStartMs !== null) { lastTurnDurationMs = Date.now() - lastTurnStartMs; lastTurnStartMs = null; } cachedCtx = ctx; }); // On /quit or /reload pi marks the ExtensionRunner stale, but the TUI // may still fire a pending render timer. Accessing ctx.sessionManager // after that throws "This extension instance is stale…". Null the // captured ctx so render() short-circuits to [] and no late draw // touches the dead runner. pi.on("session_shutdown", async () => { cachedCtx = null; }); pi.registerCommand("mechanicus-footer-off", { description: "Restore pi's default footer", handler: async (_args, ctx) => { ctx.ui.setFooter(undefined); ctx.ui.notify("Default footer restored.", "info"); }, }); pi.registerCommand("mechanicus-footer-on", { description: "Re-install the mechanicum footer", handler: async (_args, ctx) => { install(ctx); ctx.ui.notify("Mechanicum footer installed.", "info"); }, }); }