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%
34.9 KB · 991 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tui/app.ts4 * Description: The TUI application — event-bus-driven view state, key dispatch via the command registry, frame assembly.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { Coalescer } from "../session/index.js";11import type {12  CoalescedFrame,13  DurableEvent,14  EventBus,15  ModelUsage,16  ToolName,17} from "../session/index.js";18import { CommandRegistry } from "./commands.js";19import { renderDiffBlock, renderEditSummary } from "./components/diff.js";20import { renderErrorPanel } from "./components/error-panel.js";21import { renderPermissionPanel } from "./components/permission-panel.js";22import { renderStatusBar } from "./components/status-bar.js";23import type { StatusBarData } from "./components/status-bar.js";24import { renderStatusLine } from "./components/status-line.js";25import type { AgentStatus } from "./components/status-line.js";26import { renderRunningTool, renderToolLine } from "./components/tool-line.js";27import type { ToolLineKind } from "./components/tool-line.js";28import { Composer } from "./composer/composer.js";29import { createPalette, paletteMove, paletteSelection, paletteSetQuery, renderPalette } from "./composer/palette.js";30import type { PaletteItem, PaletteState } from "./composer/palette.js";31import { renderMarkdownBlock, renderTailLine } from "./markdown/render.js";32import { MarkdownStreamScanner } from "./markdown/scanner.js";33import { truncateAnsi, wrapText } from "./renderer/ansi.js";34import { detectColorDepth, probeTerminal } from "./renderer/capabilities.js";35import type { LiveFrame } from "./renderer/frame.js";36import { KeyDecoder } from "./renderer/input.js";37import type { KeyEvent } from "./renderer/input.js";38import { LiveRenderer, streamIo } from "./renderer/renderer.js";39import type { RendererIo } from "./renderer/renderer.js";40import { enterTerminal } from "./renderer/terminal.js";41import type { TerminalSession } from "./renderer/terminal.js";42import { resolveTheme } from "./theme.js";43import type { Theme } from "./theme.js";4445export type PermissionDecision = "allow-once" | "allow-always" | "deny";4647/** The app renders and dispatches; the engine (or the demo driver) acts. */48export interface TuiAppActions {49  submit(text: string, opts: { shell: boolean }): void;50  interrupt(): void;51  permission(requestId: string, decision: PermissionDecision): void;52  quit(): void;53}5455export interface ModelPricing {56  inputPerMTok: number;57  outputPerMTok: number;58  cacheReadPerMTok: number;59  cacheWritePerMTok: number;60}6162export interface TuiAppOptions {63  bus: EventBus;64  actions: TuiAppActions;65  io?: RendererIo;66  stdin?: NodeJS.ReadStream;67  stdout?: NodeJS.WriteStream;68  model: string;69  thinking?: string;70  cwdLabel?: string;71  gitBranch?: string | null;72  /** Usable context window (tokens) for the context% segment. */73  contextWindow?: number;74  /** Pricing for the configured model; absent → cost segments are absent, never invented. */75  pricing?: ModelPricing;76  /** Repository search plugs in here later; default provider returns nothing. */77  mentionProvider?: (query: string) => PaletteItem[];78  /** Force interactive (raw-mode) input on/off; default: stdin.isTTY. */79  interactive?: boolean;80  now?: () => number;81}8283interface LiveToolRow {84  toolUseId: string;85  name: ToolName;86  label: string;87  startedAt: number;88  output: string;89  detailOpen: boolean;90}9192interface PermissionOverlay {93  requestId: string;94  subject: string;95  verb: string;96  alwaysPattern: string | undefined;97}9899const TOOL_STATUS: Record<ToolName, AgentStatus["kind"]> = {100  read: "reading",101  grep: "searching",102  glob: "searching",103  edit: "editing",104  write: "editing",105  bash: "running",106  process: "running",107};108109const TOOL_VERB: Record<ToolName, string> = {110  read: "Read",111  grep: "Search",112  glob: "Glob",113  edit: "Edit",114  write: "Write",115  bash: "Run",116  process: "Process",117};118119const TOOL_KIND: Record<ToolName, ToolLineKind> = {120  read: "read",121  grep: "search",122  glob: "search",123  edit: "edit",124  write: "edit",125  bash: "exec",126  process: "process",127};128129export class TuiApp {130  private readonly bus: EventBus;131  private readonly actions: TuiAppActions;132  private readonly io: RendererIo;133  private readonly stdin: NodeJS.ReadStream;134  private readonly stdout: NodeJS.WriteStream;135  private readonly options: TuiAppOptions;136  private readonly now: () => number;137138  private theme: Theme;139  private readonly renderer: LiveRenderer;140  private readonly coalescer: Coalescer;141  private readonly decoder = new KeyDecoder();142  private readonly registry = new CommandRegistry();143  private readonly composer: Composer;144145  private terminalSession: TerminalSession | null = null;146  private ticker: ReturnType<typeof setInterval> | null = null;147  private unsubscribeFrames: (() => void) | null = null;148  private readonly onStdinData = (chunk: Buffer): void => {149    for (const key of this.decoder.push(chunk)) this.handleKey(key);150    // Every key can mutate composer/overlay state — mark dirty so the151    // immediate flush actually repaints (live echo; TUI_DESIGN §10.3).152    this.renderer.markDirty();153    this.renderer.flushNow();154  };155  private readonly onResize = (): void => {156    this.renderer.invalidate();157    this.renderer.markDirty();158  };159160  // ── view state (derived from events only — Absolute Rule #4) ──161  private scanner = new MarkdownStreamScanner();162  private currentBlockKey: string | null = null;163  private liveTools: LiveToolRow[] = [];164  private toolIndex = 0;165  private queued: { eventId: string; text: string }[] = [];166  private status: AgentStatus = { kind: "idle", startedAt: 0 };167  private statusBeforeWaiting: AgentStatus | null = null;168  private busy = false;169  private permissionOverlay: PermissionOverlay | null = null;170  private universalPalette: PaletteState | null = null;171  private lastDiff: { path: string; diff: string; stats: { added: number; removed: number } } | null =172    null;173  private usage: ModelUsage = {174    inputTokens: 0,175    outputTokens: 0,176    cacheReadTokens: 0,177    cacheWriteTokens: 0,178  };179  private lastContextTokens: number | null = null;180  private dirtyStats = { added: 0, removed: 0 };181  private processCount = 0;182  private lastCtrlC = 0;183  private stopped = false;184  /** First-run composer placeholder; cleared after the first user message. */185  private placeholder: string | null = "What do you want to build?";186  /** When set, the next universal-palette selection routes here instead of the registry. */187  private universalPaletteOnSelect: ((id: string) => void) | null = null;188189  constructor(options: TuiAppOptions) {190    this.options = options;191    this.bus = options.bus;192    this.actions = options.actions;193    this.stdin = options.stdin ?? process.stdin;194    this.stdout = options.stdout ?? process.stdout;195    this.io = options.io ?? streamIo(this.stdout);196    this.now = options.now ?? Date.now;197198    this.theme = resolveTheme({199      colorDepth: detectColorDepth(process.env, this.stdout.isTTY === true),200    });201202    this.renderer = new LiveRenderer(this.io, { frame: () => this.buildFrame() });203    this.coalescer = new Coalescer(this.bus);204    this.composer = new Composer({205      slashCommands: () => this.registry.slashItems(),206      mentions: (query) => (this.options.mentionProvider ?? (() => []))(query),207    });208    this.registerCommands();209  }210211  async start(): Promise<void> {212    const interactive = this.options.interactive ?? this.stdin.isTTY === true;213    if (interactive) {214      this.terminalSession = enterTerminal(this.stdin, this.stdout);215      const probed = await probeTerminal({ stdin: this.stdin, stdout: this.stdout });216      this.renderer.setSyncUpdates(probed.syncUpdates);217      if (probed.background !== null && probed.background !== this.theme.background) {218        this.theme = resolveTheme({219          colorDepth: this.theme.colorDepth,220          background: probed.background,221        });222      }223      this.stdin.on("data", this.onStdinData);224    }225    this.stdout.on("resize", this.onResize);226    this.unsubscribeFrames = this.coalescer.subscribe((frame) => {227      this.applyFrame(frame);228      this.renderer.markDirty();229    });230    this.ticker = setInterval(() => {231      if (this.status.kind !== "idle" || this.liveTools.length > 0) this.renderer.markDirty();232    }, 120);233    this.ticker.unref();234235    this.printStartup();236    this.renderer.flushNow();237  }238239  stop(): void {240    if (this.stopped) return;241    this.stopped = true;242    if (this.ticker !== null) clearInterval(this.ticker);243    this.unsubscribeFrames?.();244    this.coalescer.dispose();245    this.stdin.off("data", this.onStdinData);246    this.stdout.off("resize", this.onResize);247    this.renderer.unmount();248    this.terminalSession?.restore();249  }250251  /** Test/driver seam: feed a key without a real stdin. */252  pressKey(key: KeyEvent): void {253    this.handleKey(key);254    this.renderer.markDirty();255    this.renderer.flushNow();256  }257258  // ───────────────── composition-root seams (cli wiring) ─────────────────259260  /** The command registry — the cli layer registers real slash commands here. */261  get commands(): CommandRegistry {262    return this.registry;263  }264265  /** Print a settled block into the conversation (command output). */266  printBlock(lines: string[]): void {267    this.renderer.printSettled(lines);268    this.renderer.flushNow();269  }270271  /** The active theme (for cli command output styling). */272  currentTheme(): Theme {273    return this.theme;274  }275276  /** Open the universal palette with custom items; selection routes to `onSelect`. */277  openSelector(items: PaletteItem[], onSelect: (id: string) => void): void {278    this.universalPalette = createPalette("command", items);279    this.universalPaletteOnSelect = onSelect;280    this.renderer.markDirty();281    this.renderer.flushNow();282  }283284  /** Update the status-bar/status model label (after /model switches). */285  setModelLabel(model: string): void {286    this.options.model = model;287    this.renderer.markDirty();288  }289290  /** Fill in the git branch once the async lookup completes (startup is non-blocking). */291  setGitBranch(branch: string | null): void {292    this.options.gitBranch = branch;293    this.renderer.markDirty();294  }295296  /** Plug in the repository `@` mention provider once the index is ready. */297  setMentionProvider(provider: (query: string) => PaletteItem[]): void {298    this.options.mentionProvider = provider;299  }300301  // ───────────────────────── startup ─────────────────────────302303  private printStartup(): void {304    const width = this.io.columns();305    const t = this.theme;306    const where = [this.options.cwdLabel, this.options.gitBranch ?? undefined]307      .filter((s): s is string => s !== undefined && s !== "")308      .join(" · ");309    const modelLine = [this.options.model, this.options.thinking ? `thinking ${this.options.thinking}` : undefined]310      .filter((s): s is string => s !== undefined)311      .join(" · ");312    const block: string[] = ["", " " + t.paint("bold", t.paintGradient("brand", "KHAELOR"))];313    if (where !== "") block.push(" " + t.paint("dim", where));314    block.push(" " + t.paint("dim", modelLine));315    block.push(t.paint("dim", "─".repeat(Math.max(10, Math.min(width - 1, 72)))));316    this.renderer.printSettled(block);317  }318319  // ───────────────────────── event application ─────────────────────────320321  private applyFrame(frame: CoalescedFrame): void {322    for (const [key, text] of frame.textAppends) {323      // Thinking deltas keep the status line honest but are not the centerpiece.324      if (this.currentBlockKey !== null && key !== this.currentBlockKey) {325        this.settleScanner();326      }327      this.currentBlockKey = key;328      for (const settled of this.scanner.append(text)) this.settleMarkdown(settled);329      this.enforceTailCap();330    }331    for (const [toolUseId, chunk] of frame.toolOutputAppends) {332      const row = this.liveTools.find((r) => r.toolUseId === toolUseId);333      if (row) row.output += chunk;334    }335    for (const event of frame.durables) this.applyDurable(event);336  }337338  private applyDurable(event: DurableEvent): void {339    switch (event.type) {340      case "user.message-created": {341        this.settleScanner();342        this.renderer.printSettled([343          "",344          truncateAnsi(` ${this.theme.paint("accent", "❯")} ${event.payload.text}`, this.io.columns()),345        ]);346        this.busy = true;347        this.toolIndex = 0;348        this.placeholder = null;349        this.setStatus("thinking");350        break;351      }352      case "user.steering-queued":353        this.queued.push({ eventId: event.id, text: event.payload.text });354        break;355      case "user.steering-injected": {356        const idx = this.queued.findIndex((q) => q.eventId === event.payload.queuedEventId);357        if (idx !== -1) {358          const [q] = this.queued.splice(idx, 1);359          if (q) {360            this.renderer.printSettled([361              "",362              truncateAnsi(` ${this.theme.paint("accent", "❯")} ${q.text}`, this.io.columns()),363            ]);364          }365        }366        break;367      }368      case "model.request-started":369        this.busy = true;370        this.lastContextTokens = null;371        this.setStatus("thinking");372        break;373      case "model.text-block-completed":374        this.settleScanner();375        if (!this.hasRunningTools()) this.setStatus("thinking");376        break;377      case "tool.requested": {378        const name = event.payload.toolName;379        const label = `${TOOL_VERB[name]} ${describeToolInput(name, event.payload.input)}`.trimEnd();380        this.liveTools.push({381          toolUseId: event.payload.toolUseId,382          name,383          label,384          startedAt: event.ts,385          output: "",386          detailOpen: false,387        });388        this.setStatus(TOOL_STATUS[name], describeToolInput(name, event.payload.input));389        break;390      }391      case "tool.started":392        break;393      case "tool.completed": {394        this.removeLiveTool(event.payload.toolUseId);395        this.toolIndex += 1;396        this.renderer.printSettled([397          renderToolLine(398            {399              summary: event.payload.ui.summary,400              outcome: "ok",401              index: this.toolIndex,402              kind: event.payload.ui.kind,403            },404            this.io.columns(),405            this.theme,406          ),407        ]);408        if (!this.hasRunningTools()) this.setStatus("thinking");409        break;410      }411      case "tool.failed": {412        const row = this.removeLiveTool(event.payload.toolUseId);413        this.toolIndex += 1;414        const secs = (event.payload.durationMs / 1000).toFixed(1);415        const label = row?.label ?? "tool";416        this.renderer.printSettled([417          renderToolLine(418            { summary: `${label} · failed · ${secs}s`, outcome: "failed", index: this.toolIndex },419            this.io.columns(),420            this.theme,421          ),422          ...renderErrorPanel(423            {424              title: `${label} failed`,425              detailLines: event.payload.modelText.split("\n").slice(0, 4),426              followUp: "KHAELOR is inspecting the failure.",427            },428            this.io.columns(),429            this.theme,430          ),431        ]);432        if (!this.hasRunningTools()) this.setStatus("thinking");433        break;434      }435      case "tool.cancelled": {436        const row = this.removeLiveTool(event.payload.toolUseId);437        if (row) {438          const secs = ((event.ts - row.startedAt) / 1000).toFixed(1);439          this.renderer.printSettled([440            renderToolLine(441              { summary: `${row.label} · ${secs}s`, outcome: "cancelled" },442              this.io.columns(),443              this.theme,444            ),445          ]);446        }447        break;448      }449      case "file.modified": {450        this.dirtyStats.added += event.payload.diffStats.added;451        this.dirtyStats.removed += event.payload.diffStats.removed;452        if (event.payload.diff !== undefined) {453          this.lastDiff = {454            path: event.payload.path,455            diff: event.payload.diff,456            stats: event.payload.diffStats,457          };458        }459        this.renderer.printSettled([460          renderEditSummary(461            event.payload.path,462            event.payload.diffStats,463            this.io.columns(),464            this.theme,465            event.payload.diff !== undefined,466          ),467        ]);468        break;469      }470      case "permission.requested": {471        this.statusBeforeWaiting = this.status;472        this.permissionOverlay = {473          requestId: event.payload.permissionRequestId,474          verb: verbForCapability(event.payload.capability),475          subject: event.payload.descriptor,476          alwaysPattern: event.payload.suggestion?.pattern,477        };478        this.setStatus("waiting");479        break;480      }481      case "permission.granted":482      case "permission.denied": {483        this.permissionOverlay = null;484        if (this.statusBeforeWaiting !== null) {485          this.status = this.statusBeforeWaiting;486          this.statusBeforeWaiting = null;487        } else {488          this.setStatus("thinking");489        }490        break;491      }492      case "model.response-completed": {493        this.settleScanner();494        const u = event.payload.usage;495        this.usage = {496          inputTokens: this.usage.inputTokens + u.inputTokens,497          outputTokens: this.usage.outputTokens + u.outputTokens,498          cacheReadTokens: this.usage.cacheReadTokens + u.cacheReadTokens,499          cacheWriteTokens: this.usage.cacheWriteTokens + u.cacheWriteTokens,500        };501        this.lastContextTokens = u.inputTokens + u.cacheReadTokens + u.outputTokens;502        if (event.payload.stopReason !== "tool_use") {503          this.busy = false;504          this.setStatus("idle");505        }506        break;507      }508      case "model.request-failed": {509        this.settleScanner();510        this.busy = false;511        this.setStatus("idle");512        this.renderer.printSettled(513          renderErrorPanel(514            { title: "model request failed", detailLines: [event.payload.message] },515            this.io.columns(),516            this.theme,517          ),518        );519        break;520      }521      case "user.interrupted": {522        this.settleScanner();523        this.busy = false;524        this.setStatus("idle");525        this.renderer.printSettled([526          truncateAnsi(527            ` ${this.theme.paint("warning", "◌")} Interrupted — partial response kept`,528            this.io.columns(),529          ),530        ]);531        break;532      }533      case "process.started":534        this.processCount += 1;535        break;536      case "process.exited":537        this.processCount = Math.max(0, this.processCount - 1);538        break;539      case "task.completed":540      case "task.failed":541        this.busy = false;542        this.setStatus("idle");543        break;544      default:545        break;546    }547  }548549  private setStatus(kind: AgentStatus["kind"], detail?: string): void {550    if (this.status.kind === kind && this.status.detail === detail) return;551    const next: AgentStatus = { kind, startedAt: this.now() };552    if (detail !== undefined) next.detail = detail;553    this.status = next;554  }555556  private hasRunningTools(): boolean {557    return this.liveTools.length > 0;558  }559560  private removeLiveTool(toolUseId: string): LiveToolRow | null {561    const idx = this.liveTools.findIndex((r) => r.toolUseId === toolUseId);562    if (idx === -1) return null;563    const [row] = this.liveTools.splice(idx, 1);564    return row ?? null;565  }566567  private settleMarkdown(source: string): void {568    const rendered = renderMarkdownBlock(source, Math.min(this.io.columns() - 2, 88), this.theme);569    this.renderer.printSettled(["", ...rendered.map((l) => " " + l)]);570  }571572  private settleScanner(): void {573    for (const block of this.scanner.finish()) this.settleMarkdown(block);574    this.currentBlockKey = null;575  }576577  private enforceTailCap(): void {578    const cap = Math.min(240, Math.max(4, this.io.rows() - 8));579    const tailLines = this.scanner.tail().split("\n");580    if (tailLines.length > cap) {581      const flushed = this.scanner.settleHead(tailLines.length - Math.floor(cap / 2));582      if (flushed !== null) this.settleMarkdown(flushed);583    }584  }585586  // ───────────────────────── key dispatch ─────────────────────────587588  private handleKey(key: KeyEvent): void {589    // Overlay bindings (modal) suppress lower layers — TUI_DESIGN §13.590    if (this.permissionOverlay !== null) {591      this.handlePermissionKey(key, this.permissionOverlay);592      return;593    }594    if (this.universalPalette !== null) {595      this.handleUniversalPaletteKey(key);596      return;597    }598    if (this.composer.hasPalette()) {599      this.applyComposerEffect(this.composer.handleKey(key));600      return;601    }602603    // Single-key actions — composer empty only.604    if (key.type === "char" && this.composer.isEmpty()) {605      if (key.ch === "d" && this.lastDiff !== null) {606        this.printLastDiff();607        return;608      }609    }610611    // Global keys.612    if (key.type === "esc") {613      if (this.busy) {614        this.status = { kind: "stopping", startedAt: this.now() };615        this.renderer.markDirty();616        this.actions.interrupt();617      }618      return;619    }620    if (key.type === "ctrl") {621      switch (key.ch) {622        case "k":623          this.universalPalette = createPalette("command", this.registry.paletteItems());624          this.universalPaletteOnSelect = null;625          return;626        case "c": {627          const now = this.now();628          if (!this.composer.isEmpty()) {629            this.composer.reset();630          } else if (now - this.lastCtrlC < 1000) {631            this.actions.quit();632          }633          this.lastCtrlC = now;634          return;635        }636        case "d":637          if (this.composer.isEmpty()) this.actions.quit();638          return;639        case "l":640          this.renderer.invalidate();641          return;642        case "t": {643          const last = this.liveTools[this.liveTools.length - 1];644          if (last) last.detailOpen = !last.detailOpen;645          return;646        }647        default:648          break;649      }650    }651652    this.applyComposerEffect(this.composer.handleKey(key));653  }654655  private handlePermissionKey(key: KeyEvent, overlay: PermissionOverlay): void {656    if (key.type === "enter") {657      this.actions.permission(overlay.requestId, "allow-once");658    } else if (key.type === "esc") {659      this.actions.permission(overlay.requestId, "deny");660    } else if (661      key.type === "char" &&662      (key.ch === "a" || key.ch === "A") &&663      overlay.alwaysPattern !== undefined664    ) {665      this.actions.permission(overlay.requestId, "allow-always");666    }667    // Everything else is ignored while the panel is up (three keys, no typing).668  }669670  private handleUniversalPaletteKey(key: KeyEvent): void {671    const palette = this.universalPalette as PaletteState;672    if (key.type === "esc") {673      this.universalPalette = null;674      this.universalPaletteOnSelect = null;675      return;676    }677    if (key.type === "arrow" && key.key === "up") {678      this.universalPalette = paletteMove(palette, -1);679      return;680    }681    if (key.type === "arrow" && key.key === "down") {682      this.universalPalette = paletteMove(palette, 1);683      return;684    }685    if (key.type === "ctrl" && key.ch === "p") {686      this.universalPalette = paletteMove(palette, -1);687      return;688    }689    if (key.type === "ctrl" && key.ch === "n") {690      this.universalPalette = paletteMove(palette, 1);691      return;692    }693    if (key.type === "enter") {694      const item = paletteSelection(palette);695      const onSelect = this.universalPaletteOnSelect;696      this.universalPalette = null;697      this.universalPaletteOnSelect = null;698      if (item !== null) {699        if (onSelect !== null) onSelect(item.id);700        else this.registry.find(item.id)?.run();701      }702      return;703    }704    if (key.type === "char") {705      this.universalPalette = paletteSetQuery(palette, palette.query + key.ch);706      return;707    }708    if (key.type === "backspace") {709      this.universalPalette = paletteSetQuery(palette, palette.query.slice(0, -1));710      return;711    }712  }713714  private applyComposerEffect(effect: ReturnType<Composer["handleKey"]>): void {715    if (effect === null) return;716    switch (effect.type) {717      case "submit":718        this.actions.submit(effect.text, { shell: effect.shell });719        return;720      case "run-command":721        this.registry.find(effect.id)?.run();722        return;723      case "discard-queued":724        // Queue mutation is engine-owned (event-sourced); nothing local to drop.725        return;726    }727  }728729  // ───────────────────────── commands ─────────────────────────730731  private registerCommands(): void {732    const settle = (lines: string[]): void => {733      this.renderer.printSettled(lines);734    };735    const notYet = (what: string, phase: string): (() => void) => {736      return () =>737        settle(["", ` ${this.theme.paint("dim", `${what} arrives with ${phase} — not wired in this build.`)}`]);738    };739740    this.registry.register({741      id: "diff.show",742      title: "View diff",743      slash: "/diff",744      key: "d",745      description: "diff of the most recent edit",746      run: () => this.printLastDiff(),747    });748    this.registry.register({749      id: "cost.show",750      title: "Show cost",751      slash: "/cost",752      description: "session token usage and cost",753      run: () => this.printCost(),754    });755    this.registry.register({756      id: "help.show",757      title: "Help",758      slash: "/help",759      description: "list commands",760      run: () => this.printHelp(),761    });762    this.registry.register({763      id: "app.quit",764      title: "Quit",765      slash: "/quit",766      key: "Ctrl+D",767      description: "exit khaelor",768      run: () => this.actions.quit(),769    });770    this.registry.register({771      id: "palette.open",772      title: "Command palette",773      key: "Ctrl+K",774      run: () => {775        this.universalPalette = createPalette("command", this.registry.paletteItems());776        this.universalPaletteOnSelect = null;777      },778    });779    // Honest placeholders: listed (users discover the surface), never faked.780    this.registry.register({ id: "model.select", title: "Change model", slash: "/model", description: "model selector", run: notYet("The model selector", "Phase 3") });781    this.registry.register({ id: "config.open", title: "Configuration", slash: "/config", description: "configuration panel", run: notYet("The config panel", "Phase 3") });782    this.registry.register({ id: "sessions.open", title: "Sessions", slash: "/sessions", description: "browse and resume sessions", run: notYet("The session picker", "Phase 6") });783    this.registry.register({ id: "context.open", title: "Context inspector", slash: "/context", description: "context budget breakdown", run: notYet("The context inspector", "Phase 6") });784    this.registry.register({ id: "context.compact", title: "Compact context", slash: "/compact", description: "compact the context now", run: notYet("Compaction", "Phase 6") });785    this.registry.register({ id: "processes.open", title: "Show processes", slash: "/processes", description: "background processes", run: notYet("The process panel", "Phase 5") });786    this.registry.register({ id: "permissions.open", title: "Permissions", slash: "/permissions", description: "permission rules", run: notYet("The permission panel", "Phase 4") });787  }788789  private printLastDiff(): void {790    if (this.lastDiff === null) return;791    this.renderer.printSettled([792      "",793      ...renderDiffBlock(794        this.lastDiff.path,795        this.lastDiff.diff,796        this.lastDiff.stats,797        this.io.columns(),798        this.theme,799      ),800    ]);801  }802803  private printCost(): void {804    const t = this.theme;805    const p = this.options.pricing;806    const fmt = (tokens: number, perMTok: number | undefined): string => {807      const cost = perMTok !== undefined ? `$${((tokens / 1_000_000) * perMTok).toFixed(2)}` : "n/a";808      return `${tokens.toLocaleString("en-US").padStart(12)}   ${cost.padStart(8)}`;809    };810    this.renderer.printSettled([811      "",812      " " + t.paint("bold", "cost · this session"),813      t.paint("dim", `   input tokens  ${fmt(this.usage.inputTokens, p?.inputPerMTok)}`),814      t.paint("dim", `   output tokens ${fmt(this.usage.outputTokens, p?.outputPerMTok)}`),815      t.paint("dim", `   cache write   ${fmt(this.usage.cacheWriteTokens, p?.cacheWritePerMTok)}`),816      t.paint("dim", `   cache read    ${fmt(this.usage.cacheReadTokens, p?.cacheReadPerMTok)}`),817      p !== undefined818        ? ` ${t.paint("bold", `total  $${this.totalCost(p).toFixed(2)}`)}`819        : ` ${t.paint("dim", "total  n/a — no pricing configured for this model")}`,820    ]);821  }822823  private printHelp(): void {824    const lines = ["", " " + this.theme.paint("bold", "commands")];825    for (const def of this.registry.list()) {826      if (def.slash === undefined) continue;827      const key = def.key !== undefined ? `  ${def.key}` : "";828      lines.push(829        `   ${def.slash.padEnd(14)}${this.theme.paint("dim", (def.description ?? "") + key)}`,830      );831    }832    this.renderer.printSettled(lines);833  }834835  private totalCost(p: ModelPricing): number {836    return (837      (this.usage.inputTokens / 1_000_000) * p.inputPerMTok +838      (this.usage.outputTokens / 1_000_000) * p.outputPerMTok +839      (this.usage.cacheReadTokens / 1_000_000) * p.cacheReadPerMTok +840      (this.usage.cacheWriteTokens / 1_000_000) * p.cacheWritePerMTok841    );842  }843844  // ───────────────────────── frame assembly ─────────────────────────845846  private buildFrame(): LiveFrame {847    const width = this.io.columns();848    const rows = this.io.rows();849    const now = this.now();850    const t = this.theme;851    const lines: string[] = [];852853    const overlay =854      this.permissionOverlay !== null855        ? renderPermissionPanel(856            {857              verb: this.permissionOverlay.verb,858              subject: this.permissionOverlay.subject,859              ...(this.options.cwdLabel !== undefined ? { cwd: this.options.cwdLabel } : {}),860              ...(this.permissionOverlay.alwaysPattern !== undefined861                ? { alwaysPattern: this.permissionOverlay.alwaysPattern }862                : {}),863            },864            width,865            t,866          )867        : this.universalPalette !== null868          ? renderPalette(this.universalPalette, width, t, { showQuery: true, rows })869          : null;870871    if (overlay !== null) {872      lines.push("", ...overlay, "");873    } else {874      // Streaming tail — raw text, lightly styled (TUI_DESIGN §8 step 1).875      const tail = this.scanner.tail();876      if (tail !== "") {877        const cap = Math.min(240, Math.max(4, rows - 8));878        const wrapped = wrapText(tail, Math.max(20, width - 2));879        for (const line of wrapped.slice(-cap)) {880          lines.push(truncateAnsi(" " + renderTailLine(line, t), width));881        }882        lines.push("");883      }884      // Live tool rows with real elapsed timers.885      for (const row of this.liveTools) {886        const tailLines = row.detailOpen887          ? row.output.split("\n").filter((l) => l !== "").slice(-12)888          : undefined;889        lines.push(890          ...renderRunningTool(891            {892              label: row.label,893              startedAt: row.startedAt,894              kind: TOOL_KIND[row.name],895              ...(tailLines !== undefined ? { outputTail: tailLines } : {}),896            },897            now,898            width,899            t,900          ),901        );902      }903      if (this.liveTools.length > 0) lines.push("");904      // Queued steering messages.905      for (const q of this.queued) {906        lines.push(truncateAnsi(` ${t.paint("dim", "⋯")} Queued — ${q.text}`, width));907      }908      if (this.queued.length > 0) {909        lines.push("   " + t.paint("dim", "Esc cancel run · Ctrl+U discard queued"));910        lines.push("");911      }912      // Composer's own palette (slash / mention), anchored above the composer.913      const composerPalette = this.composer.paletteState();914      if (composerPalette !== null) {915        lines.push(...renderPalette(composerPalette, width, t, { rows }));916      }917    }918919    // Agent status line (collapses when idle).920    const statusLine = renderStatusLine(this.status, now, t, width);921    if (statusLine !== null) {922      lines.push(statusLine, "");923    }924925    // Composer — the bordered box (content rows capped at 8, internal scroll).926    const maxComposerRows = Math.min(8, Math.max(2, Math.floor(rows / 3)));927    const composed = this.composer.render(width, maxComposerRows, t, {928      busy: this.busy,929      ...(this.placeholder !== null ? { placeholder: this.placeholder } : {}),930      ...(this.queued.length > 0 ? { queuedCount: this.queued.length } : {}),931    });932    const caretRow = lines.length + composed.caretRow;933    lines.push(...composed.lines);934935    // Status bar — always the last row.936    lines.push(this.statusBar(width));937938    return { lines, caretRow, caretCol: composed.caretCol };939  }940941  private statusBar(width: number): string {942    const data: StatusBarData = { model: this.options.model };943    if (this.options.gitBranch !== undefined && this.options.gitBranch !== null) {944      data.branch = this.options.gitBranch;945      if (this.dirtyStats.added > 0 || this.dirtyStats.removed > 0) {946        data.dirty = { ...this.dirtyStats };947      }948    }949    if (this.lastContextTokens !== null && this.options.contextWindow !== undefined) {950      data.contextPct = (this.lastContextTokens / this.options.contextWindow) * 100;951    }952    if (this.options.pricing !== undefined) {953      const cost = this.totalCost(this.options.pricing);954      if (cost > 0) data.costUsd = cost;955    }956    if (this.processCount > 0) data.processCount = this.processCount;957    if (this.queued.length > 0) data.queuedCount = this.queued.length;958    return renderStatusBar(data, width, this.theme);959  }960}961962// ───────────────────────── helpers ─────────────────────────963964function describeToolInput(name: ToolName, input: unknown): string {965  if (input === null || typeof input !== "object") return "";966  const o = input as Record<string, unknown>;967  const str = (k: string): string | null => (typeof o[k] === "string" ? (o[k] as string) : null);968  switch (name) {969    case "read":970    case "write":971    case "edit":972      return str("file_path") ?? str("path") ?? "";973    case "grep":974      return str("pattern") !== null ? `"${str("pattern") as string}"` : "";975    case "glob":976      return str("pattern") ?? "";977    case "bash":978    case "process":979      return str("command") ?? "";980  }981}982983function verbForCapability(capability: string): string {984  if (capability.startsWith("process.")) return "Run";985  if (capability.startsWith("file.write")) return "Write";986  if (capability.startsWith("file.read")) return "Read";987  if (capability.startsWith("network.")) return "Network";988  if (capability.startsWith("git.")) return "Git";989  return "Allow";990}991