@oh-my-pi/pi-tui 18.0.0 → 18.0.3

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
@@ -1470,7 +1454,6 @@ interface StreamPrefixLineCache extends RenderSignature {
1470
1454
  text: string;
1471
1455
  tokenCount: number;
1472
1456
  lines: readonly string[];
1473
- tables: readonly TableRenderSpec[];
1474
1457
  }
1475
1458
  interface StreamingHighlightCache extends RenderSignature {
1476
1459
  lang: string | undefined;
@@ -1489,27 +1472,7 @@ function splitPushedHighlightLines(pushed: string): string[] {
1489
1472
  return lines;
1490
1473
  }
1491
1474
 
1492
- interface TableLayoutLock {
1493
- availableWidth: number;
1494
- columnWidths: readonly number[];
1495
- }
1496
-
1497
- interface TableRenderSpec extends TableLayoutLock {
1498
- key: string;
1499
- lineCount: number;
1500
- startRow: number;
1501
- endRow: number;
1502
- }
1503
-
1504
- interface RenderedTableLayout extends TableLayoutLock {
1505
- key: string;
1506
- startRow: number;
1507
- endRow: number;
1508
- }
1509
-
1510
- export class Markdown
1511
- implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay, NativeScrollbackWidthEpoch
1512
- {
1475
+ export class Markdown implements Component {
1513
1476
  #text: string;
1514
1477
  #paddingX: number; // Left/right padding
1515
1478
  #paddingY: number; // Top/bottom padding
@@ -1537,61 +1500,19 @@ export class Markdown
1537
1500
  #streamPrefixText?: string;
1538
1501
  #streamPrefixTokens?: Token[];
1539
1502
  #streamPrefixLineCache?: StreamPrefixLineCache;
1540
- // Rows of the most recent render() that are settled — top padding plus the
1541
- // rendered frozen token prefix — exposed via getLastRenderSettledRows()
1542
- // for native-scrollback commit gating.
1543
- #lastRenderSettledRows = 0;
1544
- // Frozen-prefix text backing the last non-zero settled exposure. Settled
1545
- // rows are declared final downstream, so a render whose frozen text no
1546
- // longer extends this prefix (a rewind / wholesale rewrite) resets the
1547
- // exposure to 0 and re-earns it — the exposure is hard-monotone within a
1548
- // text lineage.
1549
- #settledExposedText?: string;
1550
- // Semantic source state that produced the most recent render. Unlike #text,
1551
- // it does not advance when streaming updates arrive before the next paint.
1552
- #lastRenderedText?: string;
1553
- #lastRenderedTransientRenderCache = false;
1554
- #lastRenderedHasMutableTrailingRow = false;
1555
- #widthEpochBoundaries = new WeakMap<
1556
- object,
1557
- { text: string; transientRenderCache: boolean; hasMutableTrailingRow: boolean }
1558
- >();
1559
-
1560
1503
  // True while #renderStreamingContentLines renders the frozen token range:
1561
1504
  // frozen code blocks highlight even in transient mode so their bytes match
1562
1505
  // the finalized render (they render once into the prefix line cache, so
1563
1506
  // the FFI cost is amortized). In the volatile tail, an open fence
1564
1507
  // incrementally highlights its completed lines through a stateful
1565
1508
  // highlight stream (falling back to per-line highlighting for diff-family
1566
- // fences when the theme lacks one) so semantic colors reach native
1567
- // scrollback before rows leave the viewport; only the trailing partial
1568
- // line stays unhighlighted.
1569
- #renderingFrozenPrefix = false;
1509
+ // fences when the theme lacks one) so completed rows are styled immediately;
1510
+ // only the trailing partial line stays unhighlighted.
1511
+ #renderingStablePrefix = false;
1570
1512
  #streamingHighlightCache?: StreamingHighlightCache;
1571
1513
  #activeRenderSignature?: RenderSignature;
1572
- // Streaming tables may grow naturally while wholly repaintable. Once any
1573
- // physical row of a table enters native scrollback, its current column widths
1574
- // are locked for the rest of this append-only text lineage: future wider cells
1575
- // wrap inside those columns instead of reflowing immutable history above.
1576
- #tableLayoutWidth?: number;
1577
- #lockedTableLayouts = new Map<string, TableLayoutLock>();
1578
- #lastRenderedTableLayouts: RenderedTableLayout[] = [];
1579
- #activeTableRenderSpecs?: TableRenderSpec[];
1580
-
1581
1514
  #ignoreTight = false;
1582
- // Width-independent mutation counter for the width-epoch leading-stability
1583
- // checks (getNativeScrollbackWidthEpochRevision): a Markdown that precedes an
1584
- // epoch source (startup changelog before the transcript, a thinking block
1585
- // before the streaming answer) would otherwise be validated by comparing
1586
- // width-dependent row counts, which conflates reflow with mutation and fails
1587
- // resolution on every width change. Bumped by every content-shape mutation.
1588
- #widthEpochRevision = 0;
1589
-
1590
1515
  setIgnoreTight(ignore: boolean): this {
1591
- if (this.#ignoreTight !== ignore) {
1592
- this.#clearTableLayouts();
1593
- this.#widthEpochRevision++;
1594
- }
1595
1516
  this.#ignoreTight = ignore;
1596
1517
  this.invalidate();
1597
1518
  return this;
@@ -1621,7 +1542,6 @@ export class Markdown
1621
1542
  // full lex + wrap runs per re-emit — one of the top CPU hotspots during
1622
1543
  // streaming (issue #4353). Mirrors `Text.setText`'s guard.
1623
1544
  if (text === this.#text) return false;
1624
- if (!text.startsWith(this.#text)) this.#clearTableLayouts();
1625
1545
  this.#text = text;
1626
1546
  if (!text.trim()) {
1627
1547
  // Blank replacement: render() early-returns before #lexTokens can see
@@ -1630,17 +1550,11 @@ export class Markdown
1630
1550
  this.#streamPrefixText = undefined;
1631
1551
  this.#streamPrefixTokens = undefined;
1632
1552
  this.#streamPrefixLineCache = undefined;
1633
- this.#settledExposedText = undefined;
1634
1553
  }
1635
- this.#widthEpochRevision++;
1636
1554
  this.invalidate();
1637
1555
  return true;
1638
1556
  }
1639
1557
 
1640
- getNativeScrollbackWidthEpochRevision(): number {
1641
- return this.#widthEpochRevision;
1642
- }
1643
-
1644
1558
  invalidate(): void {
1645
1559
  this.#cachedText = undefined;
1646
1560
  this.#cachedWidth = undefined;
@@ -1654,108 +1568,9 @@ export class Markdown
1654
1568
  const next = value === true;
1655
1569
  if (this.#transientRenderCache === next) return;
1656
1570
  this.#transientRenderCache = next;
1657
- this.#widthEpochRevision++;
1658
1571
  this.invalidate();
1659
1572
  }
1660
1573
 
1661
- /**
1662
- * Rows at the top of the most recent render() (top padding + rendered
1663
- * frozen-token prefix) whose bytes are settled: byte-stable at this
1664
- * width/theme for as long as the text keeps growing append-only. Hosts
1665
- * feed this to transcript commit gating (see the coding agent's
1666
- * `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
1667
- * (`transientRenderCache`) mode, after a text rewind (re-earned on the new
1668
- * lineage), and on cache-served non-streaming renders.
1669
- */
1670
- getLastRenderSettledRows(): number {
1671
- return this.#lastRenderSettledRows;
1672
- }
1673
-
1674
- captureNativeScrollbackWidthEpoch(): unknown {
1675
- if (this.#lastRenderedText === undefined) return undefined;
1676
- const marker = {};
1677
- this.#widthEpochBoundaries.set(marker, {
1678
- text: this.#lastRenderedText,
1679
- transientRenderCache: this.#lastRenderedTransientRenderCache,
1680
- hasMutableTrailingRow: this.#lastRenderedHasMutableTrailingRow,
1681
- });
1682
- return marker;
1683
- }
1684
-
1685
- resolveNativeScrollbackWidthEpoch(boundary: unknown): number | undefined {
1686
- if (typeof boundary !== "object" || boundary === null || this.#cachedWidth === undefined) return undefined;
1687
- const captured = this.#widthEpochBoundaries.get(boundary);
1688
- if (captured === undefined) return undefined;
1689
- const snapshot = new Markdown(
1690
- captured.text,
1691
- this.#paddingX,
1692
- this.#paddingY,
1693
- this.#theme,
1694
- this.#defaultTextStyle,
1695
- this.#codeBlockIndent,
1696
- );
1697
- snapshot.#ignoreTight = this.#ignoreTight;
1698
- snapshot.#transientRenderCache = captured.transientRenderCache;
1699
- return Math.max(
1700
- 0,
1701
- snapshot.render(this.#cachedWidth).length - this.#paddingY - (captured.hasMutableTrailingRow ? 1 : 0),
1702
- );
1703
- }
1704
-
1705
- getNativeScrollbackWidthEpochRows(): number | undefined {
1706
- return this.#cachedLines === undefined ? undefined : this.#widthEpochRows(this.#cachedLines.length);
1707
- }
1708
-
1709
- isNativeScrollbackWidthEpochAppendOnly(boundary: unknown): boolean {
1710
- if (typeof boundary !== "object" || boundary === null) return true;
1711
- return this.#widthEpochBoundaries.get(boundary)?.hasMutableTrailingRow !== true;
1712
- }
1713
-
1714
- #widthEpochRows(renderedRows: number): number {
1715
- return Math.max(0, renderedRows - this.#paddingY - (this.#transientRenderCache ? 1 : 0));
1716
- }
1717
-
1718
- #recordLastRenderedState(hasContentRows: boolean): void {
1719
- this.#lastRenderedText = this.#text;
1720
- this.#lastRenderedTransientRenderCache = this.#transientRenderCache;
1721
- this.#lastRenderedHasMutableTrailingRow = this.#transientRenderCache && hasContentRows;
1722
- }
1723
-
1724
- /**
1725
- * Freeze every table whose first physical row is already part of the native
1726
- * scrollback prefix. The recorded widths came from the exact frame that was
1727
- * just emitted, so the next streamed delta cannot retroactively widen it.
1728
- */
1729
- setNativeScrollbackCommittedRows(rows: number): void {
1730
- const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
1731
- let changed = false;
1732
- for (const table of this.#lastRenderedTableLayouts) {
1733
- if (table.startRow >= committed || this.#lockedTableLayouts.has(table.key)) continue;
1734
- this.#lockedTableLayouts.set(table.key, {
1735
- availableWidth: table.availableWidth,
1736
- columnWidths: table.columnWidths.slice(),
1737
- });
1738
- changed = true;
1739
- }
1740
- if (changed) this.invalidate();
1741
- }
1742
-
1743
- /** A destructive replay removes the immutable tape this layout was guarding. */
1744
- prepareNativeScrollbackReplay(): void {
1745
- this.#clearTableLayouts();
1746
- this.#tableLayoutWidth = undefined;
1747
- this.invalidate();
1748
- }
1749
-
1750
- #clearTableLayouts(): void {
1751
- this.#lockedTableLayouts.clear();
1752
- this.#lastRenderedTableLayouts = [];
1753
- this.#activeTableRenderSpecs = undefined;
1754
- // Same-width replay/non-append rewrites could otherwise reuse physical
1755
- // prefix lines rendered with the retired locked widths.
1756
- this.#streamPrefixLineCache = undefined;
1757
- }
1758
-
1759
1574
  // Lex `text` into block tokens, reusing the frozen stable prefix when the text
1760
1575
  // only grew (the streaming path). Falls back to a full lex whenever the prefix
1761
1576
  // is no longer a prefix (non-append edit), the text carries reference-link
@@ -1763,17 +1578,19 @@ export class Markdown
1763
1578
  // raw-span offsets). Every fallback is correctness-preserving — only speed
1764
1579
  // differs; the render loop sees the identical token list either way.
1765
1580
  #lexTokens(text: string): Token[] {
1766
- 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).
1767
1586
  const prefix = this.#streamPrefixText;
1768
1587
  const prefixTokens = this.#streamPrefixTokens;
1769
- if (
1770
- canStream &&
1771
- prefix !== undefined &&
1772
- prefixTokens !== undefined &&
1773
- text.length > prefix.length &&
1774
- text.startsWith(prefix)
1775
- ) {
1776
- 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);
1777
1594
  const tokens = [...prefixTokens, ...tailTokens];
1778
1595
  this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
1779
1596
  return tokens;
@@ -1810,24 +1627,14 @@ export class Markdown
1810
1627
  }
1811
1628
 
1812
1629
  render(width: number): readonly string[] {
1813
- if (this.#tableLayoutWidth !== undefined && this.#tableLayoutWidth !== width) {
1814
- this.#clearTableLayouts();
1815
- this.invalidate();
1816
- }
1817
- this.#tableLayoutWidth = width;
1818
1630
  // L1: per-instance cache — fastest path for repeated renders of the same
1819
1631
  // instance at the same width (e.g. resize debounce, repeated redraws).
1820
1632
  // Returning the cached reference is load-bearing: parents memoize their
1821
1633
  // concatenation on reference equality.
1822
1634
  if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
1823
- this.#recordLastRenderedState(this.#cachedLines.length > 0);
1824
1635
  return this.#cachedLines;
1825
1636
  }
1826
1637
 
1827
- // Recomputed below by the streaming path; every other path (cache-served,
1828
- // empty text, non-streaming full render) exposes no settled rows.
1829
- this.#lastRenderSettledRows = 0;
1830
-
1831
1638
  // Calculate available width for content (subtract horizontal padding)
1832
1639
  const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
1833
1640
  const contentWidth = Math.max(1, width - paddingX * 2);
@@ -1837,7 +1644,6 @@ export class Markdown
1837
1644
  this.#cachedText = this.#text;
1838
1645
  this.#cachedWidth = width;
1839
1646
  this.#cachedLines = EMPTY_RENDER_LINES;
1840
- this.#recordLastRenderedState(false);
1841
1647
  return EMPTY_RENDER_LINES;
1842
1648
  }
1843
1649
 
@@ -1860,41 +1666,29 @@ export class Markdown
1860
1666
  // theme.heading is used as the representative theme probe — it's required
1861
1667
  // by MarkdownTheme and is one of the most styling-sensitive entries.
1862
1668
  let cacheKey: string | undefined;
1863
- if (!this.transientRenderCache && this.#lockedTableLayouts.size === 0) {
1669
+ if (!this.transientRenderCache) {
1864
1670
  cacheKey = this.#renderCacheKey(normalizedText, signature);
1865
1671
  const cached = renderCache.get(cacheKey);
1866
1672
  if (cached !== undefined) {
1867
- // Restore both the rendered rows and the geometry metadata that produced
1868
- // them. A later scrollback publication must never lock widths from an
1869
- // older transient frame against rows served from this cache entry.
1870
- this.#lastRenderedTableLayouts = cached.tables.map(table => ({
1871
- ...table,
1872
- columnWidths: table.columnWidths.slice(),
1873
- }));
1874
1673
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
1875
1674
  this.#cachedText = this.#text;
1876
1675
  this.#cachedWidth = width;
1877
- this.#cachedLines = cached.lines;
1878
- this.#recordLastRenderedState(cached.lines.length > 0);
1879
- return cached.lines;
1676
+ this.#cachedLines = cached;
1677
+ return cached;
1880
1678
  }
1881
1679
  }
