SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
40.5 KB · 1,134 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tui/app.ts4 * Description: The TUI application — event-bus-driven view state, key dispatch via the command registry, frame assembly.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { Coalescer } from "../session/index.js";11import type {12  CoalescedFrame,13  DurableEvent,14  EventBus,15  ModelUsage,16  ToolName,17} from "../session/index.js";18import { CommandRegistry } from "./commands.js";19import { renderDiffBlock, renderEditSummary } from "./components/diff.js";20import { renderErrorPanel } from "./components/error-panel.js";21import { renderPermissionPanel } from "./components/permission-panel.js";22import { 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