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: src/tui/renderer/renderer.ts4 * Description: The live-region renderer — 16 ms coalesced paints, settled-block flushing, resize handling, clean teardown.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { CSI } from "./ansi.js";11import { composeFrame } from "./frame.js";12import type { LiveFrame } from "./frame.js";1314/** Thin injected sink — production wraps process.stdout; tests use a fake. */15export interface RendererIo {16 write(data: string): void;17 columns(): number;18 rows(): number;19}2021/** Wrap a Node write stream as a RendererIo (the only place stdout is touched). */22export function streamIo(stream: NodeJS.WriteStream): RendererIo {23 return {24 write(data: string): void {25 stream.write(data);26 },27 columns(): number {28 return stream.columns && stream.columns > 0 ? stream.columns : 80;29 },30 rows(): number {31 return stream.rows && stream.rows > 0 ? stream.rows : 24;32 },33 };34}3536export interface RendererMetrics {37 frames: number;38 rowsRepainted: number;39 settledBlocks: number;40}4142export interface LiveRendererOptions {43 /** Builds the current live-region state at flush time (always fresh). */44 frame: () => LiveFrame;45 /** Emit DEC 2026 synchronized-update markers (mutable via setSyncUpdates). */46 syncUpdates?: boolean;47 /** Coalescing window in ms (contract: ~16 ms — TUI_DESIGN §1.3). */48 frameMs?: number;49}5051/**52 * The production renderer promoted from the winning Phase 2 spike candidate:53 * print-once immutable scrollback on the main buffer, a bounded live region54 * repainted in place with per-row damage tracking, one synchronized frame per55 * ≤16 ms window, and the hardware cursor parked at the composer caret.56 */57export class LiveRenderer {58 private readonly io: RendererIo;59 private readonly buildFrame: () => LiveFrame;60 private readonly frameMs: number;61 private syncUpdates: boolean;6263 private prevLines: string[] = [];64 private prevCaretRow = 0;65 private settledQueue: string[][] = [];66 private dirty = false;67 private timer: ReturnType<typeof setTimeout> | null = null;68 private lastFlush = 0;69 private mounted = true;7071 readonly metrics: RendererMetrics = { frames: 0, rowsRepainted: 0, settledBlocks: 0 };7273 constructor(io: RendererIo, options: LiveRendererOptions) {74 this.io = io;75 this.buildFrame = options.frame;76 this.frameMs = options.frameMs ?? 16;77 this.syncUpdates = options.syncUpdates ?? false;78 }7980 setSyncUpdates(enabled: boolean): void {81 this.syncUpdates = enabled;82 }8384 /** Queue an immutable block for scrollback; flushed with the next frame. */85 printSettled(lines: string[]): void {86 if (lines.length === 0) return;87 this.settledQueue.push(lines);88 this.metrics.settledBlocks += 1;89 this.markDirty();90 }9192 /** Coalesced repaint request: at most one paint per ~16 ms window. */93 markDirty(): void {94 this.dirty = true;95 if (this.timer !== null || !this.mounted) return;96 const wait = Math.max(0, this.frameMs - (Date.now() - this.lastFlush));97 this.timer = setTimeout(() => {98 this.timer = null;99 this.flush();100 }, wait);101 }102103 /**104 * Immediate paint — keystroke echo does not wait for the coalescing window105 * (TUI_DESIGN §10.3); stream deltas keep the 16 ms batch via markDirty.106 */107 flushNow(): void {108 if (this.timer !== null) {109 clearTimeout(this.timer);110 this.timer = null;111 }112 this.flush();113 }114115 /** Force the next frame to repaint every row (resize, Ctrl+L, corruption). */116 invalidate(): void {117 this.prevLines = [];118 }119120 /**121 * Unmount: move the cursor below the live region and leave the transcript122 * in scrollback (crash-safe transcript — TUI_DESIGN §1.1). Terminal-mode123 * restoration is the TerminalSession's job, not the renderer's.124 */125 unmount(): void {126 if (!this.mounted) return;127 this.mounted = false;128 if (this.timer !== null) {129 clearTimeout(this.timer);130 this.timer = null;131 }132 const below = this.prevLines.length - 1 - this.prevCaretRow;133 this.io.write((below > 0 ? `${CSI}${below}B` : "") + "\r\n");134 }135136 private flush(): void {137 if (!this.mounted) return;138 if (!this.dirty && this.settledQueue.length === 0) return;139 this.dirty = false;140 this.lastFlush = Date.now();141 const settled = this.settledQueue;142 this.settledQueue = [];143 const frame = composeFrame({144 prevLines: this.prevLines,145 prevCaretRow: this.prevCaretRow,146 next: this.buildFrame(),147 settled,148 width: this.io.columns(),149 sync: this.syncUpdates,150 });151 this.io.write(frame.data);152 this.prevLines = frame.lines;153 this.prevCaretRow = frame.caretRow;154 this.metrics.frames += 1;155 this.metrics.rowsRepainted += frame.repaintedRows;156 }157}158