@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.
- package/CHANGELOG.md +31 -0
- package/dist/types/components/loader.d.ts +1 -1
- package/dist/types/components/markdown.d.ts +8 -0
- package/dist/types/terminal-capabilities.d.ts +3 -3
- package/dist/types/tui.d.ts +12 -3
- package/package.json +3 -3
- package/src/components/loader.ts +10 -3
- package/src/components/markdown.ts +722 -13
- package/src/terminal-capabilities.ts +4 -1
- package/src/tui.ts +328 -54
|
@@ -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 (`#`, `😀`) — 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 (`#ab` → `#ab`,
|
|
1057
|
+
// ` ` → ` `): 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
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1651
|
-
const
|
|
1652
|
-
|
|
1653
|
-
|
|
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.#
|
|
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.#
|
|
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)
|
|
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();
|