From ec3951296051e7f8a1ccd3e2a59237da829a4caf Mon Sep 17 00:00:00 2001 From: ace-pm Date: Wed, 15 Apr 2026 16:17:24 +0200 Subject: [PATCH] Add footer renderer for TUI status display. Displays git branch status, dirty state, extension statuses, model info, cost, and context usage percentage in the footer. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/sf-tui/footer.ts | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/resources/extensions/sf-tui/footer.ts diff --git a/src/resources/extensions/sf-tui/footer.ts b/src/resources/extensions/sf-tui/footer.ts new file mode 100644 index 000000000..3a7541ef4 --- /dev/null +++ b/src/resources/extensions/sf-tui/footer.ts @@ -0,0 +1,78 @@ +import type { ExtensionContext, Theme, ReadonlyFooterDataProvider } from "@sf-run/pi-coding-agent"; +import { truncateToWidth } from "@sf-run/pi-tui"; +import { refreshGitStatus } from "./git.js"; +import { rightAlign } from "./shared.js"; + +function getSessionStats(ctx: ExtensionContext) { + let cost = 0; + let cxPct = 0; + let cxColor: "success" | "warning" | "error" | "dim" = "dim"; + + try { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "message") { + const msg = (entry as any).message; + if (msg?.role === "assistant" && msg.usage) { + cost += msg.usage.cost?.total || 0; + } + } + } + const cx = ctx.getContextUsage?.(); + if (cx?.percent != null) { + cxPct = cx.percent; + cxColor = cxPct >= 85 ? "error" : cxPct >= 60 ? "warning" : "success"; + } + } catch { + /* ignore */ + } + + return { cost, cxPct, cxColor }; +} + +export function renderFooter( + theme: Theme, + footerData: ReadonlyFooterDataProvider, + ctx: ExtensionContext, + width: number, +): string[] { + const git = refreshGitStatus(process.cwd()); + const parts: string[] = []; + + if (git.branch) { + const dirty = git.dirty + ? theme.fg("error", "✗") + : git.untracked + ? theme.fg("warning", "?") + : theme.fg("success", "✓"); + const aheadBehind = + git.ahead || git.behind + ? theme.fg("dim", `${git.ahead ? `↑${git.ahead}` : ""}${git.behind ? `↓${git.behind}` : ""}`) + : ""; + parts.push(`${theme.fg("dim", "⎇")} ${git.branch} ${dirty}${aheadBehind ? ` ${aheadBehind}` : ""}`); + } + + const statuses = Array.from(footerData.getExtensionStatuses().entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => text) + .filter(Boolean) + .join(" "); + if (statuses) { + parts.push(theme.fg("dim", statuses)); + } + + const left = parts.join(theme.fg("dim", " │ ")); + + const rightParts: string[] = []; + if (ctx.model) { + rightParts.push(theme.fg("muted", `${ctx.model.provider}/${ctx.model.id}`)); + } + + const { cost, cxPct, cxColor } = getSessionStats(ctx); + if (cost > 0) rightParts.push(theme.fg("warning", `$${cost.toFixed(2)}`)); + rightParts.push(theme.fg(cxColor, `${cxPct.toFixed(0)}%ctx`)); + + const right = rightParts.join(theme.fg("dim", " ")); + + const line = rightAlign(left, right, width); + return [truncateToWidth(line, width, theme.fg("dim", "…"))]; +}