/** * 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; } 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.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 (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 { if (branchFull !== undefined) segments.push(branchFull); const model = width < 100 ? shortModel(data) : data.model; if (model !== undefined) segments.push(theme.paint("violet", model)); 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); }