1882
1680
 
1883
1681
  // Parse markdown to HTML-like tokens
1884
1682
  const tokens = this.#lexTokens(normalizedText);
1885
1683
  let contentLines: string[];
1886
- const tableRenderSpecs: TableRenderSpec[] = [];
1887
- this.#activeTableRenderSpecs = tableRenderSpecs;
1888
1684
  this.#activeRenderSignature = signature;
1889
1685
  try {
1890
1686
  contentLines = this.transientRenderCache
1891
1687
  ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1892
- : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1688
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1893
1689
  } finally {
1894
1690
  this.#activeRenderSignature = undefined;
1895
- this.#activeTableRenderSpecs = undefined;
1896
1691
  }
1897
- this.#lastRenderedTableLayouts = this.#resolveRenderedTableLayouts(tableRenderSpecs, signature.paddingY);
1898
1692
  const emptyLines = this.#renderEmptyPaddingLines(signature);
1899
1693
 
1900
1694
  // Combine top padding, content, and bottom padding
@@ -1911,16 +1705,8 @@ export class Markdown
1911
1705
  // Update L2 module-level LRU so future instances with the same key skip
1912
1706
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1913
1707
  if (cacheKey !== undefined) {
1914
- renderCache.set(cacheKey, {
1915
- lines: result,
1916
- tables: this.#lastRenderedTableLayouts.map(table => ({
1917
- ...table,
1918
- columnWidths: table.columnWidths.slice(),
1919
- })),
1920
- });
1708
+ renderCache.set(cacheKey, result);
1921
1709
  }
