@oh-my-pi/pi-tui 17.4.2 → 18.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,12 +11,7 @@ import { latexToBlock } from "../latex-block";
11
11
  import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
12
  import type { SymbolTheme } from "../symbols";
13
13
  import { TERMINAL } from "../terminal-capabilities";
14
- import type {
15
- Component,
16
- NativeScrollbackCommittedRows,
17
- NativeScrollbackReplay,
18
- NativeScrollbackWidthEpoch,
19
- } from "../tui";
14
+ import type { Component } from "../tui";
20
15
  import {
21
16
  applyBackgroundToLine,
22
17
  Ellipsis,
@@ -924,16 +919,11 @@ function renderedLine(text: string, literalCode?: boolean): RenderedLine {
924
919
  return literalCode ? { text, literalCode: true } : { text };
925
920
  }
926
921
 
927
- interface RenderCacheEntry {
928
- lines: readonly string[];
929
- tables: readonly RenderedTableLayout[];
930
- }
931
-
932
- const renderCache = new LRUCache<string, RenderCacheEntry>({
922
+ const renderCache = new LRUCache<string, readonly string[]>({
933
923
  max: RENDER_CACHE_MAX,
934
924
  maxSize: RENDER_CACHE_MAX_SIZE,
935
925
  maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
936
- sizeCalculation: renderCacheEntrySize,
926
+ sizeCalculation: renderedLinesCacheSize,
937
927
  });
938
928
 
939
929
  function renderedLinesCacheSize(lines: readonly string[]): number {
@@ -942,12 +932,6 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
942
932
  return Math.max(1, size);
943
933
  }
944
934
 
945
- function renderCacheEntrySize(entry: RenderCacheEntry): number {
946
- let size = renderedLinesCacheSize(entry.lines);
947
- for (const table of entry.tables) size += table.key.length + table.columnWidths.length + 4;
948
- return size;
949
- }
950
-
951
935
  // A reference-link definition (`[label]: dest`) resolves across the whole
952
936
  // document, so a split lex cannot reproduce it — disable the streaming fast path
953
937
  // when one is present (rare in streamed output). The label may contain
@@ -1165,6 +1149,15 @@ export interface DefaultTextStyle {
1165
1149
  underline?: boolean;
1166
1150
  }
1167
1151
 
1152
+ /**
1153
+ * Stateful incremental code highlighter carrying parser state across pushes.
1154
+ * Produced per streaming fence by {@link MarkdownTheme.createHighlightStream}.
1155
+ */
1156
+ export interface HighlightStreamSession {
1157
+ /** Highlight the next chunk and advance parser state. */
1158
+ push(chunk: string): string;
1159
+ }
1160
+
1168
1161
  /**
1169
1162
  * Theme functions for markdown elements.
1170
1163
  * Each function takes text and returns styled text with ANSI codes.
@@ -1185,6 +1178,14 @@ export interface MarkdownTheme {
1185
1178
  strikethrough: (text: string) => string;
1186
1179
  underline: (text: string) => string;
1187
1180
  highlightCode?: (code: string, lang?: string) => string[];
1181
+ /**
1182
+ * Create a stateful incremental highlighter for one streaming code fence.
1183
+ * `push` receives newline-terminated complete lines (only the final push
1184
+ * may omit the trailing newline) and must return highlighted ANSI text for
1185
+ * exactly the pushed chunk, byte-identical to highlighting the concatenated
1186
+ * text through `highlightCode`. Return null when `lang` is unsupported.
1187
+ */
1188
+ createHighlightStream?: (lang?: string) => HighlightStreamSession | null;
1188
1189
  /**
1189
1190
  * Resolve a mermaid ASCII rendering by fenced block source text.
1190
1191
  * Return null to fall back to fenced code rendering.
@@ -1453,35 +1454,25 @@ interface StreamPrefixLineCache extends RenderSignature {
1453
1454
  text: string;
1454
1455
  tokenCount: number;
1455
1456
  lines: readonly string[];
1456
- tables: readonly TableRenderSpec[];
1457
1457
  }
1458
- interface StreamingDiffLineCache extends RenderSignature {
1458
+ interface StreamingHighlightCache extends RenderSignature {
1459
1459
  lang: string | undefined;
1460
1460
  text: string;
1461
1461
  lines: readonly string[];
1462
+ stream: HighlightStreamSession;
1462
1463
  }
1463
1464
 
1464
- interface TableLayoutLock {
1465
- availableWidth: number;
1466
- columnWidths: readonly number[];
1467
- }
1468
-
1469
- interface TableRenderSpec extends TableLayoutLock {
1470
- key: string;
1471
- lineCount: number;
1472
- startRow: number;
1473
- endRow: number;
1474
- }
1475
-
1476
- interface RenderedTableLayout extends TableLayoutLock {
1477
- key: string;
1478
- startRow: number;
1479
- endRow: number;
1465
+ /**
1466
+ * Split a highlight-stream push result (newline-terminated lines) into
1467
+ * per-line strings, dropping the empty tail produced by the final newline.
1468
+ */
1469
+ function splitPushedHighlightLines(pushed: string): string[] {
1470
+ const lines = pushed.split("\n");
1471
+ lines.pop();
1472
+ return lines;
1480
1473
  }
1481
1474
 
1482
- export class Markdown
1483
- implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
1484
- {
1475
+ export class Markdown implements Component {
1485
1476
  #text: string;
1486
1477
  #paddingX: number; // Left/right padding
1487
1478
  #paddingY: number; // Top/bottom padding
@@ -1509,48 +1500,19 @@ export class Markdown
1509
1500
  #streamPrefixText?: string;
1510
1501
  #streamPrefixTokens?: Token[];
1511
1502
  #streamPrefixLineCache?: StreamPrefixLineCache;
1512
- // Rows of the most recent render() that are settled — top padding plus the
1513
- // rendered frozen token prefix — exposed via getLastRenderSettledRows()
1514
- // for native-scrollback commit gating.
1515
- #lastRenderSettledRows = 0;
1516
- // Frozen-prefix text backing the last non-zero settled exposure. Settled
1517
- // rows are declared final downstream, so a render whose frozen text no
1518
- // longer extends this prefix (a rewind / wholesale rewrite) resets the
1519
- // exposure to 0 and re-earns it — the exposure is hard-monotone within a
1520
- // text lineage.
1521
- #settledExposedText?: string;
1522
- // Semantic source state that produced the most recent render. Unlike #text,
1523
- // it does not advance when streaming updates arrive before the next paint.
1524
- #lastRenderedText?: string;
1525
- #lastRenderedTransientRenderCache = false;
1526
- #lastRenderedHasMutableTrailingRow = false;
1527
- #widthEpochBoundaries = new WeakMap<
1528
- object,
1529
- { text: string; transientRenderCache: boolean; hasMutableTrailingRow: boolean }
1530
- >();
1531
-
1532
1503
  // True while #renderStreamingContentLines renders the frozen token range:
1533
1504
  // frozen code blocks highlight even in transient mode so their bytes match
1534
1505
  // the finalized render (they render once into the prefix line cache, so
1535
- // the FFI cost is amortized). The volatile tail normally stays
1536
- // unhighlighted; streaming diff fences line-highlight completed rows so
1537
- // semantic colors reach native scrollback before rows leave the viewport.
1538
- #renderingFrozenPrefix = false;
1539
- #streamingDiffLineCache?: StreamingDiffLineCache;
1506
+ // the FFI cost is amortized). In the volatile tail, an open fence
1507
+ // incrementally highlights its completed lines through a stateful
1508
+ // highlight stream (falling back to per-line highlighting for diff-family
1509
+ // fences when the theme lacks one) so completed rows are styled immediately;
1510
+ // only the trailing partial line stays unhighlighted.
1511
+ #renderingStablePrefix = false;
1512
+ #streamingHighlightCache?: StreamingHighlightCache;
1540
1513
  #activeRenderSignature?: RenderSignature;
1541
- // Streaming tables may grow naturally while wholly repaintable. Once any
1542
- // physical row of a table enters native scrollback, its current column widths
1543
- // are locked for the rest of this append-only text lineage: future wider cells
1544
- // wrap inside those columns instead of reflowing immutable history above.
1545
- #tableLayoutWidth?: number;
1546
- #lockedTableLayouts = new Map<string, TableLayoutLock>();
1547
- #lastRenderedTableLayouts: RenderedTableLayout[] = [];
1548
- #activeTableRenderSpecs?: TableRenderSpec[];
1549
-
1550
1514
  #ignoreTight = false;
1551
-
1552
1515
  setIgnoreTight(ignore: boolean): this {
1553
- if (this.#ignoreTight !== ignore) this.#clearTableLayouts();
1554
1516
  this.#ignoreTight = ignore;
1555
1517
  this.invalidate();
1556
1518
  return this;
@@ -1580,7 +1542,6 @@ export class Markdown
1580
1542
  // full lex + wrap runs per re-emit — one of the top CPU hotspots during
1581
1543
  // streaming (issue #4353). Mirrors `Text.setText`'s guard.
1582
1544
  if (text === this.#text) return false;
1583
- if (!text.startsWith(this.#text)) this.#clearTableLayouts();
1584
1545
  this.#text = text;
1585
1546
  if (!text.trim()) {
1586
1547
  // Blank replacement: render() early-returns before #lexTokens can see
@@ -1589,7 +1550,6 @@ export class Markdown
1589
1550
  this.#streamPrefixText = undefined;
1590
1551
  this.#streamPrefixTokens = undefined;
1591
1552
  this.#streamPrefixLineCache = undefined;
1592
- this.#settledExposedText = undefined;
1593
1553
  }
1594
1554
  this.invalidate();
1595
1555
  return true;
@@ -1611,104 +1571,6 @@ export class Markdown
1611
1571
  this.invalidate();
1612
1572
  }
1613
1573
 
1614
- /**
1615
- * Rows at the top of the most recent render() (top padding + rendered
1616
- * frozen-token prefix) whose bytes are settled: byte-stable at this
1617
- * width/theme for as long as the text keeps growing append-only. Hosts
1618
- * feed this to transcript commit gating (see the coding agent's
1619
- * `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
1620
- * (`transientRenderCache`) mode, after a text rewind (re-earned on the new
1621
- * lineage), and on cache-served non-streaming renders.
1622
- */
1623
- getLastRenderSettledRows(): number {
1624
- return this.#lastRenderSettledRows;
1625
- }
1626
-
1627
- captureNativeScrollbackWidthEpoch(): unknown {
1628
- if (this.#lastRenderedText === undefined) return undefined;
1629
- const marker = {};
1630
- this.#widthEpochBoundaries.set(marker, {
1631
- text: this.#lastRenderedText,
1632
- transientRenderCache: this.#lastRenderedTransientRenderCache,
1633
- hasMutableTrailingRow: this.#lastRenderedHasMutableTrailingRow,
1634
- });
1635
- return marker;
1636
- }
1637
-
1638
- resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
1639
- if (typeof boundary !== "object" || boundary === null || this.#cachedWidth === undefined) return undefined;
1640
- const captured = this.#widthEpochBoundaries.get(boundary);
1641
- if (captured === undefined) return undefined;
1642
- const snapshot = new Markdown(
1643
- captured.text,
1644
- this.#paddingX,
1645
- this.#paddingY,
1646
- this.#theme,
1647
- this.#defaultTextStyle,
1648
- this.#codeBlockIndent,
1649
- );
1650
- snapshot.#ignoreTight = this.#ignoreTight;
1651
- snapshot.#transientRenderCache = captured.transientRenderCache;
1652
- return Math.max(
1653
- 0,
1654
- snapshot.render(this.#cachedWidth).length - this.#paddingY - (captured.hasMutableTrailingRow ? 1 : 0),
1655
- );
1656
- }
1657
-
1658
- getNativeScrollbackWidthEpochRows(): number | undefined {
1659
- return this.#cachedLines === undefined ? undefined : this.#widthEpochRows(this.#cachedLines.length);
1660
- }
1661
-
1662
- isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
1663
- if (typeof boundary !== "object" || boundary === null) return true;
1664
- return this.#widthEpochBoundaries.get(boundary)?.hasMutableTrailingRow !== true;
1665
- }
1666
-
1667
- #widthEpochRows(renderedRows: number): number {
1668
- return Math.max(0, renderedRows - this.#paddingY - (this.#transientRenderCache ? 1 : 0));
1669
- }
1670
-
1671
- #recordLastRenderedState(hasContentRows: boolean): void {
1672
- this.#lastRenderedText = this.#text;
1673
- this.#lastRenderedTransientRenderCache = this.#transientRenderCache;
1674
- this.#lastRenderedHasMutableTrailingRow = this.#transientRenderCache && hasContentRows;
1675
- }
1676
-
1677
- /**
1678
- * Freeze every table whose first physical row is already part of the native
1679
- * scrollback prefix. The recorded widths came from the exact frame that was
1680
- * just emitted, so the next streamed delta cannot retroactively widen it.
1681
- */
1682
- setNativeScrollbackCommittedRows(rows: number): void {
1683
- const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
1684
- let changed = false;
1685
- for (const table of this.#lastRenderedTableLayouts) {
1686
- if (table.startRow >= committed || this.#lockedTableLayouts.has(table.key)) continue;
1687
- this.#lockedTableLayouts.set(table.key, {
1688
- availableWidth: table.availableWidth,
1689
- columnWidths: table.columnWidths.slice(),
1690
- });
1691
- changed = true;
1692
- }
1693
- if (changed) this.invalidate();
1694
- }
1695
-
1696
- /** A destructive replay removes the immutable tape this layout was guarding. */
1697
- prepareNativeScrollbackReplay(): void {
1698
- this.#clearTableLayouts();
1699
- this.#tableLayoutWidth = undefined;
1700
- this.invalidate();
1701
- }
1702
-
1703
- #clearTableLayouts(): void {
1704
- this.#lockedTableLayouts.clear();
1705
- this.#lastRenderedTableLayouts = [];
1706
- this.#activeTableRenderSpecs = undefined;
1707
- // Same-width replay/non-append rewrites could otherwise reuse physical
1708
- // prefix lines rendered with the retired locked widths.
1709
- this.#streamPrefixLineCache = undefined;
1710
- }
1711
-
1712
1574
  // Lex `text` into block tokens, reusing the frozen stable prefix when the text
1713
1575
  // only grew (the streaming path). Falls back to a full lex whenever the prefix
1714
1576
  // is no longer a prefix (non-append edit), the text carries reference-link
@@ -1716,17 +1578,19 @@ export class Markdown
1716
1578
  // raw-span offsets). Every fallback is correctness-preserving — only speed
1717
1579
  // differs; the render loop sees the identical token list either way.
1718
1580
  #lexTokens(text: string): Token[] {
1719
- const canStream = !HAS_REF_DEF.test(text) && !text.includes("\r");
1581
+ // When a frozen prefix exists, it was already verified ref-def-free when
1582
+ // frozen (#freezeStablePrefix only runs when canStream was true). The prefix
1583
+ // ends at a "\n\n" block boundary (stableBlockBoundary), so the tail starts
1584
+ // at a fresh line — scanning only the tail for ref defs is sufficient and
1585
+ // avoids re-scanning the growing prefix every frame (O(n²) → O(n) overall).
1720
1586
  const prefix = this.#streamPrefixText;
1721
1587
  const prefixTokens = this.#streamPrefixTokens;
1722
- if (
1723
- canStream &&
1724
- prefix !== undefined &&
1725
- prefixTokens !== undefined &&
1726
- text.length > prefix.length &&
1727
- text.startsWith(prefix)
1728
- ) {
1729
- const tailTokens = lexDocument(text.slice(prefix.length));
1588
+ const hasPrefix =
1589
+ prefix !== undefined && prefixTokens !== undefined && text.length > prefix.length && text.startsWith(prefix);
1590
+ const refDefText = hasPrefix ? text.slice(prefix.length) : text;
1591
+ const canStream = !HAS_REF_DEF.test(refDefText) && !refDefText.includes("\r");
1592
+ if (canStream && hasPrefix) {
1593
+ const tailTokens = lexDocument(refDefText);
1730
1594
  const tokens = [...prefixTokens, ...tailTokens];
1731
1595
  this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
1732
1596
  return tokens;
@@ -1763,24 +1627,14 @@ export class Markdown
1763
1627
  }
1764
1628
 
1765
1629
  render(width: number): readonly string[] {
1766
- if (this.#tableLayoutWidth !== undefined && this.#tableLayoutWidth !== width) {
1767
- this.#clearTableLayouts();
1768
- this.invalidate();
1769
- }
1770
- this.#tableLayoutWidth = width;
1771
1630
  // L1: per-instance cache — fastest path for repeated renders of the same
1772
1631
  // instance at the same width (e.g. resize debounce, repeated redraws).
1773
1632
  // Returning the cached reference is load-bearing: parents memoize their
1774
1633
  // concatenation on reference equality.
1775
1634
  if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
1776
- this.#recordLastRenderedState(this.#cachedLines.length > 0);
1777
1635
  return this.#cachedLines;
1778
1636
  }
1779
1637
 
1780
- // Recomputed below by the streaming path; every other path (cache-served,
1781
- // empty text, non-streaming full render) exposes no settled rows.
1782
- this.#lastRenderSettledRows = 0;
1783
-
1784
1638
  // Calculate available width for content (subtract horizontal padding)
1785
1639
  const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
1786
1640
  const contentWidth = Math.max(1, width - paddingX * 2);
@@ -1790,7 +1644,6 @@ export class Markdown
1790
1644
  this.#cachedText = this.#text;
1791
1645
  this.#cachedWidth = width;
1792
1646
  this.#cachedLines = EMPTY_RENDER_LINES;
1793
- this.#recordLastRenderedState(false);
1794
1647
  return EMPTY_RENDER_LINES;
1795
1648
  }
1796
1649
 
@@ -1813,41 +1666,29 @@ export class Markdown
1813
1666
  // theme.heading is used as the representative theme probe — it's required
1814
1667
  // by MarkdownTheme and is one of the most styling-sensitive entries.
1815
1668
  let cacheKey: string | undefined;
1816
- if (!this.transientRenderCache && this.#lockedTableLayouts.size === 0) {
1669
+ if (!this.transientRenderCache) {
1817
1670
  cacheKey = this.#renderCacheKey(normalizedText, signature);
1818
1671
  const cached = renderCache.get(cacheKey);
1819
1672
  if (cached !== undefined) {
1820
- // Restore both the rendered rows and the geometry metadata that produced
1821
- // them. A later scrollback publication must never lock widths from an
1822
- // older transient frame against rows served from this cache entry.
1823
- this.#lastRenderedTableLayouts = cached.tables.map(table => ({
1824
- ...table,
1825
- columnWidths: table.columnWidths.slice(),
1826
- }));
1827
1673
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
1828
1674
  this.#cachedText = this.#text;
1829
1675
  this.#cachedWidth = width;
1830
- this.#cachedLines = cached.lines;
1831
- this.#recordLastRenderedState(cached.lines.length > 0);
1832
- return cached.lines;
1676
+ this.#cachedLines = cached;
1677
+ return cached;
1833
1678
  }
1834
1679
  }
1835
1680
 
1836
1681
  // Parse markdown to HTML-like tokens
1837
1682
  const tokens = this.#lexTokens(normalizedText);
1838
1683
  let contentLines: string[];
1839
- const tableRenderSpecs: TableRenderSpec[] = [];
1840
- this.#activeTableRenderSpecs = tableRenderSpecs;
1841
1684
  this.#activeRenderSignature = signature;
1842
1685
  try {
1843
1686
  contentLines = this.transientRenderCache
1844
1687
  ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1845
- : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1688
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1846
1689
  } finally {
1847
1690
  this.#activeRenderSignature = undefined;
1848
- this.#activeTableRenderSpecs = undefined;
1849
1691
  }
1850
- this.#lastRenderedTableLayouts = this.#resolveRenderedTableLayouts(tableRenderSpecs, signature.paddingY);
1851
1692
  const emptyLines = this.#renderEmptyPaddingLines(signature);
1852
1693
 
1853
1694
  // Combine top padding, content, and bottom padding
@@ -1864,16 +1705,8 @@ export class Markdown
1864
1705
  // Update L2 module-level LRU so future instances with the same key skip
1865
1706
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1866
1707
  if (cacheKey !== undefined) {
1867
- renderCache.set(cacheKey, {
1868
- lines: result,
1869
- tables: this.#lastRenderedTableLayouts.map(table => ({
1870
- ...table,
1871
- columnWidths: table.columnWidths.slice(),
1872
- })),
1873
- });
1708
+ renderCache.set(cacheKey, result);
1874
1709
  }
