/** * sl0p Footer — dense, single-line replacement for pi's built-in footer. * * pi's native footer is 2–3 dim lines (pwd / stats / extension-status). This * replaces it entirely (ctx.ui.setFooter) with ONE dense line plus a closing * bottom border, so it looks ATTACHED to the input box above (input-frame.ts * ends in a ├──┤ divider instead of a closing corner): * * ├─────────────────────────────────────┤ ← input box divider * ↑12k ↓3.4k 38%/200k ~/dev/pi-ext (main) glm-4.6 high * ╰─────────────────────────────────────╯ * * No side rails: input-frame.ts dropped them (the zellij pane borders itself), * and this line has to match it or the seam looks broken. * * The frame uses `borderMuted` (matches the input box at rest; the input box * border shifts with thinking/bash mode, so the seam only diverges in color * there — intentionally not synced, to keep the two components decoupled). * * Metrics sit on the left (most important, kept when narrow), the path/branch * cluster is truncated first, and model + thinking effort stay right-aligned. * * Data sources (all live — the extension context uses call-time getters): * - token totals: ctx.sessionManager.getEntries() (cumulative, matches native) * - context %: ctx.getContextUsage() * - model: ctx.model * - thinking: last thinking_level_change on the branch, else model.reasoning * - git branch: footerData.getGitBranch() * * Note: this intentionally drops the native extension-status line (setStatus) * to stay single-line. Say the word and I'll fold statuses in (truncatable). */ import type { AssistantMessage, Usage } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; // ── sl0p palette (truecolor) ──────────────────────────────────────────────── const C = { cyan: "#2ed6ff", // input ↑ green: "#7ee787", // output ↓ / low context violet: "#b05cff", // cache R/W, thinking, unknown context amber: "#ff8a1e", // cost, mid context pink: "#ff2e88", // model red: "#ff5c5c", // high context branch: "#8ac6a0", // git branch }; // Current thinking effort, tracked so the footer frame color matches the input // box border (which pi recolors by thinking level). Updated via the same // thinking_level_select event that drives the input box, seeded on apply. let thinkLevel = "off"; function fg(hex: string, s: string): string { const h = hex.replace("#", ""); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`; } function fmtTok(n: number): string { if (n < 1000) return String(n); if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0) + "k"; return (n / 1_000_000).toFixed(1) + "M"; } function prettyPath(p: string): string { const home = process.env.HOME || process.env.USERPROFILE; return home && p.startsWith(home) ? "~" + p.slice(home.length) : p; } function addUsage(t: Usage, u: Usage | undefined): void { if (!u) return; t.input += u.input; t.output += u.output; t.cacheRead += u.cacheRead; t.cacheWrite += u.cacheWrite; t.cost.total += u.cost.total; } // Current thinking level = last thinking_level_change on the active branch, // falling back to the model's default reasoning effort (undefined if the model // doesn't support reasoning at all). function currentThinking(ctx: any): string | undefined { const branch = ctx.sessionManager.getBranch(); for (let i = branch.length - 1; i >= 0; i--) { if (branch[i].type === "thinking_level_change") return branch[i].thinkingLevel; } return ctx.model?.reasoning; } function renderFooter(width: number, ctx: any, theme: any, footerData: any): string[] { const dim = (s: string) => theme.fg("dim", s); // ── token burn (cumulative over all entries, like the native footer) ── const t: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } as Usage; for (const e of ctx.sessionManager.getEntries() as any[]) { if (e.type === "message" && e.message.role === "assistant") addUsage(t, (e.message as AssistantMessage).usage); else if (e.type === "message" && e.message.role === "toolResult" && e.message.usage) addUsage(t, e.message.usage); else if ((e.type === "branch_summary" || e.type === "compaction") && e.usage) addUsage(t, e.usage); } const tok: string[] = [ fg(C.cyan, "↑") + dim(fmtTok(t.input)), fg(C.green, "↓") + dim(fmtTok(t.output)), ]; // ── context usage ── const cu = ctx.getContextUsage?.(); const pct: number | null = cu?.percent ?? null; const win = fmtTok(cu?.contextWindow ?? ctx.model?.contextWindow ?? 0); const ctxColor = pct == null ? C.violet : pct > 90 ? C.red : pct > 70 ? C.amber : C.green; const ctxSeg = fg(ctxColor, (pct == null ? "?" : pct.toFixed(0)) + "%") + dim("/" + win); // ── path + branch ── const branch = footerData?.getGitBranch?.(); const pathSeg = dim(prettyPath(ctx.sessionManager.getCwd())) + (branch ? " " + dim("(") + fg(C.branch, branch) + dim(")") : ""); // ── model + thinking effort (right-aligned) ── const model = ctx.model?.id || "no-model"; const supportsThinking = !!ctx.model?.reasoning; // gate: does this model reason? const thinkSeg = !supportsThinking ? "" : thinkLevel === "off" ? dim("off") : fg(C.violet, thinkLevel); const right = fg(C.pink, model) + (thinkSeg ? " " + thinkSeg : ""); const left = [tok.join(" "), ctxSeg, pathSeg].join(" "); // Compose the dense line at the INNER width, metrics left / model right; // truncate the left (path first) if tight. const inner = Math.max(1, width - 4); // 2-col indent + 2-col right margin const lw = visibleWidth(left); const rw = visibleWidth(right); let content: string; if (lw + 2 + rw <= inner) { content = left + " ".repeat(inner - lw - rw) + right; } else { const avail = Math.max(0, inner - rw - 1); const leftT = truncateToWidth(left, avail, dim("…")); const pad = Math.max(1, inner - visibleWidth(leftT) - rw); content = truncateToWidth(leftT + " ".repeat(pad) + right, inner); } // Indent to the box interior + close with the bottom border. Color the border // with the SAME thinking-level color the input box uses, so the seam under the // divider matches (falls back to borderMuted if the theme lacks the helper). const border: (s: string) => string = typeof (theme as any).getThinkingBorderColor === "function" ? (theme as any).getThinkingBorderColor(thinkLevel || "off") : (s: string) => theme.fg("borderMuted", s); // Straight end caps to match the rest of the chrome (no side rails → nothing // for a corner to turn into). Use ╰ / ╯ here to restore the rounded box. const bottom = border("─".repeat(Math.max(0, width))); return [` ${content}`, bottom]; } export default function (pi: ExtensionAPI) { let appliedCtx: any; let tuiRef: any; const apply = (ctx: any) => { if (appliedCtx === ctx) { tuiRef?.requestRender?.(); return; } appliedCtx = ctx; ctx.ui.setFooter((tui: any, theme: any, footerData: any) => { tuiRef = tui; const unsub = footerData.onBranchChange(() => tui.requestRender()); return { dispose: unsub, invalidate() {}, render: (width: number) => renderFooter(width, ctx, theme, footerData), }; }); }; // Apply on session start (fresh/resumed/reloaded) and on the first input // (covers a mid-session /reload where session_start doesn't re-fire). Model/ // thinking changes reuse the same ctx, so they just nudge a repaint (render // reads live data). All are idempotent via the appliedCtx guard. pi.on("session_start", (_e, ctx) => { thinkLevel = currentThinking(ctx) ?? "off"; apply(ctx); }); pi.on("input", (_e, ctx) => apply(ctx)); pi.on("model_select", (_e, ctx) => { thinkLevel = currentThinking(ctx) ?? "off"; // model default effort may change apply(ctx); }); pi.on("thinking_level_select", (e, ctx) => { thinkLevel = (e as any)?.level ?? thinkLevel; // authoritative live level apply(ctx); }); pi.on("session_shutdown", () => { try { appliedCtx?.ui.setFooter(undefined); } catch { /* ignore */ } }); }