1922
- this.#recordLastRenderedState(contentLines.length > 0);
1923
-
1924
1710
  return result;
1925
1711
  }
1926
1712
 
@@ -1952,78 +1738,43 @@ export class Markdown
1952
1738
  signature: RenderSignature,
1953
1739
  contentWidth: number,
1954
1740
  ): string[] {
1955
- const frozenText = this.#streamPrefixText;
1956
- const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
1957
- if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
1958
- 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);
1959
1745
  }
1960
1746
 
1961
1747
  const contentLines: string[] = [];
1962
- const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
1748
+ const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, stableText, signature);
1963
1749
  let renderedUntil = 0;
1964
- let renderedSourceOffset = 0;
1965
- if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
1750
+ if (reusablePrefix && reusablePrefix.tokenCount <= stableTokenCount) {
1966
1751
  contentLines.push(...reusablePrefix.lines);
1967
- this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
1968
1752
  renderedUntil = reusablePrefix.tokenCount;
1969
- renderedSourceOffset = reusablePrefix.text.length;
1970
1753
  }
1971
1754
 
1972
- if (renderedUntil < frozenTokenCount) {
1973
- // Frozen tokens render with full fidelity (syntax highlighting on)
1755
+ if (renderedUntil < stableTokenCount) {
1756
+ // Stable tokens render with full fidelity (syntax highlighting on)
1974
1757
  // so these cached rows byte-match the finalized render.
1975
- this.#renderingFrozenPrefix = true;
1758
+ this.#renderingStablePrefix = true;
1976
1759
  try {
1977
1760
  contentLines.push(
1978
- ...this.#renderContentLines(
1979
- tokens,
1980
- renderedUntil,
1981
- frozenTokenCount,
1982
- contentWidth,
1983
- signature,
1984
- contentLines.length,
1985
- renderedSourceOffset,
1986
- ),
1761
+ ...this.#renderContentLines(tokens, renderedUntil, stableTokenCount, contentWidth, signature),
1987
1762
  );
1988
1763
  } finally {
1989
- this.#renderingFrozenPrefix = false;
1764
+ this.#renderingStablePrefix = false;
1990
1765
  }
1991
- renderedUntil = frozenTokenCount;
1766
+ renderedUntil = stableTokenCount;
1992
1767
  }
