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

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.
@@ -38,6 +38,27 @@ function normalizeOsc8Terminators(text: string): string {
38
38
  return text.replace(OSC8_ST_PREFIX_REGEX, "$1\x07");
39
39
  }
40
40
 
41
+ /** The longest suffix of `text` a future append could still complete into a
42
+ * full `\x1b]8;[^\x07\x1b]*\x1b\\` match: the last `\x1b]8;` plus clean
43
+ * body (or that plus the pending ST-ESC `\x1b`), or a strict prefix of the
44
+ * escape start. Any other suffix is already normalized or uncompletable
45
+ * (a BEL or an ESC follows it), so this is exactly the region a crossing
46
+ * match can occupy. */
47
+ function trailingOsc8Partial(text: string): string | undefined {
48
+ const start = text.lastIndexOf("\x1b]8;");
49
+ if (start !== -1) {
50
+ const body = text.slice(start + 4);
51
+ const cut = body.search(/[\x07\x1b]/);
52
+ if (cut === -1 || (cut === body.length - 1 && body.charCodeAt(cut) === 0x1b)) {
53
+ return text.slice(start);
54
+ }
55
+ }
56
+ if (text.endsWith("\x1b]8;") || text.endsWith("\x1b]8") || text.endsWith("\x1b]") || text.endsWith("\x1b")) {
57
+ return text.slice(text.lastIndexOf("\x1b"));
58
+ }
59
+ return undefined;
60
+ }
61
+
41
62
  const MARKDOWN_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
42
63
  const MARKDOWN_HEADING_LINE = /^ {0,3}#{1,6}[ \t]+\S/;
43
64
  const FENCED_SOURCE_INTRO = /\b(?:code|example|markdown|output|snippet|source)\s*:?\s*$/i;
@@ -932,12 +953,167 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
932
953
  return Math.max(1, size);
933
954
  }
934
955
 