1875
- this.#recordLastRenderedState(contentLines.length > 0);
1876
-
1877
1710
  return result;
1878
1711
  }
1879
1712
 
@@ -1905,78 +1738,43 @@ export class Markdown
1905
1738
  signature: RenderSignature,
1906
1739
  contentWidth: number,
1907
1740
  ): string[] {
1908
- const frozenText = this.#streamPrefixText;
1909
- const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
1910
- if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
1911
- return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1741
+ const stableText = this.#streamPrefixText;
1742
+ const stableTokenCount = this.#streamPrefixTokens?.length ?? 0;
1743
+ if (stableText === undefined || stableTokenCount === 0 || !normalizedText.startsWith(stableText)) {
1744
+ return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1912
1745
  }
1913
1746
 
1914
1747
  const contentLines: string[] = [];
1915
- const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
1748
+ const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, stableText, signature);
1916
1749
  let renderedUntil = 0;
1917
- let renderedSourceOffset = 0;
1918
- if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
1750
+ if (reusablePrefix && reusablePrefix.tokenCount <= stableTokenCount) {
1919
1751
  contentLines.push(...reusablePrefix.lines);
1920
- this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
1921
1752
  renderedUntil = reusablePrefix.tokenCount;
1922
- renderedSourceOffset = reusablePrefix.text.length;
1923
1753
  }