1993
1768
 
1994
1769
  this.#streamPrefixLineCache = {
1995
1770
  ...signature,
1996
- text: frozenText,
1997
- tokenCount: frozenTokenCount,
1771
+ text: stableText,
1772
+ tokenCount: stableTokenCount,
1998
1773
  lines: contentLines.slice(),
1999
- tables: this.#activeTableRenderSpecs?.slice() ?? [],
2000
1774
  };
2001
1775
 
2002
- // Settled exposure (hard-monotone): these rows are declared final to
2003
- // the host, so expose them only while the frozen text still extends
2004
- // the previously exposed prefix; a rewind resets to 0 and re-earns on
2005
- // the rewritten lineage.
2006
- if (contentLines.length > 0) {
2007
- if (this.#settledExposedText === undefined || frozenText.startsWith(this.#settledExposedText)) {
2008
- this.#settledExposedText = frozenText;
2009
- this.#lastRenderSettledRows = signature.paddingY + contentLines.length;
2010
- } else {
2011
- this.#settledExposedText = undefined;
2012
- }
2013
- }
2014
-
2015
1776
  if (renderedUntil < tokens.length) {
2016
- contentLines.push(
2017
- ...this.#renderContentLines(
2018
- tokens,
2019
- renderedUntil,
2020
- tokens.length,
2021
- contentWidth,
2022
- signature,
2023
- contentLines.length,
2024
- frozenText.length,
2025
- ),
2026
- );
1777
+ contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
2027
1778
  }
