SPB Git

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%
6.1 KB · 146 lines typescript
Raw Blame History
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