@linxiraos/pi-tui 1.1.4 → 1.1.6

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +3 -4
  2. package/dist/types/components/box.d.ts +2 -0
  3. package/dist/types/components/cancellable-loader.d.ts +2 -0
  4. package/dist/types/components/composer/band.d.ts +2 -0
  5. package/dist/types/components/composer/index.d.ts +1 -0
  6. package/dist/types/components/composer/types.d.ts +3 -3
  7. package/dist/types/components/editor.d.ts +4 -0
  8. package/dist/types/components/image.d.ts +2 -0
  9. package/dist/types/components/input.d.ts +2 -0
  10. package/dist/types/components/loader.d.ts +9 -1
  11. package/dist/types/components/markdown.d.ts +16 -2
  12. package/dist/types/components/scroll-view.d.ts +2 -0
  13. package/dist/types/components/select-list.d.ts +2 -0
  14. package/dist/types/components/settings-list.d.ts +4 -0
  15. package/dist/types/components/spacer.d.ts +2 -0
  16. package/dist/types/components/tab-bar.d.ts +2 -0
  17. package/dist/types/components/text.d.ts +2 -0
  18. package/dist/types/components/truncated-text.d.ts +2 -0
  19. package/dist/types/debug-server.d.ts +27 -0
  20. package/dist/types/index.d.ts +1 -0
  21. package/dist/types/latex-to-unicode.d.ts +4 -13
  22. package/dist/types/terminal-capabilities.d.ts +17 -0
  23. package/dist/types/terminal.d.ts +11 -0
  24. package/dist/types/tui.d.ts +42 -3
  25. package/package.json +3 -3
  26. package/src/autocomplete.ts +50 -8
  27. package/src/components/box.ts +10 -0
  28. package/src/components/cancellable-loader.ts +7 -0
  29. package/src/components/composer/band.ts +42 -0
  30. package/src/components/composer/index.ts +1 -0
  31. package/src/components/composer/registry.ts +2 -0
  32. package/src/components/composer/types.ts +12 -3
  33. package/src/components/editor.ts +56 -10
  34. package/src/components/image.ts +11 -0
  35. package/src/components/input.ts +13 -0
  36. package/src/components/loader.ts +50 -10
  37. package/src/components/markdown.ts +426 -71
  38. package/src/components/scroll-view.ts +12 -0
  39. package/src/components/select-list.ts +13 -0
  40. package/src/components/settings-list.ts +19 -0
  41. package/src/components/spacer.ts +4 -0
  42. package/src/components/tab-bar.ts +11 -0
  43. package/src/components/text.ts +11 -0
  44. package/src/components/truncated-text.ts +15 -1
  45. package/src/debug-server.ts +422 -0
  46. package/src/index.ts +2 -0
  47. package/src/latex-to-unicode.ts +416 -230
  48. package/src/terminal-capabilities.ts +54 -9
  49. package/src/terminal.ts +22 -2
  50. package/src/tui.ts +461 -60
  51. package/src/utils.ts +10 -1
@@ -7,8 +7,9 @@ import {
7
7
  type TokenizerAndRendererExtension,
8
8
  type Tokens,
9
9
  } from "@linxiraos/pi-utils/marked";
10
+ import { mathBlockAt, mathSpanAt, mathStartIndex } from "@linxiraos/pi-utils/math-delimiters";
10
11
  import { latexToBlock } from "../latex-block";
11
- import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
+ import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
13
  import type { SymbolTheme } from "../symbols";
13
14
  import { TERMINAL } from "../terminal-capabilities";
14
15
  import type { Component } from "../tui";
@@ -528,7 +529,7 @@ markdownParser.setOptions({
528
529
  // `math` inline token before markdown's escape/emphasis/link rules run, so
529
530
  // backslash commands (`\frac`, `\alpha`) and intraword underscores (`x_i`)
530
531
  // survive intact instead of being mangled or split. The `$…$` form uses
531
- // pandoc's anti-currency heuristic (`inlineMathSpanEnd`) so "$5 and $10" is
532
+ // pandoc's anti-currency heuristic (`mathSpanAt`) so "$5 and $10" is
532
533
  // never math. Inline extensions run before marked's escape tokenizer, so
533
534
  // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
534
535
  // renders as a literal dollar.
@@ -582,78 +583,33 @@ const customHrExtension: TokenizerAndRendererExtension = {
582
583
  },
583
584
  };
584
585
 
585
- // Leftmost-match scan replacing /\$|\\\(|\\\[/ in mathExtension.start —
586
- // marked calls start() on the remaining source at every inline position, so
587
- // the regex alternation showed up in CPU profiles (part of a ~4.3% start()
588
- // tail). Three indexOf scans yield the identical leftmost index.
589
- /** @internal exported for tests — must stay index-identical to the old regex scan. */
590
- export function mathStartIndex(src: string): number | undefined {
591
- let best = src.indexOf("$");
592
- const paren = src.indexOf("\\(");
593
- if (paren !== -1 && (best === -1 || paren < best)) best = paren;
594
- const bracket = src.indexOf("\\[");
595
- if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
596
- return best === -1 ? undefined : best;
597
- }
598
-
586
+ // Delimiters come from `@linxiraos/pi-utils/math-delimiters`; rendering policy stays here.
599
587
  const mathExtension: TokenizerAndRendererExtension = {
600
588
  name: "math",
601
589
  level: "inline",
602
- start(src) {
603
- return mathStartIndex(src);
604
- },
590
+ start: mathStartIndex,
605
591
  tokenizer(src) {
606
- if (src.startsWith("$$")) {
607
- const end = src.indexOf("$$", 2);
608
- if (end !== -1 && src.slice(2, end).trim().length > 0) {
609
- return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
610
- }
611
- return undefined;
612
- }
613
- if (src.startsWith("\\[")) {
614
- const end = src.indexOf("\\]", 2);
615
- if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
616
- return undefined;
617
- }
618
- if (src.startsWith("\\(")) {
619
- const end = src.indexOf("\\)", 2);
620
- if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: false };
621
- return undefined;
622
- }
623
- if (src.charCodeAt(0) === 0x24 /* $ */) {
624
- const end = inlineMathSpanEnd(src, 0);
625
- if (end !== -1) return { type: "math", raw: src.slice(0, end + 1), text: src.slice(1, end), display: false };
626
- }
627
- return undefined;
592
+ const span = mathSpanAt(src, 0);
593
+ if (!span) return undefined;
594
+ return { type: "math", raw: src.slice(0, span.end), text: span.body, display: span.display };
628
595
  },
629
596
  renderer(token) {
630
- return (token as { text?: string }).text ?? "";
597
+ return typeof token.text === "string" ? token.text : "";
631
598
  },
632
599
  };
