/** * Customizable footer for pi with configurable widgets on up to 3 lines. * * Commands: * /statusbar — Open the configuration TUI * /statusbar reset — Reset to default configuration * /statusbar toggle — Enable/disable the custom status bar * * Widget types available: * Widget types (grouped by category): * Core: model, cwd, turnCount, thinking, sessionName, version * Git: gitBranch, gitAheadBehind, gitChanges, gitStaged, gitUnstaged, * gitUntracked, gitCleanStatus, gitConflicts, gitRootDir, gitSHA * Tokens: tokens, tokensInput, tokensOutput, tokensCached, tokensTotal * Context: contextPct, contextLength, contextWindow * Session: cost, sessionClock * Layout: customText, separator, spacer */ import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import type { ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth, Container, Text, SelectList, matchesKey, Key, } from "@earendil-works/pi-tui"; import type { Component } from "@earendil-works/pi-tui"; import * as fs from "node:fs"; import * as path from "node:path"; import { execSync } from "node:child_process"; // ── Types ────────────────────────────────────────────────────────────── type WidgetType = // Core | "model" | "cwd" | "turnCount" | "thinking" | "sessionName" | "version" // Git | "gitBranch" | "gitAheadBehind" | "gitChanges" | "gitStaged" | "gitUnstaged" | "gitUntracked" | "gitCleanStatus" | "gitConflicts" | "gitRootDir" | "gitSHA" // Tokens | "tokens" | "tokensInput" | "tokensOutput" | "tokensCached" | "tokensTotal" // Context | "contextPct" | "contextLength" | "contextWindow" // Session | "cost" | "sessionClock" // Layout | "customText" | "separator" | "spacer"; interface WidgetConfig { type: WidgetType; /** Optional label prefix (e.g., "Model:") */ label?: string; /** For customText: the text to display */ text?: string; /** For separator: the character(s) to use */ separator?: string; /** For spacer: minimum width in chars */ minWidth?: number; /** Whether to show label before value */ showLabel?: boolean; /** Color override: theme color name ("accent", etc.) or "ansi:NN" for ANSI 16-color codes */ color?: string; } interface StatusBarConfig { /** Up to 3 lines of widgets */ lines: WidgetConfig[][]; } const MAX_LINES = 3; // ── Default configuration ────────────────────────────────────────────── const DEFAULT_CONFIG: StatusBarConfig = { lines: [ [ { type: "cwd", showLabel: false }, { type: "separator", separator: "|" }, { type: "gitBranch", showLabel: false }, ], [ { type: "tokens", showLabel: false }, { type: "separator", separator: "•" }, { type: "cost", showLabel: false }, { type: "separator", separator: "•" }, { type: "contextPct", showLabel: false }, { type: "separator", separator: "•" }, { type: "model", showLabel: false }, ], [], ], }; // ── Widget value resolvers ───────────────────────────────────────────── interface WidgetContext { // Core model: string | undefined; cwd: string; thinkingLevel: string; turnCount: number; sessionName: string | undefined; version: string; terminalWidth: number; // Git (cached — updated periodically) gitBranch: string | null; gitAheadBehind: string; gitChanges: string; gitStaged: string; gitUnstaged: string; gitUntracked: number; gitCleanStatus: string; gitConflicts: number; gitRootDir: string; gitSHA: string; // Tokens totalInput: number; totalOutput: number; totalCached: number; totalCost: number; // Context contextPercent: string; contextLength: number; contextWindow: number; // Session sessionStart: number; } // ── ANSI 16-color palette ──────────────────────────────────────────── const ANSI_COLORS = [ { value: "", label: "Default", code: "" }, { value: "black", label: "Black", code: "30" }, { value: "red", label: "Red", code: "31" }, { value: "green", label: "Green", code: "32" }, { value: "yellow", label: "Yellow", code: "33" }, { value: "blue", label: "Blue", code: "34" }, { value: "magenta", label: "Magenta", code: "35" }, { value: "cyan", label: "Cyan", code: "36" }, { value: "white", label: "White", code: "37" }, { value: "brightBlack", label: "Bright Black", code: "90" }, { value: "brightRed", label: "Bright Red", code: "91" }, { value: "brightGreen", label: "Bright Green", code: "92" }, { value: "brightYellow", label: "Bright Yellow", code: "93" }, { value: "brightBlue", label: "Bright Blue", code: "94" }, { value: "brightMagenta", label: "Bright Magenta", code: "95" }, { value: "brightCyan", label: "Bright Cyan", code: "96" }, { value: "brightWhite", label: "Bright White", code: "97" }, ]; function getAnsiColorLabel(value: string): string { return ( ANSI_COLORS.find((c) => c.value === value)?.label || ANSI_COLORS.find((c) => `ansi:${c.code}` === value)?.label || "Default" ); } function getAnsiColorCode(value: string): string { const byValue = ANSI_COLORS.find((c) => c.value === value); if (byValue) return byValue.code; const byAnsi = ANSI_COLORS.find((c) => `ansi:${c.code}` === value); if (byAnsi) return byAnsi.code; return ""; } /** * Apply either a theme color or an ANSI 16-color code to text. * Theme colors use theme.fg(), ANSI codes use direct \x1b[NNm sequences. */ function applyWidgetColor( color: string | undefined, text: string, theme: { fg: (name: string, text: string) => string }, ): string { if (!color) return theme.fg("dim", text); if (color.startsWith("ansi:")) { const code = color.slice(5); if (!code) return text; return `\x1b[${code}m${text}\x1b[0m`; } return theme.fg(color, text); } function parseNumstat(output: string): { added: number; deleted: number } { let added = 0, deleted = 0; for (const line of output.split("\n")) { const parts = line.trim().split("\t"); if (parts.length >= 2) { added += parseInt(parts[0], 10) || 0; deleted += parseInt(parts[1], 10) || 0; } } return { added, deleted }; } function getWidgetValue( w: WidgetConfig, ctx: WidgetContext, theme: { fg: (color: string, text: string) => string }, ): string { const colorVal = w.color || "dim"; switch (w.type) { // ── Core ── case "model": { const val = ctx.model || "no-model"; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "cwd": { const val = shortenCwd(ctx.cwd); const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "turnCount": { const val = `${ctx.turnCount}`; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "thinking": { const val = ctx.thinkingLevel || "off"; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "sessionName": { const val = ctx.sessionName || ""; if (!val) return ""; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "version": { const val = ctx.version || ""; if (!val) return ""; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } // ── Git ── case "gitBranch": { const val = ctx.gitBranch; if (!val) return ""; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "gitAheadBehind": { if (!ctx.gitBranch) return ""; const val = ctx.gitAheadBehind; if (!val) return "✓ up-to-date"; return applyWidgetColor(colorVal, val, theme); } case "gitChanges": { if (!ctx.gitBranch) return ""; return applyWidgetColor(colorVal, `${ctx.gitChanges}`, theme); } case "gitStaged": { if (!ctx.gitBranch) return ""; return applyWidgetColor(colorVal, `${ctx.gitStaged}`, theme); } case "gitUnstaged": { if (!ctx.gitBranch) return ""; return applyWidgetColor(colorVal, `${ctx.gitUnstaged}`, theme); } case "gitUntracked": { if (!ctx.gitBranch) return ""; return applyWidgetColor(colorVal, `${ctx.gitUntracked}`, theme); } case "gitCleanStatus": { if (!ctx.gitBranch) return ""; return applyWidgetColor(colorVal, ctx.gitCleanStatus, theme); } case "gitConflicts": { if (!ctx.gitBranch || ctx.gitConflicts === 0) return ""; return applyWidgetColor("error", `conflict:${ctx.gitConflicts}`, theme); } case "gitRootDir": { if (!ctx.gitBranch) return ""; const val = ctx.gitRootDir; if (!val) return ""; return applyWidgetColor(colorVal, val, theme); } case "gitSHA": { if (!ctx.gitBranch) return ""; const val = ctx.gitSHA; if (!val) return ""; return applyWidgetColor(colorVal, val, theme); } // ── Tokens ── case "tokens": { const parts: string[] = []; if (ctx.totalInput) parts.push(`↑${fmtTokens(ctx.totalInput)}`); if (ctx.totalOutput) parts.push(`↓${fmtTokens(ctx.totalOutput)}`); const val = parts.join(" ") || "0"; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "tokensInput": { const val = fmtTokens(ctx.totalInput); const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "tokensOutput": { const val = fmtTokens(ctx.totalOutput); const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "tokensCached": { const val = ctx.totalCached > 0 ? fmtTokens(ctx.totalCached) : "0"; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "tokensTotal": { const val = fmtTokens(ctx.totalInput + ctx.totalOutput); const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } // ── Context ── case "contextPct": { const val = ctx.contextPercent === "?" ? "?%" : `${ctx.contextPercent}%`; const label = w.showLabel && w.label ? `${w.label} ` : ""; const pctNum = ctx.contextPercent === "?" ? 0 : parseFloat(ctx.contextPercent); const pctColor = pctNum > 90 ? "error" : pctNum > 70 ? "warning" : colorVal; return pctColor === colorVal ? applyWidgetColor(colorVal, `${label}${val}`, theme) : applyWidgetColor(pctColor, `${label}${val}`, theme); } case "contextLength": { if (ctx.contextLength === 0) return ""; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor( colorVal, `${label}${fmtTokens(ctx.contextLength)}`, theme, ); } case "contextWindow": { if (ctx.contextWindow === 0) return ""; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor( colorVal, `${label}${fmtTokens(ctx.contextWindow)}`, theme, ); } // ── Session ── case "cost": { const val = `$${ctx.totalCost.toFixed(4)}`; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } case "sessionClock": { if (ctx.sessionStart === 0) return ""; const elapsed = Math.floor((Date.now() - ctx.sessionStart) / 1000); const h = Math.floor(elapsed / 3600); const m = Math.floor((elapsed % 3600) / 60); const s = elapsed % 60; const val = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; const label = w.showLabel && w.label ? `${w.label} ` : ""; return applyWidgetColor(colorVal, `${label}${val}`, theme); } // ── Layout ── case "customText": { return applyWidgetColor(colorVal, w.text || "", theme); } case "separator": { return theme.fg("dim", w.separator || "|"); } case "spacer": { return " ".repeat(Math.max(1, w.minWidth || 1)); } default: return ""; } } // ── Helpers ──────────────────────────────────────────────────────────── function fmtTokens(count: number): string { if (count < 1000) return `${count}`; if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; if (count < 1_000_000) return `${Math.round(count / 1000)}k`; return `${(count / 1_000_000).toFixed(1)}M`; } function shortenCwd(cwd: string): string { const home = process.env.HOME || process.env.USERPROFILE; if (!home) return cwd; if (cwd.startsWith(home)) { return `~${cwd.slice(home.length)}`; } return cwd; } function sanitizeStatusText(text: string): string { return text .replace(/[\r\n\t]/g, " ") .replace(/ +/g, " ") .trim(); } // ── Config persistence ───────────────────────────────────────────────── // ── Global file-based widget config ──────────────────────────────────── // Saved to ~/.pi/statusbar-config.json — shared across all sessions const CONFIG_FILE = path.join( process.env.HOME || process.env.USERPROFILE || "/tmp", ".pi", "statusbar-config.json", ); // ── Per-session cumulative counter persistence ───────────────────────── // Stored inside the pi session directory for the current project function getSessionDirName(cwd: string): string { const abs = path.resolve(cwd); // Remove leading / (pi convention: --home-lucas-...-- not ---home-lucas-...--) const stripped = abs.startsWith("/") ? abs.slice(1) : abs; return `--${stripped.replace(/\//g, "-")}--`; } function getCountersFile(cwd: string): string { const home = process.env.HOME || process.env.USERPROFILE || "/tmp"; return path.join( home, ".pi", "agent", "sessions", getSessionDirName(cwd), "cumulative-counters.json", ); } function saveSessionSha(cwd: string, sha: string): void { try { const file = getCountersFile(cwd); const dir = path.dirname(file); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // Atomic write: write to temp file then rename const tmp = file + ".tmp." + Date.now(); fs.writeFileSync(tmp, JSON.stringify({ sessionStartSha: sha }), "utf-8"); fs.renameSync(tmp, file); } catch (err) { console.error("pistatusline: failed to save session SHA", err); } } function loadSessionSha(cwd: string): string | null { try { const file = getCountersFile(cwd); if (fs.existsSync(file)) { const raw = JSON.parse(fs.readFileSync(file, "utf-8")); if (typeof raw.sessionStartSha === "string") { return raw.sessionStartSha; } } } catch (err) { console.error("pistatusline: failed to load session SHA", err); } return null; } function saveConfig(pi: ExtensionAPI, config: StatusBarConfig): void { try { const dir = path.dirname(CONFIG_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } // Atomic write: write to temp file then rename const tmp = CONFIG_FILE + ".tmp." + Date.now(); fs.writeFileSync(tmp, JSON.stringify(config, null, 2), "utf-8"); fs.renameSync(tmp, CONFIG_FILE); } catch (err) { console.error("pistatusline: failed to save config file", err); } } function loadConfig(): StatusBarConfig | null { try { if (fs.existsSync(CONFIG_FILE)) { const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); return migrateConfig(raw); } } catch (err) { console.error( "pistatusline: failed to load config file — backing up and using defaults", err, ); // Backup corrupted file so it doesn't keep failing try { const bak = CONFIG_FILE + ".corrupted." + Date.now(); fs.renameSync(CONFIG_FILE, bak); } catch { /* ignore backup failure */ } } return null; } /** Migrate old { left, center, right } config to new { lines } format */ function migrateConfig(raw: Record): StatusBarConfig { if (Array.isArray(raw.lines)) { return { lines: raw.lines as WidgetConfig[][], }; } // Old format: { left: WidgetConfig[], center: WidgetConfig[], right: WidgetConfig[] } const left = (raw.left || []) as WidgetConfig[]; const center = (raw.center || []) as WidgetConfig[]; const right = (raw.right || []) as WidgetConfig[]; return { lines: [left, center, right] }; } // ── Widget info for UI ───────────────────────────────────────────────── interface WidgetInfo { type: WidgetType; label: string; description: string; category: string; } const WIDGET_CATEGORIES = [ "Core", "Git", "Tokens", "Context", "Session", "Environment", "Layout", ] as const; const WIDGET_INFO: WidgetInfo[] = [ // ── Core ── { type: "model", label: "Model", description: "Current model name", category: "Core", }, { type: "cwd", label: "CWD", description: "Current working directory", category: "Core", }, { type: "turnCount", label: "Turn Count", description: "Number of assistant turns", category: "Core", }, { type: "thinking", label: "Thinking Level", description: "Current thinking level", category: "Core", }, { type: "sessionName", label: "Session Name", description: "Current session display name", category: "Core", }, { type: "version", label: "Version", description: "Pi version", category: "Core", }, // ── Git ── { type: "gitBranch", label: "Git Branch", description: "Current git branch name", category: "Git", }, { type: "gitAheadBehind", label: "Git Ahead/Behind", description: "Commits ahead/behind upstream", category: "Git", }, { type: "gitChanges", label: "Git Changes", description: "Added/deleted lines (staged+unstaged)", category: "Git", }, { type: "gitStaged", label: "Git Staged", description: "Added lines (staged)", category: "Git", }, { type: "gitUnstaged", label: "Git Unstaged", description: "Deleted lines (unstaged)", category: "Git", }, { type: "gitUntracked", label: "Git Untracked", description: "Untracked files count", category: "Git", }, { type: "gitCleanStatus", label: "Git Clean Status", description: "✓ clean or ✗ dirty indicator", category: "Git", }, { type: "gitConflicts", label: "Git Conflicts", description: "Conflict file count", category: "Git", }, { type: "gitRootDir", label: "Git Root Dir", description: "Git repository root name", category: "Git", }, { type: "gitSHA", label: "Git SHA", description: "Short commit hash", category: "Git", }, // ── Tokens ── { type: "tokens", label: "Tokens", description: "Token usage (↑input ↓output)", category: "Tokens", }, { type: "tokensInput", label: "Tokens Input", description: "Input token count", category: "Tokens", }, { type: "tokensOutput", label: "Tokens Output", description: "Output token count", category: "Tokens", }, { type: "tokensCached", label: "Tokens Cached", description: "Cache read+write tokens", category: "Tokens", }, { type: "tokensTotal", label: "Tokens Total", description: "Input+output token count", category: "Tokens", }, // ── Context ── { type: "contextPct", label: "Context %", description: "Context window usage percentage", category: "Context", }, { type: "contextLength", label: "Context Length", description: "Absolute context token count", category: "Context", }, { type: "contextWindow", label: "Context Window", description: "Max context window size", category: "Context", }, // ── Session ── { type: "cost", label: "Cost", description: "Session cost in USD", category: "Session", }, { type: "sessionClock", label: "Session Clock", description: "Session duration (HH:MM:SS)", category: "Session", }, // ── Layout ── { type: "customText", label: "Custom Text", description: "User-defined custom text", category: "Layout", }, { type: "separator", label: "Separator", description: "Visual separator character", category: "Layout", }, { type: "spacer", label: "Spacer", description: "Flexible spacing element", category: "Layout", }, ]; // ── Extension entry point ────────────────────────────────────────────── export default function statusBarExtension(pi: ExtensionAPI) { let config: StatusBarConfig = { lines: DEFAULT_CONFIG.lines.map((l) => [...l]), }; let enabled = false; let customFooterComponent: (Component & { dispose?: () => void }) | null = null; let activeCleanup: (() => void) | null = null; // ── Git data cache ─────────────────────────────────────────────── let gitCache = { aheadBehind: "", changes: 0, staged: 0, unstaged: 0, untracked: 0, cleanStatus: "", conflicts: 0, rootDir: "", sha: "", lastBranch: "", lastUpdated: 0, // Line-level change counts (format: "+X-Y") changesLines: "+0-0", stagedLines: "+0", unstagedLines: "-0", }; // SHA at session start, for git diff --numstat ..HEAD cumulative tracking let sessionStartSha: string | null = null; function refreshGitCache(cwd: string): void { const age = Date.now() - gitCache.lastUpdated; if (age < 5000) return; // Cache for 5s gitCache.lastUpdated = Date.now(); try { // Check if git repo execSync("git rev-parse --is-inside-work-tree 2>/dev/null", { cwd, stdio: "pipe", }); } catch { gitCache.lastBranch = ""; gitCache.lastUpdated = 0; return; } try { const status = execSync("git status --porcelain -b 2>/dev/null", { cwd, encoding: "utf8", maxBuffer: 64 * 1024, }); const lines = status.split("\n"); const branchLine = lines[0]; // Ahead/behind const abMatch = branchLine.match(/ahead (\d+).*behind (\d+)/) || branchLine.match(/ahead (\d+)/) || branchLine.match(/behind (\d+)/); if (abMatch) { const ahead = parseInt(abMatch[1] || "0", 10); const behind = parseInt( abMatch[abMatch.length === 3 ? 2 : 1] || "0", 10, ); const parts: string[] = []; if (ahead) parts.push(`↑${ahead}`); if (behind) parts.push(`↓${behind}`); gitCache.aheadBehind = parts.join(" "); } else { gitCache.aheadBehind = ""; } // Count status lines (skip branch line) with cumulative session tracking const workLines = lines.slice(1).filter((l) => l.trim().length > 0); let currentStaged = 0, currentUnstaged = 0, untracked = 0, conflicts = 0; for (const l of workLines) { const file = l.slice(3).trim(); if (!file) continue; const code = l.slice(0, 2); if (code === "??") { untracked++; continue; } if ( code.includes("D") || code.includes("U") || code.includes("A") || code.includes("M") ) { if (code[0] !== " ") currentStaged++; if (code[1] !== " ") currentUnstaged++; } if ( code === "DD" || code === "AU" || code === "UD" || code === "UA" || code === "DU" || code === "AA" || code === "UU" ) { conflicts++; } } gitCache.staged = currentStaged; gitCache.unstaged = currentUnstaged; gitCache.untracked = untracked; gitCache.conflicts = conflicts; gitCache.cleanStatus = currentStaged + currentUnstaged + untracked + conflicts === 0 ? "✓ clean" : "✗ dirty"; // Compute cumulative line diff since session start (SHA-based) let committedAdded = 0, committedDeleted = 0; if (sessionStartSha) { try { const out = execSync( `git diff --numstat ${sessionStartSha}..HEAD 2>/dev/null`, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 }, ); const n = parseNumstat(out); committedAdded = n.added; committedDeleted = n.deleted; } catch { // sha may be invalid after rebase — reset it sessionStartSha = null; } } // Current staged changes let stagedAdd = 0, stagedDel = 0; try { const out = execSync("git diff --numstat --cached 2>/dev/null", { cwd, encoding: "utf8", maxBuffer: 1024 * 1024, }); const n = parseNumstat(out); stagedAdd = n.added; stagedDel = n.deleted; } catch { /* ignore */ } // Current unstaged changes let unstagedAdd = 0, unstagedDel = 0; try { const out = execSync("git diff --numstat 2>/dev/null", { cwd, encoding: "utf8", maxBuffer: 1024 * 1024, }); const n = parseNumstat(out); unstagedAdd = n.added; unstagedDel = n.deleted; } catch { /* ignore */ } // Total = committed + current staged + current unstaged const totalAdded = committedAdded + stagedAdd + unstagedAdd; const totalDeleted = committedDeleted + stagedDel + unstagedDel; gitCache.stagedLines = `+${committedAdded + stagedAdd}`; gitCache.unstagedLines = `-${committedDeleted + unstagedDel}`; gitCache.changesLines = `+${totalAdded}-${totalDeleted}`; } catch { /* ignore git status */ } try { gitCache.rootDir = execSync( "basename $(git rev-parse --show-toplevel 2>/dev/null)", { cwd, encoding: "utf8", maxBuffer: 4096 }, ).trim(); } catch { gitCache.rootDir = ""; } try { gitCache.sha = execSync("git rev-parse --short HEAD 2>/dev/null", { cwd, encoding: "utf8", maxBuffer: 4096, }).trim(); } catch { gitCache.sha = ""; } } let sessionStartTime = 0; // ── Footer builder ────────────────────────────────────────────────── function buildFooter( tui: { requestRender: () => void }, theme: { fg: (color: string, text: string) => string }, footerData: ReadonlyFooterDataProvider, ctx: ExtensionContext, ): Component & { dispose?: () => void } { const unsubBranch = footerData.onBranchChange(() => tui.requestRender()); return { dispose: () => { unsubBranch(); }, invalidate() { // no cache }, render(width: number): string[] { // Gather data const gitBranch = footerData.getGitBranch(); // Token/cost/cache from session let totalInput = 0; let totalOutput = 0; let totalCached = 0; let totalCost = 0; let turnCount = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && entry.message.role === "assistant") { const m = entry.message as AssistantMessage; totalInput += m.usage.input; totalOutput += m.usage.output; totalCached += (m.usage.cacheRead || 0) + (m.usage.cacheWrite || 0); totalCost += m.usage.cost.total; turnCount++; } } // Context usage const contextUsage = ctx.getContextUsage(); const contextWindow = contextUsage?.contextWindow ?? 0; const contextPercent = contextUsage?.percent !== null ? (contextUsage?.percent ?? 0).toFixed(1) : "?"; const contextTokens = contextUsage?.tokens ?? 0; const sessionName = pi.getSessionName(); const terminalWidth = process.stdout.columns || width; // Git data (refresh cache when branch changes) if (gitCache.lastBranch !== gitBranch) { gitCache.lastBranch = gitBranch; gitCache.lastUpdated = 0; } if (gitBranch) { // Force refresh for branch-specific data gitCache.lastUpdated = 0; refreshGitCache(ctx.cwd); } // Session clock (start counting on first render) if (sessionStartTime === 0) { sessionStartTime = Date.now(); } const wctx: WidgetContext = { model: ctx.model?.id, cwd: ctx.cwd, thinkingLevel: pi.getThinkingLevel(), turnCount, sessionName, version: "", terminalWidth, gitBranch, gitAheadBehind: gitCache.aheadBehind, gitChanges: gitCache.changesLines, gitStaged: gitCache.stagedLines, gitUnstaged: gitCache.unstagedLines, gitUntracked: gitCache.untracked, gitCleanStatus: gitCache.cleanStatus, gitConflicts: gitCache.conflicts, gitRootDir: gitCache.rootDir, gitSHA: gitCache.sha, totalInput, totalOutput, totalCached, totalCost, contextPercent, contextLength: contextTokens, contextWindow, sessionStart: sessionStartTime, }; // Render each non-empty line const lines: string[] = []; for (let i = 0; i < MAX_LINES; i++) { const widgets = config.lines[i]; if (!widgets || widgets.length === 0) continue; const lineStr = renderWidgets(widgets, wctx, theme).trimEnd(); if (lineStr) { lines.push(truncateToWidth(lineStr, width)); } } // Extension statuses on next available line slot const extensionStatuses = footerData.getExtensionStatuses(); if (extensionStatuses.size > 0) { const sortedStatuses = Array.from(extensionStatuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); const statusLine = sortedStatuses.join(" "); if (lines.length < MAX_LINES) { lines.push( truncateToWidth(statusLine, width, theme.fg("dim", "...")), ); } else if (lines.length === MAX_LINES) { // All lines used — append to last lines[MAX_LINES - 1] = truncateToWidth( lines[MAX_LINES - 1] + " " + statusLine, width, theme.fg("dim", "..."), ); } } if (lines.length === 0) lines.push(""); return lines; }, }; } function renderWidgets( widgets: WidgetConfig[], ctx: WidgetContext, theme: { fg: (color: string, text: string) => string }, ): string { return widgets .map((w) => getWidgetValue(w, ctx, theme)) .filter((s) => s.length > 0) .join(""); } // ── Enable/disable ────────────────────────────────────────────────── function ensureLineCount() { while (config.lines.length < MAX_LINES) config.lines.push([]); config.lines = config.lines.slice(0, MAX_LINES); } function enable(ctx: ExtensionContext) { if (enabled) return; enabled = true; sessionStartTime = Date.now(); // Restore saved config first (may contain persisted cumulative counters) const saved = loadConfig(); if (saved) { config = saved; } ensureLineCount(); // Load session-start SHA from per-session directory sessionStartSha = loadSessionSha(ctx.cwd); // If no SHA yet (first enable), record current HEAD if (!sessionStartSha) { try { sessionStartSha = execSync("git rev-parse HEAD 2>/dev/null", { cwd: ctx.cwd, encoding: "utf8", maxBuffer: 4096, }).trim(); if (sessionStartSha) { saveSessionSha(ctx.cwd, sessionStartSha); } } catch { /* ignore */ } } ctx.ui.setFooter((tui, theme, footerData) => { customFooterComponent = buildFooter(tui, theme, footerData, ctx); return customFooterComponent; }); } function disable(ctx: ExtensionContext) { if (!enabled) return; enabled = false; if (activeCleanup) { activeCleanup(); activeCleanup = null; } ctx.ui.setFooter(undefined); customFooterComponent = null; } // ── Configuration UI ──────────────────────────────────────────────── async function openConfigUI(ctx: ExtensionContext) { ensureLineCount(); // Main menu loop: Escape → exit entire config while (true) { // Pick mode: Modify Widgets or Change Colors const mode = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); container.addChild( new Text( theme.fg("accent", theme.bold("Status Bar Configuration")), 1, 0, ), ); const items = [ { value: "widgets", label: "Modify Widgets", description: "Add, remove, reorder, and configure widgets", }, { value: "colors", label: "Change Colors", description: "Change widget colors using the ANSI 16-color palette", }, { value: "toggle", label: enabled ? "Disable Status Bar" : "Enable Status Bar", description: enabled ? "Turn off the custom status bar" : "Turn on the custom status bar", }, ]; const selectList = new SelectList(items, 10, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }, ); // Escape at main menu → exit config entirely if (mode === null) break; if (mode === "toggle") { if (enabled) { disable(ctx); ctx.ui.notify("Status bar disabled", "info"); } else { enable(ctx); ctx.ui.notify("Status bar enabled", "info"); } break; } if (mode === "colors") { await changeColorsFlow(ctx); continue; } // Line selection loop: Escape → back to main menu while (true) { const lineResult = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); container.addChild( new Text( theme.fg("accent", theme.bold("Status Bar — Select Line")), 1, 0, ), ); const items = [0, 1, 2].map((i) => ({ value: `${i}`, label: `Line ${i + 1}`, description: currentLinePreview(config.lines[i], theme), })); const selectList = new SelectList(items, 10, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }, ); // Escape at line selection → back to main menu if (lineResult === null) break; await editLine(parseInt(lineResult, 10), ctx); // editLine returned → back to line selector (inner loop continues) } } } function currentLinePreview( widgets: WidgetConfig[], theme: { fg: (color: string, text: string) => string }, ): string { if (!widgets || widgets.length === 0) return "(empty)"; return widgets.map((w) => w.type).join(", "); } async function editLine(lineIndex: number, ctx: ExtensionContext) { while (true) { const widgets = config.lines[lineIndex]; const items = widgets.map((w, i) => ({ value: `${i}`, label: `${i + 1}. ${w.type}${w.text ? `: "${w.text}"` : ""}${w.separator ? `: "${w.separator}"` : ""}`, description: getWidgetDescription(w), })); // Add action items items.push({ value: "__add", label: "+ Add Widget", description: "Add a new widget to this line", }); items.push({ value: "__back", label: "← Back", description: "Return to line selection", }); // Helper to sync items array with config for live SelectList updates function syncItems() { items.length = 0; const widgets = config.lines[lineIndex]; for (let i = 0; i < widgets.length; i++) { const w = widgets[i]; items.push({ value: `${i}`, label: `${i + 1}. ${w.type}${w.text ? `: "${w.text}"` : ""}${w.separator ? `: "${w.separator}"` : ""}`, description: getWidgetDescription(w), }); } items.push({ value: "__add", label: "+ Add Widget", description: "Add a new widget to this line", }); items.push({ value: "__back", label: "← Back", description: "Return to line selection", }); } const result = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); container.addChild( new Text( theme.fg( "accent", theme.bold(`Status Bar — Line ${lineIndex + 1}`), ), 1, 0, ), ); const selectList = new SelectList(items, 12, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); let reorderMode = false; selectList.onSelect = (item) => { if (item.value === "__back") { done(null); return; } done(item.value); }; selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg( "dim", "↑↓ navigate • enter select • esc back • d=delete • c=clone • a=add • space=reorder", ), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => { const lines = container.render(w); if (reorderMode && lines.length > 0) { lines.splice( 0, 0, theme.fg( "warning", "⚡ REORDER MODE — ↑↓ move • space to exit", ), ); } return lines; }, invalidate: () => container.invalidate(), handleInput: (data: string) => { // Handle special keys if (matchesKey(data, Key.escape)) { done(null); return; } // Keyboard shortcuts: d=delete, c=clone, a=add const selected = selectList.getSelectedItem(); if ( selected && selected.value !== "__add" && selected.value !== "__back" ) { if (matchesKey(data, "d") || matchesKey(data, "D")) { done(`__delete_${selected.value}`); return; } if (matchesKey(data, "c") || matchesKey(data, "C")) { done(`__clone_${selected.value}`); return; } if (matchesKey(data, "a") || matchesKey(data, "A")) { done(`__addhere_${selected.value}`); return; } } // Space toggles reorder mode if (matchesKey(data, Key.space)) { reorderMode = !reorderMode; tui.requestRender(); return; } // In reorder mode, up/down move the selected widget if ( reorderMode && selected && selected.value !== "__add" && selected.value !== "__back" ) { const idx = parseInt(selected.value, 10); if (!isNaN(idx)) { if (matchesKey(data, Key.up)) { if (idx > 0) { [ config.lines[lineIndex][idx - 1], config.lines[lineIndex][idx], ] = [ config.lines[lineIndex][idx], config.lines[lineIndex][idx - 1], ]; syncItems(); selectList.setSelectedIndex(idx - 1); saveConfig(pi, config); } tui.requestRender(); return; } if (matchesKey(data, Key.down)) { if (idx < config.lines[lineIndex].length - 1) { [ config.lines[lineIndex][idx], config.lines[lineIndex][idx + 1], ] = [ config.lines[lineIndex][idx + 1], config.lines[lineIndex][idx], ]; syncItems(); selectList.setSelectedIndex(idx + 1); saveConfig(pi, config); } tui.requestRender(); return; } } } selectList.handleInput(data); tui.requestRender(); }, }; }, ); if (result === null) { return; // back to line selector (inner loop in openConfigUI) } if (result === "__add") { await addWidgetToLine(lineIndex, ctx); } else if (typeof result === "string" && result.startsWith("__delete_")) { const idx = parseInt(result.slice(9), 10); if (!isNaN(idx) && idx >= 0 && idx < config.lines[lineIndex].length) { config.lines[lineIndex].splice(idx, 1); saveConfig(pi, config); } } else if (typeof result === "string" && result.startsWith("__clone_")) { const idx = parseInt(result.slice(8), 10); if (!isNaN(idx) && idx >= 0 && idx < config.lines[lineIndex].length) { const clone = JSON.parse( JSON.stringify(config.lines[lineIndex][idx]), ); config.lines[lineIndex].splice(idx + 1, 0, clone); saveConfig(pi, config); } } else if ( typeof result === "string" && result.startsWith("__addhere_") ) { const idx = parseInt(result.slice(10), 10); if (!isNaN(idx) && idx >= 0 && idx < config.lines[lineIndex].length) { await addWidgetToLine(lineIndex, ctx, idx + 1); } } else { const idx = parseInt(result, 10); if (!isNaN(idx) && idx >= 0 && idx < widgets.length) { await editWidgetAtIndex(lineIndex, idx, ctx); } } } } async function addWidgetToLine( lineIndex: number, ctx: ExtensionContext, insertAtIndex?: number, ) { // Step 1: Pick a category const categoryResult = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); container.addChild( new Text( theme.fg("accent", theme.bold("Add Widget — Select Category")), 1, 0, ), ); const items = WIDGET_CATEGORIES.map((cat) => ({ value: cat, label: cat, description: `${WIDGET_INFO.filter((w) => w.category === cat).length} widgets`, })); const selectList = new SelectList(items, 10, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }, ); if (!categoryResult) return; // Step 2: Pick a widget type from the selected category const categoryWidgets = WIDGET_INFO.filter( (w) => w.category === categoryResult, ); const typeResult = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); container.addChild( new Text( theme.fg("accent", theme.bold(`Add Widget — ${categoryResult}`)), 1, 0, ), ); const items = categoryWidgets.map((info) => ({ value: info.type, label: info.label, description: info.description, })); const selectList = new SelectList(items, 12, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }, ); if (!typeResult) return; const newWidget: WidgetConfig = { type: typeResult as WidgetType, showLabel: false, }; // Configure if needed if (typeResult === "customText") { const text = await ctx.ui.input("Enter custom text:", "Hello"); if (text === undefined) return; newWidget.text = text; } else if (typeResult === "separator") { const sep = await ctx.ui.input("Separator character(s):", "|"); if (sep === undefined) return; newWidget.separator = sep; } else if (typeResult === "spacer") { const minW = await ctx.ui.input("Minimum width:", "1"); if (minW === undefined) return; newWidget.minWidth = parseInt(minW, 10) || 1; } if (insertAtIndex !== undefined) { config.lines[lineIndex].splice(insertAtIndex, 0, newWidget); } else { config.lines[lineIndex].push(newWidget); } saveConfig(pi, config); } async function editWidgetAtIndex( lineIndex: number, idx: number, ctx: ExtensionContext, ) { const widget = config.lines[lineIndex][idx]; if (!widget) return; const action = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); const typeInfo = WIDGET_INFO.find((i) => i.type === widget.type); const title = `Editing: ${typeInfo?.label || widget.type}`; container.addChild( new Text(theme.fg("accent", theme.bold(title)), 1, 0), ); const items = [ { value: "delete", label: "🗑 Delete", description: "Remove this widget", }, { value: "configure", label: "⚙ Configure", description: "Edit widget options (label, text, color)", }, { value: "back", label: "← Back", description: "Return to line editor", }, ]; const selectList = new SelectList(items, 10, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }, ); if (!action || action === "back") return; switch (action) { case "delete": config.lines[lineIndex].splice(idx, 1); saveConfig(pi, config); break; case "configure": await configureWidget(widget, ctx); saveConfig(pi, config); break; } // editLine's while loop will show the refreshed widget list } async function configureWidget(widget: WidgetConfig, ctx: ExtensionContext) { if (widget.type === "customText") { const text = await ctx.ui.input("Custom text:", widget.text || ""); if (text !== undefined) widget.text = text; } else if (widget.type === "separator") { const sep = await ctx.ui.input("Separator:", widget.separator || "|"); if (sep !== undefined) widget.separator = sep; } else if (widget.type === "spacer") { const minW = await ctx.ui.input( "Minimum width:", String(widget.minWidth || 1), ); if (minW !== undefined) widget.minWidth = parseInt(minW, 10) || 1; } else { // Toggle showLabel const toggle = await ctx.ui.confirm( "Show label?", `Show "${widget.type}" label?`, ); widget.showLabel = toggle; // Optionally set a custom label if (widget.showLabel) { const label = await ctx.ui.input( "Label text:", widget.label || `${widget.type}:`, ); if (label !== undefined) widget.label = label; } } // Color picker — available for all widget types await pickAnsiColor(widget, ctx); } // ── Change Colors flow (from main menu) ──────────────────────────── async function changeColorsFlow(ctx: ExtensionContext) { // Line selection loop while (true) { const lineResult = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); container.addChild( new Text( theme.fg("accent", theme.bold("Change Colors — Select Line")), 1, 0, ), ); const items = [0, 1, 2].map((i) => ({ value: `${i}`, label: `Line ${i + 1}`, description: currentLinePreview(config.lines[i], theme), })); const selectList = new SelectList(items, 10, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }, ); // Escape at line selector → back to main menu if (lineResult === null) return; // Show each widget on this line and let user pick colors const lineIndex = parseInt(lineResult, 10); const widgets = config.lines[lineIndex]; if (!widgets || widgets.length === 0) { ctx.ui.notify("This line has no widgets", "info"); continue; // back to line selector } // Widget selector loop while (true) { const widgetResult = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); container.addChild( new Text( theme.fg( "accent", theme.bold(`Change Colors — Line ${lineIndex + 1}`), ), 1, 0, ), ); const items = widgets.map((w, i) => { const colorLabel = getAnsiColorLabel(w.color || ""); return { value: `${i}`, label: `${i + 1}. ${w.type}`, description: `color: ${colorLabel}`, }; }); items.push({ value: "__done", label: "✓ Done", description: "Return to line selection", }); const selectList = new SelectList(items, 12, { selectedPrefix: (t: string) => theme.fg("accent", t), selectedText: (t: string) => theme.fg("accent", t), description: (t: string) => theme.fg("muted", t), scrollInfo: (t: string) => theme.fg("dim", t), noMatch: (t: string) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild( new Text( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0, ), ); container.addChild( new DynamicBorder((s: string) => theme.fg("accent", s)), ); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }, ); // Escape or Done → back to line selector if (widgetResult === null || widgetResult === "__done") break; const idx = parseInt(widgetResult, 10); if (!isNaN(idx) && idx >= 0 && idx < widgets.length) { await pickAnsiColor(widgets[idx], ctx); saveConfig(pi, config); // Persist immediately after each color change } } } } // ── ANSI 16-color picker with preview ────────────────────────────── async function pickAnsiColor(widget: WidgetConfig, ctx: ExtensionContext) { await ctx.ui.custom((tui, theme, _kb, done) => { let selectedIndex = Math.max( 0, ANSI_COLORS.findIndex((c) => { const current = widget.color || ""; return c.value === current || `ansi:${c.code}` === current; }), ); const typeInfo = WIDGET_INFO.find((i) => i.type === widget.type); return { render: (width: number) => { const lines: string[] = []; lines.push(theme.fg("accent", theme.bold("Widget Color"))); lines.push( theme.fg("dim", `Widget: ${typeInfo?.label || widget.type}`), ); lines.push(""); // Current color preview line const currentLabel = getAnsiColorLabel(widget.color || ""); const currentCode = getAnsiColorCode(widget.color || ""); const currentPreview = currentCode ? applyWidgetColor(`ansi:${currentCode}`, "██", theme) : " "; lines.push( ` ${currentPreview} ${theme.fg("text", `Current: ${currentLabel}`)}`, ); lines.push(""); // Color list with preview const startIdx = Math.max( 0, Math.min(selectedIndex - 5, ANSI_COLORS.length - 11), ); const endIdx = Math.min(startIdx + 11, ANSI_COLORS.length); for (let i = startIdx; i < endIdx; i++) { const c = ANSI_COLORS[i]; const isSelected = i === selectedIndex; const prefix = isSelected ? theme.fg("accent", "→ ") : " "; const preview = c.code ? applyWidgetColor(`ansi:${c.code}`, "██", theme) : " "; const label = c.label; lines.push(`${prefix}${preview} ${label}`); } lines.push(""); lines.push( theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), ); return lines; }, invalidate: () => {}, handleInput: (data: string) => { if (matchesKey(data, Key.escape)) { done(null); return; } if (matchesKey(data, Key.up)) { selectedIndex = selectedIndex === 0 ? ANSI_COLORS.length - 1 : selectedIndex - 1; tui.requestRender(); } else if (matchesKey(data, Key.down)) { selectedIndex = selectedIndex === ANSI_COLORS.length - 1 ? 0 : selectedIndex + 1; tui.requestRender(); } else if ( matchesKey(data, Key.enter) || matchesKey(data, Key.space) ) { const selected = ANSI_COLORS[selectedIndex]; if (selected) { widget.color = selected.value ? `ansi:${selected.code}` : undefined; } done("ok"); } }, }; }); } function getWidgetDescription(w: WidgetConfig): string { const parts: string[] = []; if (w.showLabel && w.label) parts.push(`label="${w.label}"`); if (w.text) parts.push(`text="${w.text}"`); if (w.separator) parts.push(`sep="${w.separator}"`); if (w.minWidth) parts.push(`width=${w.minWidth}`); if (w.color) { const colorLabel = getAnsiColorLabel(w.color); parts.push(`color=${colorLabel}`); } return parts.length > 0 ? parts.join(", ") : "default"; } // ── Command ───────────────────────────────────────────────────────── pi.registerCommand("statusbar", { description: "Configure the status bar (add/reorder/remove widgets on up to 3 lines). Use 'reset' to restore defaults, 'toggle' to enable/disable.", handler: async (args, ctx) => { if (args?.trim() === "toggle") { if (enabled) { disable(ctx); ctx.ui.notify("Status bar disabled", "info"); } else { enable(ctx); ctx.ui.notify("Status bar enabled", "info"); } return; } if (args?.trim() === "reset") { config = { lines: DEFAULT_CONFIG.lines.map((l) => [...l]) }; saveConfig(pi, config); // Clear session SHA file so cumulative counters restart fresh try { const file = getCountersFile(ctx.cwd); if (fs.existsSync(file)) fs.unlinkSync(file); } catch { /* ignore */ } sessionStartSha = null; ctx.ui.notify("Status bar reset to defaults", "info"); if (enabled) { disable(ctx); enable(ctx); } return; } await openConfigUI(ctx); }, }); // ── Events ────────────────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { // Auto-enable status bar on launch — loads saved config internally enable(ctx); }); }