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%
6.9 KB · 230 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: prototypes/tui-spike/candidate-a/main.ts4 * Description: Candidate A — minimal custom ANSI renderer (TUI_DESIGN §1): settled content printed5 *              once to main-buffer scrollback; bounded live region repainted in place with per-row6 *              damage tracking inside DEC 2026 synchronized frames; 16 ms coalesced repaints;7 *              raw-mode input; SIGWINCH-driven full live repaint. Cursor parks at the composer caret.8 *9 * Author: Simon-Pierre Boucher10 * Contact: contact@spboucher.ai11 */1213import {14  CSI,15  HIDE_CURSOR,16  SHOW_CURSOR,17  SYNC_OFF,18  SYNC_ON,19  truncate,20  visibleWidth,21  wrapText,22} from "../shared/ansi";23import { DemoModel, STATUS_BAR, TAIL_CAP_LINES, decodeKeys } from "../shared/demo";24import { SpikeMetrics, patchStdout } from "../shared/metrics";2526const out = process.stdout;27const metrics = new SpikeMetrics("a-custom-ansi");28const unpatch = patchStdout(out, metrics, () => cols());29const model = new DemoModel();3031function cols(): number {32  return out.columns && out.columns > 0 ? out.columns : 80;33}3435/* ---------------------------------------------------------------- renderer */3637class LiveRegion {38  private prev: string[] = [];39  private prevCaretRow = 0;4041  /** Force the next frame to repaint every row (after resize or settled prints). */42  invalidate(): void {43    this.prev = [];44  }4546  height(): number {47    return this.prev.length;48  }4950  caretRow(): number {51    return this.prevCaretRow;52  }5354  /**55   * One synchronized frame: optionally flush settled blocks to scrollback above the56   * live region, then repaint only the live rows that changed, then park the cursor57   * at the composer caret. Emitted as a single stream.write.58   */59  paint(lines: string[], caretRow: number, caretCol: number, settled: string[][]): void {60    let b = SYNC_ON + HIDE_CURSOR;61    // move from the parked caret to the top of the live region, column 162    b += this.prevCaretRow > 0 ? `${CSI}${this.prevCaretRow}F` : "\r";6364    if (settled.length > 0) {65      // erase the live region, print settled lines (they scroll away, immutable)66      b += `${CSI}0J`;67      for (const block of settled) {68        for (const line of block) b += truncateForWidth(line) + "\r\n";69      }70      this.prev = [];71    }7273    const full = this.prev.length !== lines.length;74    if (full) metrics.framesFull++;75    else metrics.framesPartial++;7677    const shrunk = this.prev.length > lines.length;78    for (let i = 0; i < lines.length; i++) {79      if (full || lines[i] !== this.prev[i]) {80        b += `${CSI}2K` + lines[i];81        metrics.linesRepainted++;82      }83      if (i < lines.length - 1) b += "\r\n";84    }85    if (shrunk) b += `${CSI}0J`;8687    // park the cursor at the composer caret — unconditionally, every frame88    const up = lines.length - 1 - caretRow;89    if (up > 0) b += `${CSI}${up}A`;90    b += `${CSI}${caretCol + 1}G`;91    b += SHOW_CURSOR + SYNC_OFF;9293    out.write(b);94    this.prev = lines;95    this.prevCaretRow = caretRow;96  }97}9899function truncateForWidth(line: string): string {100  return truncate(line, cols() - 1);101}102103function buildLive(): { lines: string[]; caretRow: number; caretCol: number } {104  const w = cols() - 1;105  const snap = model.snapshot();106  const lines: string[] = [];107108  if (snap.tail.length > 0) {109    for (const l of wrapText(snap.tail, w).slice(-TAIL_CAP_LINES)) lines.push(l);110    lines.push("");111  }112  if (snap.status !== null) {113    const el = ((Date.now() - snap.status.since) / 1000).toFixed(1);114    lines.push(truncate(`● ${snap.status.text} · ${el}s`, w));115    lines.push("");116  }117  const caretRow = lines.length;118  const composerLine = truncate("❯ " + snap.composer, w);119  lines.push(composerLine);120  lines.push("\x1b[2m" + truncate(STATUS_BAR, w) + "\x1b[22m");121  return { lines, caretRow, caretCol: visibleWidth(composerLine) };122}123124/* ------------------------------------------------------- frame coalescing */125126const live = new LiveRegion();127const settledQueue: string[][] = [];128let dirty = false;129let flushTimer: NodeJS.Timeout | null = null;130let lastFlush = 0;131const FRAME_MS = 16;132133function markDirty(): void {134  dirty = true;135  if (flushTimer !== null) return;136  const wait = Math.max(0, FRAME_MS - (Date.now() - lastFlush));137  flushTimer = setTimeout(flush, wait);138}139140function flush(): void {141  flushTimer = null;142  if (!dirty && settledQueue.length === 0) return;143  dirty = false;144  lastFlush = Date.now();145  const settled = settledQueue.splice(0);146  const { lines, caretRow, caretCol } = buildLive();147  live.paint(lines, caretRow, caretCol, settled);148}149150/** Input priority (TUI_DESIGN §10.3): keystroke echo does not wait for the coalescing151 *  window — the frame flushes immediately; stream deltas keep the 16 ms batch. */152function flushNow(): void {153  if (flushTimer !== null) {154    clearTimeout(flushTimer);155    flushTimer = null;156  }157  flush();158}159160/* ------------------------------------------------------------------ wiring */161162model.on("dirty", markDirty);163model.on("settled", (block: string[]) => {164  settledQueue.push(block);165  markDirty();166});167168// status-line elapsed timer tick169const ticker = setInterval(() => {170  if (model.status !== null) markDirty();171}, 100);172ticker.unref();173174// resize: repaint the whole live region at the new width within one frame175out.on("resize", () => {176  metrics.resizes++;177  live.invalidate();178  markDirty();179});180181// raw-mode input — processed immediately, echoed in the next frame182if (process.stdin.isTTY) process.stdin.setRawMode(true);183process.stdin.resume();184process.stdin.on("data", (buf: Buffer) => {185  const t = process.hrtime.bigint();186  for (const k of decodeKeys(buf)) {187    if (k.type === "esc" || k.type === "ctrlc") {188      shutdown();189      return;190    }191    model.handleKey(k);192    // Space keystrokes are excluded: terminal frameworks may trim trailing whitespace193    // at line ends, which would make a space-terminated needle unmatchable.194    if (k.type === "char" && k.ch !== " ")195      metrics.expectEcho(t, ("❯ " + model.composer).trimEnd());196  }197  flushNow();198});199200let down = false;201function shutdown(): void {202  if (down) return;203  down = true;204  model.stop();205  clearInterval(ticker);206  if (flushTimer !== null) clearTimeout(flushTimer);207  // leave the terminal clean: cursor below the live region, everything restored208  const below = live.height() - 1 - live.caretRow();209  out.write((below > 0 ? `${CSI}${below}B` : "") + "\r\n" + SHOW_CURSOR + SYNC_OFF);210  if (process.stdin.isTTY) process.stdin.setRawMode(false);211  process.stdin.pause();212  metrics.turns = model.turn;213  metrics.settledBlocks = model.settledCount;214  const file = metrics.save(process.env.METRICS_OUT);215  unpatch();216  out.write(`metrics written: ${file}\r\n`);217  process.exit(0);218}219220process.on("SIGTERM", shutdown);221process.on("SIGINT", shutdown);222223// headless fallback: self-terminate if the driver never sends Esc224const durationMs = Number(process.env.DURATION_MS ?? 0);225if (durationMs > 0) setTimeout(shutdown, durationMs + 4000).unref();226227metrics.startMem();228model.start();229markDirty();230