/** * Footer Display Extension * * Replaces the built-in pi footer with a single clean line that assembles * status from all other extensions: * * ~dir | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2 * * Status sources: * usage:update event — set by usage-bars extension → S/W bars (Claude usage, always shown) * ctx.getContextUsage() → C bar (rendered here) * ctx.model → model short name * "lsp" — set by lsp-pi extension → strip "LSP " prefix * "mcp" — set by pi-mcp-adapter → strip " servers" suffix */ import os from "os"; import path from "path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { truncateToWidth } from "@mariozechner/pi-tui"; // --------------------------------------------------------------------------- // Braille gradient bar — used here only for the context (C) bar // --------------------------------------------------------------------------- const BRAILLE_GRADIENT = "\u28C0\u28C4\u28E4\u28E6\u28F6\u28F7\u28FF"; const BRAILLE_EMPTY = "\u28C0"; const BAR_WIDTH = 5; function renderBrailleBar(theme: any, value: number): string { const v = Math.max(0, Math.min(100, Math.round(value))); const levels = BRAILLE_GRADIENT.length - 1; const totalSteps = BAR_WIDTH * levels; const filledSteps = Math.round((v / 100) * totalSteps); const full = Math.floor(filledSteps / levels); const partial = filledSteps % levels; const empty = BAR_WIDTH - full - (partial ? 1 : 0); const color = v >= 90 ? "error" : v >= 70 ? "warning" : "success"; const filled = BRAILLE_GRADIENT[BRAILLE_GRADIENT.length - 1]!.repeat(Math.max(0, full)); const partialChar = partial ? BRAILLE_GRADIENT[partial]! : ""; const emptyChars = BRAILLE_EMPTY.repeat(Math.max(0, empty)); return theme.fg(color, filled + partialChar) + theme.fg("dim", emptyChars); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function stripAnsi(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\][^\x07]*\x07/g, ""); } function getModelShortName(modelId: string): string { // claude-haiku-4-5 → "Haiku 4.5", claude-sonnet-4-6 → "Sonnet 4.6" const m = modelId.match(/^claude-([a-z]+)-([\d]+(?:-[\d]+)*)(?:-\d{8})?$/); if (m) { const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1); return `${family} ${m[2]!.replace(/-/g, ".")}`; } // claude-3-5-sonnet, claude-3-opus, etc. const m2 = modelId.match(/^claude-[\d-]+-([a-z]+)/); if (m2) return m2[1]!.charAt(0).toUpperCase() + m2[1]!.slice(1); return modelId.replace(/^claude-/, ""); } // Format duration in milliseconds to human readable (e.g., "2h 55m") function formatDurationMs(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return "now"; const totalSeconds = Math.floor(ms / 1000); const d = Math.floor(totalSeconds / 86400); const h = Math.floor((totalSeconds % 86400) / 3600); const m = Math.floor((totalSeconds % 3600) / 60); if (d > 0 && h > 0) return `${d}d ${h}h`; if (d > 0) return `${d}d`; if (h > 0 && m > 0) return `${h}h ${m}m`; if (h > 0) return `${h}h`; if (m > 0) return `${m}m`; return "<1m"; } export default function (pi: ExtensionAPI) { let ctx: any = null; let tuiRef: any = null; let footerDataRef: any = null; // Track usage data for dynamic S/W bar rendering let usageSession: number | null = null; let usageWeekly: number | null = null; let sessionResetsAt: number | null = null; let weeklyResetsAt: number | null = null; // --------------------------------------------------------------------------- // Footer line builder — called on every render // --------------------------------------------------------------------------- function buildFooterLine(theme: any): string { const sep = theme.fg("dim", " · "); const pipeSep = theme.fg("dim", " | "); const parts: string[] = []; const statuses: ReadonlyMap = footerDataRef?.getExtensionStatuses?.() ?? new Map(); // 1. Current working directory const home = os.homedir(); const cwd = process.cwd(); const dir = cwd.startsWith(home) ? "~" + path.sep + path.relative(home, cwd) : cwd; parts.push(theme.fg("dim", dir)); // 2. S / W usage bars + C bar — joined as one |-separated block const usageRaw = statuses.get("usage-bars"); const contextUsage = ctx?.getContextUsage?.(); { let block: string; if (usageSession !== null && usageWeekly !== null) { // Build S/W bars directly from stored event data so we can cleanly // append the dynamic countdown without trying to parse ANSI strings. const session = Math.max(0, Math.min(100, Math.round(usageSession))); const weekly = Math.max(0, Math.min(100, Math.round(usageWeekly))); let sPart = theme.fg("muted", "\uF4F5 S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`); let wPart = theme.fg("muted", "\uF4F5 W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`); if (sessionResetsAt !== null) { const msLeft = sessionResetsAt - Date.now(); if (msLeft > 0) sPart += " " + theme.fg("dim", formatDurationMs(msLeft)); } if (weeklyResetsAt !== null) { const msLeft = weeklyResetsAt - Date.now(); if (msLeft > 0) wPart += " " + theme.fg("dim", `\u27F3 ${formatDurationMs(msLeft)}`); } block = sPart + pipeSep + wPart; } else { // Fallback to raw status for loading / error states block = usageRaw ?? ""; } if (contextUsage && contextUsage.percent !== null) { const pct = Math.round(contextUsage.percent); const chatStatus = statuses.get("chat-claude"); const isChatActive = !!chatStatus && chatStatus.includes("Claude"); // When chat is active and context is high, show warning indicator let cLabel = "C"; let cColor = "muted"; if (isChatActive && pct >= 70) { cLabel = pct >= 90 ? "C⚠" : "C⚡"; cColor = pct >= 90 ? "error" : "warning"; } const cBar = theme.fg(cColor, cLabel + " ") + renderBrailleBar(theme, pct) + " " + theme.fg(pct >= 70 && isChatActive ? "warning" : "dim", `${pct}%`); block = block ? block + pipeSep + cBar : cBar; } if (block) parts.push(block); } // 3. Model short name const modelId = ctx?.model?.id; if (modelId) parts.push(theme.fg("dim", getModelShortName(modelId))); // 4. LSP — strip "LSP" prefix and activity dot const lspRaw = statuses.get("lsp"); if (lspRaw) { const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim(); if (clean) parts.push(theme.fg("dim", clean)); } // 5. MCP — strip " servers" suffix const mcpRaw = statuses.get("mcp"); if (mcpRaw) { const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim(); if (clean) parts.push(theme.fg("dim", clean)); } // 6. Active Claude chat session const chatRaw = statuses.get("chat-claude"); if (chatRaw) { parts.push(theme.fg("accent", stripAnsi(chatRaw).trim())); } return parts.join(sep); } // --------------------------------------------------------------------------- // Footer installation // --------------------------------------------------------------------------- function installFooter(_ctx: any) { if (!_ctx?.hasUI) return; _ctx.ui.setFooter((_tui: any, theme: any, footerData: any) => { tuiRef = _tui; footerDataRef = footerData; const unsub = footerData.onBranchChange(() => _tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { return [truncateToWidth(buildFooterLine(theme) || "", width)]; }, }; }); } // --------------------------------------------------------------------------- // Event handlers // --------------------------------------------------------------------------- pi.on("session_start", (_event, _ctx) => { ctx = _ctx; installFooter(_ctx); }); pi.on("session_shutdown", (_event, _ctx) => { if (_ctx?.hasUI) _ctx.ui.setFooter(undefined); }); // Re-render after turns so context usage stays current pi.on("turn_end", (_event, _ctx) => { ctx = _ctx; if (tuiRef) tuiRef.requestRender(); }); // Re-render when model changes (updates model name in footer) pi.on("model_select", (_event, _ctx) => { ctx = _ctx; if (tuiRef) tuiRef.requestRender(); }); // Listen for usage updates — store raw values so we can build bars + dynamic // countdown directly rather than parsing the ANSI status string from usage-bars. pi.events.on("usage:update", (data: any) => { if (data.session !== undefined) usageSession = data.session; if (data.weekly !== undefined) usageWeekly = data.weekly; if (data.sessionResetsAt !== undefined) sessionResetsAt = data.sessionResetsAt; if (data.weeklyResetsAt !== undefined) weeklyResetsAt = data.weeklyResetsAt; if (tuiRef) tuiRef.requestRender(); }); }