1924
1754
 
1925
- if (renderedUntil < frozenTokenCount) {
1926
- // Frozen tokens render with full fidelity (syntax highlighting on)
1755
+ if (renderedUntil < stableTokenCount) {
1756
+ // Stable tokens render with full fidelity (syntax highlighting on)
1927
1757
  // so these cached rows byte-match the finalized render.
1928
- this.#renderingFrozenPrefix = true;
1758
+ this.#renderingStablePrefix = true;
1929
1759
  try {
1930
1760
  contentLines.push(
1931
- ...this.#renderContentLines(
1932
- tokens,
1933
- renderedUntil,
1934
- frozenTokenCount,
1935
- contentWidth,
1936
- signature,
1937
- contentLines.length,
1938
- renderedSourceOffset,
1939
- ),
1761
+ ...this.#renderContentLines(tokens, renderedUntil, stableTokenCount, contentWidth, signature),
1940
1762
  );
1941
1763
  } finally {
1942
- this.#renderingFrozenPrefix = false;
1764
+ this.#renderingStablePrefix = false;
1943
1765
  }
1944
- renderedUntil = frozenTokenCount;
1766
+ renderedUntil = stableTokenCount;
1945
1767
  }
1946
1768
 
1947
1769
  this.#streamPrefixLineCache = {
