/** * Pi status bar (custom footer) * * Replaces pi's default footer via `ctx.ui.setFooter()`. It mirrors the layout * and *meaning* of the Claude Code status line (`~/.config/claude/statusline.py`), * but swaps the emoji for Nerd Font glyphs โ€” the same Material Design set already * used across tmux (waybar cpu/mem/net glyphs) and nvim โ€” so it reads natively in * kitty (CaskaydiaCove Nerd Font Mono). Colors come from the active pi theme * (catppuccin-mocha), not hard-coded ANSI. * * Segments render left โ†’ right and truncate at the terminal edge, so the most * useful info stays visible on a narrow pane. Each icon echoes the Claude emoji * it replaces: * * ๓ฐ‰‹ dir cwd, ~-collapsed (Claude ๐Ÿ“) U+F024B nf-md-folder * ๓ฐ˜ฌ git current branch (only inside a repo) (Claude ๐ŸŒฟ) U+F062C nf-md-source_branch * ๓ฐšฉ model model id + ยท thinking level (Claude ๐Ÿค–) U+F06A9 nf-md-robot * ๓ฐˆš context % of context window used (+ tokens) (Claude ๐Ÿ“) U+F021A nf-md-text_box * ๓ฐ“… t/s last response's decode throughput U+F04C5 nf-md-speedometer * (llama.cpp's own server-side number + dim ` pp N` prompt-processing * speed when the final SSE chunk carries `timings`; otherwise the * client-side clock -- see "server-side timings" below) * โ†‘ โ†“ tokens session prompt / output tokens (Claude ๐Ÿ”ข) plain arrows * (prompt = input + cache read + cache write: everything the server * processed, matching the Claude bar โ€” llama.cpp reports a cached * prefix as cacheRead, so bare usage.input is a few dozen tokens) * * git, context, t/s and tokens only appear once there is data for them (i.e. after * the first response, and git only inside a git repo). Extension statuses (e.g. * plan-mode's "โธ plan") are preserved at the far left. The Claude bar's system row * (RAM/CPU/temp/disk) is intentionally omitted โ€” that data isn't in the footer API, * and the tmux bar below pi already shows cpu/mem/net. * * Two extras beyond the footer line: * - a themed pulse working-indicator (the spinner shown while pi streams), and * - a ๓ฐ€ช context-budget warning widget above the editor once the window passes * 80% full, nudging a /compact before things get truncated. * * Restore the built-in footer (and reset the indicator/widget) with `/statusbar`. */ import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { truncateToWidth } from "@earendil-works/pi-tui"; // Nerd Font (Material Design) glyphs โ€” verified to render in the user's font. const ICON = { dir: "\u{F024B}", // ๓ฐ‰‹ nf-md-folder git: "\u{F062C}", // ๓ฐ˜ฌ nf-md-source_branch model: "\u{F06A9}", // ๓ฐšฉ nf-md-robot context: "\u{F021A}", // ๓ฐˆš nf-md-text_box tps: "\u{F04C5}", // ๓ฐ“… nf-md-speedometer warn: "\u{F002A}", // ๓ฐ€ช nf-md-alert_outline (same glyph nvim uses for warnings) up: "โ†‘", // โ†‘ down: "โ†“", // โ†“ }; // Widget key for the context-budget warning shown above the editor. const CTX_WIDGET = "statusbar-context-warning"; // Show the warning once the context window is this full (%). const CTX_WARN_AT = 80; // Current thinking level, tracked via thinking_level_select (footer render has no // direct access to it). Defaults to the settings.json default of "off". let thinkingLevel = "off"; // Decode throughput (tokens/sec) of the most recent assistant response. Preferred // source: the server's own timings (below). Fallback: a client-side clock from the // first streamed token to message end, so prompt-eval time is excluded. let lastTps = 0; let lastPp = 0; // prompt-processing t/s, server-side only (0 = unknown) let genFirstTokenAt = 0; let sawToken = false; // --- server-side timings (llama.cpp) ---------------------------------------- // llama.cpp puts a `timings` object on the final SSE chunk (the one carrying `usage`): // prompt_n / prompt_ms / prompt_per_second, predicted_n / predicted_ms / // predicted_per_second, cache_n, draft_n / draft_n_accepted. pi-ai's openai-completions // provider reads only `usage` and drops the rest, and the extension API has no HTTP // hook, so the one way to see it is to wrap globalThis.fetch and tee the SSE body. // That is sanctioned: pi's http-dispatcher installs undici's fetch at startup and // deliberately keeps a later override ("If a caller replaced fetch after module load, // preserve that deliberate override"), and pi-ai builds `new OpenAI({ fetch: undefined })` // per request, which falls back to globalThis.fetch -- so a wrapper installed at // session_start sees every chat completion. Only POST */chat/completions responses // with a text/event-stream body are teed; other providers' streams carry no `timings` // and are passed through untouched apart from the tee. The tap stays installed for the // life of the process (`/statusbar` off just stops displaying it; `/reload` rebinds the // sink instead of wrapping twice). interface ServerTimings { tps: number; // predicted_per_second pp: number; // prompt_per_second predictedN: number; promptN: number; cacheN: number; } // Latest timings seen on the wire, consumed by message_end. Paired with the message by // predicted_n === usage.output, so a compaction/summary call's timings never get // attributed to the next visible response. let pendingServerTimings: ServerTimings | undefined; const FETCH_TAP_KEY = "__piStatusbarFetchTap"; /** Read one branch of a teed SSE body and report every `timings` object it carries. */ async function scanSse(stream: ReadableStream, onTimings: (t: ServerTimings) => void): Promise { const reader = stream.getReader(); const dec = new TextDecoder(); let buf = ""; try { for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let nl: number; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line.startsWith("data:")) continue; const payload = line.slice(5).trim(); if (!payload.includes('"timings"')) continue; // cheap pre-filter: parse only the final chunk try { const t = JSON.parse(payload)?.timings; if (t && typeof t.predicted_per_second === "number") { onTimings({ tps: t.predicted_per_second, pp: typeof t.prompt_per_second === "number" ? t.prompt_per_second : 0, predictedN: t.predicted_n ?? 0, promptN: t.prompt_n ?? 0, cacheN: t.cache_n ?? 0, }); } } catch { // not JSON: ignore } } } } catch { // aborted / cancelled stream: the primary branch reports that, we just stop } } /** Wrap globalThis.fetch once; on /reload only rebind the sink. */ function installFetchTap(): void { const g = globalThis as unknown as Record; const sink = (t: ServerTimings) => { pendingServerTimings = t; }; const existing = g[FETCH_TAP_KEY] as { sink: (t: ServerTimings) => void } | undefined; if (existing) { existing.sink = sink; return; } const tap = { sink }; g[FETCH_TAP_KEY] = tap; const orig = globalThis.fetch; globalThis.fetch = (async (input: Parameters[0], init?: Parameters[1]) => { const res = await orig(input, init); let url = ""; try { url = typeof input === "string" ? input : input instanceof URL ? input.href : ((input as Request)?.url ?? ""); } catch { return res; } if (!url.endsWith("/chat/completions") || !res.body) return res; if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) return res; const [ours, theirs] = res.body.tee(); void scanSse(ours, (t) => tap.sink(t)); return new Response(theirs, res); }) as typeof fetch; } // Set from inside the footer factory so events can trigger a re-render. let requestRender: (() => void) | undefined; function collapseHome(p: string): string { const home = process.env.HOME || ""; if (p === home) return "~"; if (home && p.startsWith(`${home}/`)) return `~${p.slice(home.length)}`; return p; } /** 1234 -> 1.2k, 843000 -> 843k, 1234567 -> 1.2M (same boundaries as the Claude bar's * fmt_tokens: 0.9995 / 9.95 so a value never prints as "10.0k" or "1000k"). */ function fmtTokens(n: number): string { for (const [unit, div] of [ ["M", 1e6], ["k", 1e3], ] as const) { const v = n / div; if (v < 0.9995) continue; return v >= 9.95 ? `${v.toFixed(0)}${unit}` : `${v.toFixed(1)}${unit}`; } return `${Math.round(n)}`; } /** Cumulative session tokens, plus the last request's prompt size (~ current * context-window fill). * * Prompt = input + cacheRead + cacheWrite. pi-ai splits a cached prefix out of * `usage.input` (llama.cpp too: the second call of a session reports e.g. input 20, * cacheRead 2417), so summing bare `input` undercounted โ†‘ and -- worse -- made the * context % and the โ‰ฅ80% warning read near zero on every cached call. */ function tokenStats(ctx: ExtensionContext): { input: number; output: number; lastInput: number; } { let input = 0; let output = 0; let lastInput = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "assistant") { const usage = (entry.message as AssistantMessage).usage; if (!usage) continue; // aborted stream: no usage โ€” a throwing render kills the TUI const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); input += prompt; output += usage.output ?? 0; lastInput = prompt; } } return { input, output, lastInput }; } /** Show/hide the above-editor warning when the context window is nearly full. */ function updateContextWidget(ctx: ExtensionContext): void { if (ctx.mode !== "tui") return; const contextWindow = ctx.model?.contextWindow; const { lastInput } = tokenStats(ctx); if (!contextWindow || lastInput <= 0) { ctx.ui.setWidget(CTX_WIDGET, undefined); return; } const pct = (lastInput / contextWindow) * 100; if (pct < CTX_WARN_AT) { ctx.ui.setWidget(CTX_WIDGET, undefined); return; } const th = ctx.ui.theme; const col = pct >= 90 ? "error" : "warning"; const line = th.fg(col, `${ICON.warn} context ${pct.toFixed(0)}%`) + th.fg("dim", ` (${fmtTokens(lastInput)}/${fmtTokens(contextWindow)}) โ€” /compact soon`); ctx.ui.setWidget(CTX_WIDGET, [line]); } /** A gentle catppuccin "breathing" pulse for the streaming working-indicator. */ function pulseIndicator(ctx: ExtensionContext) { const th = ctx.ui.theme; return { frames: [th.fg("dim", "ยท"), th.fg("muted", "โ€ข"), th.fg("accent", "โ—"), th.fg("muted", "โ€ข")], intervalMs: 120, }; } export default function (pi: ExtensionAPI) { let enabled = true; pi.on("thinking_level_select", async (event) => { thinkingLevel = event.level; requestRender?.(); }); pi.on("model_select", async () => { requestRender?.(); }); // --- t/s timing: bracket each assistant response and clock its generation --- pi.on("message_start", async (event) => { if (event.message?.role !== "assistant") return; sawToken = false; genFirstTokenAt = 0; }); pi.on("message_update", async (event) => { if (event.message?.role !== "assistant") return; if (!sawToken) { sawToken = true; genFirstTokenAt = Date.now(); } }); pi.on("message_end", async (event, ctx) => { if (event.message?.role !== "assistant") return; const output = (event.message as AssistantMessage).usage?.output ?? 0; // The tee reader and pi-ai consume the same final chunk; pi-ai's path to this // event is longer, but give the reader one macrotask in case it is still behind. if (!pendingServerTimings) await new Promise((r) => setTimeout(r, 20)); const srv = pendingServerTimings; pendingServerTimings = undefined; if (srv && srv.predictedN > 0 && (output === 0 || srv.predictedN === output)) { lastTps = srv.tps; lastPp = srv.pp; } else { const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0; if (secs > 0 && output > 0) lastTps = output / secs; lastPp = 0; } requestRender?.(); if (enabled) updateContextWidget(ctx); }); const install = (ctx: ExtensionContext) => { if (ctx.mode !== "tui") return; ctx.ui.setWorkingIndicator(pulseIndicator(ctx)); updateContextWidget(ctx); ctx.ui.setFooter((tui, theme, footerData) => { const sep = theme.fg("dim", " โ”‚ "); requestRender = () => tui.requestRender(); const unsub = footerData.onBranchChange(() => tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { const parts: string[] = []; // Preserve extension statuses (plan-mode "โธ plan", etc.) up front. for (const status of footerData.getExtensionStatuses().values()) { if (status) parts.push(status); } // ๓ฐ‰‹ directory parts.push(theme.fg("accent", ICON.dir) + " " + theme.fg("text", collapseHome(ctx.cwd))); // ๓ฐ˜ฌ git branch (only inside a repo) const branch = footerData.getGitBranch(); if (branch) parts.push(theme.fg("accent", ICON.git) + " " + theme.fg("success", branch)); // ๓ฐšฉ model (+ ยท thinking level) if (ctx.model?.id) { let model = theme.fg("accent", ICON.model) + " " + theme.fg("text", ctx.model.id); if (thinkingLevel && thinkingLevel !== "off") { model += theme.fg("dim", ` ยท ${thinkingLevel}`); } parts.push(model); } // ๓ฐˆš context window usage (% when the window is known, else raw tokens) const { input, output, lastInput } = tokenStats(ctx); if (lastInput > 0) { const contextWindow = ctx.model?.contextWindow; let seg = theme.fg("accent", ICON.context) + " "; if (contextWindow) { const pct = Math.min(100, (lastInput / contextWindow) * 100); const col = pct < 50 ? "success" : pct < 80 ? "warning" : "error"; seg += theme.fg(col, `${pct.toFixed(0)}%`) + theme.fg("dim", ` (${fmtTokens(lastInput)})`); } else { seg += theme.fg("text", fmtTokens(lastInput)); } parts.push(seg); } // ๓ฐ“… tokens/sec of the last response (+ dim prompt-processing t/s when the // server reported it) if (lastTps > 0) { let seg = theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s"); if (lastPp > 0) seg += theme.fg("dim", ` pp ${lastPp.toFixed(0)}`); parts.push(seg); } // โ†‘ โ†“ cumulative session prompt / output tokens (Claude's ๐Ÿ”ข segment) if (input || output) { parts.push(theme.fg("dim", `${ICON.up}${fmtTokens(input)} ${ICON.down}${fmtTokens(output)}`)); } return [truncateToWidth(parts.join(sep), width)]; }, }; }); }; pi.on("session_start", async (_event, ctx) => { // Installed here, not at load time: pi's http-dispatcher only swaps in undici's // fetch (idle timeout, proxy) while globalThis.fetch is still the original, so the // tap must not get there first. By session_start, startup is done. installFetchTap(); if (enabled) install(ctx); }); pi.registerCommand("statusbar", { description: "Toggle the custom pi status bar footer", handler: async (_args, ctx) => { enabled = !enabled; if (enabled) { install(ctx); ctx.ui.notify("Custom status bar enabled", "info"); } else { ctx.ui.setFooter(undefined); ctx.ui.setWorkingIndicator(); // restore pi's default spinner ctx.ui.setWidget(CTX_WIDGET, undefined); ctx.ui.notify("Default footer restored", "info"); } }, }); }