spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tui/components/status-bar.ts4 * Description: The bottom status bar — real segments only, width-responsive degradation (TUI_DESIGN §11).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { truncateAnsi } from "../renderer/ansi.js";11import type { Theme } from "../theme.js";1213export interface StatusBarData {14 /** Git branch (repo watcher). Absent when not in a repo. */15 branch?: string;16 /** Dirty counts as `+added −removed` (absent when clean or unknown). */17 dirty?: { added: number; removed: number };18 /** Configured model id. */19 model?: string;20 /** Short alias for narrow widths (falls back to a heuristic shortening). */21 modelAlias?: string;22 /** Real context utilization percent (usage vs usable window). */23 contextPct?: number;24 /** Real session cost from API usage metadata — absent when unpriceable. */25 costUsd?: number;26 /** Running background processes. */27 processCount?: number;28 /** Queued steering messages. */29 queuedCount?: number;30 /** Phase-gate ribbon (v2 §1): absent when gates are off. */31 phase?: "understand" | "design" | "implement";32}3334const PHASE_GLYPH: Record<NonNullable<StatusBarData["phase"]>, string> = {35 understand: "◐",36 design: "◑",37 implement: "●",38};3940const PHASE_ORDER: NonNullable<StatusBarData["phase"]>[] = ["understand", "design", "implement"];4142/**43 * Phase ribbon: the active phase in ember (accent), passed phases dimmed with44 * a ✓, future phases dim — `◐ understand ─ design ─ implement` (TUI v2 §3).45 */46export function renderPhaseRibbon(47 phase: NonNullable<StatusBarData["phase"]>,48 theme: Theme,49 compact: boolean,50): string {51 if (compact) {52 return theme.paint("accent", `${PHASE_GLYPH[phase]} ${phase.toUpperCase()}`);53 }54 const activeIndex = PHASE_ORDER.indexOf(phase);55 const parts = PHASE_ORDER.map((name, index) => {56 if (index === activeIndex) return theme.paint("accent", `${PHASE_GLYPH[name]} ${name.toUpperCase()}`);57 if (index < activeIndex) return theme.paint("dim", `✓ ${name}`);58 return theme.paint("dim", name);59 });60 return parts.join(theme.paint("dim", " ─ "));61}6263/** Context gauge `▰▰▰▱▱▱▱▱ 62%` — teal < 50, warning < 80, error above; ≥ 80 nudges /compact (TUI v2 §3). */64export function renderContextGauge(pct: number, theme: Theme): string {65 const clamped = Math.max(0, Math.min(100, Math.round(pct)));66 const filled = Math.round((clamped / 100) * 8);67 const bar = "▰".repeat(filled) + "▱".repeat(8 - filled);68 const role = clamped >= 80 ? "error" : clamped >= 50 ? "warning" : "teal";69 const nudge = clamped >= 80 ? " · /compact" : "";70 return theme.paint(role, `${bar} ${clamped}%${nudge}`);71}7273function shortModel(data: StatusBarData): string | undefined {74 if (data.modelAlias !== undefined) return data.modelAlias;75 if (data.model === undefined) return undefined;76 // "claude-sonnet-4-5" → "sonnet"; unknown shapes keep their id.77 const m = /^claude-([a-z]+)/.exec(data.model);78 return m ? (m[1] as string) : data.model;79}8081function contextSegment(data: StatusBarData, theme: Theme, withWord: boolean): string | undefined {82 if (data.contextPct === undefined) return undefined;83 const pct = Math.round(data.contextPct);84 let text = withWord ? `context ${pct}%` : `${pct}%`;85 if (pct >= 80) text += " · /compact";86 if (pct >= 90) return theme.paint("error", text);87 if (pct >= 80) return theme.paint("warning", text);88 return theme.paint("teal", text);89}9091/**92 * Render the status bar for the current width. Segments with no real data93 * are absent, not zeroed (Absolute Rule #4). Lower-priority segments drop94 * whole as width shrinks — never truncated mid-token. Each segment carries95 * its own accent (branch cyan · model violet · context teal · cost orange);96 * separators stay dim, monochrome text is unchanged.97 */98export function renderStatusBar(data: StatusBarData, width: number, theme: Theme): string {99 const segments: string[] = [];100101 const branchFull =102 data.branch !== undefined103 ? data.dirty && (data.dirty.added > 0 || data.dirty.removed > 0)104 ? `${theme.paint("cyan", data.branch)} ${theme.paint("success", `+${data.dirty.added}`)} ${theme.paint("error", `−${data.dirty.removed}`)}`105 : theme.paint("cyan", data.branch)106 : undefined;107108 if (width < 60) {109 if (data.phase !== undefined) segments.push(renderPhaseRibbon(data.phase, theme, true));110 if (data.branch !== undefined) segments.push(theme.paint("cyan", data.branch));111 const ctx = contextSegment(data, theme, false);112 if (ctx !== undefined) segments.push(ctx);113 } else if (width < 80) {114 if (data.phase !== undefined) segments.push(renderPhaseRibbon(data.phase, theme, true));115 if (branchFull !== undefined) segments.push(branchFull);116 const alias = shortModel(data);117 if (alias !== undefined) segments.push(theme.paint("violet", alias));118 const ctx = contextSegment(data, theme, false);119 if (ctx !== undefined) segments.push(ctx);120 } else {121 // The cockpit (TUI v2 §3): phase ribbon left, then branch, model, gauge, cost.122 if (data.phase !== undefined) {123 segments.push(renderPhaseRibbon(data.phase, theme, width < 110));124 }125 if (branchFull !== undefined) segments.push(branchFull);126 const model = width < 100 ? shortModel(data) : data.model;127 if (model !== undefined) segments.push(theme.paint("violet", model));128 if (data.contextPct !== undefined && width >= 110) {129 segments.push(renderContextGauge(data.contextPct, theme));130 } else {131 const ctx = contextSegment(data, theme, true);132 if (ctx !== undefined) segments.push(ctx);133 }134 if (data.costUsd !== undefined) segments.push(theme.paint("orange", `$${data.costUsd.toFixed(2)}`));135 if (data.processCount !== undefined && data.processCount > 0) {136 segments.push(`${theme.paint("accent", "●")} ${data.processCount}`);137 }138 if (data.queuedCount !== undefined && data.queuedCount > 0) {139 segments.push(theme.paint("warning", `⋯ ${data.queuedCount} queued`));140 }141 }142143 const bar = segments.join(theme.paint("dim", " │ "));144 return truncateAnsi(" " + bar, width);145}146