1948
1770
  ...signature,
1949
- text: frozenText,
1950
- tokenCount: frozenTokenCount,
1771
+ text: stableText,
1772
+ tokenCount: stableTokenCount,
1951
1773
  lines: contentLines.slice(),
1952
- tables: this.#activeTableRenderSpecs?.slice() ?? [],
1953
1774
  };
1954
1775
 
1955
- // Settled exposure (hard-monotone): these rows are declared final to
1956
- // the host, so expose them only while the frozen text still extends
1957
- // the previously exposed prefix; a rewind resets to 0 and re-earns on
1958
- // the rewritten lineage.
1959
- if (contentLines.length > 0) {
1960
- if (this.#settledExposedText === undefined || frozenText.startsWith(this.#settledExposedText)) {
1961
- this.#settledExposedText = frozenText;
1962
- this.#lastRenderSettledRows = signature.paddingY + contentLines.length;
1963
- } else {
1964
- this.#settledExposedText = undefined;
1965
- }
1966
- }
1967
-
1968
1776
  if (renderedUntil < tokens.length) {
1969
- contentLines.push(
1970
- ...this.#renderContentLines(
1971
- tokens,
1972
- renderedUntil,
1973
- tokens.length,
1974
- contentWidth,
1975
- signature,
1976
- contentLines.length,
1977
- frozenText.length,
1978
- ),
1979
- );
1777
+ contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
1980
1778
  }