2028
1779
 
2029
1780
  return contentLines;
@@ -2031,12 +1782,12 @@ export class Markdown
2031
1782
 
2032
1783
  #matchingStreamPrefixLineCache(
2033
1784
  normalizedText: string,
2034
- frozenText: string,
1785
+ stableText: string,
2035
1786
  signature: RenderSignature,
2036
1787
  ): StreamPrefixLineCache | undefined {
2037
1788
  const cache = this.#streamPrefixLineCache;
2038
1789
  if (!cache) return undefined;
2039
- if (!normalizedText.startsWith(cache.text) || !frozenText.startsWith(cache.text)) return undefined;
1790
+ if (!normalizedText.startsWith(cache.text) || !stableText.startsWith(cache.text)) return undefined;
2040
1791
  if (cache.width !== signature.width) return undefined;
2041
1792
  if (cache.paddingX !== signature.paddingX) return undefined;
2042
1793
  if (cache.paddingY !== signature.paddingY) return undefined;
@@ -2057,25 +1808,12 @@ export class Markdown
2057
1808
  end: number,
2058
1809
  contentWidth: number,
2059
1810
  signature: RenderSignature,
2060
- rowOffset: number,
2061
- startingSourceOffset: number,
2062
1811
  ): string[] {
2063
1812
  const wrappedLines: RenderedLine[] = [];
2064
- let sourceOffset = startingSourceOffset;
2065
1813
  for (let i = start; i < end; i++) {
2066
1814
  const token = tokens[i];
2067
1815
  const nextToken = tokens[i + 1];
2068
- const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2069
- const tokenWrappedRowStart = wrappedLines.length;
2070
- const tokenRowStart = rowOffset + tokenWrappedRowStart;
2071
- const renderedTokenLines = this.#renderToken(
2072
- token,
2073
- contentWidth,
2074
- nextToken?.type,
2075
- undefined,
2076
- `offset:${sourceOffset}`,
2077
- );
2078
- const tokenLineOffsets = [0];
1816
+ const renderedTokenLines = this.#renderToken(token, contentWidth, nextToken?.type);
2079
1817
  for (const renderedRow of renderedTokenLines) {
2080
1818
  // Lists wrap while their structural prefixes are still available, so
2081
1819
  // continuation rows retain the correct hanging indent. Re-wrapping the
@@ -2092,30 +1830,7 @@ export class Markdown
2092
1830
  }
2093
1831
  }
