@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.
- package/CHANGELOG.md +3 -4
- package/dist/types/components/box.d.ts +2 -0
- package/dist/types/components/cancellable-loader.d.ts +2 -0
- package/dist/types/components/composer/band.d.ts +2 -0
- package/dist/types/components/composer/index.d.ts +1 -0
- package/dist/types/components/composer/types.d.ts +3 -3
- package/dist/types/components/editor.d.ts +4 -0
- package/dist/types/components/image.d.ts +2 -0
- package/dist/types/components/input.d.ts +2 -0
- package/dist/types/components/loader.d.ts +9 -1
- package/dist/types/components/markdown.d.ts +16 -2
- package/dist/types/components/scroll-view.d.ts +2 -0
- package/dist/types/components/select-list.d.ts +2 -0
- package/dist/types/components/settings-list.d.ts +4 -0
- package/dist/types/components/spacer.d.ts +2 -0
- package/dist/types/components/tab-bar.d.ts +2 -0
- package/dist/types/components/text.d.ts +2 -0
- package/dist/types/components/truncated-text.d.ts +2 -0
- package/dist/types/debug-server.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/latex-to-unicode.d.ts +4 -13
- package/dist/types/terminal-capabilities.d.ts +17 -0
- package/dist/types/terminal.d.ts +11 -0
- package/dist/types/tui.d.ts +42 -3
- package/package.json +3 -3
- package/src/autocomplete.ts +50 -8
- package/src/components/box.ts +10 -0
- package/src/components/cancellable-loader.ts +7 -0
- package/src/components/composer/band.ts +42 -0
- package/src/components/composer/index.ts +1 -0
- package/src/components/composer/registry.ts +2 -0
- package/src/components/composer/types.ts +12 -3
- package/src/components/editor.ts +56 -10
- package/src/components/image.ts +11 -0
- package/src/components/input.ts +13 -0
- package/src/components/loader.ts +50 -10
- package/src/components/markdown.ts +426 -71
- package/src/components/scroll-view.ts +12 -0
- package/src/components/select-list.ts +13 -0
- package/src/components/settings-list.ts +19 -0
- package/src/components/spacer.ts +4 -0
- package/src/components/tab-bar.ts +11 -0
- package/src/components/text.ts +11 -0
- package/src/components/truncated-text.ts +15 -1
- package/src/debug-server.ts +422 -0
- package/src/index.ts +2 -0
- package/src/latex-to-unicode.ts +416 -230
- package/src/terminal-capabilities.ts +54 -9
- package/src/terminal.ts +22 -2
- package/src/tui.ts +461 -60
- 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 {
|
|
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 (`
|
|
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
|
-
//
|
|
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
|
|
603
|
-
return mathStartIndex(src);
|
|
604
|
-
},
|
|
590
|
+
start: mathStartIndex,
|
|
605
591
|
tokenizer(src) {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
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
|
|
647
|
-
|
|
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
|
|
652
|
-
if (!
|
|
653
|
-
return { type: "math", raw:
|
|
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
|
|
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
|
|
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 (`#`, `😀`) — 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 (`#ab` → `#ab`,
|
|
1013
|
+
// ` ` → ` `): 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
|
|
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
|
-
/**
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
|
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
|
-
|
|
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];
|