1981
1779
 
1982
1780
  return contentLines;
@@ -1984,12 +1782,12 @@ export class Markdown
1984
1782
 
1985
1783
  #matchingStreamPrefixLineCache(
1986
1784
  normalizedText: string,
1987
- frozenText: string,
1785
+ stableText: string,
1988
1786
  signature: RenderSignature,
1989
1787
  ): StreamPrefixLineCache | undefined {
1990
1788
  const cache = this.#streamPrefixLineCache;
1991
1789
  if (!cache) return undefined;
1992
- if (!normalizedText.startsWith(cache.text) || !frozenText.startsWith(cache.text)) return undefined;
1790
+ if (!normalizedText.startsWith(cache.text) || !stableText.startsWith(cache.text)) return undefined;
1993
1791
  if (cache.width !== signature.width) return undefined;
1994
1792
  if (cache.paddingX !== signature.paddingX) return undefined;
1995
1793
  if (cache.paddingY !== signature.paddingY) return undefined;
@@ -2010,25 +1808,12 @@ export class Markdown
2010
1808
  end: number,
2011
1809
  contentWidth: number,
2012
1810
  signature: RenderSignature,
2013
- rowOffset: number,
2014
- startingSourceOffset: number,
2015
1811
  ): string[] {
2016
1812
  const wrappedLines: RenderedLine[] = [];
2017
- let sourceOffset = startingSourceOffset;
2018
1813
  for (let i = start; i < end; i++) {
2019
1814
  const token = tokens[i];
2020
1815
  const nextToken = tokens[i + 1];
2021
- const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2022
- const tokenWrappedRowStart = wrappedLines.length;
2023
- const tokenRowStart = rowOffset + tokenWrappedRowStart;
2024
- const renderedTokenLines = this.#renderToken(
2025
- token,
2026
- contentWidth,
2027
- nextToken?.type,
2028
- undefined,
2029
- `offset:${sourceOffset}`,
2030
- );
2031
- const tokenLineOffsets = [0];
1816
+ const renderedTokenLines = this.#renderToken(token, contentWidth, nextToken?.type);
2032
1817
  for (const renderedRow of renderedTokenLines) {
2033
1818
  // Lists wrap while their structural prefixes are still available, so
2034
1819
  // continuation rows retain the correct hanging indent. Re-wrapping the
@@ -2045,30 +1830,7 @@ export class Markdown
2045
1830
  }
2046
1831
  }