2094
1832
  }
2095
- tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
2096
1833
  }
2097
- const tableSpecs = this.#activeTableRenderSpecs;
2098
- if (tableSpecs !== undefined) {
2099
- for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
2100
- const spec = tableSpecs[specIndex]!;
2101
- let relativeStart: number;
2102
- let relativeEnd: number;
2103
- if (token.type === "table") {
2104
- // Exclude the optional inter-block blank from a top-level table's span.
2105
- relativeStart = 0;
2106
- relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
2107
- } else {
2108
- // Container renderers express nested table spans relative to their
2109
- // returned lines. Preserve that exact span through this final wrap.
2110
- if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
2111
- relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
2112
- relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
2113
- }
2114
- spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
2115
- spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
2116
- }
2117
- }
2118
- sourceOffset += token.raw.length;
2119
1834
  }
2120
1835
 
2121
1836
  const leftMargin = padding(signature.paddingX);
@@ -2164,21 +1879,6 @@ export class Markdown
2164
1879
  return contentLines;
2165
1880
  }
2166
1881
 
2167
- #resolveRenderedTableLayouts(specs: readonly TableRenderSpec[], topPadding: number): RenderedTableLayout[] {
2168
- const layouts: RenderedTableLayout[] = [];
2169
- for (const spec of specs) {
2170
- if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
2171
- layouts.push({
2172
- key: spec.key,
2173
- availableWidth: spec.availableWidth,
2174
- columnWidths: spec.columnWidths.slice(),
2175
- startRow: topPadding + spec.startRow,
2176
- endRow: topPadding + spec.endRow,
2177
- });
2178
- }
2179
- return layouts;
2180
- }
2181
-
2182
1882
  #renderCodeBodyLines(token: Token, codeIndent: string): RenderedLine[] {
2183
1883
  const literalCode = this.#codeBlockIndent === 0;
