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%
4.7 KB · 158 lines typescript
Raw Blame History
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