/* * pi-status-bar * * MIT License * Copyright (c) 2026 Alan Colver * * Theme-aware Pi TUI footer that shows git status and an AI-generated * resume title for the current session. */ import { basename, parse } from "node:path"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; type ContentBlock = { type?: string; text?: string; name?: string; }; type SessionEntry = { type: string; customType?: string; data?: unknown; message?: { role?: string; content?: unknown; }; }; type SummaryEntry = { summary: string; updatedAt: number; entryCount: number; source?: "ai" | "fallback" | "manual"; }; type Shortcut = Parameters[0]; type GitState = { branch: string | null; isWorktree: boolean; worktreeName: string | null; pending: number | null; staged: number | null; unstaged: number | null; untracked: number | null; }; const CUSTOM_TYPE = "pi-status-bar-summary"; const SUMMARY_INTERVAL_MS = 5 * 60 * 1000; const SUMMARY_MIN_ENTRY_DELTA = 2; const GIT_INTERVAL_MS = 15 * 1000; const MAX_CONVERSATION_CHARS = 24_000; const MAX_CONVERSATION_INITIAL_CHARS = 4_000; const MAX_CONVERSATION_RECENT_CHARS = MAX_CONVERSATION_CHARS - MAX_CONVERSATION_INITIAL_CHARS; const MAX_SESSION_NAME_CHARS = 48; const MAX_CWD_NAME_WIDTH = 24; const ANSI_ESCAPE_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g; const options = { manualTitleShortcut: (process.env.PI_STATUS_BAR_TITLE_SHORTCUT ?? "ctrl+shift+r") as Shortcut, }; const defaultGitState: GitState = { branch: null, isWorktree: false, worktreeName: null, pending: null, staged: null, unstaged: null, untracked: null, }; const visibleWidth = (text: string): number => text.replace(ANSI_ESCAPE_PATTERN, "").length; const truncateToWidth = (text: string, maxWidth: number, suffix = "…"): string => { if (maxWidth <= 0) return ""; if (visibleWidth(text) <= maxWidth) return text; const suffixWidth = visibleWidth(suffix); const targetWidth = Math.max(0, maxWidth - suffixWidth); let output = ""; let width = 0; let inEscape = false; let escapeBuffer = ""; for (const char of text) { if (char === "\x1B") { inEscape = true; escapeBuffer = char; continue; } if (inEscape) { escapeBuffer += char; if (/[A-Za-z~]/.test(char)) { output += escapeBuffer; inEscape = false; escapeBuffer = ""; } continue; } if (width + 1 > targetWidth) break; output += char; width += 1; } const reset = ANSI_ESCAPE_PATTERN.test(text) ? "\x1B[0m" : ""; ANSI_ESCAPE_PATTERN.lastIndex = 0; return `${output.trimEnd()}${suffix}${reset}`; }; const extractTextParts = (content: unknown): string[] => { if (typeof content === "string") return [content]; if (!Array.isArray(content)) return []; const textParts: string[] = []; for (const part of content) { if (!part || typeof part !== "object") continue; const block = part as ContentBlock; if (block.type === "text" && typeof block.text === "string") { textParts.push(block.text); } } return textParts; }; const extractToolCallLines = (content: unknown): string[] => { if (!Array.isArray(content)) return []; const toolCalls: string[] = []; for (const part of content) { if (!part || typeof part !== "object") continue; const block = part as ContentBlock; if (block.type !== "toolCall" || typeof block.name !== "string") continue; toolCalls.push(`Assistant used tool: ${block.name}`); } return toolCalls; }; const buildConversationText = (entries: SessionEntry[]): string => { const sections: string[] = []; for (const entry of entries) { if (entry.type !== "message" || !entry.message?.role) continue; const role = entry.message.role; if (role !== "user" && role !== "assistant") continue; const lines: string[] = []; const text = extractTextParts(entry.message.content).join("\n").trim(); if (text) lines.push(`${role === "user" ? "User" : "Assistant"}: ${text}`); if (role === "assistant") lines.push(...extractToolCallLines(entry.message.content)); if (lines.length > 0) sections.push(lines.join("\n")); } const fullText = sections.join("\n\n"); if (fullText.length <= MAX_CONVERSATION_CHARS) return fullText; return [ fullText.slice(0, MAX_CONVERSATION_INITIAL_CHARS).trim(), "\n\n[...middle of conversation omitted...]\n\n", fullText.slice(-MAX_CONVERSATION_RECENT_CHARS).trim(), ].join(""); }; const buildSummaryPrompt = (conversationText: string, currentTitle: string): string => [ "Create an extremely short Pi session title for this conversation.", "Return one brief statement only, no markdown, no bullets.", "Maintain a stable, broad title for the whole session, not just the latest action.", "If the current title still fits the overarching goal, keep it or lightly refine it.", "Do not retitle around transient follow-up actions like commit, push, run tests, inspect logs, or answer a question unless they become the new main goal.", "Example: if the goal is 'Fix failing tests' and the latest user says 'Great, now commit this', prefer 'Fix failing tests' over 'Committing work'.", "Capture the main topic plus meaningful current phase only when it adds context.", "Omit next steps, recommendations, and secondary details.", "Prefer 3-6 words. Hard limit: 48 characters.", "", "", currentTitle || "(none)", "", "", "", conversationText, "", ].join("\n"); const cleanSummary = (text: string): string => { const singleLine = text.replace(/\s+/g, " ").trim().replace(/^['\"]|['\"]$/g, ""); if (singleLine.length <= MAX_SESSION_NAME_CHARS) return singleLine; return `${singleLine.slice(0, MAX_SESSION_NAME_CHARS - 1).trim()}…`; }; const buildFallbackSummary = (conversationText: string, cwd: string): string => { const lastUserLine = conversationText .split("\n") .reverse() .find((line) => line.startsWith("User: ")) ?.replace(/^User:\s*/, "") .replace(/<[^>]+>/g, " ") .replace(/[`*_#>\[\](){}]/g, " ") .trim(); return cleanSummary(lastUserLine || formatCwdName(cwd) || "Pi session") || "Pi session"; }; const loadCompleteSimple = async () => { try { return (await import("@earendil-works/pi-ai/compat")).completeSimple; } catch { return undefined; } }; const countEntries = (entries: SessionEntry[]): number => entries.filter((entry) => entry.type === "message" && entry.message?.role).length; const readLatestSummary = (entries: SessionEntry[]): SummaryEntry | undefined => { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry?.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue; const data = entry.data as Partial | undefined; if (data && typeof data.summary === "string" && typeof data.updatedAt === "number") { return { summary: data.summary, updatedAt: data.updatedAt, entryCount: typeof data.entryCount === "number" ? data.entryCount : 0, source: data.source === "manual" ? "manual" : data.source === "fallback" ? "fallback" : "ai", }; } } return undefined; }; const formatPending = (git: GitState): string => { if (git.pending === null) return "?"; if (git.pending === 0) return "✓"; const parts: string[] = [`±${git.pending}`]; if (git.staged) parts.push(`s${git.staged}`); if (git.unstaged) parts.push(`u${git.unstaged}`); if (git.untracked) parts.push(`n${git.untracked}`); return parts.join(" "); }; const formatCount = (n: number): string => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`); const formatCwdName = (cwd: string): string => { const name = basename(cwd) || parse(cwd).root || cwd || "cwd"; return truncateToWidth(name, MAX_CWD_NAME_WIDTH, "…"); }; const parseWorktreeState = (stdout: string): Pick => { const [insideWorkTree, gitDir, gitCommonDir, worktreeRoot] = stdout .split("\n") .map((line) => line.trim()) .filter(Boolean); const isWorktree = insideWorkTree === "true" && Boolean(gitDir) && Boolean(gitCommonDir) && gitDir !== gitCommonDir; return { isWorktree, worktreeName: isWorktree && worktreeRoot ? basename(worktreeRoot) || null : null, }; }; export default function (pi: ExtensionAPI) { let renderFooter: (() => void) | undefined; let summary = ""; let summaryUpdatedAt = 0; let summaryEntryCount = 0; let summaryIsFallback = false; let summaryIsManual = false; let summarizing = false; let lastSummaryError: string | undefined; let git: GitState = { ...defaultGitState }; let gitTimer: NodeJS.Timeout | undefined; let summaryTimer: NodeJS.Timeout | undefined; let enabled = true; const requestRender = () => renderFooter?.(); const applySummary = (nextSummary: string, entryCount: number, source: "ai" | "fallback" | "manual") => { summary = nextSummary; summaryUpdatedAt = Date.now(); summaryEntryCount = entryCount; summaryIsFallback = source === "fallback"; summaryIsManual = source === "manual"; if (source !== "fallback") lastSummaryError = undefined; pi.setSessionName(summary); pi.appendEntry(CUSTOM_TYPE, { summary, updatedAt: summaryUpdatedAt, entryCount: summaryEntryCount, source, }); requestRender(); }; const refreshGit = async (ctx: ExtensionContext) => { try { const branchResult = await pi.exec("git", ["branch", "--show-current"], { cwd: ctx.cwd, timeout: 2000 }); const statusResult = await pi.exec("git", ["status", "--porcelain=v1"], { cwd: ctx.cwd, timeout: 3000 }); const worktreeResult = await pi.exec( "git", ["rev-parse", "--is-inside-work-tree", "--git-dir", "--git-common-dir", "--show-toplevel"], { cwd: ctx.cwd, timeout: 2000, }, ); if (branchResult.code !== 0 || statusResult.code !== 0) { git = { ...defaultGitState }; requestRender(); return; } const lines = statusResult.stdout.split("\n").filter((line) => line.trim().length > 0); let staged = 0; let unstaged = 0; let untracked = 0; for (const line of lines) { const x = line[0]; const y = line[1]; if (x === "?" && y === "?") { untracked++; continue; } if (x && x !== " ") staged++; if (y && y !== " ") unstaged++; } const worktree = worktreeResult.code === 0 ? parseWorktreeState(worktreeResult.stdout) : defaultGitState; git = { branch: branchResult.stdout.trim() || null, isWorktree: worktree.isWorktree, worktreeName: worktree.worktreeName, pending: lines.length, staged, unstaged, untracked, }; requestRender(); } catch { git = { ...defaultGitState }; requestRender(); } }; const refreshSummary = async (ctx: ExtensionContext, force = false) => { if (summarizing || summaryIsManual) return; const branch = ctx.sessionManager.getBranch() as SessionEntry[]; const entryCount = countEntries(branch); const conversationText = buildConversationText(branch); if (!conversationText.trim()) return; const applyFallbackIfNeeded = (reason: string) => { lastSummaryError = reason; if (summary.trim() && !summaryIsFallback) return; applySummary(buildFallbackSummary(conversationText, ctx.cwd), entryCount, "fallback"); }; const hasEnoughNewConversation = entryCount - summaryEntryCount >= SUMMARY_MIN_ENTRY_DELTA; if (!force && !summaryIsFallback && summaryUpdatedAt > 0 && !hasEnoughNewConversation) return; const model = ctx.model; if (!model) { applyFallbackIfNeeded("No model selected"); return; } try { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok) { applyFallbackIfNeeded(auth.error); return; } const completeSimple = await loadCompleteSimple(); if (!completeSimple) { applyFallbackIfNeeded("@earendil-works/pi-ai is unavailable"); return; } summarizing = true; requestRender(); const response = await completeSimple( model, { systemPrompt: "You write concise session titles for a coding-agent terminal UI.", messages: [ { role: "user" as const, content: [{ type: "text" as const, text: buildSummaryPrompt(conversationText, summary) }], timestamp: Date.now(), }, ], }, { apiKey: auth.apiKey, headers: auth.headers, signal: ctx.signal, }, ); if (response.stopReason === "error" || response.stopReason === "aborted") { applyFallbackIfNeeded(response.errorMessage ?? `Model stopped: ${response.stopReason}`); return; } const nextSummary = cleanSummary( response.content .filter((part): part is { type: "text"; text: string } => part.type === "text") .map((part) => part.text) .join("\n"), ); if (!nextSummary) { applyFallbackIfNeeded("Model returned no text"); return; } applySummary(nextSummary, entryCount, "ai"); } catch (error) { applyFallbackIfNeeded(error instanceof Error ? error.message : String(error)); } finally { summarizing = false; requestRender(); } }; const getUsageText = (ctx: ExtensionContext): string => { let input = 0; let output = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message" || entry.message.role !== "assistant") continue; const message = entry.message as AssistantMessage; input += message.usage?.input ?? 0; output += message.usage?.output ?? 0; } const model = ctx.model?.id ?? "no-model"; const usage = ctx.getContextUsage(); const contextPercent = usage?.percent === null || usage?.percent === undefined ? "" : ` ${Math.round(usage.percent)}%`; return `${model}${contextPercent} ↑${formatCount(input)} ↓${formatCount(output)}`; }; const installFooter = (ctx: ExtensionContext) => { ctx.ui.setFooter((tui, theme, footerData) => { renderFooter = () => tui.requestRender(); const unsub = footerData.onBranchChange(() => { void refreshGit(ctx); tui.requestRender(); }); return { dispose: () => { unsub(); renderFooter = undefined; }, invalidate() {}, render(width: number): string[] { const cwdName = formatCwdName(ctx.cwd); const branch = git.branch ?? footerData.getGitBranch() ?? "no git"; const gitName = git.isWorktree ? git.worktreeName ?? branch : branch; const worktreeMarker = git.isWorktree ? " worktree" : ""; const leftRaw = `${cwdName} ⑂ ${gitName}${worktreeMarker} ${formatPending(git)}`; const centerRaw = summary.trim(); const rightRaw = getUsageText(ctx); const left = theme.fg(git.pending && git.pending > 0 ? "warning" : "success", leftRaw); const right = theme.fg("dim", truncateToWidth(rightRaw, Math.min(32, Math.max(12, Math.floor(width * 0.3))), "…")); const reserved = visibleWidth(left) + visibleWidth(right) + 2; const center = centerRaw ? theme.fg("accent", truncateToWidth(centerRaw, Math.max(0, width - reserved), "…")) : ""; const gap = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(center) - visibleWidth(right))); return [truncateToWidth(left + (center ? " " + center : "") + gap + right, width, "")]; }, }; }); }; const startTimers = (ctx: ExtensionContext) => { gitTimer = setInterval(() => void refreshGit(ctx), GIT_INTERVAL_MS); summaryTimer = setInterval(() => void refreshSummary(ctx), SUMMARY_INTERVAL_MS); }; const stopTimers = () => { if (gitTimer) clearInterval(gitTimer); if (summaryTimer) clearInterval(summaryTimer); gitTimer = undefined; summaryTimer = undefined; }; pi.on("session_start", async (_event, ctx) => { const saved = readLatestSummary(ctx.sessionManager.getEntries() as SessionEntry[]); if (saved?.source !== "fallback") { summary = saved?.summary ?? pi.getSessionName() ?? summary; summaryUpdatedAt = saved?.updatedAt ?? 0; summaryEntryCount = saved?.entryCount ?? 0; summaryIsFallback = false; summaryIsManual = saved?.source === "manual"; if (summary) pi.setSessionName(summary); } else { summary = ""; summaryUpdatedAt = 0; summaryEntryCount = saved.entryCount; summaryIsFallback = false; summaryIsManual = false; } if (!ctx.hasUI) return; if (enabled) installFooter(ctx); await refreshGit(ctx); void refreshSummary(ctx); startTimers(ctx); }); pi.on("agent_end", async (_event, ctx) => { if (!ctx.hasUI) return; await refreshGit(ctx); void refreshSummary(ctx); }); pi.on("session_shutdown", async (_event, ctx) => { stopTimers(); ctx.ui.setFooter(undefined); }); const promptForManualTitle = async (ctx: ExtensionContext) => { const title = cleanSummary((await ctx.ui.input("Session title:", summary || pi.getSessionName() || "")) ?? ""); if (!title) { ctx.ui.notify("Session title unchanged", "info"); return; } const branch = ctx.sessionManager.getBranch() as SessionEntry[]; applySummary(title, countEntries(branch), "manual"); ctx.ui.notify("Session title set manually; auto-title disabled", "info"); }; const clearManualTitle = async (ctx: ExtensionContext) => { if (!summaryIsManual) { ctx.ui.notify("No manual session title to clear", "info"); return; } const branch = ctx.sessionManager.getBranch() as SessionEntry[]; summary = ""; summaryUpdatedAt = 0; summaryEntryCount = countEntries(branch); summaryIsFallback = false; summaryIsManual = false; lastSummaryError = undefined; pi.setSessionName(""); pi.appendEntry(CUSTOM_TYPE, { summary, updatedAt: Date.now(), entryCount: summaryEntryCount, source: "fallback", }); requestRender(); await refreshSummary(ctx, true); ctx.ui.notify(lastSummaryError ? `Manual session title cleared; AI summary failed: ${lastSummaryError}` : "Manual session title cleared", lastSummaryError ? "warning" : "info"); }; pi.registerShortcut(options.manualTitleShortcut, { description: "Set the session title manually", handler: promptForManualTitle, }); pi.registerCommand("session-bar-title", { description: "Set the session title manually and stop AI title updates", handler: async (_args, ctx) => promptForManualTitle(ctx), }); pi.registerCommand("session-bar-clear-title", { description: "Clear the manual session title and resume AI title updates", handler: async (_args, ctx) => clearManualTitle(ctx), }); pi.registerCommand("session-bar-refresh", { description: "Refresh the sticky git/session summary bar now", handler: async (_args, ctx) => { await refreshGit(ctx); await refreshSummary(ctx, true); const message = summaryIsManual ? "Session bar refreshed; manual title preserved" : lastSummaryError ? `Session bar refreshed; AI summary failed: ${lastSummaryError}` : "Session bar refreshed"; ctx.ui.notify(message, lastSummaryError && !summaryIsManual ? "warning" : "info"); }, }); pi.registerCommand("session-bar-debug", { description: "Show status bar summary diagnostics", handler: async (_args, ctx) => { const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none"; const auth = ctx.model ? await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model) : undefined; ctx.ui.notify( [ `model=${model}`, `summarySource=${summaryIsManual ? "manual" : summaryIsFallback ? "fallback" : "ai"}`, `summaryEntries=${summaryEntryCount}`, `auth=${auth ? (auth.ok ? "ok" : auth.error) : "none"}`, `lastError=${lastSummaryError ?? "none"}`, ].join(" • "), lastSummaryError ? "warning" : "info", ); }, }); pi.registerCommand("session-bar-toggle", { description: "Toggle the sticky git/session summary bar", handler: async (_args, ctx) => { enabled = !enabled; if (enabled) { installFooter(ctx); await refreshGit(ctx); void refreshSummary(ctx); ctx.ui.notify("Session bar enabled", "info"); } else { ctx.ui.setFooter(undefined); ctx.ui.notify("Session bar disabled", "info"); } }, }); }