2047
1832
  }
2048
- tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
2049
- }
2050
- const tableSpecs = this.#activeTableRenderSpecs;
2051
- if (tableSpecs !== undefined) {
2052
- for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
2053
- const spec = tableSpecs[specIndex]!;
2054
- let relativeStart: number;
2055
- let relativeEnd: number;
2056
- if (token.type === "table") {
2057
- // Exclude the optional inter-block blank from a top-level table's span.
2058
- relativeStart = 0;
2059
- relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
2060
- } else {
2061
- // Container renderers express nested table spans relative to their
2062
- // returned lines. Preserve that exact span through this final wrap.
2063
- if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
2064
- relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
2065
- relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
2066
- }
2067
- spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
2068
- spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
2069
- }
2070
1833
  }
2071
- sourceOffset += token.raw.length;
2072
1834
  }
2073
1835
 
2074
1836
  const leftMargin = padding(signature.paddingX);
@@ -2117,37 +1879,20 @@ export class Markdown
2117
1879
  return contentLines;
2118
1880
  }
2119
1881
 
2120
- #resolveRenderedTableLayouts(specs: readonly TableRenderSpec[], topPadding: number): RenderedTableLayout[] {
2121
- const layouts: RenderedTableLayout[] = [];
2122
- for (const spec of specs) {
2123
- if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
2124
- layouts.push({
2125
- key: spec.key,
2126
- availableWidth: spec.availableWidth,
2127
- columnWidths: spec.columnWidths.slice(),
2128
- startRow: topPadding + spec.startRow,
2129
- endRow: topPadding + spec.endRow,
2130
- });
2131
- }
2132
- return layouts;
2133
- }
2134
-
2135
1882
  #renderCodeBodyLines(token: Token, codeIndent: string): RenderedLine[] {
2136
1883
  const literalCode = this.#codeBlockIndent === 0;
2137
1884
  const bodyLines: RenderedLine[] = [];
2138
1885
  const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
2139
1886
  const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined;
2140
- const normalizedLang = lang?.toLowerCase();
2141
- const canStreamDiff =
2142
- this.transientRenderCache &&
2143
- !this.#renderingFrozenPrefix &&
2144
- this.#theme.highlightCode &&
2145
- (normalizedLang === "diff" || normalizedLang === "patch" || normalizedLang === "udiff");
2146
1887
  const addBodyLine = (line: string): void => {
2147
1888
  bodyLines.push(renderedLine(literalCode ? line : codeIndent + line, literalCode));
2148
1889
  };
2149
1890
 