633
600
 
634
- // Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
635
- // their own line (≤3 leading spaces). Matched at the block level — before
636
- // paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
637
- // row breaks) renders across several lines instead of being collapsed onto one,
638
- // and blank lines inside the block don't split it. The own-line requirement
639
- // keeps inline `$$…$$` inside prose for the inline tokenizer above.
640
- const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\n([\s\S]+?)\n {0,3}\$\$[ \t]*(?:\n|$)/;
641
- const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\n([\s\S]+?)\n {0,3}\\\][ \t]*(?:\n|$)/;
642
- const MATH_BLOCK_START = /(?:^|\n) {0,3}(?:\$\$|\\\[)[ \t]*\n/;
643
601
  const mathBlockExtension: TokenizerAndRendererExtension = {
644
602
  name: "mathBlock",
645
603
  level: "block",
646
- start(src) {
647
- const m = MATH_BLOCK_START.exec(src);
648
- return m ? m.index : undefined;
649
- },
604
+ // No `start` hint: marked only probes block extensions at a block boundary
605
+ // here and never consults their hints.
650
606
  tokenizer(src) {
651
- const m = MATH_BLOCK_DOLLAR.exec(src) ?? MATH_BLOCK_BRACKET.exec(src);
652
- if (!m || m[1].trim().length === 0) return undefined;
653
- return { type: "math", raw: m[0], text: m[1], display: true };
607
+ const block = mathBlockAt(src);
608
+ if (!block) return undefined;
609
+ return { type: "math", raw: block.raw, text: block.body, display: true };
654
610
  },
655
611
  renderer(token) {
656
- return (token as { text?: string }).text ?? "";
612
+ return typeof token.text === "string" ? token.text : "";
657
613
  },
658
614
  };
659
615
 
@@ -718,7 +674,7 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
718
674
  // (return undefined) to marked's own autolink handling unchanged.
719
675
  const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
720
676
  // Case-insensitive scheme scan replacing /www\.|https?:\/\/|ftp:\/\//i in
721
- // boundedAutolinkExtension.start — like mathStartIndex above, this runs on the
677
+ // boundedAutolinkExtension.start — like `mathStartIndex`, this runs on the
722
678
  // remaining source at every inline position (part of a ~4.3% CPU start() scan
723
679
  // tail in profiles). charCode-only: no allocation, no toLowerCase copies.
724
680
  // `| 32` lower-cases ASCII letters; `.`/`:`/`/` are compared exactly, matching
@@ -953,12 +909,167 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
953
909
  return Math.max(1, size);
954
910
  }
955
911
 
