/** * KHAELOR * File: src/tui/renderer/renderer.ts * Description: The live-region renderer — 16 ms coalesced paints, settled-block flushing, resize handling, clean teardown. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { CSI } from "./ansi.js"; import { composeFrame } from "./frame.js"; import type { LiveFrame } from "./frame.js"; /** Thin injected sink — production wraps process.stdout; tests use a fake. */ export interface RendererIo { write(data: string): void; columns(): number; rows(): number; } /** Wrap a Node write stream as a RendererIo (the only place stdout is touched). */ export function streamIo(stream: NodeJS.WriteStream): RendererIo { return { write(data: string): void { stream.write(data); }, columns(): number { return stream.columns && stream.columns > 0 ? stream.columns : 80; }, rows(): number { return stream.rows && stream.rows > 0 ? stream.rows : 24; }, }; } export interface RendererMetrics { frames: number; rowsRepainted: number; settledBlocks: number; } export interface LiveRendererOptions { /** Builds the current live-region state at flush time (always fresh). */ frame: () => LiveFrame; /** Emit DEC 2026 synchronized-update markers (mutable via setSyncUpdates). */ syncUpdates?: boolean; /** Coalescing window in ms (contract: ~16 ms — TUI_DESIGN §1.3). */ frameMs?: number; } /** * The production renderer promoted from the winning Phase 2 spike candidate: * print-once immutable scrollback on the main buffer, a bounded live region * repainted in place with per-row damage tracking, one synchronized frame per * ≤16 ms window, and the hardware cursor parked at the composer caret. */ export class LiveRenderer { private readonly io: RendererIo; private readonly buildFrame: () => LiveFrame; private readonly frameMs: number; private syncUpdates: boolean; private prevLines: string[] = []; private prevCaretRow = 0; private settledQueue: string[][] = []; private dirty = false; private timer: ReturnType | null = null; private lastFlush = 0; private mounted = true; readonly metrics: RendererMetrics = { frames: 0, rowsRepainted: 0, settledBlocks: 0 }; constructor(io: RendererIo, options: LiveRendererOptions) { this.io = io; this.buildFrame = options.frame; this.frameMs = options.frameMs ?? 16; this.syncUpdates = options.syncUpdates ?? false; } setSyncUpdates(enabled: boolean): void { this.syncUpdates = enabled; } /** Queue an immutable block for scrollback; flushed with the next frame. */ printSettled(lines: string[]): void { if (lines.length === 0) return; this.settledQueue.push(lines); this.metrics.settledBlocks += 1; this.markDirty(); } /** Coalesced repaint request: at most one paint per ~16 ms window. */ markDirty(): void { this.dirty = true; if (this.timer !== null || !this.mounted) return; const wait = Math.max(0, this.frameMs - (Date.now() - this.lastFlush)); this.timer = setTimeout(() => { this.timer = null; this.flush(); }, wait); } /** * Immediate paint — keystroke echo does not wait for the coalescing window * (TUI_DESIGN §10.3); stream deltas keep the 16 ms batch via markDirty. */ flushNow(): void { if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; } this.flush(); } /** Force the next frame to repaint every row (resize, Ctrl+L, corruption). */ invalidate(): void { this.prevLines = []; } /** * Unmount: move the cursor below the live region and leave the transcript * in scrollback (crash-safe transcript — TUI_DESIGN §1.1). Terminal-mode * restoration is the TerminalSession's job, not the renderer's. */ unmount(): void { if (!this.mounted) return; this.mounted = false; if (this.timer !== null) { clearTimeout(this.timer); this.timer = null; } const below = this.prevLines.length - 1 - this.prevCaretRow; this.io.write((below > 0 ? `${CSI}${below}B` : "") + "\r\n"); } private flush(): void { if (!this.mounted) return; if (!this.dirty && this.settledQueue.length === 0) return; this.dirty = false; this.lastFlush = Date.now(); const settled = this.settledQueue; this.settledQueue = []; const frame = composeFrame({ prevLines: this.prevLines, prevCaretRow: this.prevCaretRow, next: this.buildFrame(), settled, width: this.io.columns(), sync: this.syncUpdates, }); this.io.write(frame.data); this.prevLines = frame.lines; this.prevCaretRow = frame.caretRow; this.metrics.frames += 1; this.metrics.rowsRepainted += frame.repaintedRows; } }