2150
- if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1891
+ const streaming = this.transientRenderCache && !this.#renderingStablePrefix;
1892
+ if (this.#theme.highlightCode && (!streaming || this.#codeTokenHasClosingFence(token))) {
1893
+ // Finalized content — or a fence that closed mid-stream, which
1894
+ // highlights through the same whole-block call the finalized render
1895
+ // uses so cached stable rows byte-match it.
2151
1896
  const highlightedLines = this.#theme.highlightCode(tokenText, lang);
2152
1897
  for (const hlLine of highlightedLines) {
2153
1898
  addBodyLine(hlLine);
@@ -2155,18 +1900,18 @@ export class Markdown
2155
1900
  return bodyLines;
2156
1901
  }
2157
1902
 
2158
- if (canStreamDiff) {
2159
- const closedFence = this.#codeTokenHasClosingFence(token);
1903
+ if (streaming && this.#theme.highlightCode) {
1904
+ // Open fence: highlight completed lines incrementally so semantic
1905
+ // colors reach completed rows immediately; only the trailing partial
1906
+ // line stays unhighlighted.
2160
1907
  const lineEnd = tokenText.lastIndexOf("\n");
2161
- if (closedFence || lineEnd >= 0) {
2162
- const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd);
2163
- for (const hlLine of this.#highlightStreamingDiffLines(completedText, lang)) {
1908
+ const completedLines = lineEnd >= 0 ? this.#highlightStreamingLines(tokenText.slice(0, lineEnd), lang) : null;
1909
+ if (completedLines) {
1910
+ for (const hlLine of completedLines) {
2164
1911
  addBodyLine(hlLine);
2165
1912
  }
2166
- if (!closedFence) {
2167
- for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
2168
- addBodyLine(this.#theme.codeBlock(codeLine));
2169
- }
1913
+ for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
1914
+ addBodyLine(this.#theme.codeBlock(codeLine));
2170
1915
  }
2171
1916
  return bodyLines;
2172
1917
  }
@@ -2209,11 +1954,17 @@ export class Markdown
2209
1954
  return false;
2210
1955
  }
2211
1956
 
2212
- #highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] {
2213
- const highlightCode = this.#theme.highlightCode;
2214
- if (!highlightCode) return [];
1957
+ /**
1958
+ * Highlight the completed (newline-terminated) prefix of a streaming code
1959
+ * fence. Uses a stateful per-fence highlight stream so each render pushes
1960
+ * only the newly completed lines, with output byte-identical to the
1961
+ * whole-block `highlightCode` the finalized render performs. Returns null
1962
+ * when no stream is available for `lang` (caller falls back to plain
1963
+ * code-block styling).
1964
+ */
1965
+ #highlightStreamingLines(completedText: string, lang: string | undefined): readonly string[] | null {
2215
1966
  const signature = this.#activeRenderSignature;
2216
- const cache = this.#streamingDiffLineCache;
1967
+ const cache = this.#streamingHighlightCache;
2217
1968
  if (
2218
1969
  signature &&
2219
1970
  cache &&
@@ -2233,25 +1984,49 @@ export class Markdown
2233
1984
  cache.headingProbe === signature.headingProbe
2234
1985
  ) {
2235
1986
  if (completedText.length === cache.text.length) return cache.lines;
2236
- const lines = cache.lines.slice();
1987
+ // Invariant: the stream has consumed `cache.text + "\n"`, so pushing
1988
+ // the added lines with a trailing newline advances it to
1989
+ // `completedText + "\n"` — every fed line stays newline-terminated.
2237
1990
  const addedText = completedText.slice(cache.text.length + 1);
2238
- for (const codeLine of addedText.split("\n")) {
2239
- lines.push(...highlightCode(codeLine, lang));
2240
- }
2241
- this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
1991
+ const lines = cache.lines.concat(splitPushedHighlightLines(cache.stream.push(`${addedText}\n`)));
1992
+ this.#streamingHighlightCache = { ...signature, lang, text: completedText, lines, stream: cache.stream };
2242
1993
  return lines;
2243
1994
  }
2244
1995
 
2245
- const lines: string[] = [];
2246
- for (const codeLine of completedText.split("\n")) {
2247
- lines.push(...highlightCode(codeLine, lang));
2248
- }
1996
+ const stream = this.#createHighlightStream(lang);
1997
+ if (!stream) return null;
1998
+ const lines = splitPushedHighlightLines(stream.push(`${completedText}\n`));
2249
1999
  if (signature) {
2250
- this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
2000
+ this.#streamingHighlightCache = { ...signature, lang, text: completedText, lines, stream };
2251
2001
  }
2252
2002
  return lines;
2253
2003
  }
2254
2004
 
2005
+ /**
2006
+ * Resolve a highlight stream for `lang`. Prefers the theme's stateful
2007
+ * factory; without one, emulates a stream via per-line `highlightCode` for
2008
+ * diff-family fences only — that grammar is line-local, so per-line output
2009
+ * matches the whole-block render (other grammars carry cross-line state
2010
+ * and would diverge).
2011
+ */
2012
+ #createHighlightStream(lang: string | undefined): HighlightStreamSession | null {
2013
+ const factory = this.#theme.createHighlightStream;
2014
+ if (factory) return factory(lang);
2015
+ const highlightCode = this.#theme.highlightCode;
2016
+ if (!highlightCode) return null;
2017
+ const normalizedLang = lang?.toLowerCase();
2018
+ if (normalizedLang !== "diff" && normalizedLang !== "patch" && normalizedLang !== "udiff") return null;
2019
+ return {
2020
+ push: chunk => {
2021
+ const lines = chunk.split("\n");
2022
+ const trailing = lines.pop() ?? "";
2023
+ let out = "";
2024
+ for (const line of lines) out += `${highlightCode(line, lang).join("\n")}\n`;
2025
+ return trailing ? out + highlightCode(trailing, lang).join("\n") : out;
2026
+ },
2027
+ };
2028
+ }
2029
+
2255
2030
  #renderEmptyPaddingLines(signature: RenderSignature): string[] {
2256
2031
  const emptyLine = padding(signature.width);
2257
2032
  const emptyLines: string[] = [];
@@ -2351,7 +2126,6 @@ export class Markdown
2351
2126
  width: number,
2352
2127
  nextTokenType?: string,
2353
2128
  styleContext?: InlineStyleContext,
2354
- tokenKey = "root",
2355
2129
  ): RenderedLine[] {
2356
2130
  const lines: RenderedLine[] = [];
2357
2131
 
@@ -2461,7 +2235,7 @@ export class Markdown
2461
2235
  }
2462
2236
 
2463
2237
  case "table": {
2464
- const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, tokenKey);
2238
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
2465
2239
  for (const tableLine of tableLines) lines.push(renderedLine(tableLine));
2466
2240
  break;
2467
2241
  }
@@ -2474,58 +2248,23 @@ export class Markdown
2474
2248
  const quoteContentWidth = Math.max(1, width - 2);
2475
2249
  const quoteTokens = token.tokens || [];
2476
2250
  const renderedQuoteLines: RenderedLine[] = [];
2477
- const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2478
-
2479
2251
  for (let i = 0; i < quoteTokens.length; i++) {
2480
2252
  const quoteToken = quoteTokens[i];
2481
2253
  const nextQuoteToken = quoteTokens[i + 1];
2482
- const quoteTokenRowStart = renderedQuoteLines.length;
2483
- const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2484
2254
  const quoteTokenLines = this.#renderToken(
2485
2255
  quoteToken,
2486
2256
  quoteContentWidth,
2487
2257
  nextQuoteToken?.type,
2488
2258
  quoteInlineStyleContext,
2489
- `${tokenKey}/quote:${i}`,
2490
2259
  );
