/** * KHAELOR * File: src/tui/markdown/scanner.ts * Description: Settled-block incremental markdown scanner — blocks settle at blank lines outside fences (TUI_DESIGN §8). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ const FENCE_RE = /^ {0,3}(```+|~~~+)/; /** * The Hermes StreamScanState technique, adopted as-is: stream deltas append * to a raw tail; the scanner advances only over newline-terminated input, * detecting settled top-level blocks. A block settles at a blank line outside * a code fence; a fence settles at its closing fence. Settled blocks are * rendered exactly once and flushed to scrollback; only the tail is ever * re-scanned — never O(blocks²), and settled text never reflows. */ export class MarkdownStreamScanner { /** Complete (newline-terminated) lines not yet settled. */ private lines: string[] = []; /** Trailing input without its newline yet. */ private partial = ""; private inFence = false; private fenceMarker = ""; /** Append streamed text; returns raw source of any blocks that settled. */ append(text: string): string[] { const combined = this.partial + text; const parts = combined.split("\n"); this.partial = parts.pop() ?? ""; for (const line of parts) this.lines.push(line); return this.extractSettled(); } /** End of stream: everything pending settles (including the partial line). */ finish(): string[] { const settled = this.extractSettled(); const rest: string[] = [...this.lines]; if (this.partial !== "") rest.push(this.partial); this.lines = []; this.partial = ""; this.inFence = false; this.fenceMarker = ""; const restBlock = trimBlock(rest); if (restBlock !== null) settled.push(restBlock); return settled; } /** The live raw tail (unsettled complete lines + the partial line). */ tail(): string { if (this.lines.length === 0) return this.partial; return this.lines.join("\n") + "\n" + this.partial; } /** True when the tail sits inside an open code fence. */ insideFence(): boolean { return this.inFence; } /** * Cap-pressure early flush (TUI_DESIGN §1.2/§8): settle the first * `lineCount` complete lines at the last safe boundary even without a * block boundary. Returns the flushed source or null when nothing settled. * Never splits a fence: inside a fence the flush is refused (the caller * caps display instead — fence integrity is load-bearing). */ settleHead(lineCount: number): string | null { if (this.inFence) return null; const n = Math.min(lineCount, this.lines.length); if (n <= 0) return null; // Do not cut through a fence that opens within the head. let fence = false; let marker = ""; for (let i = 0; i < n; i++) { const line = this.lines[i] as string; const m = FENCE_RE.exec(line); if (m) { if (!fence) { fence = true; marker = (m[1] as string)[0] as string; } else if ((line.trimStart()[0] ?? "") === marker) { fence = false; } } } if (fence) return null; const head = this.lines.splice(0, n); return trimBlock(head); } private extractSettled(): string[] { const settled: string[] = []; let scanFrom = 0; let current: string[] = []; // Re-walk pending lines with fence state; settle at boundaries. this.inFence = false; this.fenceMarker = ""; for (let i = 0; i < this.lines.length; i++) { const line = this.lines[i] as string; const fenceMatch = FENCE_RE.exec(line); if (this.inFence) { current.push(line); if (fenceMatch && (line.trimStart()[0] ?? "") === this.fenceMarker) { // Closing fence settles the whole fenced block immediately. this.inFence = false; const block = trimBlock(current); if (block !== null) settled.push(block); current = []; scanFrom = i + 1; } continue; } if (fenceMatch) { this.inFence = true; this.fenceMarker = (fenceMatch[1] as string)[0] as string; current.push(line); continue; } if (line.trim() === "") { // Blank line outside a fence: boundary — settle what precedes it. const block = trimBlock(current); if (block !== null) settled.push(block); current = []; scanFrom = i + 1; continue; } current.push(line); } this.lines = this.lines.slice(scanFrom); return settled; } } /** Drop leading/trailing blank lines; null when nothing remains. */ function trimBlock(lines: string[]): string | null { let start = 0; let end = lines.length; while (start < end && (lines[start] as string).trim() === "") start++; while (end > start && (lines[end - 1] as string).trim() === "") end--; if (start >= end) return null; return lines.slice(start, end).join("\n"); }