/** XTRM footer: git/model context plus one compact cached beads status line. * * xtrm-64pl0: the footer is a pure cache READER. It renders path/branch, context/model, * and a single compact beads line produced by `formatCompact` from the on-disk cache * (.xtrm/cache/beads-status.json). It never spawns bd, never refreshes the cache via a * subprocess, and exposes no expandable tree/toggle. The cache is regenerated by beads * hooks elsewhere; stale/unavailable is acceptable and rendered as-is. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { SubprocessRunner } from "../../src/core"; type CacheIssue = { id: string; title: string | null; status: string; parent?: string }; type BeadsCache = { v: number; ts: number; stale: boolean; counts: { open: number; in_progress: number; blocked: number }; activeIssues: CacheIssue[]; activeEpic: { id: string; title: string | null; closed: number; total: number } | null; }; type CacheModule = { resolveMainRoot(cwd: string): string; readCache(mainRoot: string): BeadsCache | null; formatCompact(data: BeadsCache | null, options: { cols: number; color?: boolean }): string; }; const CACHE_MODULE = ".xtrm/hooks/beads-status-cache.mjs"; const CACHE_TTL = 5000; const REFRESH_TIMEOUT_MS = 2000; const TOOL_RESULT_REFRESH_DELAY_MS = 200; const FOOTER_REAPPLY_DELAY_MS = 40; function formatTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; if (count < 1_000_000) return `${Math.round(count / 1000)}k`; return count < 10_000_000 ? `${(count / 1_000_000).toFixed(1)}M` : `${Math.round(count / 1_000_000)}M`; } function parseGitFlags(porcelain: string): string { let modified = false; let staged = false; let deleted = false; for (const line of porcelain.split("\n").filter(Boolean)) { if (/^ M|^AM|^MM/.test(line)) modified = true; if (/^A |^M /.test(line)) staged = true; if (/^ D|^D /.test(line)) deleted = true; } return `${modified ? "*" : ""}${staged ? "+" : ""}${deleted ? "-" : ""}`; } export default function registerCustomFooter(pi: ExtensionAPI): void { let capturedCtx: any = null; let cacheModule: CacheModule | null = null; let mainRoot = ""; let beadsCache: BeadsCache | null = null; let requestRender: (() => void) | null = null; let branchChangeUnsub: (() => void) | null = null; let footerReapplyTimer: ReturnType | null = null; let runtimeRefreshTimer: ReturnType | null = null; let cacheSyncTimer: ReturnType | null = null; let refreshingRuntime = false; let runtimeState = { branch: null as string | null, gitStatus: "", lastFetch: 0 }; const cwd = () => capturedCtx?.cwd || process.cwd(); const run = (command: string, args: string[], timeoutMs = REFRESH_TIMEOUT_MS) => SubprocessRunner.run(command, args, { cwd: cwd(), timeoutMs }); const loadCacheModule = async (): Promise => { if (cacheModule) return; const root = await run("git", ["rev-parse", "--show-toplevel"]); const projectRoot = root.code === 0 ? root.stdout.trim() : cwd(); const moduleUrl = pathToFileURL(join(projectRoot, CACHE_MODULE)).href; try { cacheModule = (await import(/* @vite-ignore */ moduleUrl)) as CacheModule; mainRoot = cacheModule.resolveMainRoot(cwd()); beadsCache = cacheModule.readCache(mainRoot); } catch { cacheModule = null; } }; // Re-read the on-disk cache only — no subprocess refresh. Stale/unavailable is fine. const syncCache = (): void => { if (!cacheModule || !mainRoot) return; const next = cacheModule.readCache(mainRoot); if (next?.ts === beadsCache?.ts && next?.stale === beadsCache?.stale) return; beadsCache = next; requestRender?.(); }; const startCacheSync = (): void => { if (cacheSyncTimer) clearInterval(cacheSyncTimer); cacheSyncTimer = setInterval(syncCache, CACHE_TTL); cacheSyncTimer.unref?.(); }; const refreshRuntime = async (): Promise => { if (refreshingRuntime || Date.now() - runtimeState.lastFetch < CACHE_TTL) return; refreshingRuntime = true; try { let branch: string | null = null; let gitStatus = ""; const root = await run("git", ["rev-parse", "--show-toplevel"]); if (root.code === 0 && root.stdout.trim()) { const [branchResult, porcelainResult, abResult] = await Promise.all([ run("git", ["branch", "--show-current"]), run("git", ["--no-optional-locks", "status", "--porcelain"]), run("git", ["--no-optional-locks", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"]), ]); branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null; gitStatus = porcelainResult.code === 0 ? parseGitFlags(porcelainResult.stdout) : ""; if (abResult.code === 0) { const [behind, ahead] = abResult.stdout.trim().split(/\s+/).map(Number); gitStatus += ahead > 0 && behind > 0 ? "↕" : ahead > 0 ? "↑" : behind > 0 ? "↓" : ""; } } runtimeState = { branch, gitStatus, lastFetch: Date.now() }; requestRender?.(); } finally { refreshingRuntime = false; } }; const scheduleRuntimeRefresh = (delayMs = 0): void => { if (runtimeRefreshTimer) return; runtimeRefreshTimer = setTimeout(() => { runtimeRefreshTimer = null; void refreshRuntime(); }, delayMs); }; const applyFooter = (ctx: any): void => { capturedCtx = ctx; ctx.ui.setFooter((tui: any, theme: any, footerData: any) => { const instanceRender = () => tui.requestRender(); requestRender = instanceRender; branchChangeUnsub?.(); const unsubscribe = footerData.onBranchChange(() => { runtimeState.lastFetch = 0; scheduleRuntimeRefresh(); }); branchChangeUnsub = unsubscribe; return { dispose() { unsubscribe?.(); if (branchChangeUnsub === unsubscribe) branchChangeUnsub = null; if (requestRender === instanceRender) requestRender = null; }, invalidate() {}, render(width: number): string[] { let pwd = ctx.cwd || process.cwd(); const home = process.env.HOME || process.env.USERPROFILE; if (home && pwd.startsWith(home)) pwd = `~${pwd.slice(home.length)}`; const branch = runtimeState.branch || footerData.getGitBranch(); if (branch) pwd += ` (${branch}${runtimeState.gitStatus ? ` ${runtimeState.gitStatus}` : ""})`; const lines = [truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."))]; const usage = ctx.getContextUsage(); const model = ctx.model; const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 0; const percentValue = usage?.percent ?? 0; const contextDisplay = usage?.percent == null ? `?/${formatTokens(contextWindow)}` : `${percentValue.toFixed(1)}%/${formatTokens(contextWindow)}`; let modelDisplay = model?.id || "no-model"; if (model?.reasoning) modelDisplay += ` • ${pi.getThinkingLevel() || "off"}`; if (footerData.getAvailableProviderCount() > 1 && model) modelDisplay = `(${model.provider}) ${modelDisplay}`; const available = Math.max(0, width - visibleWidth(contextDisplay) - 1); const usageColor = percentValue > 90 ? "error" : percentValue > 70 ? "warning" : "dim"; lines.push(`${theme.fg(usageColor, contextDisplay)} ${theme.fg("dim", truncateToWidth(modelDisplay, available, ""))}`); const compact = cacheModule?.formatCompact(beadsCache, { cols: width }) ?? "beads unavailable"; lines.push(truncateToWidth(compact, width)); return lines; }, }; }); }; const reapplyFooter = (ctx: any): void => { if (footerReapplyTimer) clearTimeout(footerReapplyTimer); footerReapplyTimer = setTimeout(() => { applyFooter(ctx); footerReapplyTimer = null; }, FOOTER_REAPPLY_DELAY_MS); }; const reset = (): void => { runtimeState.lastFetch = 0; beadsCache = null; cacheModule = null; mainRoot = ""; }; pi.on("session_start", async (_event, ctx) => { capturedCtx = ctx; reset(); await loadCacheModule(); applyFooter(ctx); startCacheSync(); reapplyFooter(ctx); scheduleRuntimeRefresh(); }); pi.on("session_switch", async (_event, ctx) => { capturedCtx = ctx; reset(); await loadCacheModule(); reapplyFooter(ctx); scheduleRuntimeRefresh(); }); pi.on("session_fork", async (_event, ctx) => { capturedCtx = ctx; reset(); await loadCacheModule(); reapplyFooter(ctx); scheduleRuntimeRefresh(); }); pi.on("model_select", async (_event, ctx) => reapplyFooter(ctx)); pi.on("tool_result", async (event: any) => { const command = event?.input?.command; if (!command) return undefined; if (/\bbd\s+(close|update|create|claim|reopen)\b/.test(command)) { // Beads hooks regenerate the on-disk cache; just re-read it (no subprocess). syncCache(); } if (/\bgit\s+/.test(command)) { runtimeState.lastFetch = 0; scheduleRuntimeRefresh(TOOL_RESULT_REFRESH_DELAY_MS); } return undefined; }); pi.on("session_shutdown", async () => { for (const timer of [footerReapplyTimer, runtimeRefreshTimer]) if (timer) clearTimeout(timer); if (cacheSyncTimer) clearInterval(cacheSyncTimer); footerReapplyTimer = runtimeRefreshTimer = null; cacheSyncTimer = null; branchChangeUnsub?.(); branchChangeUnsub = null; requestRender = null; }); }