/** * KHAELOR * File: src/tui/components/status-bar.ts * Description: The bottom status bar — real segments only, width-responsive degradation (TUI_DESIGN §11). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { truncateAnsi } from "../renderer/ansi.js"; import type { Theme } from "../theme.js"; export interface StatusBarData { /** Git branch (repo watcher). Absent when not in a repo. */ branch?: string; /** Dirty counts as `+added −removed` (absent when clean or unknown). */ dirty?: { added: number; removed: number }; /** Configured model id. */ model?: string; /** Short alias for narrow widths (falls back to a heuristic shortening). */ modelAlias?: string; /** Real context utilization percent (usage vs usable window). */ contextPct?: number; /** Real session cost from API usage metadata — absent when unpriceable. */ costUsd?: number; /** Running background processes. */ processCount?: number; /** Queued steering messages. */ queuedCount?: number; /** Phase-gate ribbon (v2 §1): absent when gates are off. */ phase?: "understand" | "design" | "implement"; } const PHASE_GLYPH: Record, string> = { understand: "◐", design: "◑", implement: "●", }; const PHASE_ORDER: NonNullable[] = ["understand", "design", "implement"]; /** * Phase ribbon: the active phase in ember (accent), passed phases dimmed with * a ✓, future phases dim — `◐ understand ─ design ─ implement` (TUI v2 §3). */ export function renderPhaseRibbon( phase: NonNullable, theme: Theme, compact: boolean, ): string { if (compact) { return theme.paint("accent", `${PHASE_GLYPH[phase]} ${phase.toUpperCase()}`); } const activeIndex = PHASE_ORDER.indexOf(phase); const parts = PHASE_ORDER.map((name, index) => { if (index === activeIndex) return theme.paint("accent", `${PHASE_GLYPH[name]} ${name.toUpperCase()}`); if (index < activeIndex) return theme.paint("dim", `✓ ${name}`); return theme.paint("dim", name); }); return parts.join(theme.paint("dim", " ─ ")); } /** Context gauge `▰▰▰▱▱▱▱▱ 62%` — teal < 50, warning < 80, error above; ≥ 80 nudges /compact (TUI v2 §3). */ export function renderContextGauge(pct: number, theme: Theme): string { const clamped = Math.max(0, Math.min(100, Math.round(pct))); const filled = Math.round((clamped / 100) * 8); const bar = "▰".repeat(filled) + "▱".repeat(8 - filled); const role = clamped >= 80 ? "error" : clamped >= 50 ? "warning" : "teal"; const nudge = clamped >= 80 ? " · /compact" : ""; return theme.paint(role, `${bar} ${clamped}%${nudge}`); } function shortModel(data: StatusBarData): string | undefined { if (data.modelAlias !== undefined) return data.modelAlias; if (data.model === undefined) return undefined; // "claude-sonnet-4-5" → "sonnet"; unknown shapes keep their id. const m = /^claude-([a-z]+)/.exec(data.model); return m ? (m[1] as string) : data.model; } function contextSegment(data: StatusBarData, theme: Theme, withWord: boolean): string | undefined { if (data.contextPct === undefined) return undefined; const pct = Math.round(data.contextPct); let text = withWord ? `context ${pct}%` : `${pct}%`; if (pct >= 80) text += " · /compact"; if (pct >= 90) return theme.paint("error", text); if (pct >= 80) return theme.paint("warning", text); return theme.paint("teal", text); } /** * Render the status bar for the current width. Segments with no real data * are absent, not zeroed (Absolute Rule #4). Lower-priority segments drop * whole as width shrinks — never truncated mid-token. Each segment carries * its own accent (branch cyan · model violet · context teal · cost orange); * separators stay dim, monochrome text is unchanged. */ export function renderStatusBar(data: StatusBarData, width: number, theme: Theme): string { const segments: string[] = []; const branchFull = data.branch !== undefined ? data.dirty && (data.dirty.added > 0 || data.dirty.removed > 0) ? `${theme.paint("cyan", data.branch)} ${theme.paint("success", `+${data.dirty.added}`)} ${theme.paint("error", `−${data.dirty.removed}`)}` : theme.paint("cyan", data.branch) : undefined; if (width < 60) { if (data.phase !== undefined) segments.push(renderPhaseRibbon(data.phase, theme, true)); if (data.branch !== undefined) segments.push(theme.paint("cyan", data.branch)); const ctx = contextSegment(data, theme, false); if (ctx !== undefined) segments.push(ctx); } else if (width < 80) { if (data.phase !== undefined) segments.push(renderPhaseRibbon(data.phase, theme, true)); if (branchFull !== undefined) segments.push(branchFull); const alias = shortModel(data); if (alias !== undefined) segments.push(theme.paint("violet", alias)); const ctx = contextSegment(data, theme, false); if (ctx !== undefined) segments.push(ctx); } else { // The cockpit (TUI v2 §3): phase ribbon left, then branch, model, gauge, cost. if (data.phase !== undefined) { segments.push(renderPhaseRibbon(data.phase, theme, width < 110)); } if (branchFull !== undefined) segments.push(branchFull); const model = width < 100 ? shortModel(data) : data.model; if (model !== undefined) segments.push(theme.paint("violet", model)); if (data.contextPct !== undefined && width >= 110) { segments.push(renderContextGauge(data.contextPct, theme)); } else { const ctx = contextSegment(data, theme, true); if (ctx !== undefined) segments.push(ctx); } if (data.costUsd !== undefined) segments.push(theme.paint("orange", `$${data.costUsd.toFixed(2)}`)); if (data.processCount !== undefined && data.processCount > 0) { segments.push(`${theme.paint("accent", "●")} ${data.processCount}`); } if (data.queuedCount !== undefined && data.queuedCount > 0) { segments.push(theme.paint("warning", `⋯ ${data.queuedCount} queued`)); } } const bar = segments.join(theme.paint("dim", " │ ")); return truncateAnsi(" " + bar, width); }