/** * Statusline + session-name badge. * * Footer (statusline): [· subscription] · · , with * () right-aligned on the far right. * Input bar border: session name rendered as a solid-background badge, * shown only when a name is set (via /name or * pi.setSessionName()). * * This extension owns only the statusline and session-name badge. Canvas * navigation and interactive chrome live in crouter's attach viewer. */ import { execFile } from "node:child_process"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { CustomEditor, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; function lastDir(cwd: string): string { const trimmed = cwd.replace(/\/+$/, ""); const base = trimmed.split("/").pop(); return base && base.length > 0 ? base : "/"; } function fmtTokens(n: number): string { return n < 1000 ? `${n}` : `${Math.round(n / 1000)}k`; } interface GitStatus { staged: number; modified: number; untracked: number; conflicts: number; ahead: number; behind: number; stashed: number; } /** Run `git status --porcelain=v1 --branch` and parse a summary. */ function loadGitStatus(cwd: string): Promise { return new Promise((resolve) => { execFile( "git", ["status", "--porcelain=v1", "--branch", "--untracked-files=all"], { cwd, timeout: 2000, windowsHide: true }, (err, stdout) => { if (err) return resolve(null); const s: GitStatus = { staged: 0, modified: 0, untracked: 0, conflicts: 0, ahead: 0, behind: 0, stashed: 0, }; for (const line of stdout.split("\n")) { if (line.startsWith("## ")) { const ahead = line.match(/ahead (\d+)/); const behind = line.match(/behind (\d+)/); if (ahead) s.ahead = Number(ahead[1]); if (behind) s.behind = Number(behind[1]); continue; } if (line.length < 2) continue; const x = line[0]; const y = line[1]; if (x === "?" && y === "?") { s.untracked++; } else if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) { s.conflicts++; } else { if (x !== " " && x !== "?") s.staged++; if (y !== " " && y !== "?") s.modified++; } } resolve(s); }, ); }); } /** Render git status using standard prompt symbols. */ function fmtGitStatus(s: GitStatus | null): string { if (!s) return ""; const parts: string[] = []; if (s.ahead) parts.push(`⇡${s.ahead}`); // ⇡ ahead if (s.behind) parts.push(`⇣${s.behind}`); // ⇣ behind if (s.conflicts) parts.push(`×${s.conflicts}`); // × conflicts if (s.staged) parts.push(`+${s.staged}`); // + staged if (s.modified) parts.push(`!${s.modified}`); // ! modified if (s.untracked) parts.push(`?${s.untracked}`); // ? untracked if (s.stashed) parts.push(`$${s.stashed}`); // $ stashed if (parts.length === 0) return "✓"; // ✓ clean return parts.join(" "); } function formatTokens(ctx: Pick): string { const usage = ctx.getContextUsage(); if (!usage || usage.tokens == null) return "0 tokens"; return `${fmtTokens(usage.tokens)} tokens`; } interface FooterDataLike { // pi's provider reports "no branch" as null; keep undefined too so tests can pass a // bare stub. getGitBranch(): string | null | undefined; getExtensionStatuses(): ReadonlyMap; } export function buildStatuslineFooterLine( width: number, ctx: Pick, footerData: FooterDataLike, gitStatus: GitStatus | null, ): string { if (width <= 0) return ""; const cwd = lastDir(ctx.cwd); const branch = footerData.getGitBranch(); const status = fmtGitStatus(gitStatus); const gitStr = branch ? status ? `${branch} ${status}` : branch : ""; const cwdStr = gitStr ? `${cwd} (${gitStr})` : cwd; const model = ctx.model?.id ?? "no model"; const subscription = footerData.getExtensionStatuses().get("provider-rotation")?.trim() ?? ""; const tokens = formatTokens(ctx); const cyclesEnv = process.env["CRTR_CYCLES"]?.trim(); const cycleCount = cyclesEnv ? Number.parseInt(cyclesEnv, 10) : Number.NaN; const cycle = Number.isFinite(cycleCount) ? `↻${cycleCount}` : ""; const leftParts = [model]; if (subscription !== "") leftParts.push(subscription); leftParts.push(tokens); if (cycle !== "") leftParts.push(cycle); const leftRaw = leftParts.join(" · "); let rightRaw = cwdStr; let rightWidth = visibleWidth(rightRaw); if (rightWidth > width) { const cwdWidth = visibleWidth(cwd); if (cwdWidth < width) { rightRaw = cwd; rightWidth = cwdWidth; } else { return truncateToWidth(cwd, width); } } if (rightWidth === width) return rightRaw; const leftLimit = Math.max(0, width - rightWidth - 1); const leftRawClamped = truncateToWidth(leftRaw, leftLimit); const leftWidth = visibleWidth(leftRawClamped); const padWidth = Math.max(1, width - leftWidth - rightWidth); return `${leftRawClamped}${" ".repeat(padWidth)}${rightRaw}`; } /** Insert `badge` into a single border line at the left, after one corner cell. */ function borderWithBadge( width: number, badge: string, border: (text: string) => string, ): string { if (width <= 0) return ""; const badgeWidth = visibleWidth(badge); // 1 leading border cell + badge + fill out to width. if (badgeWidth + 1 >= width) { return border("─") + truncateToWidth(badge, Math.max(0, width - 1), ""); } const fill = border("─".repeat(width - 1 - badgeWidth)); return `${border("─")}${badge}${fill}`; } export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { // --- Statusline (footer): model · [subscription] · tokens · cycle, with cwd/git right-aligned --- ctx.ui.setFooter((tui, theme, footerData) => { let gitStatus: GitStatus | null = null; const refreshStatus = () => { if (!footerData.getGitBranch()) { if (gitStatus) { gitStatus = null; tui.requestRender(); } return; } void loadGitStatus(ctx.cwd).then((next) => { if (JSON.stringify(next) !== JSON.stringify(gitStatus)) { gitStatus = next; tui.requestRender(); } }); }; refreshStatus(); const unsubBranch = footerData.onBranchChange(() => { tui.requestRender(); refreshStatus(); }); const timer = setInterval(refreshStatus, 4000); return { dispose() { unsubBranch(); clearInterval(timer); }, invalidate() {}, render(width: number): string[] { return [theme.fg("dim", buildStatuslineFooterLine(width, ctx, footerData, gitStatus))]; }, }; }); // --- Input bar border: session-name badge with solid background --- // Compose with whatever editor is already installed (e.g. the mode-switch // badge on the bottom border) instead of replacing it: wrap the previous // factory in a Proxy that overrides only render (session-name badge on the // top border). Other editor decorations keep working regardless of load // order. const previousEditorFactory = ctx.ui.getEditorComponent(); ctx.ui.setEditorComponent((tui, theme, keybindings) => { const base = ( previousEditorFactory ? previousEditorFactory(tui, theme, keybindings) : new CustomEditor(tui, theme, keybindings) ) as unknown as CustomEditor; const render = (width: number): string[] => { const lines = base.render(width); const name = pi.getSessionName(); if (!name || lines.length === 0) return lines; const thm = ctx.ui.theme; const badge = thm.bg("selectedBg", thm.fg("text", ` ${name} `)); const borderColor = (text: string) => base.borderColor(text); lines[0] = borderWithBadge(width, badge, borderColor); return lines; }; return new Proxy(base, { get(target, prop) { if (prop === "render") return render; const value = Reflect.get(target, prop, target); return typeof value === "function" ? value.bind(target) : value; }, }); }); }); }