2184
1884
  const bodyLines: RenderedLine[] = [];
@@ -2188,11 +1888,11 @@ export class Markdown
2188
1888
  bodyLines.push(renderedLine(literalCode ? line : codeIndent + line, literalCode));
2189
1889
  };
2190
1890
 
2191
- const streaming = this.transientRenderCache && !this.#renderingFrozenPrefix;
1891
+ const streaming = this.transientRenderCache && !this.#renderingStablePrefix;
2192
1892
  if (this.#theme.highlightCode && (!streaming || this.#codeTokenHasClosingFence(token))) {
2193
1893
  // Finalized content — or a fence that closed mid-stream, which
2194
1894
  // highlights through the same whole-block call the finalized render
2195
- // uses so rows entering native scrollback byte-match it.
1895
+ // uses so cached stable rows byte-match it.
2196
1896
  const highlightedLines = this.#theme.highlightCode(tokenText, lang);
2197
1897
  for (const hlLine of highlightedLines) {
2198
1898
  addBodyLine(hlLine);
@@ -2202,8 +1902,8 @@ export class Markdown
2202
1902
 
2203
1903
  if (streaming && this.#theme.highlightCode) {
2204
1904
  // Open fence: highlight completed lines incrementally so semantic
2205
- // colors reach native scrollback before rows leave the viewport;
2206
- // only the trailing partial line stays unhighlighted.
1905
+ // colors reach completed rows immediately; only the trailing partial
1906
+ // line stays unhighlighted.
2207
1907
  const lineEnd = tokenText.lastIndexOf("\n");
2208
1908
  const completedLines = lineEnd >= 0 ? this.#highlightStreamingLines(tokenText.slice(0, lineEnd), lang) : null;
2209
1909
  if (completedLines) {
@@ -2426,7 +2126,6 @@ export class Markdown
2426
2126
  width: number,
2427
2127
  nextTokenType?: string,
2428
2128
  styleContext?: InlineStyleContext,
2429
- tokenKey = "root",
2430
2129
  ): RenderedLine[] {
2431
2130
  const lines: RenderedLine[] = [];
2432
2131
 
@@ -2536,7 +2235,7 @@ export class Markdown
2536
2235
  }
2537
2236
 
2538
2237
  case "table": {
2539
- const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, tokenKey);
2238
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
2540
2239
  for (const tableLine of tableLines) lines.push(renderedLine(tableLine));
2541
2240
  break;
2542
2241
  }
@@ -2549,58 +2248,23 @@ export class Markdown
2549
2248
  const quoteContentWidth = Math.max(1, width - 2);
2550
2249
  const quoteTokens = token.tokens || [];
2551
2250
  const renderedQuoteLines: RenderedLine[] = [];
2552
- const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2553
-
2554
2251
  for (let i = 0; i < quoteTokens.length; i++) {
2555
2252
  const quoteToken = quoteTokens[i];
2556
2253
  const nextQuoteToken = quoteTokens[i + 1];
2557
- const quoteTokenRowStart = renderedQuoteLines.length;
2558
- const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2559
2254
  const quoteTokenLines = this.#renderToken(
2560
2255
  quoteToken,
2561
2256
  quoteContentWidth,
2562
2257
  nextQuoteToken?.type,
2563
2258
  quoteInlineStyleContext,
2564
- `${tokenKey}/quote:${i}`,
2565
2259
  );
2566
2260
  renderedQuoteLines.push(...quoteTokenLines);
2567
-
2568
- const tableSpecs = this.#activeTableRenderSpecs;
2569
- if (tableSpecs !== undefined) {
2570
- for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
2571
- const spec = tableSpecs[specIndex]!;
2572
- if (spec.startRow < 0) {
2573
- // Direct child tables initially have no row coordinates. Their
2574
- // structural line count excludes any inter-block blank.
2575
- spec.startRow = quoteTokenRowStart;
2576
- spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
2577
- } else {
2578
- // A nested blockquote already mapped the table into its own
2579
- // returned rows; translate those rows into this quote's input.
2580
- spec.startRow += quoteTokenRowStart;
2581
- spec.endRow += quoteTokenRowStart;
2582
- }
2583
- }
2584
- }
2585
2261
  }
2586
2262
 
