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 { renderDesignPanel } from "./components/design-panel.js";23import { renderStatusBar } from "./components/status-bar.js";24import type { StatusBarData } from "./components/status-bar.js";25import { renderStatusLine } from "./components/status-line.js";26import type { AgentStatus } from "./components/status-line.js";27import { renderRunningTool, renderToolLine } from "./components/tool-line.js";28import type { ToolLineKind } from "./components/tool-line.js";29import { Composer } from "./composer/composer.js";30import { createPalette, paletteMove, paletteSelection, paletteSetQuery, renderPalette } from "./composer/palette.js";31import type { PaletteItem, PaletteState } from "./composer/palette.js";32import { renderMarkdownBlock, renderTailLine } from "./markdown/render.js";33import { MarkdownStreamScanner } from "./markdown/scanner.js";34import { truncateAnsi, wrapText } from "./renderer/ansi.js";35import { detectColorDepth, probeTerminal } from "./renderer/capabilities.js";36import type { LiveFrame } from "./renderer/frame.js";37import { KeyDecoder } from "./renderer/input.js";38import type { KeyEvent } from "./renderer/input.js";39import { LiveRenderer, streamIo } from "./renderer/renderer.js";40import type { RendererIo } from "./renderer/renderer.js";41import { enterTerminal } from "./renderer/terminal.js";42import type { TerminalSession } from "./renderer/terminal.js";43import { resolveTheme } from "./theme.js";44import type { Theme } from "./theme.js";4546export type PermissionDecision = "allow-once" | "allow-always" | "deny";4748/** The app renders and dispatches; the engine (or the demo driver) acts. */49export interface TuiAppActions {50 submit(text: string, opts: { shell: boolean }): void;51 interrupt(): void;52 permission(requestId: string, decision: PermissionDecision): void;53 quit(): void;54}5556export interface ModelPricing {57 inputPerMTok: number;58 outputPerMTok: number;59 cacheReadPerMTok: number;60 cacheWritePerMTok: number;61}6263export interface TuiAppOptions {64 bus: EventBus;65 actions: TuiAppActions;66 io?: RendererIo;67 stdin?: NodeJS.ReadStream;68 stdout?: NodeJS.WriteStream;69 model: string;70 thinking?: string;71 cwdLabel?: string;72 gitBranch?: string | null;73 /** Usable context window (tokens) for the context% segment. */74 contextWindow?: number;75 /** Pricing for the configured model; absent → cost segments are absent, never invented. */76 pricing?: ModelPricing;77 /** Repository search plugs in here later; default provider returns nothing. */78 mentionProvider?: (query: string) => PaletteItem[];79 /** Force interactive (raw-mode) input on/off; default: stdin.isTTY. */80 interactive?: boolean;81 now?: () => number;82}8384interface LiveToolRow {85 toolUseId: string;86 name: ToolName;87 label: string;88 startedAt: number;89 output: string;90 detailOpen: boolean;91}9293interface PermissionOverlay {94 requestId: string;95 subject: string;96 verb: string;97 alwaysPattern: string | undefined;98}99100const TOOL_STATUS: Record<ToolName, AgentStatus["kind"]> = {101 read: "reading",102 grep: "searching",103 glob: "searching",104 edit: "editing",105 write: "editing",106 bash: "running",107 process: "running",108 design: "thinking",109 remember: "editing",110 symbols: "searching",111 refs: "searching",112};113114const TOOL_VERB: Record<ToolName, string> = {115 read: "Read",116 grep: "Search",117 glob: "Glob",118 edit: "Edit",119 write: "Write",120 bash: "Run",121 process: "Process",122 design: "Design",123 remember: "Remember",124 symbols: "Symbols",125 refs: "Refs",126};127128const TOOL_KIND: Record<ToolName, ToolLineKind> = {129 read: "read",130 grep: "search",131 glob: "search",132 edit: "edit",133 write: "edit",134 bash: "exec",135 process: "process",136 design: "exec",137 remember: "edit",138 symbols: "search",139 refs: "search",140};141142export class TuiApp {143 private readonly bus: EventBus;144 private readonly actions: TuiAppActions;145 private readonly io: RendererIo;146 private readonly stdin: NodeJS.ReadStream;147 private readonly stdout: NodeJS.WriteStream;148 private readonly options: TuiAppOptions;149 private readonly now: () => number;150151 private theme: Theme;152 private readonly renderer: LiveRenderer;153 private readonly coalescer: Coalescer;154 private readonly decoder = new KeyDecoder();155 private readonly registry = new CommandRegistry();156 private readonly composer: Composer;157158 private terminalSession: TerminalSession | null = null;159 private ticker: ReturnType<typeof setInterval> | null = null;160 private unsubscribeFrames: (() => void) | null = null;161 private readonly onStdinData = (chunk: Buffer): void => {162 for (const key of this.decoder.push(chunk)) this.handleKey(key);163 // Every key can mutate composer/overlay state — mark dirty so the164 // immediate flush actually repaints (live echo; TUI_DESIGN §10.3).165 this.renderer.markDirty();166 this.renderer.flushNow();167 };168 private readonly onResize = (): void => {169 this.renderer.invalidate();170 this.renderer.markDirty();171 };172173 // ── view state (derived from events only — Absolute Rule #4) ──174 private scanner = new MarkdownStreamScanner();175 private currentBlockKey: string | null = null;176 private liveTools: LiveToolRow[] = [];177 private toolIndex = 0;178 private queued: { eventId: string; text: string }[] = [];179 private status: AgentStatus = { kind: "idle", startedAt: 0 };180 private statusBeforeWaiting: AgentStatus | null = null;181 private busy = false;182 private permissionOverlay: PermissionOverlay | null = null;183 private universalPalette: PaletteState | null = null;184 private lastDiff: { path: string; diff: string; stats: { added: number; removed: number } } | null =185 null;186 private usage: ModelUsage = {187 inputTokens: 0,188 outputTokens: 0,189 cacheReadTokens: 0,190 cacheWriteTokens: 0,191 };192 private lastContextTokens: number | null = null;193 /** Phase-gate ribbon state (v2 §1); null until a phase.entered arrives. */194 private currentPhase: "understand" | "design" | "implement" | null = null;195 /** Last submitted design artifact — collapsed on approval/rejection. */196 private lastArtifact: {197 goal: string;198 filesTouched: string[];199 approach: string;200 risks: string[];201 verification: string;202 outOfScope: string[];203 } | null = null;204 private dirtyStats = { added: 0, removed: 0 };205 private processCount = 0;206 private lastCtrlC = 0;207 private stopped = false;208 /** First-run composer placeholder; cleared after the first user message. */209 private placeholder: string | null = "What do you want to build?";210 /** When set, the next universal-palette selection routes here instead of the registry. */211 private universalPaletteOnSelect: ((id: string) => void) | null = null;212 private universalPaletteOnCancel: (() => void) | null = null;213214 constructor(options: TuiAppOptions) {215 this.options = options;216 this.bus = options.bus;217 this.actions = options.actions;218 this.stdin = options.stdin ?? process.stdin;219 this.stdout = options.stdout ?? process.stdout;220 this.io = options.io ?? streamIo(this.stdout);221 this.now = options.now ?? Date.now;222223 this.theme = resolveTheme({224 colorDepth: detectColorDepth(process.env, this.stdout.isTTY === true),225 });226227 this.renderer = new LiveRenderer(this.io, { frame: () => this.buildFrame() });228 this.coalescer = new Coalescer(this.bus);229 this.composer = new Composer({230 slashCommands: () => this.registry.slashItems(),231 mentions: (query) => (this.options.mentionProvider ?? (() => []))(query),232 });233 this.registerCommands();234 }235236 async start(): Promise<void> {237 const interactive = this.options.interactive ?? this.stdin.isTTY === true;238 if (interactive) {239 this.terminalSession = enterTerminal(this.stdin, this.stdout);240 const probed = await probeTerminal({ stdin: this.stdin, stdout: this.stdout });241 this.renderer.setSyncUpdates(probed.syncUpdates);242 if (probed.background !== null && probed.background !== this.theme.background) {243 this.theme = resolveTheme({244 colorDepth: this.theme.colorDepth,245 background: probed.background,246 });247 }248 this.stdin.on("data", this.onStdinData);249 }250 this.stdout.on("resize", this.onResize);251 this.unsubscribeFrames = this.coalescer.subscribe((frame) => {252 this.applyFrame(frame);253 this.renderer.markDirty();254 });255 this.ticker = setInterval(() => {256 if (this.status.kind !== "idle" || this.liveTools.length > 0) this.renderer.markDirty();257 }, 120);258 this.ticker.unref();259260 this.printStartup();261 this.renderer.flushNow();262 }263264 stop(): void {265 if (this.stopped) return;266 this.stopped = true;267 if (this.ticker !== null) clearInterval(this.ticker);268 this.unsubscribeFrames?.();269 this.coalescer.dispose();270 this.stdin.off("data", this.onStdinData);271 this.stdout.off("resize", this.onResize);272 this.renderer.unmount();273 this.terminalSession?.restore();274 }275276 /** Test/driver seam: feed a key without a real stdin. */277 pressKey(key: KeyEvent): void {278 this.handleKey(key);279 this.renderer.markDirty();280 this.renderer.flushNow();281 }282283 // ───────────────── composition-root seams (cli wiring) ─────────────────284285 /** The command registry — the cli layer registers real slash commands here. */286 get commands(): CommandRegistry {287 return this.registry;288 }289290 /** Print a settled block into the conversation (command output). */291 printBlock(lines: string[]): void {292 this.renderer.printSettled(lines);293 this.renderer.flushNow();294 }295296 /** The active theme (for cli command output styling). */297 currentTheme(): Theme {298 return this.theme;299 }300301 /** Open the universal palette with custom items; selection routes to `onSelect`. */302 openSelector(items: PaletteItem[], onSelect: (id: string) => void, onCancel?: () => void): void {303 this.universalPalette = createPalette("command", items);304 this.universalPaletteOnSelect = onSelect;305 this.universalPaletteOnCancel = onCancel ?? null;306 this.renderer.markDirty();307 this.renderer.flushNow();308 }309310 /** Update the status-bar/status model label (after /model switches). */311 setModelLabel(model: string): void {312 this.options.model = model;313 this.renderer.markDirty();314 }315316 /** Fill in the git branch once the async lookup completes (startup is non-blocking). */317 setGitBranch(branch: string | null): void {318 this.options.gitBranch = branch;319 this.renderer.markDirty();320 }321322 /** Plug in the repository `@` mention provider once the index is ready. */323 setMentionProvider(provider: (query: string) => PaletteItem[]): void {324 this.options.mentionProvider = provider;325 }326327 // ───────────────────────── startup ─────────────────────────328329 private printStartup(): void {330 const width = this.io.columns();331 const t = this.theme;332 const where = [this.options.cwdLabel, this.options.gitBranch ?? undefined]333 .filter((s): s is string => s !== undefined && s !== "")334 .join(" · ");335 const modelLine = [this.options.model, this.options.thinking ? `thinking ${this.options.thinking}` : undefined]336 .filter((s): s is string => s !== undefined)337 .join(" · ");338 const block: string[] = ["", " " + t.paint("bold", t.paintGradient("brand", "KHAELOR"))];339 if (where !== "") block.push(" " + t.paint("dim", where));340 block.push(" " + t.paint("dim", modelLine));341 block.push(t.paint("dim", "─".repeat(Math.max(10, Math.min(width - 1, 72)))));342 this.renderer.printSettled(block);343 }344345 // ───────────────────────── event application ─────────────────────────346347 private applyFrame(frame: CoalescedFrame): void {348 for (const [key, text] of frame.textAppends) {349 // Thinking deltas keep the status line honest but are not the centerpiece.350 if (this.currentBlockKey !== null && key !== this.currentBlockKey) {351 this.settleScanner();352 }353 this.currentBlockKey = key;354 for (const settled of this.scanner.append(text)) this.settleMarkdown(settled);355 this.enforceTailCap();356 }357 for (const [toolUseId, chunk] of frame.toolOutputAppends) {358 const row = this.liveTools.find((r) => r.toolUseId === toolUseId);359 if (row) row.output += chunk;360 }361 for (const event of frame.durables) this.applyDurable(event);362 }363364 private applyDurable(event: DurableEvent): void {365 switch (event.type) {366 case "user.message-created": {367 this.settleScanner();368 this.renderer.printSettled([369 "",370 truncateAnsi(` ${this.theme.paint("accent", "❯")} ${event.payload.text}`, this.io.columns()),371 ]);372 this.busy = true;373 this.toolIndex = 0;374 this.placeholder = null;375 this.setStatus("thinking");376 break;377 }378 case "user.steering-queued":379 this.queued.push({ eventId: event.id, text: event.payload.text });380 break;381 case "user.steering-injected": {382 const idx = this.queued.findIndex((q) => q.eventId === event.payload.queuedEventId);383 if (idx !== -1) {384 const [q] = this.queued.splice(idx, 1);385 if (q) {386 this.renderer.printSettled([387 "",388 truncateAnsi(` ${this.theme.paint("accent", "❯")} ${q.text}`, this.io.columns()),389 ]);390 }391 }392 break;393 }394 case "model.request-started":395 this.busy = true;396 this.lastContextTokens = null;397 this.setStatus("thinking");398 break;399 case "model.text-block-completed":400 this.settleScanner();401 if (!this.hasRunningTools()) this.setStatus("thinking");402 break;403 case "tool.requested": {404 const name = event.payload.toolName;405 const label = `${TOOL_VERB[name]} ${describeToolInput(name, event.payload.input)}`.trimEnd();406 this.liveTools.push({407 toolUseId: event.payload.toolUseId,408 name,409 label,410 startedAt: event.ts,411 output: "",412 detailOpen: false,413 });414 this.setStatus(TOOL_STATUS[name], describeToolInput(name, event.payload.input));415 break;416 }417 case "tool.started":418 break;419 case "tool.completed": {420 this.removeLiveTool(event.payload.toolUseId);421 this.toolIndex += 1;422 this.renderer.printSettled([423 renderToolLine(424 {425 summary: event.payload.ui.summary,426 outcome: "ok",427 index: this.toolIndex,428 kind: event.payload.ui.kind,429 },430 this.io.columns(),431 this.theme,432 ),433 ]);434 if (!this.hasRunningTools()) this.setStatus("thinking");435 break;436 }437 case "tool.failed": {438 const row = this.removeLiveTool(event.payload.toolUseId);439 this.toolIndex += 1;440 const secs = (event.payload.durationMs / 1000).toFixed(1);441 const label = row?.label ?? "tool";442 this.renderer.printSettled([443 renderToolLine(444 { summary: `${label} · failed · ${secs}s`, outcome: "failed", index: this.toolIndex },445 this.io.columns(),446 this.theme,447 ),448 ...renderErrorPanel(449 {450 title: `${label} failed`,451 detailLines: event.payload.modelText.split("\n").slice(0, 4),452 followUp: "KHAELOR is inspecting the failure.",453 },454 this.io.columns(),455 this.theme,456 ),457 ]);458 if (!this.hasRunningTools()) this.setStatus("thinking");459 break;460 }461 case "tool.cancelled": {462 const row = this.removeLiveTool(event.payload.toolUseId);463 if (row) {464 const secs = ((event.ts - row.startedAt) / 1000).toFixed(1);465 this.renderer.printSettled([466 renderToolLine(467 { summary: `${row.label} · ${secs}s`, outcome: "cancelled" },468 this.io.columns(),469 this.theme,470 ),471 ]);472 }473 break;474 }475 case "file.modified": {476 this.dirtyStats.added += event.payload.diffStats.added;477 this.dirtyStats.removed += event.payload.diffStats.removed;478 if (event.payload.diff !== undefined) {479 this.lastDiff = {480 path: event.payload.path,481 diff: event.payload.diff,482 stats: event.payload.diffStats,483 };484 }485 this.renderer.printSettled([486 renderEditSummary(487 event.payload.path,488 event.payload.diffStats,489 this.io.columns(),490 this.theme,491 event.payload.diff !== undefined,492 ),493 ]);494 break;495 }496 case "permission.requested": {497 this.statusBeforeWaiting = this.status;498 this.permissionOverlay = {499 requestId: event.payload.permissionRequestId,500 verb: verbForCapability(event.payload.capability),501 subject: event.payload.descriptor,502 alwaysPattern: event.payload.suggestion?.pattern,503 };504 this.setStatus("waiting");505 break;506 }507 case "permission.granted":508 case "permission.denied": {509 this.permissionOverlay = null;510 if (this.statusBeforeWaiting !== null) {511 this.status = this.statusBeforeWaiting;512 this.statusBeforeWaiting = null;513 } else {514 this.setStatus("thinking");515 }516 break;517 }518 case "model.response-completed": {519 this.settleScanner();520 const u = event.payload.usage;521 this.usage = {522 inputTokens: this.usage.inputTokens + u.inputTokens,523 outputTokens: this.usage.outputTokens + u.outputTokens,524 cacheReadTokens: this.usage.cacheReadTokens + u.cacheReadTokens,525 cacheWriteTokens: this.usage.cacheWriteTokens + u.cacheWriteTokens,526 };527 this.lastContextTokens = u.inputTokens + u.cacheReadTokens + u.outputTokens;528 if (event.payload.stopReason !== "tool_use") {529 this.busy = false;530 this.setStatus("idle");531 }532 break;533 }534 case "model.request-failed": {535 this.settleScanner();536 this.busy = false;537 this.setStatus("idle");538 this.renderer.printSettled(539 renderErrorPanel(540 { title: "model request failed", detailLines: [event.payload.message] },541 this.io.columns(),542 this.theme,543 ),544 );545 break;546 }547 case "user.interrupted": {548 this.settleScanner();549 this.busy = false;550 this.setStatus("idle");551 this.renderer.printSettled([552 truncateAnsi(553 ` ${this.theme.paint("warning", "◌")} Interrupted — partial response kept`,554 this.io.columns(),555 ),556 ]);557 break;558 }559 case "process.started":560 this.processCount += 1;561 break;562 case "process.exited":563 this.processCount = Math.max(0, this.processCount - 1);564 break;565 case "task.completed":566 case "task.failed":567 this.busy = false;568 this.setStatus("idle");569 break;570 case "phase.entered": {571 this.currentPhase = event.payload.phase;572 const glyph =573 event.payload.phase === "understand" ? "◐" : event.payload.phase === "design" ? "◑" : "●";574 // The session-start understand entry stays silent — the ribbon carries it.575 if (event.payload.via !== "session-start") {576 this.renderer.printSettled([577 truncateAnsi(578 ` ${this.theme.paint("accent", glyph)} phase → ${this.theme.paint("bold", event.payload.phase)}${event.payload.via === "user-override" ? this.theme.paint("dim", " (user override)") : ""}`,579 this.io.columns(),580 ),581 ]);582 }583 break;584 }585 case "phase.artifact": {586 this.lastArtifact = event.payload.artifact;587 const files = event.payload.artifact.filesTouched.length;588 this.renderer.printSettled([589 truncateAnsi(590 ` ${this.theme.paint("accent", "◑")} design submitted · ${files} file${files === 1 ? "" : "s"} ${this.theme.paint("dim", `· ${event.payload.artifact.goal.slice(0, 60)}`)}`,591 this.io.columns(),592 ),593 ]);594 break;595 }596 case "phase.approved": {597 if (event.payload.phase !== "design") break;598 const artifact = this.lastArtifact;599 this.renderer.printSettled(600 renderDesignPanel(601 {602 goal: artifact?.goal ?? "",603 filesTouched: artifact?.filesTouched ?? [],604 approach: artifact?.approach ?? "",605 risks: artifact?.risks ?? [],606 verification: artifact?.verification ?? "",607 outOfScope: artifact?.outOfScope ?? [],608 decision: event.payload.approvedBy === "auto-policy" ? "auto-approved" : "approved",609 },610 this.io.columns(),611 this.theme,612 ),613 );614 break;615 }616 case "phase.rejected": {617 this.renderer.printSettled([618 truncateAnsi(619 ` ${this.theme.paint("error", "✗")} design rejected ${this.theme.paint("dim", `· ${event.payload.reason.slice(0, 70)}`)}`,620 this.io.columns(),621 ),622 ]);623 break;624 }625 case "verify.result": {626 const secs = (event.payload.durationMs / 1000).toFixed(1);627 if (event.payload.ok) {628 this.renderer.printSettled([629 truncateAnsi(630 ` ${this.theme.paint("success", "✓")} verify ${event.payload.check} ${this.theme.paint("dim", `${secs}s`)}`,631 this.io.columns(),632 ),633 ]);634 } else {635 const exit = event.payload.exitCode === null ? "killed" : `exit ${event.payload.exitCode}`;636 this.renderer.printSettled([637 truncateAnsi(638 ` ${this.theme.paint("error", "✗")} verify ${event.payload.check} ${this.theme.paint("dim", `${secs}s · ${exit} — repairing`)}`,639 this.io.columns(),640 ),641 ...event.payload.output642 .split("\n")643 .slice(0, 3)644 .map((line) => truncateAnsi(` ${this.theme.paint("dim", line)}`, this.io.columns())),645 ]);646 }647 break;648 }649 case "subtask.created": {650 this.renderer.printSettled([651 truncateAnsi(652 ` ${this.theme.paint("accent", "●")} subtask ${event.payload.taskId} spawned ${this.theme.paint("dim", `· ${event.payload.description.slice(0, 56)}`)}`,653 this.io.columns(),654 ),655 ]);656 break;657 }658 case "subtask.completed": {659 const ok = event.payload.outcome === "done";660 const verify =661 event.payload.verifyOk === null ? "" : event.payload.verifyOk ? " · verify ✓" : " · verify ✗";662 this.renderer.printSettled([663 truncateAnsi(664 ` ${this.theme.paint(ok ? "success" : "error", ok ? "✓" : "✗")} subtask ${event.payload.taskId} ${event.payload.outcome} ` +665 `${this.theme.paint("success", `+${event.payload.diffStats.added}`)} ${this.theme.paint("error", `−${event.payload.diffStats.removed}`)}${verify} ${this.theme.paint("dim", "· /tasks · /merge")}`,666 this.io.columns(),667 ),668 ]);669 break;670 }671 default:672 break;673 }674 }675676 private setStatus(kind: AgentStatus["kind"], detail?: string): void {677 if (this.status.kind === kind && this.status.detail === detail) return;678 const next: AgentStatus = { kind, startedAt: this.now() };679 if (detail !== undefined) next.detail = detail;680 this.status = next;681 }682683 private hasRunningTools(): boolean {684 return this.liveTools.length > 0;685 }686687 private removeLiveTool(toolUseId: string): LiveToolRow | null {688 const idx = this.liveTools.findIndex((r) => r.toolUseId === toolUseId);689 if (idx === -1) return null;690 const [row] = this.liveTools.splice(idx, 1);691 return row ?? null;692 }693694 private settleMarkdown(source: string): void {695 const rendered = renderMarkdownBlock(source, Math.min(this.io.columns() - 2, 88), this.theme);696 this.renderer.printSettled(["", ...rendered.map((l) => " " + l)]);697 }698699 private settleScanner(): void {700 for (const block of this.scanner.finish()) this.settleMarkdown(block);701 this.currentBlockKey = null;702 }703704 private enforceTailCap(): void {705 const cap = Math.min(240, Math.max(4, this.io.rows() - 8));706 const tailLines = this.scanner.tail().split("\n");707 if (tailLines.length > cap) {708 const flushed = this.scanner.settleHead(tailLines.length - Math.floor(cap / 2));709 if (flushed !== null) this.settleMarkdown(flushed);710 }711 }712713 // ───────────────────────── key dispatch ─────────────────────────714715 private handleKey(key: KeyEvent): void {716 // Overlay bindings (modal) suppress lower layers — TUI_DESIGN §13.717 if (this.permissionOverlay !== null) {718 this.handlePermissionKey(key, this.permissionOverlay);719 return;720 }721 if (this.universalPalette !== null) {722 this.handleUniversalPaletteKey(key);723 return;724 }725 if (this.composer.hasPalette()) {726 this.applyComposerEffect(this.composer.handleKey(key));727 return;728 }729730 // Single-key actions — composer empty only.731 if (key.type === "char" && this.composer.isEmpty()) {732 if (key.ch === "d" && this.lastDiff !== null) {733 this.printLastDiff();734 return;735 }736 }737738 // Global keys.739 if (key.type === "esc") {740 if (this.busy) {741 this.status = { kind: "stopping", startedAt: this.now() };742 this.renderer.markDirty();743 this.actions.interrupt();744 }745 return;746 }747 if (key.type === "ctrl") {748 switch (key.ch) {749 case "k":750 this.universalPalette = createPalette("command", this.registry.paletteItems());751 this.universalPaletteOnSelect = null;752 return;753 case "c": {754 const now = this.now();755 if (!this.composer.isEmpty()) {756 this.composer.reset();757 } else if (now - this.lastCtrlC < 1000) {758 this.actions.quit();759 }760 this.lastCtrlC = now;761 return;762 }763 case "d":764 if (this.composer.isEmpty()) this.actions.quit();765 return;766 case "l":767 this.renderer.invalidate();768 return;769 case "t": {770 const last = this.liveTools[this.liveTools.length - 1];771 if (last) last.detailOpen = !last.detailOpen;772 return;773 }774 default:775 break;776 }777 }778779 this.applyComposerEffect(this.composer.handleKey(key));780 }781782 private handlePermissionKey(key: KeyEvent, overlay: PermissionOverlay): void {783 if (key.type === "enter") {784 this.actions.permission(overlay.requestId, "allow-once");785 } else if (key.type === "esc") {786 this.actions.permission(overlay.requestId, "deny");787 } else if (788 key.type === "char" &&789 (key.ch === "a" || key.ch === "A") &&790 overlay.alwaysPattern !== undefined791 ) {792 this.actions.permission(overlay.requestId, "allow-always");793 }794 // Everything else is ignored while the panel is up (three keys, no typing).795 }796797 private handleUniversalPaletteKey(key: KeyEvent): void {798 const palette = this.universalPalette as PaletteState;799 if (key.type === "esc") {800 const onCancel = this.universalPaletteOnCancel;801 this.universalPalette = null;802 this.universalPaletteOnSelect = null;803 this.universalPaletteOnCancel = null;804 if (onCancel !== null) onCancel();805 return;806 }807 if (key.type === "arrow" && key.key === "up") {808 this.universalPalette = paletteMove(palette, -1);809 return;810 }811 if (key.type === "arrow" && key.key === "down") {812 this.universalPalette = paletteMove(palette, 1);813 return;814 }815 if (key.type === "ctrl" && key.ch === "p") {816 this.universalPalette = paletteMove(palette, -1);817 return;818 }819 if (key.type === "ctrl" && key.ch === "n") {820 this.universalPalette = paletteMove(palette, 1);821 return;822 }823 if (key.type === "enter") {824 const item = paletteSelection(palette);825 const onSelect = this.universalPaletteOnSelect;826 const onCancel = this.universalPaletteOnCancel;827 this.universalPalette = null;828 this.universalPaletteOnSelect = null;829 this.universalPaletteOnCancel = null;830 if (item !== null) {831 if (onSelect !== null) onSelect(item.id);832 else this.registry.find(item.id)?.run();833 } else if (onCancel !== null) {834 onCancel();835 }836 return;837 }838 if (key.type === "char") {839 this.universalPalette = paletteSetQuery(palette, palette.query + key.ch);840 return;841 }842 if (key.type === "backspace") {843 this.universalPalette = paletteSetQuery(palette, palette.query.slice(0, -1));844 return;845 }846 }847848 private applyComposerEffect(effect: ReturnType<Composer["handleKey"]>): void {849 if (effect === null) return;850 switch (effect.type) {851 case "submit":852 this.actions.submit(effect.text, { shell: effect.shell });853 return;854 case "run-command":855 this.registry.find(effect.id)?.run();856 return;857 case "discard-queued":858 // Queue mutation is engine-owned (event-sourced); nothing local to drop.859 return;860 }861 }862863 // ───────────────────────── commands ─────────────────────────864865 private registerCommands(): void {866 const settle = (lines: string[]): void => {867 this.renderer.printSettled(lines);868 };869 const notYet = (what: string, phase: string): (() => void) => {870 return () =>871 settle(["", ` ${this.theme.paint("dim", `${what} arrives with ${phase} — not wired in this build.`)}`]);872 };873874 this.registry.register({875 id: "diff.show",876 title: "View diff",877 slash: "/diff",878 key: "d",879 description: "diff of the most recent edit",880 run: () => this.printLastDiff(),881 });882 this.registry.register({883 id: "cost.show",884 title: "Show cost",885 slash: "/cost",886 description: "session token usage and cost",887 run: () => this.printCost(),888 });889 this.registry.register({890 id: "help.show",891 title: "Help",892 slash: "/help",893 description: "list commands",894 run: () => this.printHelp(),895 });896 this.registry.register({897 id: "app.quit",898 title: "Quit",899 slash: "/quit",900 key: "Ctrl+D",901 description: "exit khaelor",902 run: () => this.actions.quit(),903 });904 this.registry.register({905 id: "palette.open",906 title: "Command palette",907 key: "Ctrl+K",908 run: () => {909 this.universalPalette = createPalette("command", this.registry.paletteItems());910 this.universalPaletteOnSelect = null;911 },912 });913 // Honest placeholders: listed (users discover the surface), never faked.914 this.registry.register({ id: "model.select", title: "Change model", slash: "/model", description: "model selector", run: notYet("The model selector", "Phase 3") });915 this.registry.register({ id: "config.open", title: "Configuration", slash: "/config", description: "configuration panel", run: notYet("The config panel", "Phase 3") });916 this.registry.register({ id: "sessions.open", title: "Sessions", slash: "/sessions", description: "browse and resume sessions", run: notYet("The session picker", "Phase 6") });917 this.registry.register({ id: "context.open", title: "Context inspector", slash: "/context", description: "context budget breakdown", run: notYet("The context inspector", "Phase 6") });918 this.registry.register({ id: "context.compact", title: "Compact context", slash: "/compact", description: "compact the context now", run: notYet("Compaction", "Phase 6") });919 this.registry.register({ id: "processes.open", title: "Show processes", slash: "/processes", description: "background processes", run: notYet("The process panel", "Phase 5") });920 this.registry.register({ id: "permissions.open", title: "Permissions", slash: "/permissions", description: "permission rules", run: notYet("The permission panel", "Phase 4") });921 }922923 private printLastDiff(): void {924 if (this.lastDiff === null) return;925 this.renderer.printSettled([926 "",927 ...renderDiffBlock(928 this.lastDiff.path,929 this.lastDiff.diff,930 this.lastDiff.stats,931 this.io.columns(),932 this.theme,933 ),934 ]);935 }936937 private printCost(): void {938 const t = this.theme;939 const p = this.options.pricing;940 const fmt = (tokens: number, perMTok: number | undefined): string => {941 const cost = perMTok !== undefined ? `$${((tokens / 1_000_000) * perMTok).toFixed(2)}` : "n/a";942 return `${tokens.toLocaleString("en-US").padStart(12)} ${cost.padStart(8)}`;943 };944 this.renderer.printSettled([945 "",946 " " + t.paint("bold", "cost · this session"),947 t.paint("dim", ` input tokens ${fmt(this.usage.inputTokens, p?.inputPerMTok)}`),948 t.paint("dim", ` output tokens ${fmt(this.usage.outputTokens, p?.outputPerMTok)}`),949 t.paint("dim", ` cache write ${fmt(this.usage.cacheWriteTokens, p?.cacheWritePerMTok)}`),950 t.paint("dim", ` cache read ${fmt(this.usage.cacheReadTokens, p?.cacheReadPerMTok)}`),951 p !== undefined952 ? ` ${t.paint("bold", `total $${this.totalCost(p).toFixed(2)}`)}`953 : ` ${t.paint("dim", "total n/a — no pricing configured for this model")}`,954 ]);955 }956957 private printHelp(): void {958 const lines = ["", " " + this.theme.paint("bold", "commands")];959 for (const def of this.registry.list()) {960 if (def.slash === undefined) continue;961 const key = def.key !== undefined ? ` ${def.key}` : "";962 lines.push(963 ` ${def.slash.padEnd(14)}${this.theme.paint("dim", (def.description ?? "") + key)}`,964 );965 }966 this.renderer.printSettled(lines);967 }968969 private totalCost(p: ModelPricing): number {970 return (971 (this.usage.inputTokens / 1_000_000) * p.inputPerMTok +972 (this.usage.outputTokens / 1_000_000) * p.outputPerMTok +973 (this.usage.cacheReadTokens / 1_000_000) * p.cacheReadPerMTok +974 (this.usage.cacheWriteTokens / 1_000_000) * p.cacheWritePerMTok975 );976 }977978 // ───────────────────────── frame assembly ─────────────────────────979980 private buildFrame(): LiveFrame {981 const width = this.io.columns();982 const rows = this.io.rows();983 const now = this.now();984 const t = this.theme;985 const lines: string[] = [];986987 const overlay =988 this.permissionOverlay !== null989 ? renderPermissionPanel(990 {991 verb: this.permissionOverlay.verb,992 subject: this.permissionOverlay.subject,993 ...(this.options.cwdLabel !== undefined ? { cwd: this.options.cwdLabel } : {}),994 ...(this.permissionOverlay.alwaysPattern !== undefined995 ? { alwaysPattern: this.permissionOverlay.alwaysPattern }996 : {}),997 },998 width,999 t,1000 )1001 : this.universalPalette !== null1002 ? renderPalette(this.universalPalette, width, t, { showQuery: true, rows })1003 : null;10041005 if (overlay !== null) {1006 lines.push("", ...overlay, "");1007 } else {1008 // Streaming tail — raw text, lightly styled (TUI_DESIGN §8 step 1).1009 const tail = this.scanner.tail();1010 if (tail !== "") {1011 const cap = Math.min(240, Math.max(4, rows - 8));1012 const wrapped = wrapText(tail, Math.max(20, width - 2));1013 for (const line of wrapped.slice(-cap)) {1014 lines.push(truncateAnsi(" " + renderTailLine(line, t), width));1015 }1016 lines.push("");1017 }1018 // Live tool rows with real elapsed timers.1019 for (const row of this.liveTools) {1020 const tailLines = row.detailOpen1021 ? row.output.split("\n").filter((l) => l !== "").slice(-12)1022 : undefined;1023 lines.push(1024 ...renderRunningTool(1025 {1026 label: row.label,1027 startedAt: row.startedAt,1028 kind: TOOL_KIND[row.name],1029 ...(tailLines !== undefined ? { outputTail: tailLines } : {}),1030 },1031 now,1032 width,1033 t,1034 ),1035 );1036 }1037 if (this.liveTools.length > 0) lines.push("");1038 // Queued steering messages.1039 for (const q of this.queued) {1040 lines.push(truncateAnsi(` ${t.paint("dim", "⋯")} Queued — ${q.text}`, width));1041 }1042 if (this.queued.length > 0) {1043 lines.push(" " + t.paint("dim", "Esc cancel run · Ctrl+U discard queued"));1044 lines.push("");1045 }1046 // Composer's own palette (slash / mention), anchored above the composer.1047 const composerPalette = this.composer.paletteState();1048 if (composerPalette !== null) {1049 lines.push(...renderPalette(composerPalette, width, t, { rows }));1050 }1051 }10521053 // Agent status line (collapses when idle).1054 const statusLine = renderStatusLine(this.status, now, t, width);1055 if (statusLine !== null) {1056 lines.push(statusLine, "");1057 }10581059 // Composer — the bordered box (content rows capped at 8, internal scroll).1060 const maxComposerRows = Math.min(8, Math.max(2, Math.floor(rows / 3)));1061 const composed = this.composer.render(width, maxComposerRows, t, {1062 busy: this.busy,1063 ...(this.placeholder !== null ? { placeholder: this.placeholder } : {}),1064 ...(this.queued.length > 0 ? { queuedCount: this.queued.length } : {}),1065 });1066 const caretRow = lines.length + composed.caretRow;1067 lines.push(...composed.lines);10681069 // Status bar — always the last row.1070 lines.push(this.statusBar(width));10711072 return { lines, caretRow, caretCol: composed.caretCol };1073 }10741075 private statusBar(width: number): string {1076 const data: StatusBarData = { model: this.options.model };1077 if (this.options.gitBranch !== undefined && this.options.gitBranch !== null) {1078 data.branch = this.options.gitBranch;1079 if (this.dirtyStats.added > 0 || this.dirtyStats.removed > 0) {1080 data.dirty = { ...this.dirtyStats };1081 }1082 }1083 if (this.lastContextTokens !== null && this.options.contextWindow !== undefined) {1084 data.contextPct = (this.lastContextTokens / this.options.contextWindow) * 100;1085 }1086 if (this.options.pricing !== undefined) {1087 const cost = this.totalCost(this.options.pricing);1088 if (cost > 0) data.costUsd = cost;1089 }1090 if (this.processCount > 0) data.processCount = this.processCount;1091 if (this.queued.length > 0) data.queuedCount = this.queued.length;1092 if (this.currentPhase !== null) data.phase = this.currentPhase;1093 return renderStatusBar(data, width, this.theme);1094 }1095}10961097// ───────────────────────── helpers ─────────────────────────10981099function describeToolInput(name: ToolName, input: unknown): string {1100 if (input === null || typeof input !== "object") return "";1101 const o = input as Record<string, unknown>;1102 const str = (k: string): string | null => (typeof o[k] === "string" ? (o[k] as string) : null);1103 switch (name) {1104 case "read":1105 case "write":1106 case "edit":1107 return str("file_path") ?? str("path") ?? "";1108 case "grep":1109 return str("pattern") !== null ? `"${str("pattern") as string}"` : "";1110 case "glob":1111 return str("pattern") ?? "";1112 case "bash":1113 case "process":1114 return str("command") ?? "";1115 case "design":1116 return str("goal") ?? "";1117 case "remember":1118 return str("section") ?? "";1119 case "symbols":1120 return str("query") !== null ? `"${str("query") as string}"` : "";1121 case "refs":1122 return str("symbol") ?? "";1123 }1124}11251126function verbForCapability(capability: string): string {1127 if (capability.startsWith("process.")) return "Run";1128 if (capability.startsWith("file.write")) return "Write";1129 if (capability.startsWith("file.read")) return "Read";1130 if (capability.startsWith("network.")) return "Network";1131 if (capability.startsWith("git.")) return "Git";1132 return "Allow";1133}1134