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.8 KB · 151 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tui/markdown/scanner.ts4 * Description: Settled-block incremental markdown scanner — blocks settle at blank lines outside fences (TUI_DESIGN §8).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910const FENCE_RE = /^ {0,3}(```+|~~~+)/;1112/**13 * The Hermes StreamScanState technique, adopted as-is: stream deltas append14 * to a raw tail; the scanner advances only over newline-terminated input,15 * detecting settled top-level blocks. A block settles at a blank line outside16 * a code fence; a fence settles at its closing fence. Settled blocks are17 * rendered exactly once and flushed to scrollback; only the tail is ever18 * re-scanned — never O(blocks²), and settled text never reflows.19 */20export class MarkdownStreamScanner {21  /** Complete (newline-terminated) lines not yet settled. */22  private lines: string[] = [];23  /** Trailing input without its newline yet. */24  private partial = "";25  private inFence = false;26  private fenceMarker = "";2728  /** Append streamed text; returns raw source of any blocks that settled. */29  append(text: string): string[] {30    const combined = this.partial + text;31    const parts = combined.split("\n");32    this.partial = parts.pop() ?? "";33    for (const line of parts) this.lines.push(line);34    return this.extractSettled();35  }3637  /** End of stream: everything pending settles (including the partial line). */38  finish(): string[] {39    const settled = this.extractSettled();40    const rest: string[] = [...this.lines];41    if (this.partial !== "") rest.push(this.partial);42    this.lines = [];43    this.partial = "";44    this.inFence = false;45    this.fenceMarker = "";46    const restBlock = trimBlock(rest);47    if (restBlock !== null) settled.push(restBlock);48    return settled;49  }5051  /** The live raw tail (unsettled complete lines + the partial line). */52  tail(): string {53    if (this.lines.length === 0) return this.partial;54    return this.lines.join("\n") + "\n" + this.partial;55  }5657  /** True when the tail sits inside an open code fence. */58  insideFence(): boolean {59    return this.inFence;60  }6162  /**63   * Cap-pressure early flush (TUI_DESIGN §1.2/§8): settle the first64   * `lineCount` complete lines at the last safe boundary even without a65   * block boundary. Returns the flushed source or null when nothing settled.66   * Never splits a fence: inside a fence the flush is refused (the caller67   * caps display instead — fence integrity is load-bearing).68   */69  settleHead(lineCount: number): string | null {70    if (this.inFence) return null;71    const n = Math.min(lineCount, this.lines.length);72    if (n <= 0) return null;73    // Do not cut through a fence that opens within the head.74    let fence = false;75    let marker = "";76    for (let i = 0; i < n; i++) {77      const line = this.lines[i] as string;78      const m = FENCE_RE.exec(line);79      if (m) {80        if (!fence) {81          fence = true;82          marker = (m[1] as string)[0] as string;83        } else if ((line.trimStart()[0] ?? "") === marker) {84          fence = false;85        }86      }87    }88    if (fence) return null;89    const head = this.lines.splice(0, n);90    return trimBlock(head);91  }9293  private extractSettled(): string[] {94    const settled: string[] = [];95    let scanFrom = 0;96    let current: string[] = [];9798    // Re-walk pending lines with fence state; settle at boundaries.99    this.inFence = false;100    this.fenceMarker = "";101    for (let i = 0; i < this.lines.length; i++) {102      const line = this.lines[i] as string;103      const fenceMatch = FENCE_RE.exec(line);104105      if (this.inFence) {106        current.push(line);107        if (fenceMatch && (line.trimStart()[0] ?? "") === this.fenceMarker) {108          // Closing fence settles the whole fenced block immediately.109          this.inFence = false;110          const block = trimBlock(current);111          if (block !== null) settled.push(block);112          current = [];113          scanFrom = i + 1;114        }115        continue;116      }117118      if (fenceMatch) {119        this.inFence = true;120        this.fenceMarker = (fenceMatch[1] as string)[0] as string;121        current.push(line);122        continue;123      }124125      if (line.trim() === "") {126        // Blank line outside a fence: boundary — settle what precedes it.127        const block = trimBlock(current);128        if (block !== null) settled.push(block);129        current = [];130        scanFrom = i + 1;131        continue;132      }133134      current.push(line);135    }136137    this.lines = this.lines.slice(scanFrom);138    return settled;139  }140}141142/** Drop leading/trailing blank lines; null when nothing remains. */143function trimBlock(lines: string[]): string | null {144  let start = 0;145  let end = lines.length;146  while (start < end && (lines[start] as string).trim() === "") start++;147  while (end > start && (lines[end - 1] as string).trim() === "") end--;148  if (start >= end) return null;149  return lines.slice(start, end).join("\n");150}151