956
+ // ---------------------------------------------------------------------------
957
+ // Fast-tail (B+) hazard gates
958
+ // ---------------------------------------------------------------------------
959
+ // Tier-1 eligibility: the appended delta is "markdown-inert" — it cannot open
960
+ // or close an inline token, change block structure, or shift a swatch
961
+ // boundary. A delta carrying a marker is re-lexed through the REAL inline
962
+ // pipeline so self-contained marker pairs render styled — exactly what a full
963
+ // re-lex of the grown row produces. `_` is included but narrowed: an
964
+ // intraword `_` is literal per CommonMark flanking rules, so only FLANKED
965
+ // underscores disarm (FAST_ROW_UNDERSCORE_RE on the row text, plus the
966
+ // delta-edge trailingDelimiterSeamHazard check).
967
+ const FAST_DELTA_RE = /[\n\r\\[`<!*_~$#&@\x1b]/;
968
+
969
+ // Disarm when the captured row's RAW tail ends in trailing whitespace (wrap
970
+ // trims it; appending a char moves the trim boundary), a trailing backslash
971
+ // (it can become an escape once the delta supplies the next char — the `\\`
972
+ // clause covers that escape-completion hazard), or a full/partial hex swatch
973
+ // run (a `#` + 3-8 hex is a swatch glyph; the byte range may shift).
974
+ const FAST_RUN_END_RE = /(?:[ \t\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
975
+
976
+ // A partial `#` + 1-2 hex digits can grow into a 3-8 digit swatch glyph
977
+ // across the seam (delta hex digits are inert).
978
+ const FAST_SWATCH_SEAM_RE = /#[0-9a-fA-F]{0,2}$/;
979
+
980
+ // A partial HTML entity at the seam (`&am` + delta `p;`) OR a complete
981
+ // numeric entity (`&#35;`, `&#x1f600;`) — which decodes to `#`, a swatch
982
+ // lead — would normalize to different bytes than the plain concat.
983
+ const FAST_ENTITY_SEAM_RE = /&(?:[A-Za-z0-9#]{0,31}|#[0-9]{1,7};|#[xX][0-9a-fA-F]{1,6};)$/;
984
+
985
+ // A bare URL/email anywhere in the delta or across the seam (a URL the regex
986
+ // cut at a trailing delimiter can re-link once the delta supplies more chars;
987
+ // a protocol head ending at the seam completes in the delta) makes the full
988
+ // re-lex autolink while the plain concat would not.
989
+ const FAST_URL_ANYWHERE_RE = /(?:https?|ftp):\/\/|www\.[A-Za-z0-9]|[A-Za-z0-9._%+-]+@/i;
990
+
991
+ // A bare-URL/email PREFIX may end at the seam and complete in the delta
992
+ // (`ht` + `tps://x`, `foo@` + `bar.com`).
993
+ const FAST_URL_PREFIX_SEAM_RE = /(?:https?|ftp):?\/{0,2}$|www\.$|[A-Za-z0-9._+-]+@[A-Za-z0-9._+-]*$/;
994
+
995
+ // Inline-markup delimiters that survive into rendered output as LITERAL text
996
+ // when unpaired. The fast path detects open constructs by walking the REAL
997
+ // inline token stream (capture) and the delta's inline token stream (frame):
998
+ // any top-level `text` token still carrying one of these bytes holds an open
999
+ // delimiter, so a later delta could close it and a full re-lex would restyle
1000
+ // the seam. Closed pairs tokenize into styled tokens and never appear here.
1001
+ // `_` is excluded (intraword `_` is inert); a FLANKED underscore is caught
1002
+ // by FAST_ROW_UNDERSCORE_RE on the raw text (SGR bytes precede text, so word
1003
+ // boundaries are invisible after styling).
1004
+ const FAST_LITERAL_MARKER_RE = /[*~`[\]<>()$&#]/;
1005
+
1006
+ // A flanking underscore (start-of-line or preceded by a non-word char) can
1007
+ // open an emphasis that a future delta closes. Only flanked `_` is a
1008
+ // delimiter; intraword `_` (a_b) is literal.
1009
+ const FAST_ROW_UNDERSCORE_RE = /(?:^|[^\w])_/;
1010
+ // Two distinct CommonMark word-char notions drive the seam re-flank checks.
1011
+ // For the `_`-underscore seam, "word char" = ASCII `\w` (which includes `_`)
1012
+ // plus Unicode letters/numbers — `[\w\p{L}\p{N}]`. `\w` alone missed a row
1013
+ // ending in a Unicode letter (`é`); CM's char class `[^\s\p{P}\p{S}]` would
1014
+ // wrongly treat `_` (\p{Pc}) as a word char and break the `_..._` intraword
1015
+ // gate. For `*`/`~` closing emphasis, marked's flanking test uses the full
1016
+ // class `[^\s\p{P}\p{S}]` (which covers format/combining marks like U+200C and
1017
+ // U+0301), so branch 2 must use that wider class.
1018
+ const FAST_UNDERSCORE_WORD_AT_END_RE = /[\w\p{L}\p{N}]$/u;
1019
+ const FAST_CMARK_WORD_AT_START_RE = /^[^\s\p{P}\p{S}]/u;
1020
+ // A GFM table delimiter row lets a preceding pipe-header line flip into a
1021
+ // table when a future inert delta completes it — even a marker-free delta
1022
+ // (`| col_a | col_b |\n| --` + `--- | -`). The cold render then re-wraps and
1023
+ // restyles the header, so the splice must disarm. The gate runs on the GROWN
1024
+ // last line (`recipe.rowRaw`'s last line + deltaTabs) in render().
1025
+ const FAST_TABLE_DELIM_ROW_RE = /^\s*(?:\|[\s:]*-+\s*(?:\|[\s:]*-+\s*)*|[\s:]*-+\s*(?:\|[\s:]*-+\s*)+)\|?\s*$/;
1026
+
1027
+ // A paragraph's LAST line can complete into a different block kind under an
1028
+ // inert delta (ATX heading, blockquote, bullet marker, HR, ref-def) — disarm
1029
+ // when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE).
1030
+ const FAST_LINE_START_HAZARD_RE =
1031
+ // `-` is placed LAST so it is a literal, not a range bound. The other
1032
+ // chars are in ASCENDING code-point order (no reversed ranges that
1033
+ // rely on engine leniency): * + = – — ─ ━ ═ then the literal `-`.
1034
+ /^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)](?:[ \t]|$)|[*+=–—─━═-](?:[ \t]|$)|(?:[*+=–—─━═-][ \t]*){2,}[ \t]*$)/;
1035
+ /** @internal exported for tests — counts fast-tail splice frames. A future
1036
+ * regression that silently disarms the fast path (e.g. an over-broad gate)
1037
+ * leaves byte-identity intact but drops the counter to zero. */
1038
+ export let fastTailSplices = 0;
1039
+ /** @internal exported for tests — resets the splice counter. */
1040
+ export function resetFastTailSplices(): void {
1041
+ fastTailSplices = 0;
1042
+ }
1043
+
1044
+ /** @internal exported for tests — the grown-line-start block-kind gate. */
1045
+ export function fastLineStartHazard(grownLine: string): boolean {
1046
+ return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine);
1047
+ }
1048
+
1049
+ /** Seam hazards between the captured raw row tail and the delta: the row must
1050
+ * not end in a wrap-trim, escape, swatch, entity, or URL/email prefix, and
1051
+ * must hold no unbalanced bracket an inert delta could close into a link. */
1052
+ function fastTailSeamSafe(raw: string): boolean {
1053
+ if (FAST_RUN_END_RE.test(raw)) return false;
1054
+ if (FAST_SWATCH_SEAM_RE.test(raw)) return false;
1055
+ if (FAST_ENTITY_SEAM_RE.test(raw)) return false;
1056
+ // Entities decode before swatch/whitespace detection (`&#35;ab` → `#ab`,
1057
+ // `&nbsp;` → ` `): scan the DECODED tail so an entity-indirected swatch
1058
+ // lead OR a decoded trailing space (wrap-trim boundary shifts) disarms.
1059
+ // Autolinks are lex-time on RAW text, so URL prefix stays raw.
1060
+ const rawTail = raw.length > 32 ? raw.slice(-32) : raw;
1061
+ const decodedTail = normalizeHtmlEntitiesForTerminal(rawTail);
1062
+ if (decodedTail !== rawTail && (FAST_RUN_END_RE.test(decodedTail) || FAST_SWATCH_SEAM_RE.test(decodedTail))) {
1063
+ return false;
1064
+ }
1065
+ if (FAST_URL_PREFIX_SEAM_RE.test(raw)) return false;
1066
+ if (raw.endsWith("]") || raw.lastIndexOf("[") > raw.lastIndexOf("]")) return false;
1067
+ if (raw.lastIndexOf("(") > raw.lastIndexOf(")") || raw.lastIndexOf("<") > raw.lastIndexOf(">")) return false;
1068
+ return true;
1069
+ }
1070
+ // Fast-tail (B+) recipe: captured frame state for the next inert-delta splice.
1071
+ interface FastTailRecipe {
1072
+ readonly lines: readonly string[]; // frame rows at capture
1073
+ readonly source: string; // raw #text at capture (append-only predicate)
1074
+ readonly width: number; // contentWidth at capture
1075
+ rowText: string; // RENDERED last wrap-output row (re-wrap input)
1076
+ rowRaw: string; // RAW tail source backing that row (seam scan)
1077
+ rowStart: number; // result[] index of the replaced row
1078
+ rowEnd: number; // exclusive result[] index
1079
+ readonly signature: RenderSignature; // full render signature at capture (bgColor etc.)
1080
+ }
1081
+
1082
+ /** True when the inline token stream holds an OPEN construct: a `text` token
1083
+ * that still carries a literal delimiter (an unpaired `*`/`` ` ``/`[`/… or a
1084
+ * flanking `_`), or raw HTML. Closed constructs are styled tokens whose
1085
+ * delimiters are absent from their text. An open means a FUTURE delta could
1086
+ * close it — the fast path disarms so the splice always matches a full lex. */
1087
+ function inlineHasOpen(tokens: readonly Token[]): boolean {
1088
+ for (const token of tokens) {
1089
+ if (isMathToken(token)) continue;
1090
+ if (token.type === "codespan") continue; // styled leaf; its content cannot re-pair
1091
+ if (token.type === "html") return true; // raw HTML — conservative
1092
+ if (token.type === "text") {
1093
+ const text = "text" in token && typeof token.text === "string" ? token.text : "";
1094
+ if (FAST_LITERAL_MARKER_RE.test(text) || FAST_ROW_UNDERSCORE_RE.test(text)) return true;
1095
+ }
1096
+ if ("tokens" in token && Array.isArray(token.tokens)) {
1097
+ if (inlineHasOpen(token.tokens as Token[])) return true;
1098
+ }
1099
+ }
1100
+ return false;
1101
+ }
1102
+
1103
+ /** Isolated inline lex of a same-line delta. A single-line delta has no block
1104
+ * structure, so the isolated inline pass equals the full lex's inline pass
1105
+ * (marked's paragraph tokens run the same `inlineTokens` entry point). */
1106
+ function lexInlineTokens(text: string): Token[] {
1107
+ return new Lexer(markdownParser.defaults).inlineTokens(text);
1108
+ }
1109
+
935
1110
  // A reference-link definition (`[label]: dest`) resolves across the whole
936
1111
  // document, so a split lex cannot reproduce it — disable the streaming fast path
937
1112
  // when one is present (rare in streamed output). The label may contain
938
1113
  // backslash-escaped characters (`[a\]b]: x`), so escapes are matched explicitly;
939
1114
  // over-matching is safe (it only costs the fast path), under-matching is not.
940
- const HAS_REF_DEF = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/m;
1115
+ const REF_DEF_LINE_RE = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/;
1116
+ const HAS_REF_DEF = new RegExp(REF_DEF_LINE_RE.source, "m");
941
1117
 
942
1118
  // marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
943
1119
  // blank lines only when the remaining source matches
@@ -1020,12 +1196,22 @@ const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
1020
1196
  * - A preceding `list` must be provably closed: CommonMark lets a same-marker
1021
1197
  * item continue the list across the blank line, and marked merges both into
1022
1198
  * one renumbered loose list (`listMayContinueAt`).
1199
+ *
1200
+ * `startIndex` resumes the scan at `tokens[startIndex]` (positions still
1201
+ * accumulate from `base`). The streaming freeze passes the frozen-prefix
1202
+ * token count: that prefix's boundary is permanent under append-only growth
1203
+ * (re-verified when frozen), so only the mutable tail can hold a new one.
1023
1204
  */
1024
- function stableBlockBoundary(text: string, base: number, tokens: Token[]): { end: number; count: number } {
1205
+ function stableBlockBoundary(
1206
+ text: string,
1207
+ base: number,
1208
+ tokens: Token[],
1209
+ startIndex = 0,
1210
+ ): { end: number; count: number } {
1025
1211
  let pos = base;
1026
1212
  let end = 0;
1027
1213
  let count = 0;
1028
- for (let i = 0; i < tokens.length; i++) {
1214
+ for (let i = startIndex; i < tokens.length; i++) {
1029
1215
  const raw = tokens[i].raw;
1030
1216
  const tokenEnd = pos + raw.length;
1031
1217
  if (raw.endsWith("\n\n")) {
@@ -1455,6 +1641,57 @@ interface StreamPrefixLineCache extends RenderSignature {
1455
1641
  tokenCount: number;
1456
1642
  lines: readonly string[];
1457
1643
  }
1644
+ /**
1645
+ * Per-token row cache for the *unfrozen tail* (PoC H). The tail re-lexes every
1646
+ * streaming frame, but the token sequence is prefix-stable under append-only
1647
+ * growth: only the last block token grows, and a closed block's raw bytes and
1648
+ * token type never change once later text arrives (a growing structure — open
1649
+ * fence, lazy list, setext underline — is always the last token; when it
1650
+ * closes, later appends cannot re-segment it). Cached rows are therefore
1651
+ * byte-identical to a fresh render of the same token, and splicing them skips
1652
+ * the O(tail) styled-text + wrap cost that remains after the lex is skipped.
1653
+ *
1654
+ * Validity gates (checked on every reuse):
1655
+ * - signature equality (width, padding, theme probes — same set as the
1656
+ * prefix cache) and token-list alignment (`tokenStart` matches the frozen
1657
+ * prefix count);
1658
+ * - token raw equality against the cached snapshot (string equality, so the
1659
+ * cache works both with reused token objects and with a fresh lex that
1660
+ * re-produces the same raw text);
1661
+ * - `nextTypes[i]`: `#renderToken` decides trailing spacing rows from the
1662
+ * next token's type, so a cached row is only valid while the following
1663
+ * token keeps the type it had when the row was produced;
1664
+ * - token type: `table` tokens are never cached — their layout depends on
1665
+ * the whole token and the width budget, and the splice path is not
1666
+ * covered by the byte-identity suite, so they stay conservative.
1667
+ * `code` tokens are cacheable: the open-fence highlight stream is
1668
+ * deterministic on the cumulative token text, and whole-block highlight
1669
+ * fidelity applies only to fences that already have a closing fence.
1670
+ */
1671
+ interface TailRowCache extends RenderSignature {
1672
+ tokenStart: number;
1673
+ // Upper bound (exclusive) of absolute token indices covered by `rows`.
1674
+ cachedThrough: number;
1675
+ // Per-token final content rows (1:1 with the rendered content lines),
1676
+ // indexed relative to `tokenStart`; undefined for uncacheable tokens.
1677
+ rows: (readonly string[] | undefined)[];
1678
+ // Raw snapshot per token (string value gate).
1679
+ raws: (string | undefined)[];
1680
+ // type of token[i+1] when the rows were produced (blank/spacing gate).
1681
+ nextTypes: (string | undefined)[];
1682
+ }
1683
+ /**
1684
+ * Mutable per-token record collector passed to #renderContentLines while
1685
+ * rendering the streaming tail. The render loop fills `raws`/`nextTypes`
1686
+ * per token as it goes and stores each token's final content rows into
1687
+ * `rows` (relative to the render's `start`), so the tail cache can splice byte-identical
1688
+ * rows for every token whose raw text and following-token type match.
1689
+ */
1690
+ interface TailRenderRecorder {
1691
+ rows: (readonly string[] | undefined)[];
1692
+ raws: (string | undefined)[];
1693
+ nextTypes: (string | undefined)[];
1694
+ }
1458
1695
  interface StreamingHighlightCache extends RenderSignature {
1459
1696
  lang: string | undefined;
1460
1697
  text: string;
@@ -1474,6 +1711,9 @@ function splitPushedHighlightLines(pushed: string): string[] {
1474
1711
 
1475
1712
  export class Markdown implements Component {
1476
1713
  #text: string;
1714
+ // Suffix of #text a future append could still complete into a match
1715
+ // (see trailingOsc8Partial); drives the append-only fast path.
1716
+ #oscPartialEscape?: string;
1477
1717
  #paddingX: number; // Left/right padding
1478
1718
  #paddingY: number; // Top/bottom padding
1479
1719
  #defaultTextStyle?: DefaultTextStyle;
@@ -1500,6 +1740,30 @@ export class Markdown implements Component {
1500
1740
  #streamPrefixText?: string;
1501
1741
  #streamPrefixTokens?: Token[];
1502
1742
  #streamPrefixLineCache?: StreamPrefixLineCache;
1743
+ // Guard-scan memo (PoC C): the ref-def/CR verdict with the exact text
1744
+ // length it was checked on. Reuse is sound only while setText has been
1745
+ // append-only since (tracked via the startsWith that setText performs): a
1746
+ // FALSE verdict stays valid — appending cannot remove an offending ref or
1747
+ // CR; a TRUE verdict can flip only when the delta gains a "[" at a fresh
1748
+ // line or a "]" / ":" completing a dangling "[…" that straddles the scan
1749
+ // edge, or "\n" / "\r". Byte-identity of the checked region: replaceTabs
1750
+ // is a per-char map and normalizeOsc8Terminators changes old bytes only
1751
+ // when an OSC8 terminator straddles the boundary (which breaks startsWith
1752
+ // — the flag then reads non-append), so transient-mode appends are
1753
+ // byte-identical. Non-transient repairOrphanClosingFence can additionally
1754
+ // delete a bare fence line; its triggers (heading + table lines) always
1755
+ // bring "\n" with them, so such frames take the suspicious-delta path,
1756
+ // and a deletion that shortens the text trips the length gate — either
1757
+ // way the verdict is re-derived, never reused across the deletion.
1758
+ #lastScanLength = -1;
1759
+ #lastScanCanStream = false;
1760
+ #lastScanValid = false;
1761
+ #appendOnlySinceLastScan = true;
1762
+ // PoC H: per-token row cache for the unfrozen tail. Invalidated together
1763
+ // with the prefix cache (width/signature changes, non-append edits) — see
1764
+ // the blank-replacement branch of setText and the fallback branch of
1765
+ // #lexTokens.
1766
+ #tailRowCache?: TailRowCache;
1503
1767
  // True while #renderStreamingContentLines renders the frozen token range:
1504
1768
  // frozen code blocks highlight even in transient mode so their bytes match
1505
1769
  // the finalized render (they render once into the prefix line cache, so
@@ -1511,6 +1775,9 @@ export class Markdown implements Component {
1511
1775
  #renderingStablePrefix = false;
1512
1776
  #streamingHighlightCache?: StreamingHighlightCache;
1513
1777
  #activeRenderSignature?: RenderSignature;
1778
+ #fastTail?: FastTailRecipe; // undefined = disarmed
1779
+ // B+ capture plumbing: #renderContentLines records the last rendered paragraph row.
1780
+ #lastTailCapture?: { kind: "paragraph"; open: boolean; rowInput: string; rowRaw: string };
1514
1781
  #ignoreTight = false;
1515
1782
  setIgnoreTight(ignore: boolean): this {
1516
1783
  this.#ignoreTight = ignore;
@@ -1527,6 +1794,7 @@ export class Markdown implements Component {
1527
1794
  codeBlockIndent: number = 2,
1528
1795
  ) {
1529
1796
  this.#text = normalizeOsc8Terminators(text);
1797
+ this.#oscPartialEscape = trailingOsc8Partial(this.#text);
1530
1798
  this.#paddingX = paddingX;
1531
1799
  this.#paddingY = paddingY;
1532
1800
  this.#theme = theme;
@@ -1535,13 +1803,46 @@ export class Markdown implements Component {
1535
1803
  }
1536
1804
 
1537
1805
  setText(text: string): boolean {
1806
+ // Identical re-emit (throttled tick): fully normalized already.
1807
+ if (text === this.#text) return false;
1808
+ // Streaming path: append-only growth. Only the memoized pending escape
1809
+ // suffix plus the delta can hold a not-yet-normalized match (a crossing
1810
+ // match starts in the pending suffix; everything else is in the delta).
1811
+ // Normalize that region alone and splice it onto the old prefix;
1812
+ // String.replace returns the input unchanged when nothing matches, so
1813
+ // the common clean-delta frame allocates nothing. Once a match is
1814
+ // rewritten (ST → BEL), the caller's raw text no longer aligns with
1815
+ // #text (2-byte ST vs 1-byte BEL), so later frames fall back to the
1816
+ // cold full-document pass — still byte-correct, just not faster.
1817
+ if (text.length > this.#text.length && text.startsWith(this.#text)) {
1818
+ const memoized = this.#oscPartialEscape;
1819
+ const pending = (memoized ?? "") + text.slice(this.#text.length);
1820
+ const normalized = normalizeOsc8Terminators(pending);
1821
+ if (normalized !== pending) {
1822
+ // A stored byte was rewritten (ST → BEL on a crossing match): the
1823
+ // stream-prefix lex caches self-invalidate via startsWith guards
1824
+ // against #text, so nothing else needs clearing.
1825
+ text = this.#text.slice(0, this.#text.length - (memoized?.length ?? 0)) + normalized;
1826
+ }
1827
+ this.#oscPartialEscape = trailingOsc8Partial(normalized);
1828
+ this.#text = text;
1829
+ this.invalidate();
1830
+ return true;
1831
+ }
1832
+ // Non-append edits / cold path: full-document pass.
1538
1833
  text = normalizeOsc8Terminators(text);
1834
+ this.#oscPartialEscape = trailingOsc8Partial(text);
1539
1835
  // Equality guard: streaming re-emits identical text on ticks that carried
1540
1836
  // no delta (throttled provider frames, reconciled tool-execution updates).
1541
1837
  // Without this, the caller-side `#cachedLines` gets thrown away and the
1542
1838
  // full lex + wrap runs per re-emit — one of the top CPU hotspots during
1543
1839
  // streaming (issue #4353). Mirrors `Text.setText`'s guard.
1544
1840
  if (text === this.#text) return false;
1841
+ if (!text.startsWith(this.#text)) {
1842
+ // Non-append edit: the previous frame's guard verdict cannot be
1843
+ // reused — the checked region may have changed anywhere.
1844
+ this.#appendOnlySinceLastScan = false;
1845
+ }
1545
1846
  this.#text = text;
1546
1847
  if (!text.trim()) {
1547
1848
  // Blank replacement: render() early-returns before #lexTokens can see
@@ -1550,6 +1851,10 @@ export class Markdown implements Component {
1550
1851
  this.#streamPrefixText = undefined;
1551
1852
  this.#streamPrefixTokens = undefined;
1552
1853
  this.#streamPrefixLineCache = undefined;
1854
+ this.#tailRowCache = undefined;
1855
+ // B+: the captured fast-path rows index the replaced content — drop
1856
+ // the recipe so a fresh stream cannot splice onto stale rows.
1857
+ this.#fastTail = undefined;
1553
1858
  }
1554
1859
  this.invalidate();
1555
1860
  return true;
@@ -1560,6 +1865,7 @@ export class Markdown implements Component {
1560
1865
  this.#cachedWidth = undefined;
1561
1866
  this.#cachedLines = undefined;
1562
1867
  }
1868
+
1563
1869
  get transientRenderCache(): boolean {
1564
1870
  return this.#transientRenderCache;
1565
1871
  }
@@ -1568,6 +1874,16 @@ export class Markdown implements Component {
1568
1874
  const next = value === true;
1569
1875
  if (this.#transientRenderCache === next) return;
1570
1876
  this.#transientRenderCache = next;
1877
+ // The mode switch changes which normalization applies to the raw text
1878
+ // (transient: replaceTabs; final: repairOrphanClosingFence(replaceTabs)),
1879
+ // so a memo computed on the other mode's buffer must not be reused —
1880
+ // re-derive on the next frame instead.
1881
+ this.#appendOnlySinceLastScan = false;
1882
+ // B+ reset site: a transient flip (finalize, or a fresh stream on
1883
+ // rewound text) means the next render re-lexes from the current
1884
+ // source — drop the fast-path recipe so stale rows cannot be served
1885
+ // across the transition.
1886
+ this.#fastTail = undefined;
1571
1887
  this.invalidate();
1572
1888
  }
1573
1889
 
@@ -1582,13 +1898,52 @@ export class Markdown implements Component {
1582
1898
  // frozen (#freezeStablePrefix only runs when canStream was true). The prefix
1583
1899
  // ends at a "\n\n" block boundary (stableBlockBoundary), so the tail starts
1584
1900
  // 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).
1901
+ // avoids re-scanning the grown prefix every frame (O(n²) → O(n) overall).
1586
1902
  const prefix = this.#streamPrefixText;
1587
1903
  const prefixTokens = this.#streamPrefixTokens;
1588
1904
  const hasPrefix =
1589
1905
  prefix !== undefined && prefixTokens !== undefined && text.length > prefix.length && text.startsWith(prefix);
1590
1906
  const refDefText = hasPrefix ? text.slice(prefix.length) : text;
1591
- const canStream = !HAS_REF_DEF.test(refDefText) && !refDefText.includes("\r");
1907
+ // Guard-scan memo (PoC C): while setText has been append-only and the
1908
+ // grown delta introduces no "[", "]", ":", "\n" or "\r", the previous
1909
+ // verdict stays valid — the checked region is byte-identical (OSC8/tab
1910
+ // normalization is prefix-stable on appends) and none of the chars a
1911
+ // ref-def or CR needs crossed the scan edge. A false verdict is monotone
1912
+ // (appends cannot delete an existing ref def or CR), so it is reused
1913
+ // even when the delta is suspicious; only a true verdict on a suspicious
1914
+ // delta re-runs the tail scan (PR #9303). The tail scan is also the
1915
+ // cold path after non-append edits, which clear the memo. A FALSE
1916
+ // verdict is monotone under appends alone (transient mode: no repair,
1917
+ // appends cannot delete a ref-def or CR), so there it is reused even
1918
+ // on a suspicious delta. Final mode is the exception: render() detects
1919
+ // repairOrphanClosingFence deletions (the normalized buffer shrank)
1920
+ // and invalidates the memo on the affected frame, so the re-derive
1921
+ // happens exactly when the CR/ref-def trigger behind a false verdict
1922
+ // may have been deleted — never left stale, and never re-scanned on
1923
+ // frames where the memo is sound.
1924
+ let canStream: boolean;
1925
+ if (this.#lastScanValid && this.#appendOnlySinceLastScan && text.length > this.#lastScanLength) {
1926
+ const delta = text.slice(this.#lastScanLength);
1927
+ if (
1928
+ !delta.includes("[") &&
1929
+ !delta.includes("]") &&
1930
+ !delta.includes(":") &&
1931
+ !delta.includes("\n") &&
1932
+ !delta.includes("\r")
1933
+ ) {
1934
+ canStream = this.#lastScanCanStream;
1935
+ } else if (this.#lastScanCanStream) {
1936
+ canStream = !HAS_REF_DEF.test(refDefText) && !refDefText.includes("\r");
1937
+ } else {
1938
+ canStream = false;
1939
+ }
1940
+ } else {
1941
+ canStream = !HAS_REF_DEF.test(refDefText) && !refDefText.includes("\r");
1942
+ }
1943
+ this.#lastScanLength = text.length;
1944
+ this.#lastScanCanStream = canStream;
1945
+ this.#lastScanValid = true;
1946
+ this.#appendOnlySinceLastScan = true;
1592
1947
  if (canStream && hasPrefix) {
1593
1948
  const tailTokens = lexDocument(refDefText);
1594
1949
  const tokens = [...prefixTokens, ...tailTokens];
@@ -1602,6 +1957,7 @@ export class Markdown implements Component {
1602
1957
  this.#streamPrefixText = undefined;
1603
1958
  this.#streamPrefixTokens = undefined;
1604
1959
  this.#streamPrefixLineCache = undefined;
1960
+ this.#tailRowCache = undefined;
1605
1961
  }
1606
1962
  return tokens;
1607
1963
  }
@@ -1612,7 +1968,21 @@ export class Markdown implements Component {
1612
1968
  // reference definitions, so each token's `raw` is a verbatim slice of `text`
1613
1969
  // and the summed offsets address `text` exactly.
1614
1970
  #freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
1615
- const frozen = stableBlockBoundary(text, 0, tokens);
1971
+ // On the streaming-concat path (preserveExisting), tokens[0..prefixCount)
1972
+ // ARE the previously frozen prefix and the text above it is byte-
1973
+ // identical, so its boundary cannot move: re-walking those tokens every
1974
+ // frame is pure overhead (O(prefix) per frame, O(n²) over a stream).
1975
+ // Skip them and resume at the first tail token; `base` starts at the
1976
+ // prefix length so accumulated offsets stay global. The cold full-lex
1977
+ // path (preserveExisting: false) re-derives the whole stream, so it
1978
+ // must keep walking from 0.
1979
+ const skipPrefix = opts.preserveExisting ? (this.#streamPrefixTokens?.length ?? 0) : 0;
1980
+ const frozen = stableBlockBoundary(
1981
+ text,
1982
+ skipPrefix > 0 ? (this.#streamPrefixText?.length ?? 0) : 0,
1983
+ tokens,
1984
+ skipPrefix,
1985
+ );
1616
1986
  if (frozen.count > 0) {
1617
1987
  this.#streamPrefixText = text.slice(0, frozen.end);
1618
1988
  this.#streamPrefixTokens = tokens.slice(0, frozen.count);
@@ -1623,6 +1993,7 @@ export class Markdown implements Component {
1623
1993
  this.#streamPrefixText = undefined;
1624
1994
  this.#streamPrefixTokens = undefined;
1625
1995
  this.#streamPrefixLineCache = undefined;
1996
+ this.#tailRowCache = undefined;
1626
1997
  }
1627
1998
  }
1628
1999
 
@@ -1647,11 +2018,144 @@ export class Markdown implements Component {
1647
2018
  return EMPTY_RENDER_LINES;
1648
2019
  }
1649
2020
 
1650
- // Replace tabs with 3 spaces for consistent rendering
1651
- const normalizedText = this.transientRenderCache
1652
- ? replaceTabs(this.#text)
1653
- : repairOrphanClosingFence(replaceTabs(this.#text));
2021
+ // Replace tabs with spaces, then repair orphan fences in final mode.
2022
+ const tabbed = replaceTabs(this.#text);
2023
+ const normalizedText = this.transientRenderCache ? tabbed : repairOrphanClosingFence(tabbed);
2024
+ if (!this.transientRenderCache && normalizedText.length < tabbed.length) {
2025
+ // repairOrphanClosingFence deleted bytes this frame (orphan fence
2026
+ // removed): the guard-scan memo's checked region is no longer
2027
+ // byte-identical, and a cached false verdict may have been based
2028
+ // on the very CR/ref-def line that was deleted. Invalidate so the
2029
+ // next #lexTokens re-derives on the repaired buffer.
2030
+ this.#lastScanValid = false;
2031
+ }
1654
2032
  const signature = this.#renderSignature(width, paddingX);
2033
+ // B+ fast path: an append-only, same-line delta re-renders ONLY the
2034
+ // last content row (the paragraph's trailing wrapped row) with the
2035
+ // grown source, so the new text shows every frame while staying
2036
+ // byte-identical to a cold full render (the full re-lex produces the
2037
+ // same grown inline tokens and the same wrap). The previous frame's
2038
+ // rows live in #fastTail.lines — the L1 #cachedLines was invalidated
2039
+ // by setText, so the fast path cannot read it back. An inert delta
2040
+ // has no "\n", so the frozen prefix cannot advance and the rows above
2041
+ // the spliced span stay byte-identical.
2042
+ if (
2043
+ this.transientRenderCache &&
2044
+ this.#fastTail !== undefined &&
2045
+ contentWidth === this.#fastTail.width &&
2046
+ this.#text.length > this.#fastTail.source.length &&
2047
+ this.#text.startsWith(this.#fastTail.source)
2048
+ ) {
2049
+ // Re-probe signature (pure) and require equality — a bgColor/theme
2050
+ // change (width-constant) must not splice rows of the stale recipe.
2051
+ const recipe = this.#fastTail;
2052
+ if (!this.#signatureEquals(signature, recipe.signature)) {
2053
+ this.#fastTail = undefined;
2054
+ } else {
2055
+ const delta = this.#text.slice(recipe.source.length);
2056
+ const deltaTabs = replaceTabs(delta);
2057
+ // Seam window contains the delta, so one URL/email scan catches both.
2058
+ const seamSafe = fastTailSeamSafe(recipe.rowRaw);
2059
+ const seamWindow =
2060
+ recipe.rowRaw.slice(Math.max(recipe.rowRaw.lastIndexOf(" "), recipe.rowRaw.lastIndexOf("\t")) + 1) +
2061
+ deltaTabs;
2062
+ // A paragraph's last line can complete into a different block
2063
+ // kind under an inert delta — disarm (single gate helper, kept
2064
+ // in sync with the exported test surface).
2065
+ const grownLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
2066
+ const lineStartHazard = fastLineStartHazard(grownLine);
2067
+ // Only same-line deltas splice; marker deltas re-lex through the
2068
+ // REAL inline pipeline so self-contained pairs render styled.
2069
+ const hardDelta = /[\n\r\x1b]/.test(delta);
2070
+ const markerDelta = FAST_DELTA_RE.test(delta);
2071
+ // A delta starting/ending `_` after a word char pairs in isolation
2072
+ // but stays intraword-literal in the full text. Symmetrically, a
2073
+ // row ending with a closing delimiter (`_`, `*`, `~`) followed by
2074
+ // a word-char delta makes the delimiter intraword / non-flanking in
2075
+ // the joined text — the cold render drops the emphasis, but the
2076
+ // splice keeps it. A row ending `$` (closed inline math) followed
2077
+ // by a digit is invalidated by the anti-currency rule ($x$123 is
2078
+ // literal, not math) — disarm.
2079
+ const grownLastLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
2080
+ const trailingDelimiterSeamHazard =
2081
+ (markerDelta &&
2082
+ (deltaTabs.startsWith("_") || deltaTabs.endsWith("_")) &&
2083
+ FAST_UNDERSCORE_WORD_AT_END_RE.test(recipe.rowRaw)) ||
2084
+ (!markerDelta && /[*~_]$/.test(recipe.rowRaw) && FAST_CMARK_WORD_AT_START_RE.test(deltaTabs)) ||
2085
+ (!markerDelta && recipe.rowRaw.endsWith("$") && /^[0-9]/.test(deltaTabs));
2086
+ // A delta opening a pairing char when the captured row ENDS with the
2087
+ // same char can re-pair across the seam: cold lex of the joined run
2088
+ // makes ONE token (x *a**b* → em("a**b")), the splice keeps two.
2089
+ // An image marker (`x!` + `[a](u)`) re-pairs the same way.
2090
+ const pairSeamHazard =
2091
+ markerDelta &&
2092
+ ((/^[*~`]/.test(deltaTabs) && /[*~`]$/.test(recipe.rowRaw)) ||
2093
+ // "x!" + "[a](u)": cold lexes text("x") + image(alt); the splice would
2094
+ // keep "x!" + a styled link byte-run.
2095
+ (deltaTabs.startsWith("[") && recipe.rowRaw.endsWith("!")));
2096
+ const deltaTokens = markerDelta && !hardDelta ? lexInlineTokens(deltaTabs) : null;
2097
+ if (
2098
+ seamSafe &&
2099
+ !lineStartHazard &&
2100
+ !hardDelta &&
2101
+ !trailingDelimiterSeamHazard &&
2102
+ // A grown GFM delimiter last line flips a preceding pipe-header
2103
+ // into a table on a marker-free delta (`| --` + `--- | -`).
2104
+ !FAST_TABLE_DELIM_ROW_RE.test(grownLastLine) &&
2105
+ (!markerDelta || (!this.#lastTailCapture?.open && !pairSeamHazard && !inlineHasOpen(deltaTokens!))) &&
2106
+ !FAST_URL_ANYWHERE_RE.test(seamWindow)
2107
+ ) {
2108
+ // Same text paths a full re-lex applies: real pipeline for marker
2109
+ // deltas, plain swatch/entity render for inert deltas.
2110
+ const { applyText } = this.#getDefaultInlineStyleContext();
2111
+ const grown =
2112
+ recipe.rowText +
2113
+ (markerDelta
2114
+ ? this.#renderInlineTokens(deltaTokens!)
2115
+ : renderTextWithSwatches(
2116
+ normalizeHtmlEntitiesForTerminal(deltaTabs),
2117
+ applyText,
2118
+ this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
2119
+ ));
2120
+ const wrapped = wrapTextWithAnsi(grown, contentWidth);
2121
+ const fastPaddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
2122
+ const leftMargin = padding(fastPaddingX);
2123
+ const rightMargin = padding(fastPaddingX);
2124
+ const bgFn = this.#defaultTextStyle?.bgColor;
2125
+ const fastRows: string[] = [];
2126
+ for (const row of wrapped) {
2127
+ const withMargins = leftMargin + row + rightMargin;
2128
+ fastRows.push(
2129
+ bgFn
2130
+ ? applyBackgroundToLine(withMargins, width, bgFn)
2131
+ : withMargins + padding(Math.max(0, width - visibleWidth(withMargins))),
2132
+ );
2133
+ }
2134
+ // Splice onto the previous frame's rows (new array — parent may
2135
+ // hold the old one).
2136
+ const prev = recipe.lines;
2137
+ const fastResult = [...prev.slice(0, recipe.rowStart), ...fastRows, ...prev.slice(recipe.rowEnd)];
2138
+ this.#cachedText = this.#text;
2139
+ this.#cachedWidth = width;
2140
+ this.#cachedLines = fastResult;
2141
+ this.#fastTail = {
2142
+ lines: fastResult,
2143
+ source: this.#text,
2144
+ width: recipe.width,
2145
+ rowText: wrapped[wrapped.length - 1] ?? "",
2146
+ rowRaw: recipe.rowRaw + deltaTabs,
2147
+ rowStart: recipe.rowStart + wrapped.length - 1,
2148
+ rowEnd: recipe.rowStart + wrapped.length,
2149
+ signature: recipe.signature,
2150
+ };
2151
+ fastTailSplices++;
2152
+ return fastResult;
2153
+ }
2154
+ }
2155
+ // Hazard → disarm until the next real render re-captures.
2156
+ this.#fastTail = undefined;
2157
+ }
2158
+ // Replace tabs with 3 spaces for consistent rendering
1655
2159
 
1656
2160
  // L2: module-level LRU — survives component disposal/recreation across
1657
2161
  // session-tree navigations. Key encodes every dimension that affects the
@@ -1702,6 +2206,37 @@ export class Markdown implements Component {
1702
2206
  this.#cachedWidth = width;
1703
2207
  this.#cachedLines = result;
1704
2208
 
2209
+ const fastEligible =
2210
+ this.transientRenderCache &&
2211
+ contentLines.length > 0 &&
2212
+ // B+ invariant: only the FINAL #renderContentLines call captures;
2213
+ // the all-cache-hit prefix path clears #lastTailCapture at its top.
2214
+ this.#lastTailCapture !== undefined &&
2215
+ this.#lastTailCapture.kind === "paragraph" &&
2216
+ // Run-level default styling (color/bold/italic/strikethrough/
2217
+ // underline) disarms: the splice yields two ANSI runs where a cold
2218
+ // render yields one; bgColor is line-level and stays eligible.
2219
+ !this.#defaultTextStyle?.color &&
2220
+ !this.#defaultTextStyle?.bold &&
2221
+ !this.#defaultTextStyle?.italic &&
2222
+ !this.#defaultTextStyle?.strikethrough &&
2223
+ !this.#defaultTextStyle?.underline;
2224
+ if (fastEligible && this.#lastTailCapture !== undefined) {
2225
+ const capture = this.#lastTailCapture;
2226
+ this.#fastTail = {
2227
+ lines: result,
2228
+ source: this.#text,
2229
+ width: contentWidth,
2230
+ rowText: capture.rowInput,
2231
+ rowRaw: capture.rowRaw,
2232
+ rowStart: signature.paddingY + contentLines.length - 1,
2233
+ rowEnd: signature.paddingY + contentLines.length,
2234
+ signature,
2235
+ };
2236
+ } else {
2237
+ this.#fastTail = undefined;
2238
+ }
2239
+
1705
2240
  // Update L2 module-level LRU so future instances with the same key skip
1706
2241
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1707
2242
  if (cacheKey !== undefined) {
@@ -1727,6 +2262,10 @@ export class Markdown implements Component {
1727
2262
  headingProbe,
1728
2263
  };
1729
2264
  }
2265
+ // All-primitive signature — compare via the canonical render-cache encoding.
2266
+ #signatureEquals(a: RenderSignature, b: RenderSignature): boolean {
2267
+ return this.#renderCacheKey("", a) === this.#renderCacheKey("", b);
2268
+ }
1730
2269
 
1731
2270
  #renderCacheKey(normalizedText: string, signature: RenderSignature): string {
1732
2271
  return `${normalizedText}\x00${signature.width}\x00${signature.paddingX}\x00${signature.paddingY}\x00${signature.codeBlockIndent}\x00${signature.themeId}\x00${signature.defaultTextStyleId}\x00${signature.imageProtocol}\x00${signature.hyperlinks ? 1 : 0}\x00${signature.textSizing ? 1 : 0}\x00${signature.bgColorProbe}\x00${signature.headingProbe}`;
@@ -1741,7 +2280,7 @@ export class Markdown implements Component {
1741
2280
  const stableText = this.#streamPrefixText;
1742
2281
  const stableTokenCount = this.#streamPrefixTokens?.length ?? 0;
1743
2282
  if (stableText === undefined || stableTokenCount === 0 || !normalizedText.startsWith(stableText)) {
1744
- return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
2283
+ return this.#renderStreamingTail(tokens, 0, contentWidth, signature);
1745
2284
  }
1746
2285
 
1747
2286
  const contentLines: string[] = [];
@@ -1774,7 +2313,7 @@ export class Markdown implements Component {
1774
2313
  };
1775
2314
 
1776
2315
  if (renderedUntil < tokens.length) {
1777
- contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
2316
+ contentLines.push(...this.#renderStreamingTail(tokens, renderedUntil, contentWidth, signature));
1778
2317
  }
1779
2318
 
1780
2319
  return contentLines;
@@ -1802,17 +2341,118 @@ export class Markdown implements Component {
1802
2341
  return cache;
1803
2342
  }
1804
2343
 
2344
+ /**
2345
+ * Render the unfrozen tail, splicing byte-identical rows from
2346
+ * {@link #tailRowCache} for every token whose raw text and following-token
2347
+ * type still match the cached snapshot. The splice reuses the exact content
2348
+ * lines a fresh render would produce — the row offsets are implicit in the
2349
+ * array order, so no offset recomputation is needed. The growing last token
2350
+ * is never spliced (its raw text always differs); it renders fresh and is
2351
+ * recorded again, so the cache trails the stream by one token.
2352
+ */
2353
+ #renderStreamingTail(tokens: Token[], start: number, contentWidth: number, signature: RenderSignature): string[] {
2354
+ const out: string[] = [];
2355
+ let spliceEnd = start;
2356
+ const cache = this.#tailRowCache;
2357
+ if (cache !== undefined) {
2358
+ spliceEnd = this.#tailSpliceEnd(cache, start, signature, tokens);
2359
+ for (let i = start; i < spliceEnd; i++) {
2360
+ out.push(...cache.rows[i - start]!);
2361
+ }
2362
+ }
2363
+
2364
+ const recorder: TailRenderRecorder = {
2365
+ rows: new Array(tokens.length - spliceEnd).fill(undefined),
2366
+ raws: new Array(tokens.length - spliceEnd).fill(undefined),
2367
+ nextTypes: new Array(tokens.length - spliceEnd).fill(undefined),
2368
+ };
2369
+ const fresh = this.#renderContentLines(tokens, spliceEnd, tokens.length, contentWidth, signature, recorder);
2370
+ out.push(...fresh);
2371
+
2372
+ // Refresh the cache: keep entries for spliced tokens (their raws stay
2373
+ // valid), overlay the fresh entries, and re-derive the contiguous
2374
+ // covered prefix (splicing stops at the first uncacheable or
2375
+ // changed token). All arrays are tail-relative (index 0 = token
2376
+ // `start`), so a mostly-frozen document allocates only for the
2377
+ // unfrozen tail instead of the whole token list every frame.
2378
+ const tailCount = tokens.length - start;
2379
+ const rows: (readonly string[] | undefined)[] = new Array(tailCount).fill(undefined);
2380
+ const raws: (string | undefined)[] = new Array(tailCount).fill(undefined);
2381
+ const nextTypes: (string | undefined)[] = new Array(tailCount).fill(undefined);
2382
+ if (cache !== undefined && cache.tokenStart === start) {
2383
+ for (let i = start; i < Math.min(cache.cachedThrough, spliceEnd); i++) {
2384
+ rows[i - start] = cache.rows[i - start];
2385
+ raws[i - start] = cache.raws[i - start];
2386
+ nextTypes[i - start] = cache.nextTypes[i - start];
2387
+ }
2388
+ }
2389
+ for (let i = spliceEnd; i < tokens.length; i++) {
2390
+ rows[i - start] = recorder.rows[i - spliceEnd];
2391
+ raws[i - start] = recorder.raws[i - spliceEnd];
2392
+ nextTypes[i - start] = recorder.nextTypes[i - spliceEnd];
2393
+ }
2394
+ let cachedThrough = start;
2395
+ while (cachedThrough < tokens.length && rows[cachedThrough - start] !== undefined) cachedThrough++;
2396
+ this.#tailRowCache = {
2397
+ ...signature,
2398
+ tokenStart: start,
2399
+ cachedThrough,
2400
+ rows,
2401
+ raws,
2402
+ nextTypes,
2403
+ };
2404
+ return out;
2405
+ }
2406
+
2407
+ // Longest cache-spliceable prefix: every cached row from `start` up to
2408
+ // (but not including) the returned index is byte-identical to a fresh
2409
+ // render of the same token. Stops at the first uncacheable token (rows
2410
+ // undefined), the first token whose raw text changed (the growing tail
2411
+ // token), or a following-token type change.
2412
+ #tailSpliceEnd(cache: TailRowCache, start: number, signature: RenderSignature, tokens: Token[]): number {
2413
+ if (cache.tokenStart !== start) return start;
2414
+ if (cache.width !== signature.width) return start;
2415
+ if (cache.paddingX !== signature.paddingX) return start;
2416
+ if (cache.paddingY !== signature.paddingY) return start;
2417
+ if (cache.codeBlockIndent !== signature.codeBlockIndent) return start;
2418
+ if (cache.themeId !== signature.themeId) return start;
2419
+ if (cache.defaultTextStyleId !== signature.defaultTextStyleId) return start;
2420
+ if (cache.imageProtocol !== signature.imageProtocol) return start;
2421
+ if (cache.hyperlinks !== signature.hyperlinks) return start;
2422
+ if (cache.textSizing !== signature.textSizing) return start;
2423
+ if (cache.bgColorProbe !== signature.bgColorProbe) return start;
2424
+ if (cache.headingProbe !== signature.headingProbe) return start;
2425
+ const limit = Math.min(cache.cachedThrough, tokens.length);
2426
+ for (let i = start; i < limit; i++) {
2427
+ if (cache.rows[i - start] === undefined) return i; // uncacheable token stops the splice
2428
+ const cachedRaw = cache.raws[i - start];
2429
+ const token = tokens[i];
2430
+ if (cachedRaw === undefined || token === undefined) return start;
2431
+ if (token.raw !== cachedRaw) return i; // changed/growing token: fresh-render from here
2432
+ if ((tokens[i + 1]?.type ?? undefined) !== cache.nextTypes[i - start]) return i;
2433
+ }
2434
+ return limit;
2435
+ }
2436
+
1805
2437
  #renderContentLines(
1806
2438
  tokens: Token[],
1807
2439
  start: number,
1808
2440
  end: number,
1809
2441
  contentWidth: number,
1810
2442
  signature: RenderSignature,
2443
+ tailRecorder?: TailRenderRecorder,
1811
2444
  ): string[] {
2445
+ // A non-capturing final call must not serve a stale recipe, so the
2446
+ // B+ plumbing is cleared up front; the per-token capture re-fills it.
2447
+ if (end === tokens.length) this.#lastTailCapture = undefined;
1812
2448
  const wrappedLines: RenderedLine[] = [];
2449
+ // Wrapped-row span per absolute token index. Call-local: stale values
2450
+ // are never read across renders.
2451
+ const tokenWrappedRowCounts: number[] = [];
1813
2452
  for (let i = start; i < end; i++) {
1814
2453
  const token = tokens[i];
1815
2454
  const nextToken = tokens[i + 1];
2455
+ const tokenWrappedRowStart = wrappedLines.length;
1816
2456
  const renderedTokenLines = this.#renderToken(token, contentWidth, nextToken?.type);
1817
2457
  for (const renderedRow of renderedTokenLines) {
1818
2458
  // Lists wrap while their structural prefixes are still available, so
@@ -1831,6 +2471,37 @@ export class Markdown implements Component {
1831
2471
  }
1832
2472
  }
1833
2473
  }
2474
+ tokenWrappedRowCounts[i] = wrappedLines.length - tokenWrappedRowStart;
2475
+ // B+ capture hook: the LAST token of the FINAL call is the frame's
2476
+ // true trailing content row — record its rendered last row and raw
2477
+ // tail so render() can build the fast-path recipe. The frozen
2478
+ // prefix call (end < tokens.length) must never capture.
2479
+ if (end === tokens.length && i === end - 1 && token.type === "paragraph") {
2480
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
2481
+ const lastLine = renderedTokenLines[renderedTokenLines.length - 1];
2482
+ // Display math, a newline-terminated paragraph (a fresh line
2483
+ // grows next frame — the captured row is not the mutable tail),
2484
+ // or a tree-guide/OSC-8/OSC-66 trailing row are not
2485
+ // self-contained.
2486
+ if (
2487
+ soleDisplayMath(token.tokens) ||
2488
+ raw.endsWith("\n") ||
2489
+ !lastLine ||
2490
+ TREE_GUIDE_ANCHOR_RE.test(lastLine.text) ||
2491
+ lastLine.text.includes("\x1b]") ||
2492
+ TERMINAL.isImageLine(lastLine.text) ||
2493
+ isOsc66Line(lastLine.text)
2494
+ ) {
2495
+ continue;
2496
+ }
2497
+ const wrappedLast = wrappedLines[wrappedLines.length - 1];
2498
+ this.#lastTailCapture = {
2499
+ kind: "paragraph",
2500
+ rowInput: wrappedLast?.text ?? lastLine.text,
2501
+ rowRaw: raw.slice(raw.lastIndexOf("\n") + 1),
2502
+ open: inlineHasOpen(token.tokens ?? []),
2503
+ };
2504
+ }
1834
2505
  }
1835
2506
 
1836
2507
  const leftMargin = padding(signature.paddingX);
@@ -1876,6 +2547,36 @@ export class Markdown implements Component {
1876
2547
  }
1877
2548
  }
1878
2549
 
2550
+ // PoC H: record per-token row slices for the tail cache. The pad pass
2551
+ // maps every wrapped row to exactly one content line (structural blanks
2552
+ // after OSC 66 sized headings are pushed unpadded but still present), so
2553
+ // slicing by the per-token wrap spans recovers each token's exact rows.
2554
+ if (tailRecorder !== undefined) {
2555
+ const rows = tailRecorder.rows;
2556
+ const raws = tailRecorder.raws;
2557
+ const nextTypes = tailRecorder.nextTypes;
2558
+ let wrappedStart = 0;
2559
+ let contentCursor = 0;
2560
+ for (let i = start; i < end; i++) {
2561
+ const token = tokens[i]!;
2562
+ const wrappedEnd = wrappedStart + tokenWrappedRowCounts[i]!;
2563
+ const rowCount = wrappedEnd - wrappedStart;
2564
+ raws[i - start] = token.raw;
2565
+ nextTypes[i - start] = tokens[i + 1]?.type;
2566
+ // Tables are never cached: their layout depends on the whole
2567
+ // token and the width budget, and the splice path is not
2568
+ // covered by the byte-identity suite. Keep the raw/nextTypes
2569
+ // gates but drop rows.
2570
+ if (token.type === "table") {
2571
+ rows[i - start] = undefined;
2572
+ } else {
2573
+ rows[i - start] = contentLines.slice(contentCursor, contentCursor + rowCount);
2574
+ }
2575
+ contentCursor += rowCount;
2576
+ wrappedStart = wrappedEnd;
2577
+ }
2578
+ }
2579
+
1879
2580
  return contentLines;
1880
2581
  }
1881
2582
 
@@ -2011,7 +2712,15 @@ export class Markdown implements Component {
2011
2712
  */
2012
2713
  #createHighlightStream(lang: string | undefined): HighlightStreamSession | null {
2013
2714
  const factory = this.#theme.createHighlightStream;
2014
- if (factory) return factory(lang);
2715
+ if (factory) {
2716
+ try {
2717
+ return factory(lang);
2718
+ } catch {
2719
+ // Render must not throw: a broken theme factory (stale natives
2720
+ // `HighlightStream`, napi error) falls through to the unhighlighted
2721
+ // path / diff-family per-line emulation below.
2722
+ }
2723
+ }
2015
2724
  const highlightCode = this.#theme.highlightCode;
2016
2725
  if (!highlightCode) return null;
2017
2726
  const normalizedLang = lang?.toLowerCase();