2491
2260
  renderedQuoteLines.push(...quoteTokenLines);
2492
-
2493
- const tableSpecs = this.#activeTableRenderSpecs;
2494
- if (tableSpecs !== undefined) {
2495
- for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
2496
- const spec = tableSpecs[specIndex]!;
2497
- if (spec.startRow < 0) {
2498
- // Direct child tables initially have no row coordinates. Their
2499
- // structural line count excludes any inter-block blank.
2500
- spec.startRow = quoteTokenRowStart;
2501
- spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
2502
- } else {
2503
- // A nested blockquote already mapped the table into its own
2504
- // returned rows; translate those rows into this quote's input.
2505
- spec.startRow += quoteTokenRowStart;
2506
- spec.endRow += quoteTokenRowStart;
2507
- }
2508
- }
2509
- }
2510
2261
  }
2511
2262
 
2512
2263
  while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1]!.text === "") {
2513
2264
  renderedQuoteLines.pop();
2514
2265
  }
2515
2266
 
2516
- const quoteRowOffsets: number[] = [];
2517
- const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
2518
- const tableSpecs = this.#activeTableRenderSpecs;
2519
- if (tableSpecs !== undefined) {
2520
- for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
2521
- const spec = tableSpecs[specIndex]!;
2522
- if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
2523
- const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
2524
- const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
2525
- spec.startRow = quoteRowOffsets[relativeStart]!;
2526
- spec.endRow = quoteRowOffsets[relativeEnd]!;
2527
- }
2528
- }
2267
+ const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width);
2529
2268
  lines.push(...borderedQuoteLines);
2530
2269
  if (nextTokenType && nextTokenType !== "space") {
2531
2270
  lines.push(renderedLine("")); // Add spacing after blockquotes (unless space token follows)
@@ -2573,7 +2312,7 @@ export class Markdown
2573
2312
  * Wrap already-rendered lines in the blockquote border and quote styling.
2574
2313
  * `width` is the full content width; the border reserves two cells.
2575
2314
  */
2576
- #applyQuoteBorder(renderedLines: RenderedLine[], width: number, sourceRowOffsets?: number[]): RenderedLine[] {
2315
+ #applyQuoteBorder(renderedLines: RenderedLine[], width: number): RenderedLine[] {
2577
2316
  const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
2578
2317
  const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
2579
2318
  const applyQuoteStyle = (line: string): string => {
@@ -2585,7 +2324,6 @@ export class Markdown
2585
2324
  };
2586
2325
  const quoteContentWidth = Math.max(1, width - 2);
2587
2326
  const lines: RenderedLine[] = [];
2588
- sourceRowOffsets?.push(0);
2589
2327
  for (const quoteLine of renderedLines) {
2590
2328
  if (quoteLine.literalCode) {
2591
2329
  const wrappedLiteralRows = wrapTextWithAnsi(quoteLine.text, quoteContentWidth);
@@ -2602,7 +2340,6 @@ export class Markdown
2602
2340
  lines.push(renderedLine(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine));
2603
2341
  }
2604
2342
  }
2605
- sourceRowOffsets?.push(lines.length);
2606
2343
  }
2607
2344
  return lines;
2608
2345
  }
@@ -2976,7 +2713,6 @@ export class Markdown
2976
2713
  availableWidth: number,
2977
2714
  nextTokenType?: string,
2978
2715
  styleContext?: InlineStyleContext,
2979
- tableKey = "table",
2980
2716
  ): string[] {
2981
2717
  const lines: string[] = [];
2982
2718
  const numCols = token.header.length;
@@ -3091,17 +2827,6 @@ export class Markdown
3091
2827
  }
3092
2828
  }
3093
2829
 
3094
- const lockedLayout = this.#lockedTableLayouts.get(tableKey);
3095
- if (
3096
- lockedLayout !== undefined &&
3097
- lockedLayout.availableWidth === availableWidth &&
3098
- lockedLayout.columnWidths.length === numCols &&
3099
- lockedLayout.columnWidths.every(width => Number.isFinite(width) && width >= 1) &&
3100
- lockedLayout.columnWidths.reduce((total, width) => total + width, borderOverhead) <= availableWidth
3101
- ) {
3102
- columnWidths = lockedLayout.columnWidths.slice();
3103
- }
3104
-
3105
2830
  const t = this.#theme.symbols.table;
3106
2831
  const h = t.horizontal;
3107
2832
  const v = t.vertical;
@@ -3157,15 +2882,6 @@ export class Markdown
3157
2882
  const bottomBorderCells = columnWidths.map(w => h.repeat(w));
3158
2883
  const bottomBorder = `${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`;
3159
2884
  lines.push(bottomBorder);
3160
- this.#activeTableRenderSpecs?.push({
3161
- key: tableKey,
3162
- availableWidth,
3163
- columnWidths: columnWidths.slice(),
3164
- lineCount: lines.length,
3165
- startRow: -1,
3166
- endRow: -1,
3167
- });
3168
-
3169
2885
  if (nextTokenType && nextTokenType !== "space") {
3170
2886
  lines.push(""); // Add spacing after table
3171
2887
  }