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/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