/** * Custom Footer Extension for pi * * Replaces the default footer with: * - Context shown as absolute tokens (e.g. "120k/524k") instead of percentage * - Cache hit rate displayed as "CH94%" instead of raw cache tokens * - Thinking level shown next to model name * - Same layout: cwd+branch on line 1, stats on line 2, extension statuses on line 3 */ import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, relative, resolve, sep } from "node:path"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; // ── Helpers ────────────────────────────────────────────────────────────────── function fmtTokens(n: number): string { if (n < 1000) return n.toString(); if (n < 10000) return `${(n / 1000).toFixed(1)}k`; if (n < 1000000) return `${Math.round(n / 1000)}k`; if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`; return `${Math.round(n / 1000000)}M`; } function fmtCwd(cwd: string): string { const home = process.env.HOME || process.env.USERPROFILE || ""; if (!home) return cwd; const r = resolve(cwd); const rel = relative(resolve(home), r); if (rel === "") return "~"; if (rel.startsWith("..")) return cwd; return `~${sep}${rel}`; } // ── Extension Entry Point ──────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { let fCtx: ExtensionContext | null = null; // Track thinking level: read default from settings.json, update via events let currentThinkingLevel = ""; try { const settingsPath = join(homedir(), ".pi", "agent", "settings.json"); const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); currentThinkingLevel = settings.defaultThinkingLevel ?? ""; } catch { /* ignore */ } pi.on("thinking_level_select", async (event) => { currentThinkingLevel = event.level; }); // ── Install Footer ─────────────────────────────────────────────────────── function installFooter(ctx: ExtensionContext) { fCtx = ctx; ctx.ui.setFooter((tui, theme, footerData) => { const unsub = footerData.onBranchChange(() => tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { if (!fCtx) return []; const sm = fCtx.sessionManager; const model = fCtx.model; // Token stats from all entries let tIn = 0, tOut = 0, tCR = 0, tCW = 0, tCost = 0; for (const e of sm.getEntries()) { if (e.type === "message" && e.message.role === "assistant") { const u = (e.message as AssistantMessage).usage; tIn += u.input; tOut += u.output; tCR += u.cacheRead; tCW += u.cacheWrite; tCost += u.cost.total; } } // Context usage const cu = fCtx.getContextUsage(); const cw = cu?.contextWindow ?? model?.contextWindow ?? 0; const ct = cu?.tokens; // Build stats parts const parts: string[] = []; if (tIn) parts.push(`↑${fmtTokens(tIn)}`); if (tOut) parts.push(`↓${fmtTokens(tOut)}`); // Cache hit rate: cacheRead / (input + cacheRead + cacheWrite) const totalPrompt = tIn + tCR + tCW; if (totalPrompt > 0) { const hitRate = (tCR / totalPrompt) * 100; if (hitRate > 0) { const hitStr = hitRate >= 99.5 ? "100%" : `${hitRate.toFixed(0)}%`; parts.push(`CH${hitStr}`); } } if (tCost) parts.push(`$${tCost.toFixed(3)}`); // Context: "120k/524k (auto)" instead of "26.2%/524k (auto)" const ctStr = ct != null ? fmtTokens(ct) : "?"; const ctxDisplay = `${ctStr}/${fmtTokens(cw)} (auto)`; const pct = cu?.percent ?? 0; if (pct > 90) parts.push(theme.fg("error", ctxDisplay)); else if (pct > 70) parts.push(theme.fg("warning", ctxDisplay)); else parts.push(ctxDisplay); let left = parts.join(" "); let leftW = visibleWidth(left); if (leftW > width) { left = truncateToWidth(left, width, "..."); leftW = visibleWidth(left); } // Right side: model + thinking level const mId = model?.id ?? "no-model"; let right = mId; if (model?.reasoning && currentThinkingLevel) { right = currentThinkingLevel === "off" ? `${mId} • thinking off` : `${mId} • ${currentThinkingLevel}`; } const rightW = visibleWidth(right); const gap = 2; let line: string; if (leftW + gap + rightW <= width) { line = left + " ".repeat(width - leftW - rightW) + right; } else { const avail = width - leftW - gap; if (avail > 0) { const tr = truncateToWidth(right, avail, ""); line = left + " ".repeat(Math.max(0, width - leftW - visibleWidth(tr))) + tr; } else { line = left; } } const dimLeft = theme.fg("dim", left); const dimRight = theme.fg("dim", line.slice(left.length)); // Line 1: cwd + branch + session name const cwd = fmtCwd(sm.getCwd?.() ?? process.cwd()); const branch = footerData.getGitBranch(); const sName = sm.getSessionName?.(); let pwd = cwd; if (branch) pwd = `${pwd} (${branch})`; if (sName) pwd = `${pwd} • ${sName}`; const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")); const lines = [pwdLine, dimLeft + dimRight]; // Line 3: extension statuses const statuses = footerData.getExtensionStatuses(); if (statuses.size > 0) { const sorted = Array.from(statuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, t]) => t.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim()); lines.push(truncateToWidth(sorted.join(" "), width, theme.fg("dim", "..."))); } return lines; }, }; }); } // ── Lifecycle ──────────────────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { installFooter(ctx); }); pi.on("session_shutdown", async () => { fCtx = null; }); }