Instantly share code, notes, and snippets.
Created
May 18, 2026 19:21
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save kfr2/4ffc401b51e5fd312212dfb2088edafb to your computer and use it in GitHub Desktop.
A footer for pi.dev which emulates the Pure prompt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Pure Footer Extension | |
| * | |
| * Replicates the Pure zsh prompt as Pi's footer status bar. | |
| * | |
| * Layout: | |
| * /Users/kevin/project main* ≡ ⇡2 5.3s ↑↑12.3k ↓4.5k $0.042 ctx 18%/200k claude-sonnet medium | |
| * | |
| * Colors match Pure exactly: | |
| * path → blue | |
| * branch → gray (242) | |
| * dirty * → pink (218) | |
| * stash ≡ → cyan | |
| * arrows ⇡⇣ → cyan | |
| * exec time → yellow (only shown when ≥ 5s, like Pure) | |
| * tokens/cost/ctx → gray (242) [left-aligned, after exec time] | |
| * model → gray (242) | |
| * effort → gray (242) | |
| */ | |
| import type { AssistantMessage } from "@earendil-works/pi-ai"; | |
| import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; | |
| import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; | |
| import { exec } from "node:child_process"; | |
| import { homedir } from "node:os"; | |
| import { promisify } from "node:util"; | |
| const execAsync = promisify(exec); | |
| // ── Pure color helpers ──────────────────────────────────────────────────────── | |
| const R = "\x1b[0m"; | |
| const col = (code: string, text: string): string => `\x1b[${code}m${text}${R}`; | |
| const pure = { | |
| path: (t: string) => col("34", t), // blue | |
| branch: (t: string) => col("38;5;242", t), // gray 242 | |
| dirty: (t: string) => col("38;5;218", t), // pink 218 | |
| stash: (t: string) => col("36", t), // cyan | |
| arrows: (t: string) => col("36", t), // cyan | |
| execTime: (t: string) => col("33", t), // yellow | |
| prompt: (t: string) => col("35", t), // magenta | |
| model: (t: string) => col("38;5;242", t), // gray 242 | |
| tokens: (t: string) => col("38;5;242", t), // gray 242 | |
| }; | |
| // ── Git status ──────────────────────────────────────────────────────────────── | |
| interface GitStatus { | |
| dirty: boolean; | |
| stash: boolean; | |
| ahead: number; | |
| behind: number; | |
| } | |
| const EMPTY_GIT: GitStatus = { dirty: false, stash: false, ahead: 0, behind: 0 }; | |
| async function fetchGitStatus(cwd: string): Promise<GitStatus> { | |
| try { | |
| const [dirtyOut, stashOut, arrowOut] = await Promise.all([ | |
| execAsync("git status --porcelain", { cwd }).catch(() => ({ stdout: "" })), | |
| execAsync("git stash list", { cwd }).catch(() => ({ stdout: "" })), | |
| execAsync("git rev-list --count --left-right @{upstream}...HEAD", { cwd }).catch(() => ({ stdout: "" })), | |
| ]); | |
| const dirty = dirtyOut.stdout.trim().length > 0; | |
| const stash = stashOut.stdout.trim().length > 0; | |
| const parts = arrowOut.stdout.trim().split("\t"); | |
| const behind = parseInt(parts[0] ?? "0", 10); | |
| const ahead = parseInt(parts[1] ?? "0", 10); | |
| return { | |
| dirty, | |
| stash, | |
| ahead: isNaN(ahead) ? 0 : ahead, | |
| behind: isNaN(behind) ? 0 : behind, | |
| }; | |
| } catch { | |
| return EMPTY_GIT; | |
| } | |
| } | |
| // ── Helpers ─────────────────────────────────────────────────────────────────── | |
| // ── Token / context helpers ───────────────────────────────────────────────── | |
| function fmtTokens(n: number): string { | |
| return n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; | |
| } | |
| function tokenStats(ctx: ExtensionContext): string { | |
| let input = 0, output = 0, cost = 0; | |
| for (const e of ctx.sessionManager.getBranch()) { | |
| if (e.type === "message" && e.message.role === "assistant") { | |
| const m = e.message as AssistantMessage; | |
| input += m.usage.input; | |
| output += m.usage.output; | |
| cost += m.usage.cost.total; | |
| } | |
| } | |
| if (input === 0 && output === 0) return ""; | |
| return `↑${fmtTokens(input)} ↓${fmtTokens(output)} $${cost.toFixed(3)}`; | |
| } | |
| function contextUsage(ctx: ExtensionContext): string { | |
| const usage = ctx.getContextUsage(); | |
| const window = usage?.contextWindow ?? ctx.model?.contextWindow; | |
| if (!window || !usage || usage.percent === null) return ""; | |
| return `ctx ${Math.round(usage.percent)}%/${(window / 1000).toFixed(0)}k`; | |
| } | |
| // ── Path helper ─────────────────────────────────────────────────────────────── | |
| function shortenPath(cwd: string): string { | |
| const home = homedir(); | |
| return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd; | |
| } | |
| function humanTime(seconds: number): string { | |
| const d = Math.floor(seconds / 86400); | |
| const h = Math.floor((seconds % 86400) / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = seconds % 60; | |
| const parts: string[] = []; | |
| if (d > 0) parts.push(`${d}d`); | |
| if (h > 0) parts.push(`${h}h`); | |
| if (m > 0) parts.push(`${m}m`); | |
| parts.push(`${s}s`); | |
| return parts.join(" "); | |
| } | |
| // ── Extension ───────────────────────────────────────────────────────────────── | |
| export default function (pi: ExtensionAPI) { | |
| let turnStartTime: number | null = null; | |
| let lastTurnDuration: number | null = null; | |
| let gitStatus: GitStatus = EMPTY_GIT; | |
| let requestRender: (() => void) | null = null; | |
| pi.on("session_start", async (_event, ctx) => { | |
| // Fetch git status once up front so the footer isn't blank on first render. | |
| gitStatus = await fetchGitStatus(ctx.cwd); | |
| ctx.ui.setFooter((tui, _theme, footerData) => { | |
| requestRender = () => tui.requestRender(); | |
| // Refresh git status (and re-render) whenever the branch changes. | |
| const unsub = footerData.onBranchChange(() => { | |
| fetchGitStatus(ctx.cwd).then((s) => { | |
| gitStatus = s; | |
| tui.requestRender(); | |
| }); | |
| }); | |
| return { | |
| dispose: unsub, | |
| invalidate() {}, | |
| render(width: number): string[] { | |
| // ── Left side ────────────────────────────────────────────────────── | |
| // Path | |
| let left = pure.path(shortenPath(ctx.cwd)); | |
| // Git branch + status | |
| const branch = footerData.getGitBranch(); | |
| if (branch) { | |
| left += " " + pure.branch(branch); | |
| if (gitStatus.dirty) left += pure.dirty("*"); | |
| if (gitStatus.stash) left += " " + pure.stash("≡"); | |
| const arrowParts: string[] = []; | |
| if (gitStatus.ahead > 0) arrowParts.push(`⇡${gitStatus.ahead}`); | |
| if (gitStatus.behind > 0) arrowParts.push(`⇣${gitStatus.behind}`); | |
| if (arrowParts.length) left += " " + pure.arrows(arrowParts.join(" ")); | |
| } | |
| // Execution time — only when ≥ 5s, matching Pure's PURE_CMD_MAX_EXEC_TIME | |
| if (lastTurnDuration !== null && lastTurnDuration >= 5) { | |
| left += " " + pure.execTime(humanTime(lastTurnDuration)); | |
| } | |
| // Token stats + context usage (left-aligned, after exec time) | |
| const stats = tokenStats(ctx); | |
| const ctxPct = contextUsage(ctx); | |
| const statStr = [stats, ctxPct].filter(Boolean).join(" "); | |
| if (statStr) left += " " + pure.tokens(statStr); | |
| // ── Right side ───────────────────────────────────────────────────── | |
| const level = pi.getThinkingLevel(); | |
| const levelStr = level && level !== "off" ? " " + pure.tokens(level) : ""; | |
| const right = pure.model(ctx.model?.id ?? "") + levelStr; | |
| // ── Assemble ─────────────────────────────────────────────────────── | |
| const gap = Math.max(2, width - visibleWidth(left) - visibleWidth(right)); | |
| return [truncateToWidth(left + " ".repeat(gap) + right, width)]; | |
| }, | |
| }; | |
| }); | |
| }); | |
| pi.on("thinking_level_select", async () => { | |
| requestRender?.(); | |
| }); | |
| pi.on("turn_start", async () => { | |
| turnStartTime = Date.now(); | |
| }); | |
| pi.on("turn_end", async (_event, ctx) => { | |
| // Record duration for exec-time display. | |
| if (turnStartTime !== null) { | |
| lastTurnDuration = Math.round((Date.now() - turnStartTime) / 1000); | |
| turnStartTime = null; | |
| } | |
| // Refresh git status — a turn may have committed, merged, pushed, etc. | |
| fetchGitStatus(ctx.cwd).then((s) => { | |
| gitStatus = s; | |
| requestRender?.(); | |
| }); | |
| }); | |
| pi.on("session_shutdown", async () => { | |
| turnStartTime = null; | |
| lastTurnDuration = null; | |
| gitStatus = EMPTY_GIT; | |
| requestRender = null; | |
| }); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment