/** * KHAELOR * File: src/tui/app.ts * Description: The TUI application — event-bus-driven view state, key dispatch via the command registry, frame assembly. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { Coalescer } from "../session/index.js"; import type { CoalescedFrame, DurableEvent, EventBus, ModelUsage, ToolName, } from "../session/index.js"; import { CommandRegistry } from "./commands.js"; import { renderDiffBlock, renderEditSummary } from "./components/diff.js"; import { renderErrorPanel } from "./components/error-panel.js"; import { renderPermissionPanel } from "./components/permission-panel.js"; import { renderDesignPanel } from "./components/design-panel.js"; import { renderStatusBar } from "./components/status-bar.js"; import type { StatusBarData } from "./components/status-bar.js"; import { renderStatusLine } from "./components/status-line.js"; import type { AgentStatus } from "./components/status-line.js"; import { renderRunningTool, renderToolLine } from "./components/tool-line.js"; import type { ToolLineKind } from "./components/tool-line.js"; import { Composer } from "./composer/composer.js"; import { createPalette, paletteMove, paletteSelection, paletteSetQuery, renderPalette } from "./composer/palette.js"; import type { PaletteItem, PaletteState } from "./composer/palette.js"; import { renderMarkdownBlock, renderTailLine } from "./markdown/render.js"; import { MarkdownStreamScanner } from "./markdown/scanner.js"; import { truncateAnsi, wrapText } from "./renderer/ansi.js"; import { detectColorDepth, probeTerminal } from "./renderer/capabilities.js"; import type { LiveFrame } from "./renderer/frame.js"; import { KeyDecoder } from "./renderer/input.js"; import type { KeyEvent } from "./renderer/input.js"; import { LiveRenderer, streamIo } from "./renderer/renderer.js"; import type { RendererIo } from "./renderer/renderer.js"; import { enterTerminal } from "./renderer/terminal.js"; import type { TerminalSession } from "./renderer/terminal.js"; import { resolveTheme } from "./theme.js"; import type { Theme } from "./theme.js"; export type PermissionDecision = "allow-once" | "allow-always" | "deny"; /** The app renders and dispatches; the engine (or the demo driver) acts. */ export interface TuiAppActions { submit(text: string, opts: { shell: boolean }): void; interrupt(): void; permission(requestId: string, decision: PermissionDecision): void; quit(): void; } export interface ModelPricing { inputPerMTok: number; outputPerMTok: number; cacheReadPerMTok: number; cacheWritePerMTok: number; } export interface TuiAppOptions { bus: EventBus; actions: TuiAppActions; io?: RendererIo; stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream; model: string; thinking?: string; cwdLabel?: string; gitBranch?: string | null; /** Usable context window (tokens) for the context% segment. */ contextWindow?: number; /** Pricing for the configured model; absent → cost segments are absent, never invented. */ pricing?: ModelPricing; /** Repository search plugs in here later; default provider returns nothing. */ mentionProvider?: (query: string) => PaletteItem[]; /** Force interactive (raw-mode) input on/off; default: stdin.isTTY. */ interactive?: boolean; now?: () => number; } interface LiveToolRow { toolUseId: string; name: ToolName; label: string; startedAt: number; output: string; detailOpen: boolean; } interface PermissionOverlay { requestId: string; subject: string; verb: string; alwaysPattern: string | undefined; } const TOOL_STATUS: Record = { read: "reading", grep: "searching", glob: "searching", edit: "editing", write: "editing", bash: "running", process: "running", design: "thinking", remember: "editing", symbols: "searching", refs: "searching", }; const TOOL_VERB: Record = { read: "Read", grep: "Search", glob: "Glob", edit: "Edit", write: "Write", bash: "Run", process: "Process", design: "Design", remember: "Remember", symbols: "Symbols", refs: "Refs", }; const TOOL_KIND: Record = { read: "read", grep: "search", glob: "search", edit: "edit", write: "edit", bash: "exec", process: "process", design: "exec", remember: "edit", symbols: "search", refs: "search", }; export class TuiApp { private readonly bus: EventBus; private readonly actions: TuiAppActions; private readonly io: RendererIo; private readonly stdin: NodeJS.ReadStream; private readonly stdout: NodeJS.WriteStream; private readonly options: TuiAppOptions; private readonly now: () => number; private theme: Theme; private readonly renderer: LiveRenderer; private readonly coalescer: Coalescer; private readonly decoder = new KeyDecoder(); private readonly registry = new CommandRegistry(); private readonly composer: Composer; private terminalSession: TerminalSession | null = null; private ticker: ReturnType | null = null; private unsubscribeFrames: (() => void) | null = null; private readonly onStdinData = (chunk: Buffer): void => { for (const key of this.decoder.push(chunk)) this.handleKey(key); // Every key can mutate composer/overlay state — mark dirty so the // immediate flush actually repaints (live echo; TUI_DESIGN §10.3). this.renderer.markDirty(); this.renderer.flushNow(); }; private readonly onResize = (): void => { this.renderer.invalidate(); this.renderer.markDirty(); }; // ── view state (derived from events only — Absolute Rule #4) ── private scanner = new MarkdownStreamScanner(); private currentBlockKey: string | null = null; private liveTools: LiveToolRow[] = []; private toolIndex = 0; private queued: { eventId: string; text: string }[] = []; private status: AgentStatus = { kind: "idle", startedAt: 0 }; private statusBeforeWaiting: AgentStatus | null = null; private busy = false; private permissionOverlay: PermissionOverlay | null = null; private universalPalette: PaletteState | null = null; private lastDiff: { path: string; diff: string; stats: { added: number; removed: number } } | null = null; private usage: ModelUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, }; private lastContextTokens: number | null = null; /** Phase-gate ribbon state (v2 §1); null until a phase.entered arrives. */ private currentPhase: "understand" | "design" | "implement" | null = null; /** Last submitted design artifact — collapsed on approval/rejection. */ private lastArtifact: { goal: string; filesTouched: string[]; approach: string; risks: string[]; verification: string; outOfScope: string[]; } | null = null; private dirtyStats = { added: 0, removed: 0 }; private processCount = 0; private lastCtrlC = 0; private stopped = false; /** First-run composer placeholder; cleared after the first user message. */ private placeholder: string | null = "What do you want to build?"; /** When set, the next universal-palette selection routes here instead of the registry. */ private universalPaletteOnSelect: ((id: string) => void) | null = null; private universalPaletteOnCancel: (() => void) | null = null; constructor(options: TuiAppOptions) { this.options = options; this.bus = options.bus; this.actions = options.actions; this.stdin = options.stdin ?? process.stdin; this.stdout = options.stdout ?? process.stdout; this.io = options.io ?? streamIo(this.stdout); this.now = options.now ?? Date.now; this.theme = resolveTheme({ colorDepth: detectColorDepth(process.env, this.stdout.isTTY === true), }); this.renderer = new LiveRenderer(this.io, { frame: () => this.buildFrame() }); this.coalescer = new Coalescer(this.bus); this.composer = new Composer({ slashCommands: () => this.registry.slashItems(), mentions: (query) => (this.options.mentionProvider ?? (() => []))(query), }); this.registerCommands(); } async start(): Promise { const interactive = this.options.interactive ?? this.stdin.isTTY === true; if (interactive) { this.terminalSession = enterTerminal(this.stdin, this.stdout); const probed = await probeTerminal({ stdin: this.stdin, stdout: this.stdout }); this.renderer.setSyncUpdates(probed.syncUpdates); if (probed.background !== null && probed.background !== this.theme.background) { this.theme = resolveTheme({ colorDepth: this.theme.colorDepth, background: probed.background, }); } this.stdin.on("data", this.onStdinData); } this.stdout.on("resize", this.onResize); this.unsubscribeFrames = this.coalescer.subscribe((frame) => { this.applyFrame(frame); this.renderer.markDirty(); }); this.ticker = setInterval(() => { if (this.status.kind !== "idle" || this.liveTools.length > 0) this.renderer.markDirty(); }, 120); this.ticker.unref(); this.printStartup(); this.renderer.flushNow(); } stop(): void { if (this.stopped) return; this.stopped = true; if (this.ticker !== null) clearInterval(this.ticker); this.unsubscribeFrames?.(); this.coalescer.dispose(); this.stdin.off("data", this.onStdinData); this.stdout.off("resize", this.onResize); this.renderer.unmount(); this.terminalSession?.restore(); } /** Test/driver seam: feed a key without a real stdin. */ pressKey(key: KeyEvent): void { this.handleKey(key); this.renderer.markDirty(); this.renderer.flushNow(); } // ───────────────── composition-root seams (cli wiring) ───────────────── /** The command registry — the cli layer registers real slash commands here. */ get commands(): CommandRegistry { return this.registry; } /** Print a settled block into the conversation (command output). */ printBlock(lines: string[]): void { this.renderer.printSettled(lines); this.renderer.flushNow(); } /** The active theme (for cli command output styling). */ currentTheme(): Theme { return this.theme; } /** Open the universal palette with custom items; selection routes to `onSelect`. */ openSelector(items: PaletteItem[], onSelect: (id: string) => void, onCancel?: () => void): void { this.universalPalette = createPalette("command", items); this.universalPaletteOnSelect = onSelect; this.universalPaletteOnCancel = onCancel ?? null; this.renderer.markDirty(); this.renderer.flushNow(); } /** Update the status-bar/status model label (after /model switches). */ setModelLabel(model: string): void { this.options.model = model; this.renderer.markDirty(); } /** Fill in the git branch once the async lookup completes (startup is non-blocking). */ setGitBranch(branch: string | null): void { this.options.gitBranch = branch; this.renderer.markDirty(); } /** Plug in the repository `@` mention provider once the index is ready. */ setMentionProvider(provider: (query: string) => PaletteItem[]): void { this.options.mentionProvider = provider; } // ───────────────────────── startup ───────────────────────── private printStartup(): void { const width = this.io.columns(); const t = this.theme; const where = [this.options.cwdLabel, this.options.gitBranch ?? undefined] .filter((s): s is string => s !== undefined && s !== "") .join(" · "); const modelLine = [this.options.model, this.options.thinking ? `thinking ${this.options.thinking}` : undefined] .filter((s): s is string => s !== undefined) .join(" · "); const block: string[] = ["", " " + t.paint("bold", t.paintGradient("brand", "KHAELOR"))]; if (where !== "") block.push(" " + t.paint("dim", where)); block.push(" " + t.paint("dim", modelLine)); block.push(t.paint("dim", "─".repeat(Math.max(10, Math.min(width - 1, 72))))); this.renderer.printSettled(block); } // ───────────────────────── event application ───────────────────────── private applyFrame(frame: CoalescedFrame): void { for (const [key, text] of frame.textAppends) { // Thinking deltas keep the status line honest but are not the centerpiece. if (this.currentBlockKey !== null && key !== this.currentBlockKey) { this.settleScanner(); } this.currentBlockKey = key; for (const settled of this.scanner.append(text)) this.settleMarkdown(settled); this.enforceTailCap(); } for (const [toolUseId, chunk] of frame.toolOutputAppends) { const row = this.liveTools.find((r) => r.toolUseId === toolUseId); if (row) row.output += chunk; } for (const event of frame.durables) this.applyDurable(event); } private applyDurable(event: DurableEvent): void { switch (event.type) { case "user.message-created": { this.settleScanner(); this.renderer.printSettled([ "", truncateAnsi(` ${this.theme.paint("accent", "❯")} ${event.payload.text}`, this.io.columns()), ]); this.busy = true; this.toolIndex = 0; this.placeholder = null; this.setStatus("thinking"); break; } case "user.steering-queued": this.queued.push({ eventId: event.id, text: event.payload.text }); break; case "user.steering-injected": { const idx = this.queued.findIndex((q) => q.eventId === event.payload.queuedEventId); if (idx !== -1) { const [q] = this.queued.splice(idx, 1); if (q) { this.renderer.printSettled([ "", truncateAnsi(` ${this.theme.paint("accent", "❯")} ${q.text}`, this.io.columns()), ]); } } break; } case "model.request-started": this.busy = true; this.lastContextTokens = null; this.setStatus("thinking"); break; case "model.text-block-completed": this.settleScanner(); if (!this.hasRunningTools()) this.setStatus("thinking"); break; case "tool.requested": { const name = event.payload.toolName; const label = `${TOOL_VERB[name]} ${describeToolInput(name, event.payload.input)}`.trimEnd(); this.liveTools.push({ toolUseId: event.payload.toolUseId, name, label, startedAt: event.ts, output: "", detailOpen: false, }); this.setStatus(TOOL_STATUS[name], describeToolInput(name, event.payload.input)); break; } case "tool.started": break; case "tool.completed": { this.removeLiveTool(event.payload.toolUseId); this.toolIndex += 1; this.renderer.printSettled([ renderToolLine( { summary: event.payload.ui.summary, outcome: "ok", index: this.toolIndex, kind: event.payload.ui.kind, }, this.io.columns(), this.theme, ), ]); if (!this.hasRunningTools()) this.setStatus("thinking"); break; } case "tool.failed": { const row = this.removeLiveTool(event.payload.toolUseId); this.toolIndex += 1; const secs = (event.payload.durationMs / 1000).toFixed(1); const label = row?.label ?? "tool"; this.renderer.printSettled([ renderToolLine( { summary: `${label} · failed · ${secs}s`, outcome: "failed", index: this.toolIndex }, this.io.columns(), this.theme, ), ...renderErrorPanel( { title: `${label} failed`, detailLines: event.payload.modelText.split("\n").slice(0, 4), followUp: "KHAELOR is inspecting the failure.", }, this.io.columns(), this.theme, ), ]); if (!this.hasRunningTools()) this.setStatus("thinking"); break; } case "tool.cancelled": { const row = this.removeLiveTool(event.payload.toolUseId); if (row) { const secs = ((event.ts - row.startedAt) / 1000).toFixed(1); this.renderer.printSettled([ renderToolLine( { summary: `${row.label} · ${secs}s`, outcome: "cancelled" }, this.io.columns(), this.theme, ), ]); } break; } case "file.modified": { this.dirtyStats.added += event.payload.diffStats.added; this.dirtyStats.removed += event.payload.diffStats.removed; if (event.payload.diff !== undefined) { this.lastDiff = { path: event.payload.path, diff: event.payload.diff, stats: event.payload.diffStats, }; } this.renderer.printSettled([ renderEditSummary( event.payload.path, event.payload.diffStats, this.io.columns(), this.theme, event.payload.diff !== undefined, ), ]); break; } case "permission.requested": { this.statusBeforeWaiting = this.status; this.permissionOverlay = { requestId: event.payload.permissionRequestId, verb: verbForCapability(event.payload.capability), subject: event.payload.descriptor, alwaysPattern: event.payload.suggestion?.pattern, }; this.setStatus("waiting"); break; } case "permission.granted": case "permission.denied": { this.permissionOverlay = null; if (this.statusBeforeWaiting !== null) { this.status = this.statusBeforeWaiting; this.statusBeforeWaiting = null; } else { this.setStatus("thinking"); } break; } case "model.response-completed": { this.settleScanner(); const u = event.payload.usage; this.usage = { inputTokens: this.usage.inputTokens + u.inputTokens, outputTokens: this.usage.outputTokens + u.outputTokens, cacheReadTokens: this.usage.cacheReadTokens + u.cacheReadTokens, cacheWriteTokens: this.usage.cacheWriteTokens + u.cacheWriteTokens, }; this.lastContextTokens = u.inputTokens + u.cacheReadTokens + u.outputTokens; if (event.payload.stopReason !== "tool_use") { this.busy = false; this.setStatus("idle"); } break; } case "model.request-failed": { this.settleScanner(); this.busy = false; this.setStatus("idle"); this.renderer.printSettled( renderErrorPanel( { title: "model request failed", detailLines: [event.payload.message] }, this.io.columns(), this.theme, ), ); break; } case "user.interrupted": { this.settleScanner(); this.busy = false; this.setStatus("idle"); this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint("warning", "◌")} Interrupted — partial response kept`, this.io.columns(), ), ]); break; } case "process.started": this.processCount += 1; break; case "process.exited": this.processCount = Math.max(0, this.processCount - 1); break; case "task.completed": case "task.failed": this.busy = false; this.setStatus("idle"); break; case "phase.entered": { this.currentPhase = event.payload.phase; const glyph = event.payload.phase === "understand" ? "◐" : event.payload.phase === "design" ? "◑" : "●"; // The session-start understand entry stays silent — the ribbon carries it. if (event.payload.via !== "session-start") { this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint("accent", glyph)} phase → ${this.theme.paint("bold", event.payload.phase)}${event.payload.via === "user-override" ? this.theme.paint("dim", " (user override)") : ""}`, this.io.columns(), ), ]); } break; } case "phase.artifact": { this.lastArtifact = event.payload.artifact; const files = event.payload.artifact.filesTouched.length; this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint("accent", "◑")} design submitted · ${files} file${files === 1 ? "" : "s"} ${this.theme.paint("dim", `· ${event.payload.artifact.goal.slice(0, 60)}`)}`, this.io.columns(), ), ]); break; } case "phase.approved": { if (event.payload.phase !== "design") break; const artifact = this.lastArtifact; this.renderer.printSettled( renderDesignPanel( { goal: artifact?.goal ?? "", filesTouched: artifact?.filesTouched ?? [], approach: artifact?.approach ?? "", risks: artifact?.risks ?? [], verification: artifact?.verification ?? "", outOfScope: artifact?.outOfScope ?? [], decision: event.payload.approvedBy === "auto-policy" ? "auto-approved" : "approved", }, this.io.columns(), this.theme, ), ); break; } case "phase.rejected": { this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint("error", "✗")} design rejected ${this.theme.paint("dim", `· ${event.payload.reason.slice(0, 70)}`)}`, this.io.columns(), ), ]); break; } case "verify.result": { const secs = (event.payload.durationMs / 1000).toFixed(1); if (event.payload.ok) { this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint("success", "✓")} verify ${event.payload.check} ${this.theme.paint("dim", `${secs}s`)}`, this.io.columns(), ), ]); } else { const exit = event.payload.exitCode === null ? "killed" : `exit ${event.payload.exitCode}`; this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint("error", "✗")} verify ${event.payload.check} ${this.theme.paint("dim", `${secs}s · ${exit} — repairing`)}`, this.io.columns(), ), ...event.payload.output .split("\n") .slice(0, 3) .map((line) => truncateAnsi(` ${this.theme.paint("dim", line)}`, this.io.columns())), ]); } break; } case "subtask.created": { this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint("accent", "●")} subtask ${event.payload.taskId} spawned ${this.theme.paint("dim", `· ${event.payload.description.slice(0, 56)}`)}`, this.io.columns(), ), ]); break; } case "subtask.completed": { const ok = event.payload.outcome === "done"; const verify = event.payload.verifyOk === null ? "" : event.payload.verifyOk ? " · verify ✓" : " · verify ✗"; this.renderer.printSettled([ truncateAnsi( ` ${this.theme.paint(ok ? "success" : "error", ok ? "✓" : "✗")} subtask ${event.payload.taskId} ${event.payload.outcome} ` + `${this.theme.paint("success", `+${event.payload.diffStats.added}`)} ${this.theme.paint("error", `−${event.payload.diffStats.removed}`)}${verify} ${this.theme.paint("dim", "· /tasks · /merge")}`, this.io.columns(), ), ]); break; } default: break; } } private setStatus(kind: AgentStatus["kind"], detail?: string): void { if (this.status.kind === kind && this.status.detail === detail) return; const next: AgentStatus = { kind, startedAt: this.now() }; if (detail !== undefined) next.detail = detail; this.status = next; } private hasRunningTools(): boolean { return this.liveTools.length > 0; } private removeLiveTool(toolUseId: string): LiveToolRow | null { const idx = this.liveTools.findIndex((r) => r.toolUseId === toolUseId); if (idx === -1) return null; const [row] = this.liveTools.splice(idx, 1); return row ?? null; } private settleMarkdown(source: string): void { const rendered = renderMarkdownBlock(source, Math.min(this.io.columns() - 2, 88), this.theme); this.renderer.printSettled(["", ...rendered.map((l) => " " + l)]); } private settleScanner(): void { for (const block of this.scanner.finish()) this.settleMarkdown(block); this.currentBlockKey = null; } private enforceTailCap(): void { const cap = Math.min(240, Math.max(4, this.io.rows() - 8)); const tailLines = this.scanner.tail().split("\n"); if (tailLines.length > cap) { const flushed = this.scanner.settleHead(tailLines.length - Math.floor(cap / 2)); if (flushed !== null) this.settleMarkdown(flushed); } } // ───────────────────────── key dispatch ───────────────────────── private handleKey(key: KeyEvent): void { // Overlay bindings (modal) suppress lower layers — TUI_DESIGN §13. if (this.permissionOverlay !== null) { this.handlePermissionKey(key, this.permissionOverlay); return; } if (this.universalPalette !== null) { this.handleUniversalPaletteKey(key); return; } if (this.composer.hasPalette()) { this.applyComposerEffect(this.composer.handleKey(key)); return; } // Single-key actions — composer empty only. if (key.type === "char" && this.composer.isEmpty()) { if (key.ch === "d" && this.lastDiff !== null) { this.printLastDiff(); return; } } // Global keys. if (key.type === "esc") { if (this.busy) { this.status = { kind: "stopping", startedAt: this.now() }; this.renderer.markDirty(); this.actions.interrupt(); } return; } if (key.type === "ctrl") { switch (key.ch) { case "k": this.universalPalette = createPalette("command", this.registry.paletteItems()); this.universalPaletteOnSelect = null; return; case "c": { const now = this.now(); if (!this.composer.isEmpty()) { this.composer.reset(); } else if (now - this.lastCtrlC < 1000) { this.actions.quit(); } this.lastCtrlC = now; return; } case "d": if (this.composer.isEmpty()) this.actions.quit(); return; case "l": this.renderer.invalidate(); return; case "t": { const last = this.liveTools[this.liveTools.length - 1]; if (last) last.detailOpen = !last.detailOpen; return; } default: break; } } this.applyComposerEffect(this.composer.handleKey(key)); } private handlePermissionKey(key: KeyEvent, overlay: PermissionOverlay): void { if (key.type === "enter") { this.actions.permission(overlay.requestId, "allow-once"); } else if (key.type === "esc") { this.actions.permission(overlay.requestId, "deny"); } else if ( key.type === "char" && (key.ch === "a" || key.ch === "A") && overlay.alwaysPattern !== undefined ) { this.actions.permission(overlay.requestId, "allow-always"); } // Everything else is ignored while the panel is up (three keys, no typing). } private handleUniversalPaletteKey(key: KeyEvent): void { const palette = this.universalPalette as PaletteState; if (key.type === "esc") { const onCancel = this.universalPaletteOnCancel; this.universalPalette = null; this.universalPaletteOnSelect = null; this.universalPaletteOnCancel = null; if (onCancel !== null) onCancel(); return; } if (key.type === "arrow" && key.key === "up") { this.universalPalette = paletteMove(palette, -1); return; } if (key.type === "arrow" && key.key === "down") { this.universalPalette = paletteMove(palette, 1); return; } if (key.type === "ctrl" && key.ch === "p") { this.universalPalette = paletteMove(palette, -1); return; } if (key.type === "ctrl" && key.ch === "n") { this.universalPalette = paletteMove(palette, 1); return; } if (key.type === "enter") { const item = paletteSelection(palette); const onSelect = this.universalPaletteOnSelect; const onCancel = this.universalPaletteOnCancel; this.universalPalette = null; this.universalPaletteOnSelect = null; this.universalPaletteOnCancel = null; if (item !== null) { if (onSelect !== null) onSelect(item.id); else this.registry.find(item.id)?.run(); } else if (onCancel !== null) { onCancel(); } return; } if (key.type === "char") { this.universalPalette = paletteSetQuery(palette, palette.query + key.ch); return; } if (key.type === "backspace") { this.universalPalette = paletteSetQuery(palette, palette.query.slice(0, -1)); return; } } private applyComposerEffect(effect: ReturnType): void { if (effect === null) return; switch (effect.type) { case "submit": this.actions.submit(effect.text, { shell: effect.shell }); return; case "run-command": this.registry.find(effect.id)?.run(); return; case "discard-queued": // Queue mutation is engine-owned (event-sourced); nothing local to drop. return; } } // ───────────────────────── commands ───────────────────────── private registerCommands(): void { const settle = (lines: string[]): void => { this.renderer.printSettled(lines); }; const notYet = (what: string, phase: string): (() => void) => { return () => settle(["", ` ${this.theme.paint("dim", `${what} arrives with ${phase} — not wired in this build.`)}`]); }; this.registry.register({ id: "diff.show", title: "View diff", slash: "/diff", key: "d", description: "diff of the most recent edit", run: () => this.printLastDiff(), }); this.registry.register({ id: "cost.show", title: "Show cost", slash: "/cost", description: "session token usage and cost", run: () => this.printCost(), }); this.registry.register({ id: "help.show", title: "Help", slash: "/help", description: "list commands", run: () => this.printHelp(), }); this.registry.register({ id: "app.quit", title: "Quit", slash: "/quit", key: "Ctrl+D", description: "exit khaelor", run: () => this.actions.quit(), }); this.registry.register({ id: "palette.open", title: "Command palette", key: "Ctrl+K", run: () => { this.universalPalette = createPalette("command", this.registry.paletteItems()); this.universalPaletteOnSelect = null; }, }); // Honest placeholders: listed (users discover the surface), never faked. this.registry.register({ id: "model.select", title: "Change model", slash: "/model", description: "model selector", run: notYet("The model selector", "Phase 3") }); this.registry.register({ id: "config.open", title: "Configuration", slash: "/config", description: "configuration panel", run: notYet("The config panel", "Phase 3") }); this.registry.register({ id: "sessions.open", title: "Sessions", slash: "/sessions", description: "browse and resume sessions", run: notYet("The session picker", "Phase 6") }); this.registry.register({ id: "context.open", title: "Context inspector", slash: "/context", description: "context budget breakdown", run: notYet("The context inspector", "Phase 6") }); this.registry.register({ id: "context.compact", title: "Compact context", slash: "/compact", description: "compact the context now", run: notYet("Compaction", "Phase 6") }); this.registry.register({ id: "processes.open", title: "Show processes", slash: "/processes", description: "background processes", run: notYet("The process panel", "Phase 5") }); this.registry.register({ id: "permissions.open", title: "Permissions", slash: "/permissions", description: "permission rules", run: notYet("The permission panel", "Phase 4") }); } private printLastDiff(): void { if (this.lastDiff === null) return; this.renderer.printSettled([ "", ...renderDiffBlock( this.lastDiff.path, this.lastDiff.diff, this.lastDiff.stats, this.io.columns(), this.theme, ), ]); } private printCost(): void { const t = this.theme; const p = this.options.pricing; const fmt = (tokens: number, perMTok: number | undefined): string => { const cost = perMTok !== undefined ? `$${((tokens / 1_000_000) * perMTok).toFixed(2)}` : "n/a"; return `${tokens.toLocaleString("en-US").padStart(12)} ${cost.padStart(8)}`; }; this.renderer.printSettled([ "", " " + t.paint("bold", "cost · this session"), t.paint("dim", ` input tokens ${fmt(this.usage.inputTokens, p?.inputPerMTok)}`), t.paint("dim", ` output tokens ${fmt(this.usage.outputTokens, p?.outputPerMTok)}`), t.paint("dim", ` cache write ${fmt(this.usage.cacheWriteTokens, p?.cacheWritePerMTok)}`), t.paint("dim", ` cache read ${fmt(this.usage.cacheReadTokens, p?.cacheReadPerMTok)}`), p !== undefined ? ` ${t.paint("bold", `total $${this.totalCost(p).toFixed(2)}`)}` : ` ${t.paint("dim", "total n/a — no pricing configured for this model")}`, ]); } private printHelp(): void { const lines = ["", " " + this.theme.paint("bold", "commands")]; for (const def of this.registry.list()) { if (def.slash === undefined) continue; const key = def.key !== undefined ? ` ${def.key}` : ""; lines.push( ` ${def.slash.padEnd(14)}${this.theme.paint("dim", (def.description ?? "") + key)}`, ); } this.renderer.printSettled(lines); } private totalCost(p: ModelPricing): number { return ( (this.usage.inputTokens / 1_000_000) * p.inputPerMTok + (this.usage.outputTokens / 1_000_000) * p.outputPerMTok + (this.usage.cacheReadTokens / 1_000_000) * p.cacheReadPerMTok + (this.usage.cacheWriteTokens / 1_000_000) * p.cacheWritePerMTok ); } // ───────────────────────── frame assembly ───────────────────────── private buildFrame(): LiveFrame { const width = this.io.columns(); const rows = this.io.rows(); const now = this.now(); const t = this.theme; const lines: string[] = []; const overlay = this.permissionOverlay !== null ? renderPermissionPanel( { verb: this.permissionOverlay.verb, subject: this.permissionOverlay.subject, ...(this.options.cwdLabel !== undefined ? { cwd: this.options.cwdLabel } : {}), ...(this.permissionOverlay.alwaysPattern !== undefined ? { alwaysPattern: this.permissionOverlay.alwaysPattern } : {}), }, width, t, ) : this.universalPalette !== null ? renderPalette(this.universalPalette, width, t, { showQuery: true, rows }) : null; if (overlay !== null) { lines.push("", ...overlay, ""); } else { // Streaming tail — raw text, lightly styled (TUI_DESIGN §8 step 1). const tail = this.scanner.tail(); if (tail !== "") { const cap = Math.min(240, Math.max(4, rows - 8)); const wrapped = wrapText(tail, Math.max(20, width - 2)); for (const line of wrapped.slice(-cap)) { lines.push(truncateAnsi(" " + renderTailLine(line, t), width)); } lines.push(""); } // Live tool rows with real elapsed timers. for (const row of this.liveTools) { const tailLines = row.detailOpen ? row.output.split("\n").filter((l) => l !== "").slice(-12) : undefined; lines.push( ...renderRunningTool( { label: row.label, startedAt: row.startedAt, kind: TOOL_KIND[row.name], ...(tailLines !== undefined ? { outputTail: tailLines } : {}), }, now, width, t, ), ); } if (this.liveTools.length > 0) lines.push(""); // Queued steering messages. for (const q of this.queued) { lines.push(truncateAnsi(` ${t.paint("dim", "⋯")} Queued — ${q.text}`, width)); } if (this.queued.length > 0) { lines.push(" " + t.paint("dim", "Esc cancel run · Ctrl+U discard queued")); lines.push(""); } // Composer's own palette (slash / mention), anchored above the composer. const composerPalette = this.composer.paletteState(); if (composerPalette !== null) { lines.push(...renderPalette(composerPalette, width, t, { rows })); } } // Agent status line (collapses when idle). const statusLine = renderStatusLine(this.status, now, t, width); if (statusLine !== null) { lines.push(statusLine, ""); } // Composer — the bordered box (content rows capped at 8, internal scroll). const maxComposerRows = Math.min(8, Math.max(2, Math.floor(rows / 3))); const composed = this.composer.render(width, maxComposerRows, t, { busy: this.busy, ...(this.placeholder !== null ? { placeholder: this.placeholder } : {}), ...(this.queued.length > 0 ? { queuedCount: this.queued.length } : {}), }); const caretRow = lines.length + composed.caretRow; lines.push(...composed.lines); // Status bar — always the last row. lines.push(this.statusBar(width)); return { lines, caretRow, caretCol: composed.caretCol }; } private statusBar(width: number): string { const data: StatusBarData = { model: this.options.model }; if (this.options.gitBranch !== undefined && this.options.gitBranch !== null) { data.branch = this.options.gitBranch; if (this.dirtyStats.added > 0 || this.dirtyStats.removed > 0) { data.dirty = { ...this.dirtyStats }; } } if (this.lastContextTokens !== null && this.options.contextWindow !== undefined) { data.contextPct = (this.lastContextTokens / this.options.contextWindow) * 100; } if (this.options.pricing !== undefined) { const cost = this.totalCost(this.options.pricing); if (cost > 0) data.costUsd = cost; } if (this.processCount > 0) data.processCount = this.processCount; if (this.queued.length > 0) data.queuedCount = this.queued.length; if (this.currentPhase !== null) data.phase = this.currentPhase; return renderStatusBar(data, width, this.theme); } } // ───────────────────────── helpers ───────────────────────── function describeToolInput(name: ToolName, input: unknown): string { if (input === null || typeof input !== "object") return ""; const o = input as Record; const str = (k: string): string | null => (typeof o[k] === "string" ? (o[k] as string) : null); switch (name) { case "read": case "write": case "edit": return str("file_path") ?? str("path") ?? ""; case "grep": return str("pattern") !== null ? `"${str("pattern") as string}"` : ""; case "glob": return str("pattern") ?? ""; case "bash": case "process": return str("command") ?? ""; case "design": return str("goal") ?? ""; case "remember": return str("section") ?? ""; case "symbols": return str("query") !== null ? `"${str("query") as string}"` : ""; case "refs": return str("symbol") ?? ""; } } function verbForCapability(capability: string): string { if (capability.startsWith("process.")) return "Run"; if (capability.startsWith("file.write")) return "Write"; if (capability.startsWith("file.read")) return "Read"; if (capability.startsWith("network.")) return "Network"; if (capability.startsWith("git.")) return "Git"; return "Allow"; }