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