/** * KHAELOR * File: prototypes/tui-spike/candidate-a/main.ts * Description: Candidate A — minimal custom ANSI renderer (TUI_DESIGN §1): settled content printed * once to main-buffer scrollback; bounded live region repainted in place with per-row * damage tracking inside DEC 2026 synchronized frames; 16 ms coalesced repaints; * raw-mode input; SIGWINCH-driven full live repaint. Cursor parks at the composer caret. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { CSI, HIDE_CURSOR, SHOW_CURSOR, SYNC_OFF, SYNC_ON, truncate, visibleWidth, wrapText, } from "../shared/ansi"; import { DemoModel, STATUS_BAR, TAIL_CAP_LINES, decodeKeys } from "../shared/demo"; import { SpikeMetrics, patchStdout } from "../shared/metrics"; const out = process.stdout; const metrics = new SpikeMetrics("a-custom-ansi"); const unpatch = patchStdout(out, metrics, () => cols()); const model = new DemoModel(); function cols(): number { return out.columns && out.columns > 0 ? out.columns : 80; } /* ---------------------------------------------------------------- renderer */ class LiveRegion { private prev: string[] = []; private prevCaretRow = 0; /** Force the next frame to repaint every row (after resize or settled prints). */ invalidate(): void { this.prev = []; } height(): number { return this.prev.length; } caretRow(): number { return this.prevCaretRow; } /** * One synchronized frame: optionally flush settled blocks to scrollback above the * live region, then repaint only the live rows that changed, then park the cursor * at the composer caret. Emitted as a single stream.write. */ paint(lines: string[], caretRow: number, caretCol: number, settled: string[][]): void { let b = SYNC_ON + HIDE_CURSOR; // move from the parked caret to the top of the live region, column 1 b += this.prevCaretRow > 0 ? `${CSI}${this.prevCaretRow}F` : "\r"; if (settled.length > 0) { // erase the live region, print settled lines (they scroll away, immutable) b += `${CSI}0J`; for (const block of settled) { for (const line of block) b += truncateForWidth(line) + "\r\n"; } this.prev = []; } const full = this.prev.length !== lines.length; if (full) metrics.framesFull++; else metrics.framesPartial++; const shrunk = this.prev.length > lines.length; for (let i = 0; i < lines.length; i++) { if (full || lines[i] !== this.prev[i]) { b += `${CSI}2K` + lines[i]; metrics.linesRepainted++; } if (i < lines.length - 1) b += "\r\n"; } if (shrunk) b += `${CSI}0J`; // park the cursor at the composer caret — unconditionally, every frame const up = lines.length - 1 - caretRow; if (up > 0) b += `${CSI}${up}A`; b += `${CSI}${caretCol + 1}G`; b += SHOW_CURSOR + SYNC_OFF; out.write(b); this.prev = lines; this.prevCaretRow = caretRow; } } function truncateForWidth(line: string): string { return truncate(line, cols() - 1); } function buildLive(): { lines: string[]; caretRow: number; caretCol: number } { const w = cols() - 1; const snap = model.snapshot(); const lines: string[] = []; if (snap.tail.length > 0) { for (const l of wrapText(snap.tail, w).slice(-TAIL_CAP_LINES)) lines.push(l); lines.push(""); } if (snap.status !== null) { const el = ((Date.now() - snap.status.since) / 1000).toFixed(1); lines.push(truncate(`● ${snap.status.text} · ${el}s`, w)); lines.push(""); } const caretRow = lines.length; const composerLine = truncate("❯ " + snap.composer, w); lines.push(composerLine); lines.push("\x1b[2m" + truncate(STATUS_BAR, w) + "\x1b[22m"); return { lines, caretRow, caretCol: visibleWidth(composerLine) }; } /* ------------------------------------------------------- frame coalescing */ const live = new LiveRegion(); const settledQueue: string[][] = []; let dirty = false; let flushTimer: NodeJS.Timeout | null = null; let lastFlush = 0; const FRAME_MS = 16; function markDirty(): void { dirty = true; if (flushTimer !== null) return; const wait = Math.max(0, FRAME_MS - (Date.now() - lastFlush)); flushTimer = setTimeout(flush, wait); } function flush(): void { flushTimer = null; if (!dirty && settledQueue.length === 0) return; dirty = false; lastFlush = Date.now(); const settled = settledQueue.splice(0); const { lines, caretRow, caretCol } = buildLive(); live.paint(lines, caretRow, caretCol, settled); } /** Input priority (TUI_DESIGN §10.3): keystroke echo does not wait for the coalescing * window — the frame flushes immediately; stream deltas keep the 16 ms batch. */ function flushNow(): void { if (flushTimer !== null) { clearTimeout(flushTimer); flushTimer = null; } flush(); } /* ------------------------------------------------------------------ wiring */ model.on("dirty", markDirty); model.on("settled", (block: string[]) => { settledQueue.push(block); markDirty(); }); // status-line elapsed timer tick const ticker = setInterval(() => { if (model.status !== null) markDirty(); }, 100); ticker.unref(); // resize: repaint the whole live region at the new width within one frame out.on("resize", () => { metrics.resizes++; live.invalidate(); markDirty(); }); // raw-mode input — processed immediately, echoed in the next frame if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on("data", (buf: Buffer) => { const t = process.hrtime.bigint(); for (const k of decodeKeys(buf)) { if (k.type === "esc" || k.type === "ctrlc") { shutdown(); return; } model.handleKey(k); // Space keystrokes are excluded: terminal frameworks may trim trailing whitespace // at line ends, which would make a space-terminated needle unmatchable. if (k.type === "char" && k.ch !== " ") metrics.expectEcho(t, ("❯ " + model.composer).trimEnd()); } flushNow(); }); let down = false; function shutdown(): void { if (down) return; down = true; model.stop(); clearInterval(ticker); if (flushTimer !== null) clearTimeout(flushTimer); // leave the terminal clean: cursor below the live region, everything restored const below = live.height() - 1 - live.caretRow(); out.write((below > 0 ? `${CSI}${below}B` : "") + "\r\n" + SHOW_CURSOR + SYNC_OFF); if (process.stdin.isTTY) process.stdin.setRawMode(false); process.stdin.pause(); metrics.turns = model.turn; metrics.settledBlocks = model.settledCount; const file = metrics.save(process.env.METRICS_OUT); unpatch(); out.write(`metrics written: ${file}\r\n`); process.exit(0); } process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); // headless fallback: self-terminate if the driver never sends Esc const durationMs = Number(process.env.DURATION_MS ?? 0); if (durationMs > 0) setTimeout(shutdown, durationMs + 4000).unref(); metrics.startMem(); model.start(); markDirty();