2587
2263
  while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1]!.text === "") {
2588
2264
  renderedQuoteLines.pop();
2589
2265
  }
2590
2266
 
2591
- const quoteRowOffsets: number[] = [];
2592
- const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
2593
- const tableSpecs = this.#activeTableRenderSpecs;
2594
- if (tableSpecs !== undefined) {
2595
- for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
2596
- const spec = tableSpecs[specIndex]!;
2597
- if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
2598
- const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
2599
- const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
2600
- spec.startRow = quoteRowOffsets[relativeStart]!;
2601
- spec.endRow = quoteRowOffsets[relativeEnd]!;
2602
- }
2603
- }
2267
+ const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width);
2604
2268
  lines.push(...borderedQuoteLines);
2605
2269
  if (nextTokenType && nextTokenType !== "space") {
2606
2270
  lines.push(renderedLine("")); // Add spacing after blockquotes (unless space token follows)
@@ -2648,7 +2312,7 @@ export class Markdown
2648
2312
  * Wrap already-rendered lines in the blockquote border and quote styling.
2649
2313
  * `width` is the full content width; the border reserves two cells.
2650
2314
  */
2651
- #applyQuoteBorder(renderedLines: RenderedLine[], width: number, sourceRowOffsets?: number[]): RenderedLine[] {
2315
+ #applyQuoteBorder(renderedLines: RenderedLine[], width: number): RenderedLine[] {
2652
2316
  const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
2653
2317
  const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
2654
2318
  const applyQuoteStyle = (line: string): string => {
@@ -2660,7 +2324,6 @@ export class Markdown
2660
2324
  };
2661
2325
  const quoteContentWidth = Math.max(1, width - 2);
2662
2326
  const lines: RenderedLine[] = [];
2663
- sourceRowOffsets?.push(0);
2664
2327
  for (const quoteLine of renderedLines) {
2665
2328
  if (quoteLine.literalCode) {
2666
2329
  const wrappedLiteralRows = wrapTextWithAnsi(quoteLine.text, quoteContentWidth);
@@ -2677,7 +2340,6 @@ export class Markdown
2677
2340
  lines.push(renderedLine(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine));
2678
2341
  }
2679
2342
  }
2680
- sourceRowOffsets?.push(lines.length);
2681
2343
  }
2682
2344
  return lines;
2683
2345
  }
@@ -3051,7 +2713,6 @@ export class Markdown
3051
2713
  availableWidth: number,
3052
2714
  nextTokenType?: string,
3053
2715
  styleContext?: InlineStyleContext,
3054
- tableKey = "table",
3055
2716
  ): string[] {
3056
2717
  const lines: string[] = [];
3057
2718
  const numCols = token.header.length;
@@ -3166,17 +2827,6 @@ export class Markdown
3166
2827
  }
3167
2828
  }
3168
2829
 
3169
- const lockedLayout = this.#lockedTableLayouts.get(tableKey);
3170
- if (
3171
- lockedLayout !== undefined &&
3172
- lockedLayout.availableWidth === availableWidth &&
3173
- lockedLayout.columnWidths.length === numCols &&
3174
- lockedLayout.columnWidths.every(width => Number.isFinite(width) && width >= 1) &&
3175
- lockedLayout.columnWidths.reduce((total, width) => total + width, borderOverhead) <= availableWidth
3176
- ) {
3177
- columnWidths = lockedLayout.columnWidths.slice();
3178
- }
3179
-
3180
2830
  const t = this.#theme.symbols.table;
3181
2831
  const h = t.horizontal;
3182
2832
  const v = t.vertical;
@@ -3232,15 +2882,6 @@ export class Markdown
3232
2882
  const bottomBorderCells = columnWidths.map(w => h.repeat(w));
3233
2883
  const bottomBorder = `${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`;
3234
2884
  lines.push(bottomBorder);
3235
- this.#activeTableRenderSpecs?.push({
3236
- key: tableKey,
3237
- availableWidth,
3238
- columnWidths: columnWidths.slice(),
3239
- lineCount: lines.length,
3240
- startRow: -1,
3241
- endRow: -1,
3242
- });
3243
-
3244
2885
  if (nextTokenType && nextTokenType !== "space") {
3245
2886
  lines.push(""); // Add spacing after table
3246
2887
  }