912
+ // ---------------------------------------------------------------------------
913
+ // Fast-tail (B+) hazard gates
914
+ // ---------------------------------------------------------------------------
915
+ // Tier-1 eligibility: the appended delta is "markdown-inert" — it cannot open
916
+ // or close an inline token, change block structure, or shift a swatch
917
+ // boundary. A delta carrying a marker is re-lexed through the REAL inline
918
+ // pipeline so self-contained marker pairs render styled — exactly what a full
919
+ // re-lex of the grown row produces. `_` is included but narrowed: an
920
+ // intraword `_` is literal per CommonMark flanking rules, so only FLANKED
921
+ // underscores disarm (FAST_ROW_UNDERSCORE_RE on the row text, plus the
922
+ // delta-edge trailingDelimiterSeamHazard check).
923
+ const FAST_DELTA_RE = /[\n\r\\[`<!*_~$#&@\x1b]/;
924
+
925
+ // Disarm when the captured row's RAW tail ends in trailing whitespace (wrap
926
+ // trims it; appending a char moves the trim boundary), a trailing backslash
927
+ // (it can become an escape once the delta supplies the next char — the `\\`
928
+ // clause covers that escape-completion hazard), or a full/partial hex swatch
929
+ // run (a `#` + 3-8 hex is a swatch glyph; the byte range may shift).
930
+ const FAST_RUN_END_RE = /(?:[ \t\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
931
+
932
+ // A partial `#` + 1-2 hex digits can grow into a 3-8 digit swatch glyph
933
+ // across the seam (delta hex digits are inert).
934
+ const FAST_SWATCH_SEAM_RE = /#[0-9a-fA-F]{0,2}$/;
935
+
936
+ // A partial HTML entity at the seam (`&am` + delta `p;`) OR a complete
937
+ // numeric entity (`&#35;`, `&#x1f600;`) — which decodes to `#`, a swatch
938
+ // lead — would normalize to different bytes than the plain concat.
939
+ const FAST_ENTITY_SEAM_RE = /&(?:[A-Za-z0-9#]{0,31}|#[0-9]{1,7};|#[xX][0-9a-fA-F]{1,6};)$/;
940
+
941
+ // A bare URL/email anywhere in the delta or across the seam (a URL the regex
942
+ // cut at a trailing delimiter can re-link once the delta supplies more chars;
943
+ // a protocol head ending at the seam completes in the delta) makes the full
944
+ // re-lex autolink while the plain concat would not.
945
+ const FAST_URL_ANYWHERE_RE = /(?:https?|ftp):\/\/|www\.[A-Za-z0-9]|[A-Za-z0-9._%+-]+@/i;
946
+
947
+ // A bare-URL/email PREFIX may end at the seam and complete in the delta
948
+ // (`ht` + `tps://x`, `foo@` + `bar.com`).
949
+ const FAST_URL_PREFIX_SEAM_RE = /(?:https?|ftp):?\/{0,2}$|www\.$|[A-Za-z0-9._+-]+@[A-Za-z0-9._+-]*$/;
950
+
951
+ // Inline-markup delimiters that survive into rendered output as LITERAL text
952
+ // when unpaired. The fast path detects open constructs by walking the REAL
953
+ // inline token stream (capture) and the delta's inline token stream (frame):
954
+ // any top-level `text` token still carrying one of these bytes holds an open
955
+ // delimiter, so a later delta could close it and a full re-lex would restyle
956
+ // the seam. Closed pairs tokenize into styled tokens and never appear here.
957
+ // `_` is excluded (intraword `_` is inert); a FLANKED underscore is caught
958
+ // by FAST_ROW_UNDERSCORE_RE on the raw text (SGR bytes precede text, so word
959
+ // boundaries are invisible after styling).
960
+ const FAST_LITERAL_MARKER_RE = /[*~`[\]<>()$&#]/;
961
+
962
+ // A flanking underscore (start-of-line or preceded by a non-word char) can
963
+ // open an emphasis that a future delta closes. Only flanked `_` is a
964
+ // delimiter; intraword `_` (a_b) is literal.
965
+ const FAST_ROW_UNDERSCORE_RE = /(?:^|[^\w])_/;
966
+ // Two distinct CommonMark word-char notions drive the seam re-flank checks.
967
+ // For the `_`-underscore seam, "word char" = ASCII `\w` (which includes `_`)
968
+ // plus Unicode letters/numbers — `[\w\p{L}\p{N}]`. `\w` alone missed a row
969
+ // ending in a Unicode letter (`é`); CM's char class `[^\s\p{P}\p{S}]` would
970
+ // wrongly treat `_` (\p{Pc}) as a word char and break the `_..._` intraword
971
+ // gate. For `*`/`~` closing emphasis, marked's flanking test uses the full
972
+ // class `[^\s\p{P}\p{S}]` (which covers format/combining marks like U+200C and
973
+ // U+0301), so branch 2 must use that wider class.
974
+ const FAST_UNDERSCORE_WORD_AT_END_RE = /[\w\p{L}\p{N}]$/u;
975
+ const FAST_CMARK_WORD_AT_START_RE = /^[^\s\p{P}\p{S}]/u;
976
+ // A GFM table delimiter row lets a preceding pipe-header line flip into a
977
+ // table when a future inert delta completes it — even a marker-free delta
978
+ // (`| col_a | col_b |\n| --` + `--- | -`). The cold render then re-wraps and
979
+ // restyles the header, so the splice must disarm. The gate runs on the GROWN
980
+ // last line (`recipe.rowRaw`'s last line + deltaTabs) in render().
981
+ const FAST_TABLE_DELIM_ROW_RE = /^\s*(?:\|[\s:]*-+\s*(?:\|[\s:]*-+\s*)*|[\s:]*-+\s*(?:\|[\s:]*-+\s*)+)\|?\s*$/;
982
+
983
+ // A paragraph's LAST line can complete into a different block kind under an
984
+ // inert delta (ATX heading, blockquote, bullet marker, HR, ref-def) — disarm
985
+ // when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE).
986
+ const FAST_LINE_START_HAZARD_RE =
987
+ // `-` is placed LAST so it is a literal, not a range bound. The other
988
+ // chars are in ASCENDING code-point order (no reversed ranges that
989
+ // rely on engine leniency): * + = – — ─ ━ ═ then the literal `-`.
990
+ /^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)](?:[ \t]|$)|[*+=–—─━═-](?:[ \t]|$)|(?:[*+=–—─━═-][ \t]*){2,}[ \t]*$)/;
991
+ /** @internal exported for tests — counts fast-tail splice frames. A future
992
+ * regression that silently disarms the fast path (e.g. an over-broad gate)
993
+ * leaves byte-identity intact but drops the counter to zero. */
994
+ export let fastTailSplices = 0;
995
+ /** @internal exported for tests — resets the splice counter. */
996
+ export function resetFastTailSplices(): void {
997
+ fastTailSplices = 0;
998
+ }
999
+
1000
+ /** @internal exported for tests — the grown-line-start block-kind gate. */
1001
+ export function fastLineStartHazard(grownLine: string): boolean {
1002
+ return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine);
1003
+ }
1004
+
1005
+ /** Seam hazards between the captured raw row tail and the delta: the row must
1006
+ * not end in a wrap-trim, escape, swatch, entity, or URL/email prefix, and
1007
+ * must hold no unbalanced bracket an inert delta could close into a link. */
1008
+ function fastTailSeamSafe(raw: string): boolean {
1009
+ if (FAST_RUN_END_RE.test(raw)) return false;
1010
+ if (FAST_SWATCH_SEAM_RE.test(raw)) return false;
1011
+ if (FAST_ENTITY_SEAM_RE.test(raw)) return false;
1012
+ // Entities decode before swatch/whitespace detection (`&#35;ab` → `#ab`,
1013
+ // `&nbsp;` → ` `): scan the DECODED tail so an entity-indirected swatch
1014
+ // lead OR a decoded trailing space (wrap-trim boundary shifts) disarms.
1015
+ // Autolinks are lex-time on RAW text, so URL prefix stays raw.
1016
+ const rawTail = raw.length > 32 ? raw.slice(-32) : raw;
1017
+ const decodedTail = normalizeHtmlEntitiesForTerminal(rawTail);
1018
+ if (decodedTail !== rawTail && (FAST_RUN_END_RE.test(decodedTail) || FAST_SWATCH_SEAM_RE.test(decodedTail))) {
1019
+ return false;
1020
+ }
1021
+ if (FAST_URL_PREFIX_SEAM_RE.test(raw)) return false;
1022
+ if (raw.endsWith("]") || raw.lastIndexOf("[") > raw.lastIndexOf("]")) return false;
1023
+ if (raw.lastIndexOf("(") > raw.lastIndexOf(")") || raw.lastIndexOf("<") > raw.lastIndexOf(">")) return false;
1024
+ return true;
1025
+ }
1026
+ // Fast-tail (B+) recipe: captured frame state for the next inert-delta splice.
1027
+ interface FastTailRecipe {
1028
+ readonly lines: readonly string[]; // frame rows at capture
1029
+ readonly source: string; // raw #text at capture (append-only predicate)
1030
+ readonly width: number; // contentWidth at capture
1031
+ rowText: string; // RENDERED last wrap-output row (re-wrap input)
1032
+ rowRaw: string; // RAW tail source backing that row (seam scan)
1033
+ rowStart: number; // result[] index of the replaced row
1034
+ rowEnd: number; // exclusive result[] index
1035
+ readonly signature: RenderSignature; // full render signature at capture (bgColor etc.)
1036
+ }
1037
+
1038
+ /** True when the inline token stream holds an OPEN construct: a `text` token
1039
+ * that still carries a literal delimiter (an unpaired `*`/`` ` ``/`[`/… or a
1040
+ * flanking `_`), or raw HTML. Closed constructs are styled tokens whose
1041
+ * delimiters are absent from their text. An open means a FUTURE delta could
1042
+ * close it — the fast path disarms so the splice always matches a full lex. */
1043
+ function inlineHasOpen(tokens: readonly Token[]): boolean {
1044
+ for (const token of tokens) {
1045
+ if (isMathToken(token)) continue;
1046
+ if (token.type === "codespan") continue; // styled leaf; its content cannot re-pair
1047
+ if (token.type === "html") return true; // raw HTML — conservative
1048
+ if (token.type === "text") {
1049
+ const text = "text" in token && typeof token.text === "string" ? token.text : "";
1050
+ if (FAST_LITERAL_MARKER_RE.test(text) || FAST_ROW_UNDERSCORE_RE.test(text)) return true;
1051
+ }
1052
+ if ("tokens" in token && Array.isArray(token.tokens)) {
1053
+ if (inlineHasOpen(token.tokens as Token[])) return true;
1054
+ }
1055
+ }
1056
+ return false;
1057
+ }
1058
+
1059
+ /** Isolated inline lex of a same-line delta. A single-line delta has no block
1060
+ * structure, so the isolated inline pass equals the full lex's inline pass
1061
+ * (marked's paragraph tokens run the same `inlineTokens` entry point). */
1062
+ function lexInlineTokens(text: string): Token[] {
1063
+ return new Lexer(markdownParser.defaults).inlineTokens(text);
1064
+ }
1065
+
956
1066
  // A reference-link definition (`[label]: dest`) resolves across the whole
957
1067
  // document, so a split lex cannot reproduce it — disable the streaming fast path
958
1068
  // when one is present (rare in streamed output). The label may contain
959
1069
  // backslash-escaped characters (`[a\]b]: x`), so escapes are matched explicitly;
960
1070
  // over-matching is safe (it only costs the fast path), under-matching is not.
961
- const HAS_REF_DEF = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/m;
1071
+ const REF_DEF_LINE_RE = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/;
1072
+ const HAS_REF_DEF = new RegExp(REF_DEF_LINE_RE.source, "m");
962
1073
 
963
1074
  // marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
964
1075
  // blank lines only when the remaining source matches
@@ -1428,12 +1539,28 @@ function classifyHexColor(hex: string, strict: boolean): boolean {
1428
1539
  return true;
1429
1540
  }
1430
1541
 
1431
- /** ANSI-painted `glyph` for `#${hex}`, or "" when the color can't be encoded. */
1432
- function colorSwatch(hex: string, glyph: string): string {
1433
- const ansi = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
1434
- // Reset only the foreground (\x1b[39m) so an enclosing background/decoration
1435
- // applied later by the line renderer survives across the swatch.
1436
- return ansi ? `${ansi}${glyph}\x1b[39m ` : "";
1542
+ /** Black-or-white foreground legible on `#${hex}` used as a fill. VS Code's
1543
+ * rule (Color.isLighter): YIQ brightness (r·299 + g·587 + b·114)/1000 ≥ 128
1544
+ * → dark text, else light. */
1545
+ function swatchContrastFg(hex: string): string {
1546
+ const rgba = Bun.color(`#${hex}`, "{rgba}");
1547
+ if (!rgba) return "";
1548
+ const light = (rgba.r * 299 + rgba.g * 587 + rgba.b * 114) / 1000 >= 128;
1549
+ if (TERMINAL.trueColor) return light ? "\x1b[38;2;0;0;0m" : "\x1b[38;2;255;255;255m";
1550
+ return light ? "\x1b[38;5;16m" : "\x1b[38;5;231m";
1551
+ }
1552
+
1553
+ /** Chip glyph + `text` (the `#hex` mention) painted onto the color itself:
1554
+ * a fg-painted chip, then the token with the color as background and a
1555
+ * YIQ-contrast foreground (VS Code's color-picker rule). Returns "" when the
1556
+ * color can't be encoded. Foreground closes with \x1b[39m and background with
1557
+ * a standalone \x1b[49m so line-level backgrounds re-open themselves via
1558
+ * applyBackgroundToLine / Theme.bgFill. */
1559
+ function colorSwatch(hex: string, glyph: string, text: string): string {
1560
+ const fg = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
1561
+ if (!fg) return "";
1562
+ const bg = fg.replace("[38;", "[48;");
1563
+ return `${fg}${glyph}\x1b[39m ${bg}${swatchContrastFg(hex)}${text}\x1b[39m\x1b[49m`;
1437
1564
  }
1438
1565
 
1439
1566
  /**
@@ -1449,10 +1576,10 @@ function renderTextWithSwatches(text: string, applySegment: (t: string) => strin
1449
1576
  const match = HEX_COLOR_REGEX.exec(text);
1450
1577
  if (match === null) break;
1451
1578
  if (!classifyHexColor(match[1], true)) continue;
1452
- const swatch = colorSwatch(match[1], glyph);
1579
+ const swatch = colorSwatch(match[1], glyph, match[0]);
1453
1580
  if (!swatch) continue;
1454
1581
  if (match.index > last) result += applySegment(text.slice(last, match.index));
1455
- result += swatch + applySegment(match[0]);
1582
+ result += swatch;
1456
1583
  last = match.index + match[0].length;
1457
1584
  }
1458
1585
  if (last === 0) return applySegment(text);
@@ -1464,7 +1591,7 @@ function renderTextWithSwatches(text: string, applySegment: (t: string) => strin
1464
1591
  function codespanSwatch(code: string, glyph: string): string {
1465
1592
  const match = HEX_COLOR_EXACT_REGEX.exec(code.trim());
1466
1593
  if (!match || !classifyHexColor(match[1], false)) return "";
1467
- return colorSwatch(match[1], glyph);
1594
+ return colorSwatch(match[1], glyph, match[0]);
1468
1595
  }
1469
1596
 
1470
1597
  interface RenderSignature {
@@ -1620,6 +1747,9 @@ export class Markdown implements Component {
1620
1747
  #renderingStablePrefix = false;
1621
1748
  #streamingHighlightCache?: StreamingHighlightCache;
1622
1749
  #activeRenderSignature?: RenderSignature;
1750
+ #fastTail?: FastTailRecipe; // undefined = disarmed
1751
+ // B+ capture plumbing: #renderContentLines records the last rendered paragraph row.
1752
+ #lastTailCapture?: { kind: "paragraph"; open: boolean; rowInput: string; rowRaw: string };
1623
1753
  #ignoreTight = false;
1624
1754
  setIgnoreTight(ignore: boolean): this {
1625
1755
  this.#ignoreTight = ignore;
@@ -1643,6 +1773,18 @@ export class Markdown implements Component {
1643
1773
  this.#defaultTextStyle = defaultTextStyle;
1644
1774
  this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
1645
1775
  }
1776
+ /** Return bounded source text and layout state for debug inspection. */
1777
+ debugState(): Record<string, unknown> {
1778
+ return {
1779
+ textPreview: this.#text.slice(0, 120),
1780
+ textLength: this.#text.length,
1781
+ previewTruncated: this.#text.length > 120,
1782
+ paddingX: this.#paddingX,
1783
+ paddingY: this.#paddingY,
1784
+ codeBlockIndent: this.#codeBlockIndent,
1785
+ ignoreTight: this.#ignoreTight,
1786
+ };
1787
+ }
1646
1788
 
1647
1789
  setText(text: string): boolean {
1648
1790
  // Identical re-emit (throttled tick): fully normalized already.
@@ -1694,6 +1836,9 @@ export class Markdown implements Component {
1694
1836
  this.#streamPrefixTokens = undefined;
1695
1837
  this.#streamPrefixLineCache = undefined;
1696
1838
  this.#tailRowCache = undefined;
1839
+ // B+: the captured fast-path rows index the replaced content — drop
1840
+ // the recipe so a fresh stream cannot splice onto stale rows.
1841
+ this.#fastTail = undefined;
1697
1842
  }
1698
1843
  this.invalidate();
1699
1844
  return true;
@@ -1704,6 +1849,16 @@ export class Markdown implements Component {
1704
1849
  this.#cachedWidth = undefined;
1705
1850
  this.#cachedLines = undefined;
1706
1851
  }
1852
+
1853
+ /**
1854
+ * Width-independent source prefix of the last render ending at a frozen
1855
+ * Markdown block boundary. Only meaningful while streaming (transient
1856
+ * render cache on); grows monotonically under append-only `setText`.
1857
+ */
1858
+ getLastRenderStableText(): string {
1859
+ return this.#transientRenderCache ? (this.#streamPrefixText ?? "") : "";
1860
+ }
1861
+
1707
1862
  get transientRenderCache(): boolean {
1708
1863
  return this.#transientRenderCache;
1709
1864
  }
@@ -1717,6 +1872,11 @@ export class Markdown implements Component {
1717
1872
  // so a memo computed on the other mode's buffer must not be reused —
1718
1873
  // re-derive on the next frame instead.
1719
1874
  this.#appendOnlySinceLastScan = false;
1875
+ // B+ reset site: a transient flip (finalize, or a fresh stream on
1876
+ // rewound text) means the next render re-lexes from the current
1877
+ // source — drop the fast-path recipe so stale rows cannot be served
1878
+ // across the transition.
1879
+ this.#fastTail = undefined;
1720
1880
  this.invalidate();
1721
1881
  }
1722
1882
 
@@ -1863,6 +2023,132 @@ export class Markdown implements Component {
1863
2023
  this.#lastScanValid = false;
1864
2024
  }
1865
2025
  const signature = this.#renderSignature(width, paddingX);
2026
+ // B+ fast path: an append-only, same-line delta re-renders ONLY the
2027
+ // last content row (the paragraph's trailing wrapped row) with the
2028
+ // grown source, so the new text shows every frame while staying
2029
+ // byte-identical to a cold full render (the full re-lex produces the
2030
+ // same grown inline tokens and the same wrap). The previous frame's
2031
+ // rows live in #fastTail.lines — the L1 #cachedLines was invalidated
2032
+ // by setText, so the fast path cannot read it back. An inert delta
2033
+ // has no "\n", so the frozen prefix cannot advance and the rows above
2034
+ // the spliced span stay byte-identical.
2035
+ if (
2036
+ this.transientRenderCache &&
2037
+ this.#fastTail !== undefined &&
2038
+ contentWidth === this.#fastTail.width &&
2039
+ this.#text.length > this.#fastTail.source.length &&
2040
+ this.#text.startsWith(this.#fastTail.source)
2041
+ ) {
2042
+ // Re-probe signature (pure) and require equality — a bgColor/theme
2043
+ // change (width-constant) must not splice rows of the stale recipe.
2044
+ const recipe = this.#fastTail;
2045
+ if (!this.#signatureEquals(signature, recipe.signature)) {
2046
+ this.#fastTail = undefined;
2047
+ } else {
2048
+ const delta = this.#text.slice(recipe.source.length);
2049
+ const deltaTabs = replaceTabs(delta);
2050
+ // Seam window contains the delta, so one URL/email scan catches both.
2051
+ const seamSafe = fastTailSeamSafe(recipe.rowRaw);
2052
+ const seamWindow =
2053
+ recipe.rowRaw.slice(Math.max(recipe.rowRaw.lastIndexOf(" "), recipe.rowRaw.lastIndexOf("\t")) + 1) +
2054
+ deltaTabs;
2055
+ // A paragraph's last line can complete into a different block
2056
+ // kind under an inert delta — disarm (single gate helper, kept
2057
+ // in sync with the exported test surface).
2058
+ const grownLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
2059
+ const lineStartHazard = fastLineStartHazard(grownLine);
2060
+ // Only same-line deltas splice; marker deltas re-lex through the
2061
+ // REAL inline pipeline so self-contained pairs render styled.
2062
+ const hardDelta = /[\n\r\x1b]/.test(delta);
2063
+ const markerDelta = FAST_DELTA_RE.test(delta);
2064
+ // A delta starting/ending `_` after a word char pairs in isolation
2065
+ // but stays intraword-literal in the full text. Symmetrically, a
2066
+ // row ending with a closing delimiter (`_`, `*`, `~`) followed by
2067
+ // a word-char delta makes the delimiter intraword / non-flanking in
2068
+ // the joined text — the cold render drops the emphasis, but the
2069
+ // splice keeps it. A row ending `$` (closed inline math) followed
2070
+ // by a digit is invalidated by the anti-currency rule ($x$123 is
2071
+ // literal, not math) — disarm.
2072
+ const grownLastLine = recipe.rowRaw.slice(recipe.rowRaw.lastIndexOf("\n") + 1) + deltaTabs;
2073
+ const trailingDelimiterSeamHazard =
2074
+ (markerDelta &&
2075
+ (deltaTabs.startsWith("_") || deltaTabs.endsWith("_")) &&
2076
+ FAST_UNDERSCORE_WORD_AT_END_RE.test(recipe.rowRaw)) ||
2077
+ (!markerDelta && /[*~_]$/.test(recipe.rowRaw) && FAST_CMARK_WORD_AT_START_RE.test(deltaTabs)) ||
2078
+ (!markerDelta && recipe.rowRaw.endsWith("$") && /^[0-9]/.test(deltaTabs));
2079
+ // A delta opening a pairing char when the captured row ENDS with the
2080
+ // same char can re-pair across the seam: cold lex of the joined run
2081
+ // makes ONE token (x *a**b* → em("a**b")), the splice keeps two.
2082
+ // An image marker (`x!` + `[a](u)`) re-pairs the same way.
2083
+ const pairSeamHazard =
2084
+ markerDelta &&
2085
+ ((/^[*~`]/.test(deltaTabs) && /[*~`]$/.test(recipe.rowRaw)) ||
2086
+ // "x!" + "[a](u)": cold lexes text("x") + image(alt); the splice would
2087
+ // keep "x!" + a styled link byte-run.
2088
+ (deltaTabs.startsWith("[") && recipe.rowRaw.endsWith("!")));
2089
+ const deltaTokens = markerDelta && !hardDelta ? lexInlineTokens(deltaTabs) : null;
2090
+ if (
2091
+ seamSafe &&
2092
+ !lineStartHazard &&
2093
+ !hardDelta &&
2094
+ !trailingDelimiterSeamHazard &&
2095
+ // A grown GFM delimiter last line flips a preceding pipe-header
2096
+ // into a table on a marker-free delta (`| --` + `--- | -`).
2097
+ !FAST_TABLE_DELIM_ROW_RE.test(grownLastLine) &&
2098
+ (!markerDelta || (!this.#lastTailCapture?.open && !pairSeamHazard && !inlineHasOpen(deltaTokens!))) &&
2099
+ !FAST_URL_ANYWHERE_RE.test(seamWindow)
2100
+ ) {
2101
+ // Same text paths a full re-lex applies: real pipeline for marker
2102
+ // deltas, plain swatch/entity render for inert deltas.
2103
+ const { applyText } = this.#getDefaultInlineStyleContext();
2104
+ const grown =
2105
+ recipe.rowText +
2106
+ (markerDelta
2107
+ ? this.#renderInlineTokens(deltaTokens!)
2108
+ : renderTextWithSwatches(
2109
+ normalizeHtmlEntitiesForTerminal(deltaTabs),
2110
+ applyText,
2111
+ this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
2112
+ ));
2113
+ const wrapped = wrapTextWithAnsi(grown, contentWidth);
2114
+ const fastPaddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
2115
+ const leftMargin = padding(fastPaddingX);
2116
+ const rightMargin = padding(fastPaddingX);
2117
+ const bgFn = this.#defaultTextStyle?.bgColor;
2118
+ const fastRows: string[] = [];
2119
+ for (const row of wrapped) {
2120
+ const withMargins = leftMargin + row + rightMargin;
2121
+ fastRows.push(
2122
+ bgFn
2123
+ ? applyBackgroundToLine(withMargins, width, bgFn)
2124
+ : withMargins + padding(Math.max(0, width - visibleWidth(withMargins))),
2125
+ );
2126
+ }
2127
+ // Splice onto the previous frame's rows (new array — parent may
2128
+ // hold the old one).
2129
+ const prev = recipe.lines;
2130
+ const fastResult = [...prev.slice(0, recipe.rowStart), ...fastRows, ...prev.slice(recipe.rowEnd)];
2131
+ this.#cachedText = this.#text;
2132
+ this.#cachedWidth = width;
2133
+ this.#cachedLines = fastResult;
2134
+ this.#fastTail = {
2135
+ lines: fastResult,
2136
+ source: this.#text,
2137
+ width: recipe.width,
2138
+ rowText: wrapped[wrapped.length - 1] ?? "",
2139
+ rowRaw: recipe.rowRaw + deltaTabs,
2140
+ rowStart: recipe.rowStart + wrapped.length - 1,
2141
+ rowEnd: recipe.rowStart + wrapped.length,
2142
+ signature: recipe.signature,
2143
+ };
2144
+ fastTailSplices++;
2145
+ return fastResult;
2146
+ }
2147
+ }
2148
+ // Hazard → disarm until the next real render re-captures.
2149
+ this.#fastTail = undefined;
2150
+ }
2151
+ // Replace tabs with 3 spaces for consistent rendering
1866
2152
 
1867
2153
  // L2: module-level LRU — survives component disposal/recreation across
1868
2154
  // session-tree navigations. Key encodes every dimension that affects the
@@ -1913,6 +2199,37 @@ export class Markdown implements Component {
1913
2199
  this.#cachedWidth = width;
1914
2200
  this.#cachedLines = result;
1915
2201
 
2202
+ const fastEligible =
2203
+ this.transientRenderCache &&
2204
+ contentLines.length > 0 &&
2205
+ // B+ invariant: only the FINAL #renderContentLines call captures;
2206
+ // the all-cache-hit prefix path clears #lastTailCapture at its top.
2207
+ this.#lastTailCapture !== undefined &&
2208
+ this.#lastTailCapture.kind === "paragraph" &&
2209
+ // Run-level default styling (color/bold/italic/strikethrough/
2210
+ // underline) disarms: the splice yields two ANSI runs where a cold
2211
+ // render yields one; bgColor is line-level and stays eligible.
2212
+ !this.#defaultTextStyle?.color &&
2213
+ !this.#defaultTextStyle?.bold &&
2214
+ !this.#defaultTextStyle?.italic &&
2215
+ !this.#defaultTextStyle?.strikethrough &&
2216
+ !this.#defaultTextStyle?.underline;
2217
+ if (fastEligible && this.#lastTailCapture !== undefined) {
2218
+ const capture = this.#lastTailCapture;
2219
+ this.#fastTail = {
2220
+ lines: result,
2221
+ source: this.#text,
2222
+ width: contentWidth,
2223
+ rowText: capture.rowInput,
2224
+ rowRaw: capture.rowRaw,
2225
+ rowStart: signature.paddingY + contentLines.length - 1,
2226
+ rowEnd: signature.paddingY + contentLines.length,
2227
+ signature,
2228
+ };
2229
+ } else {
2230
+ this.#fastTail = undefined;
2231
+ }
2232
+
1916
2233
  // Update L2 module-level LRU so future instances with the same key skip
1917
2234
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1918
2235
  if (cacheKey !== undefined) {
@@ -1938,6 +2255,10 @@ export class Markdown implements Component {
1938
2255
  headingProbe,
1939
2256
  };
1940
2257
  }
2258
+ // All-primitive signature — compare via the canonical render-cache encoding.
2259
+ #signatureEquals(a: RenderSignature, b: RenderSignature): boolean {
2260
+ return this.#renderCacheKey("", a) === this.#renderCacheKey("", b);
2261
+ }
1941
2262
 
1942
2263
  #renderCacheKey(normalizedText: string, signature: RenderSignature): string {
1943
2264
  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}`;
@@ -2114,6 +2435,9 @@ export class Markdown implements Component {
2114
2435
  signature: RenderSignature,
2115
2436
  tailRecorder?: TailRenderRecorder,
2116
2437
  ): string[] {
2438
+ // A non-capturing final call must not serve a stale recipe, so the
2439
+ // B+ plumbing is cleared up front; the per-token capture re-fills it.
2440
+ if (end === tokens.length) this.#lastTailCapture = undefined;
2117
2441
  const wrappedLines: RenderedLine[] = [];
2118
2442
  // Wrapped-row span per absolute token index. Call-local: stale values
2119
2443
  // are never read across renders.
@@ -2141,6 +2465,36 @@ export class Markdown implements Component {
2141
2465
  }
2142
2466
  }
2143
2467
  tokenWrappedRowCounts[i] = wrappedLines.length - tokenWrappedRowStart;
2468
+ // B+ capture hook: the LAST token of the FINAL call is the frame's
2469
+ // true trailing content row — record its rendered last row and raw
2470
+ // tail so render() can build the fast-path recipe. The frozen
2471
+ // prefix call (end < tokens.length) must never capture.
2472
+ if (end === tokens.length && i === end - 1 && token.type === "paragraph") {
2473
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
2474
+ const lastLine = renderedTokenLines[renderedTokenLines.length - 1];
2475
+ // Display math, a newline-terminated paragraph (a fresh line
2476
+ // grows next frame — the captured row is not the mutable tail),
2477
+ // or a tree-guide/OSC-8/OSC-66 trailing row are not
2478
+ // self-contained.
2479
+ if (
2480
+ soleDisplayMath(token.tokens) ||
2481
+ raw.endsWith("\n") ||
2482
+ !lastLine ||
2483
+ TREE_GUIDE_ANCHOR_RE.test(lastLine.text) ||
2484
+ lastLine.text.includes("\x1b]") ||
2485
+ TERMINAL.isImageLine(lastLine.text) ||
2486
+ isOsc66Line(lastLine.text)
2487
+ ) {
2488
+ continue;
2489
+ }
2490
+ const wrappedLast = wrappedLines[wrappedLines.length - 1];
2491
+ this.#lastTailCapture = {
2492
+ kind: "paragraph",
2493
+ rowInput: wrappedLast?.text ?? lastLine.text,
2494
+ rowRaw: raw.slice(raw.lastIndexOf("\n") + 1),
2495
+ open: inlineHasOpen(token.tokens ?? []),
2496
+ };
2497
+ }
2144
2498
  }
2145
2499
 
2146
2500
  const leftMargin = padding(signature.paddingX);
@@ -2792,7 +3146,8 @@ export class Markdown implements Component {
2792
3146
 
2793
3147
  case "codespan": {
2794
3148
  markHtmlItemWhenContent(token.text);
2795
- result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
3149
+ const painted = codespanSwatch(token.text, swatchGlyph);
3150
+ result += (painted || this.#theme.code(token.text)) + stylePrefix;
2796
3151
  break;
2797
3152
  }
2798
3153
 
@@ -79,6 +79,18 @@ export class ScrollView implements Component {
79
79
  this.#fastScrollLines = Math.max(1, Math.trunc(options.fastScrollLines ?? 5));
80
80
  this.#clampScrollOffset();
81
81
  }
82
+ /** Return viewport, content, and scroll position state for debug inspection. */
83
+ debugState(): Record<string, unknown> {
84
+ const rowCount = this.#totalRows ?? this.#lines.length;
85
+ return {
86
+ scrollOffset: this.#scrollOffset,
87
+ maxScrollOffset: this.getMaxScrollOffset(),
88
+ height: this.#height,
89
+ rowCount,
90
+ bufferedLineCount: this.#lines.length,
91
+ scrollbar: this.#scrollbar,
92
+ };
93
+ }
82
94
 
83
95
  setLines(lines: readonly string[]): void {
84
96
  this.#lines = [...lines];