@oh-my-pi/pi-tui 18.5.0 → 18.6.0
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 +36 -11
- package/dist/types/active-terminal.d.ts +30 -0
- package/dist/types/components/editor.d.ts +3 -0
- package/dist/types/components/markdown.d.ts +13 -4
- package/dist/types/native/encode.d.ts +1 -0
- package/dist/types/native/node.d.ts +6 -0
- package/dist/types/overlays/hub-frame.d.ts +4 -1
- package/dist/types/overlays/model-hub.d.ts +14 -0
- package/dist/types/prompt/custom-editor.d.ts +4 -1
- package/dist/types/terminal-capabilities.d.ts +1 -1
- package/dist/types/terminal.d.ts +1 -10
- package/dist/types/tools/bash.d.ts +7 -2
- package/dist/types/windows-input-mode.d.ts +33 -1
- package/package.json +9 -9
- package/src/active-terminal.ts +80 -0
- package/src/bracketed-paste.ts +10 -3
- package/src/components/editor.ts +6 -0
- package/src/components/input.ts +1 -0
- package/src/components/markdown.ts +473 -105
- package/src/kitty-graphics.ts +7 -1
- package/src/native/backend.ts +11 -1
- package/src/native/encode.ts +13 -1
- package/src/native/node.ts +3 -1
- package/src/overlays/ask-dialog.ts +3 -7
- package/src/overlays/hub-frame.ts +19 -1
- package/src/overlays/model-browser.ts +30 -16
- package/src/overlays/model-hub.ts +137 -12
- package/src/prompt/custom-editor.ts +35 -10
- package/src/terminal-capabilities.ts +22 -3
- package/src/terminal.ts +64 -62
- package/src/theme/symbols.ts +5 -3
- package/src/tools/bash.ts +24 -5
- package/src/tui.ts +283 -53
- package/src/windows-input-mode.ts +204 -6
|
@@ -6,8 +6,18 @@ import {
|
|
|
6
6
|
type TokenizerAndRendererExtension,
|
|
7
7
|
type TokenizerThis,
|
|
8
8
|
type Tokens,
|
|
9
|
+
type TokensList,
|
|
9
10
|
} from "@oh-my-pi/pi-utils/marked";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
MathBlockScan,
|
|
13
|
+
type MathBlockOpener,
|
|
14
|
+
mathBlockAt,
|
|
15
|
+
mathBlockCloserIndex,
|
|
16
|
+
mathBlockMayCloseAt,
|
|
17
|
+
mathBlockOpenerAt,
|
|
18
|
+
mathSpanInContext,
|
|
19
|
+
mathStartIndex,
|
|
20
|
+
} from "@oh-my-pi/pi-utils/math-delimiters";
|
|
11
21
|
import { latexToBlock } from "../latex-block";
|
|
12
22
|
import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
13
23
|
import { plainText } from "../native/spans";
|
|
@@ -15,6 +25,7 @@ import { md } from "../native/describe";
|
|
|
15
25
|
import type { DescribeContext, NativeNode } from "../native/node";
|
|
16
26
|
import type { SymbolTheme } from "../symbols";
|
|
17
27
|
import { TERMINAL } from "../terminal-capabilities";
|
|
28
|
+
import { getSymbolTheme } from "../theme/theme";
|
|
18
29
|
import type { Component } from "../tui";
|
|
19
30
|
import {
|
|
20
31
|
applyBackgroundToLine,
|
|
@@ -683,7 +694,9 @@ const BARE_ENV_BEGIN = /(?:^|\n)[ \t]{0,3}\\begin\{([A-Za-z]+\*?)\}/;
|
|
|
683
694
|
function bareMathEnvBlock(src: string): readonly [number, number] | null {
|
|
684
695
|
const bm = BARE_ENV_BEGIN.exec(src);
|
|
685
696
|
if (!bm || !isBareMathEnvironment(bm[1])) return null;
|
|
686
|
-
|
|
697
|
+
// Skip a matched leading `\n`, at offset 0 too: a blank line before the block
|
|
698
|
+
// is a space token of its own, not part of the block.
|
|
699
|
+
const beginLineStart = src[bm.index] === "\n" ? bm.index + 1 : bm.index;
|
|
687
700
|
const endToken = `\\end{${bm[1]}}`;
|
|
688
701
|
const endAt = src.indexOf(endToken, bm.index);
|
|
689
702
|
if (endAt === -1) return null;
|
|
@@ -897,12 +910,16 @@ function appendLines(dst: string[], src: readonly string[]): void {
|
|
|
897
910
|
// delta-edge trailingDelimiterSeamHazard check).
|
|
898
911
|
const FAST_DELTA_RE = /[\n\r\\[`<!*_~$#&@\x1b]/;
|
|
899
912
|
|
|
913
|
+
// Emphasis delimiters, which can pair across the fast path's seam.
|
|
914
|
+
const FAST_EMPHASIS_DELIMITERS = ["*", "_", "~"] as const;
|
|
915
|
+
|
|
900
916
|
// Disarm when the captured row's RAW tail ends in trailing whitespace (wrap
|
|
901
|
-
// trims it; appending a char moves the trim
|
|
902
|
-
// (it can become an escape once the delta
|
|
903
|
-
// clause covers that escape-completion
|
|
904
|
-
// run (a `#` + 3-8 hex is a swatch
|
|
905
|
-
|
|
917
|
+
// trims it, a no-break space included; appending a char moves the trim
|
|
918
|
+
// boundary), a trailing backslash (it can become an escape once the delta
|
|
919
|
+
// supplies the next char — the `\\` clause covers that escape-completion
|
|
920
|
+
// hazard), or a full/partial hex swatch run (a `#` + 3-8 hex is a swatch
|
|
921
|
+
// glyph; the byte range may shift).
|
|
922
|
+
const FAST_RUN_END_RE = /(?:[\s\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
|
|
906
923
|
|
|
907
924
|
// A partial `#` + 1-2 hex digits can grow into a 3-8 digit swatch glyph
|
|
908
925
|
// across the seam (delta hex digits are inert).
|
|
@@ -957,16 +974,18 @@ const FAST_TABLE_DELIM_ROW_RE = /^\s*(?:\|[\s:]*-+\s*(?:\|[\s:]*-+\s*)*|[\s:]*-+
|
|
|
957
974
|
|
|
958
975
|
// A paragraph's LAST line can complete into a different block kind under an
|
|
959
976
|
// inert delta (ATX heading, blockquote, bullet marker, HR, ref-def) — disarm
|
|
960
|
-
// when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE).
|
|
977
|
+
// when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE). A line
|
|
978
|
+
// holding `\end{` can also close a bare math environment the paragraph opened
|
|
979
|
+
// lines above (`\end{al` + `ign}`), making those lines one display block.
|
|
961
980
|
const FAST_LINE_START_HAZARD_RE =
|
|
962
981
|
// `-` is placed LAST so it is a literal, not a range bound. The other
|
|
963
982
|
// chars are in ASCENDING code-point order (no reversed ranges that
|
|
964
983
|
// rely on engine leniency): * + = – — ─ ━ ═ then the literal `-`.
|
|
965
984
|
/^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)](?:[ \t]|$)|[*+=–—─━═-](?:[ \t]|$)|(?:[*+=–—─━═-][ \t]*){2,}[ \t]*$)/;
|
|
966
985
|
|
|
967
|
-
/** @internal exported for tests — the grown-line
|
|
986
|
+
/** @internal exported for tests — the grown-line block-kind gate. */
|
|
968
987
|
export function fastLineStartHazard(grownLine: string): boolean {
|
|
969
|
-
return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine);
|
|
988
|
+
return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine) || grownLine.includes("\\end{");
|
|
970
989
|
}
|
|
971
990
|
|
|
972
991
|
/** Seam hazards between the captured raw row tail and the delta: the row must
|
|
@@ -1101,43 +1120,122 @@ function listMayContinueAt(text: string, tailStart: number, listRaw: string): bo
|
|
|
1101
1120
|
return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
|
|
1102
1121
|
}
|
|
1103
1122
|
|
|
1104
|
-
|
|
1123
|
+
/** A probe window: see {@link stableBlockBoundary}. */
|
|
1124
|
+
interface ProbeWindow {
|
|
1125
|
+
/** Offset in `text` where the window, and so the lex of `tokens`, ends. */
|
|
1126
|
+
end: number;
|
|
1127
|
+
/** The display-math blocks of the whole of `text`. */
|
|
1128
|
+
mathBlocks: MathBlockScan;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** The last stable block boundary of a token run: see {@link stableBlockBoundary}. */
|
|
1132
|
+
interface BlockBoundary {
|
|
1133
|
+
/** Offset just past the boundary token, or 0 when the run holds none. */
|
|
1134
|
+
end: number;
|
|
1135
|
+
/** Number of tokens up to and including the boundary token, or 0. */
|
|
1136
|
+
count: number;
|
|
1137
|
+
/** End of the display-math block a probe's scan stopped at, which a later window must reach past; 0 when it stopped at none. */
|
|
1138
|
+
blockEnd: number;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// A whitespace-only line, capturing its terminator: "\n", or "" at the end of the text.
|
|
1142
|
+
const WHITESPACE_LINE_RE = /[^\S\n]*(\n|$)/y;
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* A display-math opener in the frozen streaming prefix whose block an append
|
|
1146
|
+
* could still close: the first such opener of its kind.
|
|
1147
|
+
*/
|
|
1148
|
+
interface PrefixOpener {
|
|
1149
|
+
kind: MathBlockOpener;
|
|
1150
|
+
/** Offset of the opener's line. */
|
|
1151
|
+
at: number;
|
|
1152
|
+
/** The stable boundary in front of the opener, as a text length and a token count. */
|
|
1153
|
+
rewindEnd: number;
|
|
1154
|
+
rewindCount: number;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const NO_OPENERS: readonly PrefixOpener[] = [];
|
|
1105
1158
|
|
|
1106
1159
|
/**
|
|
1107
1160
|
* Offset just past the last token in `tokens` that closes a block on a hard
|
|
1108
1161
|
* `"\n\n"` break, together with the number of tokens up to and including it.
|
|
1109
1162
|
* `count === 0` means the run holds no usable boundary.
|
|
1110
1163
|
*
|
|
1111
|
-
* `base` is where `tokens[0]` starts inside `text`.
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1164
|
+
* `base` is where `tokens[0]` starts inside `text`. Without `window`, `tokens`
|
|
1165
|
+
* lex the rest of `text`, which appends may still extend: the streaming
|
|
1166
|
+
* freeze. With it, they lex `text.slice(base, window.end)` of a whole
|
|
1167
|
+
* document: a probe. A boundary qualifies only when splitting there is
|
|
1168
|
+
* invisible to the lexer, i.e. `lex(head) ++ lex(tail) === lex(text)`:
|
|
1169
|
+
* - In a probe, the token must end before `window.end`. An unclosed fence,
|
|
1170
|
+
* HTML block or comment runs to the end of its input, so a window that
|
|
1171
|
+
* ends just after a blank line inside one hands back a truncated token
|
|
1172
|
+
* whose raw ends in `"\n\n"`.
|
|
1114
1173
|
* - The break must sit inside `text`. At end-of-text the next character is
|
|
1115
1174
|
* unknown (and, while streaming, may still arrive), so the cut is deferred.
|
|
1116
|
-
* - The next
|
|
1117
|
-
* block separator straddles the cut — e.g. a fence followed by
|
|
1118
|
-
* `"\n\n\n- list"` — and the two lexes desync.
|
|
1175
|
+
* - The next line must start real block content. A leading space or newline
|
|
1176
|
+
* means the block separator straddles the cut — e.g. a fence followed by
|
|
1177
|
+
* `"\n\n\n- list"` — and the two lexes desync. So does a line of any other
|
|
1178
|
+
* whitespace, such as a no-break space: the lexer's blank line is
|
|
1179
|
+
* `/^\s*\n$/`, so it joins the blank run in front of the cut. While
|
|
1180
|
+
* streaming, a whitespace-only last line may still become one.
|
|
1119
1181
|
* - A preceding `list` must be provably closed: CommonMark lets a same-marker
|
|
1120
1182
|
* item continue the list across the blank line, and marked merges both into
|
|
1121
1183
|
* one renumbered loose list (`listMayContinueAt`).
|
|
1184
|
+
* - In a probe, no earlier token may open a display-math block that the
|
|
1185
|
+
* window cut short: a token other than `math` (which is the block itself)
|
|
1186
|
+
* where `window.mathBlocks` finds a block in the whole document. The
|
|
1187
|
+
* one-pass lex makes that block one `math` token across its blank lines,
|
|
1188
|
+
* so the scan stops there and reports the block's end as `blockEnd`. An
|
|
1189
|
+
* opener with no closer, or with a whitespace-only body, is no block in
|
|
1190
|
+
* either lex, so it leaves later boundaries alone.
|
|
1191
|
+
* - While streaming, `tokens` are the one-pass lex of `text` as it stands,
|
|
1192
|
+
* so a block the lex already made is a `math` token, and one that an
|
|
1193
|
+
* append could still close becomes one only once a closer line arrives,
|
|
1194
|
+
* which Markdown#lexTokens watches for. With `settle`, the scan instead
|
|
1195
|
+
* stops at a token whose display-math block an append could still close
|
|
1196
|
+
* (`mathBlockMayCloseAt`), so no append changes the tokens before the
|
|
1197
|
+
* boundary it finds. A pair whose closer line has already ended around a
|
|
1198
|
+
* whitespace-only body (`$$`, ` `, `$$`) is no block whatever follows, so
|
|
1199
|
+
* that scan goes on past it.
|
|
1122
1200
|
*
|
|
1123
1201
|
* `startIndex` resumes the scan at `tokens[startIndex]` (positions still
|
|
1124
1202
|
* accumulate from `base`). The streaming freeze passes the frozen-prefix
|
|
1125
1203
|
* token count: that prefix's boundary is permanent under append-only growth
|
|
1126
1204
|
* (re-verified when frozen), so only the mutable tail can hold a new one.
|
|
1205
|
+
* `endIndex` ends the scan in front of `tokens[endIndex]`, for the last
|
|
1206
|
+
* boundary in front of a given token.
|
|
1127
1207
|
*/
|
|
1128
1208
|
function stableBlockBoundary(
|
|
1129
1209
|
text: string,
|
|
1130
1210
|
base: number,
|
|
1131
1211
|
tokens: Token[],
|
|
1132
|
-
|
|
1133
|
-
|
|
1212
|
+
{
|
|
1213
|
+
startIndex = 0,
|
|
1214
|
+
endIndex = tokens.length,
|
|
1215
|
+
window,
|
|
1216
|
+
settle = false,
|
|
1217
|
+
}: { startIndex?: number; endIndex?: number; window?: ProbeWindow; settle?: boolean } = {},
|
|
1218
|
+
): BlockBoundary {
|
|
1134
1219
|
let pos = base;
|
|
1135
1220
|
let end = 0;
|
|
1136
1221
|
let count = 0;
|
|
1137
|
-
|
|
1138
|
-
|
|
1222
|
+
let blockEnd = 0;
|
|
1223
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
1224
|
+
const token = tokens[i];
|
|
1225
|
+
const raw = token.raw;
|
|
1139
1226
|
const tokenEnd = pos + raw.length;
|
|
1140
|
-
if (
|
|
1227
|
+
if (token.type !== "math") {
|
|
1228
|
+
if (window !== undefined) {
|
|
1229
|
+
const block = window.mathBlocks.at(pos);
|
|
1230
|
+
if (block !== undefined) {
|
|
1231
|
+
blockEnd = pos + block.raw.length;
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
} else if (settle && mathBlockMayCloseAt(text, pos)) {
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (raw.endsWith("\n\n") && (window === undefined || tokenEnd < window.end)) {
|
|
1141
1239
|
const prev = i > 0 ? tokens[i - 1] : undefined;
|
|
1142
1240
|
if (prev === undefined || prev.type !== "list" || !listMayContinueAt(text, tokenEnd, prev.raw)) {
|
|
1143
1241
|
end = tokenEnd;
|
|
@@ -1146,10 +1244,13 @@ function stableBlockBoundary(
|
|
|
1146
1244
|
}
|
|
1147
1245
|
pos = tokenEnd;
|
|
1148
1246
|
}
|
|
1149
|
-
if (count === 0 || end >= text.length) return
|
|
1247
|
+
if (count === 0 || end >= text.length) return { end: 0, count: 0, blockEnd };
|
|
1150
1248
|
const next = text.charCodeAt(end);
|
|
1151
|
-
if (next === 0x20 /* space */ || next === 0x0a /* \n */) return
|
|
1152
|
-
|
|
1249
|
+
if (next === 0x20 /* space */ || next === 0x0a /* \n */) return { end: 0, count: 0, blockEnd };
|
|
1250
|
+
WHITESPACE_LINE_RE.lastIndex = end;
|
|
1251
|
+
const blank = WHITESPACE_LINE_RE.exec(text);
|
|
1252
|
+
if (blank !== null && (blank[1] === "\n" || window === undefined)) return { end: 0, count: 0, blockEnd };
|
|
1253
|
+
return { end, count, blockEnd };
|
|
1153
1254
|
}
|
|
1154
1255
|
|
|
1155
1256
|
// Bun's regex engine skips the start-anchor optimization for several of marked's
|
|
@@ -1159,7 +1260,8 @@ function stableBlockBoundary(
|
|
|
1159
1260
|
// document length: an 800 KB message costs ~41 s under Bun where Node/V8 needs
|
|
1160
1261
|
// ~60 ms, and it runs on the render path, freezing the UI. Bounded windows keep
|
|
1161
1262
|
// every scan short and restore linear behavior (~0.7 s for that same message).
|
|
1162
|
-
|
|
1263
|
+
/** @internal exported for tests — the windowed lexer's first-probe window size. */
|
|
1264
|
+
export const LEX_WINDOW_BYTES = 2 * 1024;
|
|
1163
1265
|
// Under this size a single pass beats probing for window boundaries; the
|
|
1164
1266
|
// crossover measured on pathological Markdown sits around 16 KB.
|
|
1165
1267
|
const WINDOWED_LEX_MIN_BYTES = 16 * 1024;
|
|
@@ -1171,50 +1273,80 @@ const WINDOWED_LEX_MIN_BYTES = 16 * 1024;
|
|
|
1171
1273
|
* Window cuts come from marked itself: a throwaway BLOCK-ONLY probe lex of the
|
|
1172
1274
|
* window reports its last stable block boundary ({@link stableBlockBoundary})
|
|
1173
1275
|
* and only that confirmed segment is handed to the real lexer; a window
|
|
1174
|
-
* holding no boundary
|
|
1175
|
-
* never run inline tokenization (their inlineQueue is
|
|
1176
|
-
* is a property of block structure alone, and probe
|
|
1177
|
-
* dominant cost of an earlier revision. Block
|
|
1178
|
-
* while inline tokenization is deferred to the
|
|
1179
|
-
* so a `[label]: dest` definition anywhere in
|
|
1180
|
-
* every inline span.
|
|
1276
|
+
* holding no boundary grows ({@link nextProbeSize}) until it finds one or
|
|
1277
|
+
* reaches the end. Probes never run inline tokenization (their inlineQueue is
|
|
1278
|
+
* discarded) — a boundary is a property of block structure alone, and probe
|
|
1279
|
+
* inline passes were the dominant cost of an earlier revision. Block
|
|
1280
|
+
* tokenization runs per window while inline tokenization is deferred to the
|
|
1281
|
+
* end — mirroring `Lexer.lex` — so a `[label]: dest` definition anywhere in
|
|
1282
|
+
* the document still resolves for every inline span.
|
|
1181
1283
|
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1185
|
-
* that is one long tight list) instead of probing sizes that cannot succeed.
|
|
1284
|
+
* Each round's first window reaches just past the next blank line
|
|
1285
|
+
* ({@link firstProbeSize}); a tail with no blank line left (e.g. one long
|
|
1286
|
+
* tight list) goes to the lexer whole.
|
|
1186
1287
|
*/
|
|
1187
|
-
function lexWindowed(text: string):
|
|
1288
|
+
function lexWindowed(text: string): TokensList {
|
|
1188
1289
|
const lexer = new Lexer(markdownParser.defaults);
|
|
1290
|
+
const mathBlocks = new MathBlockScan(text);
|
|
1189
1291
|
let offset = 0;
|
|
1190
1292
|
while (offset < text.length) {
|
|
1191
|
-
let
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
for (let size = minSize; segment.length === 0; size *= 2) {
|
|
1198
|
-
if (offset + size >= text.length) {
|
|
1199
|
-
segment = text.slice(offset);
|
|
1200
|
-
break;
|
|
1201
|
-
}
|
|
1202
|
-
const probe = new Lexer(markdownParser.defaults);
|
|
1203
|
-
probe.blockTokens(text.slice(offset, offset + size), probe.tokens);
|
|
1204
|
-
const boundary = stableBlockBoundary(text, offset, probe.tokens);
|
|
1205
|
-
if (boundary.count > 0) segment = text.slice(offset, boundary.end);
|
|
1293
|
+
let end = text.length;
|
|
1294
|
+
for (let size = firstProbeSize(text, offset); offset + size < text.length;) {
|
|
1295
|
+
const boundary = probeBoundary(text, offset, size, mathBlocks);
|
|
1296
|
+
if (boundary.end > 0) {
|
|
1297
|
+
end = boundary.end;
|
|
1298
|
+
break;
|
|
1206
1299
|
}
|
|
1300
|
+
size = nextProbeSize(text, offset, size, boundary.blockEnd);
|
|
1207
1301
|
}
|
|
1208
|
-
lexer.blockTokens(
|
|
1209
|
-
offset
|
|
1302
|
+
lexer.blockTokens(text.slice(offset, end), lexer.tokens);
|
|
1303
|
+
offset = end;
|
|
1210
1304
|
}
|
|
1211
1305
|
for (const queued of lexer.inlineQueue) lexer.inlineTokens(queued.src, queued.tokens);
|
|
1212
1306
|
lexer.inlineQueue = [];
|
|
1213
1307
|
return lexer.tokens;
|
|
1214
1308
|
}
|
|
1215
1309
|
|
|
1216
|
-
/**
|
|
1217
|
-
|
|
1310
|
+
/**
|
|
1311
|
+
* Size of the first probe window at `offset`: `LEX_WINDOW_BYTES`, or up to one
|
|
1312
|
+
* character past the next blank line when that lies further. A boundary is a
|
|
1313
|
+
* token whose raw ends in `"\n\n"` and that ends inside its window, so no
|
|
1314
|
+
* smaller window can cut. With no blank line left, it is the rest of the text.
|
|
1315
|
+
*/
|
|
1316
|
+
function firstProbeSize(text: string, offset: number): number {
|
|
1317
|
+
const nextBlank = text.indexOf("\n\n", offset);
|
|
1318
|
+
return nextBlank === -1 ? text.length - offset : Math.max(LEX_WINDOW_BYTES, nextBlank + 3 - offset);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Size of the next probe window after the one of `size` at `offset` gave no
|
|
1323
|
+
* usable cut: at least double, and one character past the first blank line
|
|
1324
|
+
* that reaches the window's edge, or `blockEnd` when the probe stopped at a
|
|
1325
|
+
* display-math block ending there, since only such a blank line can end a
|
|
1326
|
+
* later boundary. Doubling toward a far closer would re-lex the block once
|
|
1327
|
+
* per window. With no blank line left, it is the rest of the text.
|
|
1328
|
+
*/
|
|
1329
|
+
function nextProbeSize(text: string, offset: number, size: number, blockEnd: number): number {
|
|
1330
|
+
const nextBlank = text.indexOf("\n\n", Math.max(offset + size, blockEnd) - 2);
|
|
1331
|
+
return nextBlank === -1 ? text.length - offset : Math.max(2 * size, nextBlank + 3 - offset);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* The last stable block boundary ({@link stableBlockBoundary}) in the window
|
|
1336
|
+
* `text.slice(offset, offset + size)`, from a throwaway block-only lex of the
|
|
1337
|
+
* window. `mathBlocks` holds the display-math blocks of all of `text`.
|
|
1338
|
+
*/
|
|
1339
|
+
function probeBoundary(text: string, offset: number, size: number, mathBlocks: MathBlockScan): BlockBoundary {
|
|
1340
|
+
const probe = new Lexer(markdownParser.defaults);
|
|
1341
|
+
probe.blockTokens(text.slice(offset, offset + size), probe.tokens);
|
|
1342
|
+
return stableBlockBoundary(text, offset, probe.tokens, { window: { end: offset + size, mathBlocks } });
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Lex a whole document, windowing anything large enough for the quadratic scan
|
|
1347
|
+
* to bite. `links` holds every reference definition, at any nesting depth.
|
|
1348
|
+
*/
|
|
1349
|
+
function lexDocument(text: string): TokensList {
|
|
1218
1350
|
// A CR shifts every `raw` span (marked normalizes CRLF before tokenizing), so
|
|
1219
1351
|
// window offsets would address the wrong characters — lex those in one pass.
|
|
1220
1352
|
if (text.length < WINDOWED_LEX_MIN_BYTES || text.includes("\r")) return markdownParser.lexer(text);
|
|
@@ -1355,7 +1487,12 @@ export interface MarkdownTheme {
|
|
|
1355
1487
|
* Return null to fall back to fenced code rendering.
|
|
1356
1488
|
*/
|
|
1357
1489
|
resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
|
|
1358
|
-
|
|
1490
|
+
/**
|
|
1491
|
+
* Glyphs for quote borders, rules, tables and color swatches. Optional so themes
|
|
1492
|
+
* built to the upstream pi-tui `MarkdownTheme` shape (which has no `symbols`)
|
|
1493
|
+
* still render; omitted symbols fall back to the active theme's set.
|
|
1494
|
+
*/
|
|
1495
|
+
symbols?: SymbolTheme;
|
|
1359
1496
|
}
|
|
1360
1497
|
|
|
1361
1498
|
interface InlineStyleContext {
|
|
@@ -1631,6 +1768,15 @@ interface RenderSignature {
|
|
|
1631
1768
|
textSizing: boolean;
|
|
1632
1769
|
bgColorProbe: string;
|
|
1633
1770
|
headingProbe: string;
|
|
1771
|
+
/** Fallback glyphs for themes without `symbols`; their identity alone cannot tell presets apart. */
|
|
1772
|
+
symbolsProbe: string;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/** A shorter prefix the cached rows grew from: its length, its token count and the row count its rows end at. */
|
|
1776
|
+
interface PrefixMark {
|
|
1777
|
+
textEnd: number;
|
|
1778
|
+
tokenCount: number;
|
|
1779
|
+
lineEnd: number;
|
|
1634
1780
|
}
|
|
1635
1781
|
|
|
1636
1782
|
interface StreamPrefixLineCache extends RenderSignature {
|
|
@@ -1639,6 +1785,34 @@ interface StreamPrefixLineCache extends RenderSignature {
|
|
|
1639
1785
|
// Private to the cache and never handed to callers (each frame copies it
|
|
1640
1786
|
// into a fresh output array), so an advancing prefix appends in place.
|
|
1641
1787
|
lines: string[];
|
|
1788
|
+
// The shorter prefixes the rows grew from, oldest first. Each ends on a
|
|
1789
|
+
// frozen block boundary, so its rows are a render of its tokens alone,
|
|
1790
|
+
// and a rewound prefix keeps the rows of the last one it still covers.
|
|
1791
|
+
marks: PrefixMark[];
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* `cache` cut back to the rows of its last prefix of at most `tokenCount`
|
|
1796
|
+
* tokens, in fresh arrays, or `undefined` when it has none. A put-aside prefix
|
|
1797
|
+
* may still hold `cache`, so its arrays are never shared.
|
|
1798
|
+
*/
|
|
1799
|
+
function rewoundLineCache(
|
|
1800
|
+
cache: StreamPrefixLineCache | undefined,
|
|
1801
|
+
tokenCount: number,
|
|
1802
|
+
): StreamPrefixLineCache | undefined {
|
|
1803
|
+
if (cache === undefined) return undefined;
|
|
1804
|
+
if (cache.tokenCount <= tokenCount) return { ...cache, lines: cache.lines.slice(), marks: cache.marks.slice() };
|
|
1805
|
+
let i = cache.marks.length - 1;
|
|
1806
|
+
while (i >= 0 && cache.marks[i]!.tokenCount > tokenCount) i--;
|
|
1807
|
+
if (i < 0) return undefined;
|
|
1808
|
+
const mark = cache.marks[i]!;
|
|
1809
|
+
return {
|
|
1810
|
+
...cache,
|
|
1811
|
+
text: cache.text.slice(0, mark.textEnd),
|
|
1812
|
+
tokenCount: mark.tokenCount,
|
|
1813
|
+
lines: cache.lines.slice(0, mark.lineEnd),
|
|
1814
|
+
marks: cache.marks.slice(0, i),
|
|
1815
|
+
};
|
|
1642
1816
|
}
|
|
1643
1817
|
/**
|
|
1644
1818
|
* Per-token row cache for the *unfrozen tail* (PoC H). The tail re-lexes every
|
|
@@ -1691,6 +1865,17 @@ interface TailRenderRecorder {
|
|
|
1691
1865
|
raws: (string | undefined)[];
|
|
1692
1866
|
nextTypes: (string | undefined)[];
|
|
1693
1867
|
}
|
|
1868
|
+
/**
|
|
1869
|
+
* The frozen streaming prefix as it stood before a rewind whose only cause was
|
|
1870
|
+
* a closer on the still-growing last line, with the row caches keyed on it.
|
|
1871
|
+
*/
|
|
1872
|
+
interface RewoundPrefix {
|
|
1873
|
+
text: string;
|
|
1874
|
+
tokens: Token[];
|
|
1875
|
+
openers: readonly PrefixOpener[];
|
|
1876
|
+
lineCache: StreamPrefixLineCache | undefined;
|
|
1877
|
+
tailRowCache: TailRowCache | undefined;
|
|
1878
|
+
}
|
|
1694
1879
|
interface StreamingHighlightCache extends RenderSignature {
|
|
1695
1880
|
lang: string | undefined;
|
|
1696
1881
|
text: string;
|
|
@@ -1719,6 +1904,8 @@ export class Markdown implements Component {
|
|
|
1719
1904
|
#paddingY: number; // Top/bottom padding
|
|
1720
1905
|
#defaultTextStyle?: DefaultTextStyle;
|
|
1721
1906
|
#theme: MarkdownTheme;
|
|
1907
|
+
#symbols: SymbolTheme;
|
|
1908
|
+
#symbolsProbe: string;
|
|
1722
1909
|
#defaultStylePrefix?: string;
|
|
1723
1910
|
/** Number of spaces used to indent code block content. */
|
|
1724
1911
|
#codeBlockIndent: number;
|
|
@@ -1741,6 +1928,20 @@ export class Markdown implements Component {
|
|
|
1741
1928
|
#streamPrefixText?: string;
|
|
1742
1929
|
#streamPrefixTokens?: Token[];
|
|
1743
1930
|
#streamPrefixLineCache?: StreamPrefixLineCache;
|
|
1931
|
+
// The first display-math opener of each kind in the frozen prefix whose
|
|
1932
|
+
// block an append could still close. The prefix lexes them as the text
|
|
1933
|
+
// stands, which stays right until a tail line could close one of them
|
|
1934
|
+
// (mathBlockCloserIndex). Such a line turns the text from its kind's opener
|
|
1935
|
+
// on into one math block, so the prefix goes back to the boundary in front
|
|
1936
|
+
// of that opener, keeps the openers in front of it, and the rest is re-lexed.
|
|
1937
|
+
#streamPrefixOpeners: readonly PrefixOpener[] = NO_OPENERS;
|
|
1938
|
+
// The frozen prefix's leading run in front of the first of those openers:
|
|
1939
|
+
// no append can change its tokens, so getLastRenderStableText publishes it.
|
|
1940
|
+
#streamSettledText?: string;
|
|
1941
|
+
// The prefix before a rewind that only a closer on the still-growing last
|
|
1942
|
+
// line caused. Once a later text holds no closer line after it, that
|
|
1943
|
+
// prefix is right again, so #lexTokens puts it back.
|
|
1944
|
+
#streamRewound?: RewoundPrefix;
|
|
1744
1945
|
// Guard-scan memo (PoC C): the ref-def/CR verdict with the exact text
|
|
1745
1946
|
// length it was checked on. Reuse is sound only while setText has been
|
|
1746
1947
|
// append-only since (tracked via the startsWith that setText performs): a
|
|
@@ -1800,6 +2001,9 @@ export class Markdown implements Component {
|
|
|
1800
2001
|
this.#paddingX = paddingX;
|
|
1801
2002
|
this.#paddingY = paddingY;
|
|
1802
2003
|
this.#theme = theme;
|
|
2004
|
+
this.#symbols = theme.symbols ?? getSymbolTheme();
|
|
2005
|
+
const { quoteBorder, hrChar, colorSwatch, table } = this.#symbols;
|
|
2006
|
+
this.#symbolsProbe = theme.symbols ? "" : [quoteBorder, hrChar, colorSwatch, ...Object.values(table)].join("");
|
|
1803
2007
|
this.#defaultTextStyle = defaultTextStyle;
|
|
1804
2008
|
this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
|
|
1805
2009
|
}
|
|
@@ -1854,8 +2058,13 @@ export class Markdown implements Component {
|
|
|
1854
2058
|
if (text === this.#text) return false;
|
|
1855
2059
|
if (!text.startsWith(this.#text)) {
|
|
1856
2060
|
// Non-append edit: the previous frame's guard verdict cannot be
|
|
1857
|
-
// reused — the checked region may have changed anywhere.
|
|
2061
|
+
// reused — the checked region may have changed anywhere. The frozen
|
|
2062
|
+
// prefix, or one a rewind set aside, may still start the new text,
|
|
2063
|
+
// but the line after it was replaced, and a boundary holds only
|
|
2064
|
+
// while that line starts a block of its own.
|
|
1858
2065
|
this.#appendOnlySinceLastScan = false;
|
|
2066
|
+
this.#dropStreamPrefix();
|
|
2067
|
+
this.#streamRewound = undefined;
|
|
1859
2068
|
}
|
|
1860
2069
|
this.#text = text;
|
|
1861
2070
|
if (!text.trim()) {
|
|
@@ -1896,11 +2105,13 @@ export class Markdown implements Component {
|
|
|
1896
2105
|
|
|
1897
2106
|
/**
|
|
1898
2107
|
* Width-independent source prefix of the last render ending at a frozen
|
|
1899
|
-
* Markdown block boundary
|
|
1900
|
-
*
|
|
2108
|
+
* Markdown block boundary that no append can move: it stops in front of a
|
|
2109
|
+
* display-math opener whose block an append could still close. Only
|
|
2110
|
+
* meaningful while streaming (transient render cache on); grows
|
|
2111
|
+
* monotonically under append-only `setText`.
|
|
1901
2112
|
*/
|
|
1902
2113
|
getLastRenderStableText(): string {
|
|
1903
|
-
return this.#transientRenderCache ? (this.#
|
|
2114
|
+
return this.#transientRenderCache ? (this.#streamSettledText ?? "") : "";
|
|
1904
2115
|
}
|
|
1905
2116
|
|
|
1906
2117
|
get transientRenderCache(): boolean {
|
|
@@ -1931,6 +2142,7 @@ export class Markdown implements Component {
|
|
|
1931
2142
|
this.#streamPrefixLineCache = undefined;
|
|
1932
2143
|
this.#tailRowCache = undefined;
|
|
1933
2144
|
this.#streamingHighlightCache = undefined;
|
|
2145
|
+
this.#streamRewound = undefined;
|
|
1934
2146
|
}
|
|
1935
2147
|
this.invalidate();
|
|
1936
2148
|
}
|
|
@@ -1942,15 +2154,25 @@ export class Markdown implements Component {
|
|
|
1942
2154
|
// raw-span offsets). Every fallback is correctness-preserving — only speed
|
|
1943
2155
|
// differs; the render loop sees the identical token list either way.
|
|
1944
2156
|
#lexTokens(text: string): Token[] {
|
|
1945
|
-
//
|
|
1946
|
-
//
|
|
1947
|
-
//
|
|
1948
|
-
//
|
|
1949
|
-
//
|
|
2157
|
+
// A prefix is frozen only while canStream holds and its lex registered no
|
|
2158
|
+
// definitions, and it ends at a "\n\n" block boundary (stableBlockBoundary),
|
|
2159
|
+
// so the tail starts at a fresh line. The scan below then catches a
|
|
2160
|
+
// top-level definition or CR in the new text and the tail lex's links catch
|
|
2161
|
+
// a nested one, so the grown prefix is never re-scanned (O(n²) → O(n)).
|
|
2162
|
+
if (this.#streamRewound !== undefined) this.#restoreRewoundPrefix(text);
|
|
2163
|
+
const frozenText = this.#streamPrefixText;
|
|
2164
|
+
const frozenTokens = this.#streamPrefixTokens;
|
|
2165
|
+
const grewPastPrefix = frozenText !== undefined && text.length > frozenText.length && text.startsWith(frozenText);
|
|
2166
|
+
// A tail line that could close a display-math block the prefix holds open
|
|
2167
|
+
// makes the text from that block's opener on one `math` token, so the
|
|
2168
|
+
// prefix goes back to the boundary in front of the opener, and the rest
|
|
2169
|
+
// is lexed again. The check reads only the tail, which is lexed anyway.
|
|
2170
|
+
if (grewPastPrefix && frozenTokens !== undefined && this.#streamPrefixOpeners.length > 0) {
|
|
2171
|
+
this.#rewindForCloser(text, frozenText, frozenTokens);
|
|
2172
|
+
}
|
|
1950
2173
|
const prefix = this.#streamPrefixText;
|
|
1951
2174
|
const prefixTokens = this.#streamPrefixTokens;
|
|
1952
|
-
const hasPrefix =
|
|
1953
|
-
prefix !== undefined && prefixTokens !== undefined && text.length > prefix.length && text.startsWith(prefix);
|
|
2175
|
+
const hasPrefix = grewPastPrefix && prefix !== undefined && prefixTokens !== undefined;
|
|
1954
2176
|
const refDefText = hasPrefix ? text.slice(prefix.length) : text;
|
|
1955
2177
|
// Guard-scan memo (PoC C): while setText has been append-only and the
|
|
1956
2178
|
// grown delta introduces no "[", "]", ":", "\n" or "\r", the previous
|
|
@@ -2000,13 +2222,21 @@ export class Markdown implements Component {
|
|
|
2000
2222
|
this.#appendOnlySinceLastScan = true;
|
|
2001
2223
|
if (canStream && hasPrefix) {
|
|
2002
2224
|
const tailTokens = lexDocument(refDefText);
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2225
|
+
// HAS_REF_DEF sees top-level definition lines only. A definition nested
|
|
2226
|
+
// in a quote or list item still registers for the whole document and
|
|
2227
|
+
// can resolve a reference in the frozen prefix, which was lexed
|
|
2228
|
+
// without it, so any definition in the tail sends the text to a full lex.
|
|
2229
|
+
if (Object.keys(tailTokens.links).length === 0) {
|
|
2230
|
+
const tokens = [...prefixTokens, ...tailTokens];
|
|
2231
|
+
if (retainPrefix) this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
|
|
2232
|
+
else this.#dropStreamPrefix();
|
|
2233
|
+
return tokens;
|
|
2234
|
+
}
|
|
2007
2235
|
}
|
|
2008
2236
|
const tokens = lexDocument(text);
|
|
2009
|
-
|
|
2237
|
+
// A definition frozen into the prefix would be missing from every later
|
|
2238
|
+
// tail lex, so a full lex that registered any definition freezes nothing.
|
|
2239
|
+
if (canStream && retainPrefix && Object.keys(tokens.links).length === 0) {
|
|
2010
2240
|
this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
|
|
2011
2241
|
} else {
|
|
2012
2242
|
this.#dropStreamPrefix();
|
|
@@ -2014,19 +2244,103 @@ export class Markdown implements Component {
|
|
|
2014
2244
|
return tokens;
|
|
2015
2245
|
}
|
|
2016
2246
|
|
|
2017
|
-
/**
|
|
2247
|
+
/**
|
|
2248
|
+
* Drop the frozen lex prefix and the transient row caches keyed on it. A
|
|
2249
|
+
* prefix set aside for #restoreRewoundPrefix checks itself against each
|
|
2250
|
+
* later text, so it stays until a non-append edit.
|
|
2251
|
+
*/
|
|
2018
2252
|
#dropStreamPrefix(): void {
|
|
2019
2253
|
this.#streamPrefixText = undefined;
|
|
2020
2254
|
this.#streamPrefixTokens = undefined;
|
|
2255
|
+
this.#streamPrefixOpeners = NO_OPENERS;
|
|
2256
|
+
this.#streamSettledText = undefined;
|
|
2021
2257
|
this.#streamPrefixLineCache = undefined;
|
|
2022
2258
|
this.#tailRowCache = undefined;
|
|
2023
2259
|
}
|
|
2024
2260
|
|
|
2261
|
+
/**
|
|
2262
|
+
* Rewind the frozen prefix `prefix` (its tokens `tokens`) for a line of the
|
|
2263
|
+
* tail after it that could close one of its openers: back to the boundary
|
|
2264
|
+
* in front of the first opener whose closer a tail line holds. When that
|
|
2265
|
+
* closer, and no other, is on the still-growing last line, the next chunk
|
|
2266
|
+
* can turn the line into text again, so the prefix is set aside for
|
|
2267
|
+
* #restoreRewoundPrefix.
|
|
2268
|
+
*/
|
|
2269
|
+
#rewindForCloser(text: string, prefix: string, tokens: Token[]): void {
|
|
2270
|
+
let target: PrefixOpener | undefined;
|
|
2271
|
+
let lastLineOnly = false;
|
|
2272
|
+
for (const opener of this.#streamPrefixOpeners) {
|
|
2273
|
+
const closer = mathBlockCloserIndex(text, prefix.length, opener.kind);
|
|
2274
|
+
if (closer === undefined) continue;
|
|
2275
|
+
lastLineOnly = target === undefined && !text.includes("\n", closer);
|
|
2276
|
+
target ??= opener;
|
|
2277
|
+
}
|
|
2278
|
+
if (target === undefined) return;
|
|
2279
|
+
this.#streamRewound = lastLineOnly
|
|
2280
|
+
? {
|
|
2281
|
+
text: prefix,
|
|
2282
|
+
tokens,
|
|
2283
|
+
openers: this.#streamPrefixOpeners,
|
|
2284
|
+
lineCache: this.#streamPrefixLineCache,
|
|
2285
|
+
tailRowCache: this.#tailRowCache,
|
|
2286
|
+
}
|
|
2287
|
+
: undefined;
|
|
2288
|
+
this.#rewindStreamPrefix(target.rewindEnd, target.rewindCount);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/**
|
|
2292
|
+
* Cut the frozen prefix back to the boundary `end` characters and `count`
|
|
2293
|
+
* tokens in, keeping the openers in front of it and the rows of the prefix
|
|
2294
|
+
* that remains, and dropping the tail row cache keyed on the old prefix.
|
|
2295
|
+
*/
|
|
2296
|
+
#rewindStreamPrefix(end: number, count: number): void {
|
|
2297
|
+
this.#streamPrefixLineCache = rewoundLineCache(this.#streamPrefixLineCache, count);
|
|
2298
|
+
this.#tailRowCache = undefined;
|
|
2299
|
+
if (count === 0) {
|
|
2300
|
+
this.#streamPrefixText = undefined;
|
|
2301
|
+
this.#streamPrefixTokens = undefined;
|
|
2302
|
+
this.#streamPrefixOpeners = NO_OPENERS;
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
this.#streamPrefixText = this.#streamPrefixText?.slice(0, end);
|
|
2306
|
+
this.#streamPrefixTokens = this.#streamPrefixTokens?.slice(0, count);
|
|
2307
|
+
this.#streamPrefixOpeners = this.#streamPrefixOpeners.filter(opener => opener.at < end);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* Put back the prefix #rewindForCloser set aside once no line of `text`
|
|
2312
|
+
* after it could close one of its openers: the last line it rewound for
|
|
2313
|
+
* has grown into text, so the prefix lexes as the text stands again. A
|
|
2314
|
+
* closer line that has ended stays one under every append, so a prefix
|
|
2315
|
+
* with one after it can never come back.
|
|
2316
|
+
*/
|
|
2317
|
+
#restoreRewoundPrefix(text: string): void {
|
|
2318
|
+
const rewound = this.#streamRewound;
|
|
2319
|
+
if (rewound === undefined) return;
|
|
2320
|
+
if (text.length <= rewound.text.length || !text.startsWith(rewound.text)) {
|
|
2321
|
+
this.#streamRewound = undefined;
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
for (const opener of rewound.openers) {
|
|
2325
|
+
const closer = mathBlockCloserIndex(text, rewound.text.length, opener.kind);
|
|
2326
|
+
if (closer === undefined) continue;
|
|
2327
|
+
if (text.includes("\n", closer)) this.#streamRewound = undefined;
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
this.#streamRewound = undefined;
|
|
2331
|
+
this.#streamPrefixText = rewound.text;
|
|
2332
|
+
this.#streamPrefixTokens = rewound.tokens;
|
|
2333
|
+
this.#streamPrefixOpeners = rewound.openers;
|
|
2334
|
+
this.#streamPrefixLineCache = rewound.lineCache;
|
|
2335
|
+
this.#tailRowCache = rewound.tailRowCache;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2025
2338
|
// Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
|
|
2026
|
-
// (complete and immutable under append-only growth
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
//
|
|
2339
|
+
// (complete, and immutable under append-only growth until a tail line could
|
|
2340
|
+
// close a display-math block it holds open) so the next streaming render
|
|
2341
|
+
// re-lexes only the unfrozen tail. Caller guarantees no CR / no reference
|
|
2342
|
+
// definitions, so each token's `raw` is a verbatim slice of `text` and the
|
|
2343
|
+
// summed offsets address `text` exactly.
|
|
2030
2344
|
#freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
|
|
2031
2345
|
// On the streaming-concat path (preserveExisting), tokens[0..prefixCount)
|
|
2032
2346
|
// ARE the previously frozen prefix and the text above it is byte-
|
|
@@ -2037,19 +2351,50 @@ export class Markdown implements Component {
|
|
|
2037
2351
|
// path (preserveExisting: false) re-derives the whole stream, so it
|
|
2038
2352
|
// must keep walking from 0.
|
|
2039
2353
|
const skipPrefix = opts.preserveExisting ? (this.#streamPrefixTokens?.length ?? 0) : 0;
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
skipPrefix,
|
|
2045
|
-
);
|
|
2046
|
-
if (frozen.count > 0) {
|
|
2047
|
-
this.#streamPrefixText = text.slice(0, frozen.end);
|
|
2048
|
-
this.#streamPrefixTokens = tokens.slice(0, frozen.count);
|
|
2354
|
+
const base = skipPrefix > 0 ? (this.#streamPrefixText?.length ?? 0) : 0;
|
|
2355
|
+
const frozen = stableBlockBoundary(text, base, tokens, { startIndex: skipPrefix });
|
|
2356
|
+
if (frozen.count === 0) {
|
|
2357
|
+
if (!opts.preserveExisting) this.#dropStreamPrefix();
|
|
2049
2358
|
return;
|
|
2050
2359
|
}
|
|
2051
|
-
|
|
2052
|
-
if (
|
|
2360
|
+
let openers = skipPrefix > 0 ? this.#streamPrefixOpeners : NO_OPENERS;
|
|
2361
|
+
if (openers.length === 0) {
|
|
2362
|
+
// The prefix so far is all settled, so the settled part grows up to the
|
|
2363
|
+
// last boundary in front of the first opener an append could close.
|
|
2364
|
+
const settled = stableBlockBoundary(text, base, tokens, { startIndex: skipPrefix, settle: true });
|
|
2365
|
+
if (settled.count > 0) {
|
|
2366
|
+
this.#streamSettledText = text.slice(0, settled.end);
|
|
2367
|
+
} else if (skipPrefix === 0) {
|
|
2368
|
+
this.#streamSettledText = undefined;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
// Record the first opener of each kind in the newly frozen tokens that an
|
|
2372
|
+
// append could still close, with the boundary a closer of its kind
|
|
2373
|
+
// rewinds to: the last one in front of it, or the prefix end when the new
|
|
2374
|
+
// tokens hold none. It is never in front of an earlier opener's, so the
|
|
2375
|
+
// first opener whose closer a tail line holds has the earliest boundary.
|
|
2376
|
+
let pos = base;
|
|
2377
|
+
for (let i = skipPrefix; i < frozen.count && openers.length < 2; i++) {
|
|
2378
|
+
const token = tokens[i];
|
|
2379
|
+
if (token.type !== "math") {
|
|
2380
|
+
const kind = mathBlockOpenerAt(text, pos);
|
|
2381
|
+
if (kind !== undefined && !openers.some(opener => opener.kind === kind) && mathBlockMayCloseAt(text, pos)) {
|
|
2382
|
+
const boundary = stableBlockBoundary(text, base, tokens, { startIndex: skipPrefix, endIndex: i });
|
|
2383
|
+
let rewindEnd = boundary.count > 0 ? boundary.end : base;
|
|
2384
|
+
let rewindCount = boundary.count > 0 ? boundary.count : skipPrefix;
|
|
2385
|
+
const earlier = openers.at(-1);
|
|
2386
|
+
if (earlier !== undefined && earlier.rewindEnd > rewindEnd) {
|
|
2387
|
+
rewindEnd = earlier.rewindEnd;
|
|
2388
|
+
rewindCount = earlier.rewindCount;
|
|
2389
|
+
}
|
|
2390
|
+
openers = [...openers, { kind, at: pos, rewindEnd, rewindCount }];
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
pos += token.raw.length;
|
|
2394
|
+
}
|
|
2395
|
+
this.#streamPrefixText = text.slice(0, frozen.end);
|
|
2396
|
+
this.#streamPrefixTokens = tokens.slice(0, frozen.count);
|
|
2397
|
+
this.#streamPrefixOpeners = openers;
|
|
2053
2398
|
}
|
|
2054
2399
|
|
|
2055
2400
|
render(width: number): readonly string[] {
|
|
@@ -2131,11 +2476,16 @@ export class Markdown implements Component {
|
|
|
2131
2476
|
(!markerDelta && recipe.rowRaw.endsWith("$") && /^[0-9]/.test(deltaTabs));
|
|
2132
2477
|
// A delta opening a pairing char when the captured row ENDS with the
|
|
2133
2478
|
// same char can re-pair across the seam: cold lex of the joined run
|
|
2134
|
-
// makes ONE token (x *a**b* → em("a**b")), the splice keeps two.
|
|
2135
|
-
//
|
|
2479
|
+
// makes ONE token (x *a**b* → em("a**b")), the splice keeps two. So
|
|
2480
|
+
// can an emphasis delimiter in the delta and the same char anywhere
|
|
2481
|
+
// in the row, once the joined text makes the row's one flanking
|
|
2482
|
+
// (`a_{3` + `} + b_{` renders `{3} + b` emphasized): the delta lexed
|
|
2483
|
+
// alone has nothing to pair with. An image marker (`x!` + `[a](u)`)
|
|
2484
|
+
// re-pairs the same way.
|
|
2136
2485
|
const pairSeamHazard =
|
|
2137
2486
|
markerDelta &&
|
|
2138
2487
|
((/^[*~`]/.test(deltaTabs) && /[*~`]$/.test(recipe.rowRaw)) ||
|
|
2488
|
+
FAST_EMPHASIS_DELIMITERS.some(c => deltaTabs.includes(c) && recipe.rowRaw.includes(c)) ||
|
|
2139
2489
|
// "x!" + "[a](u)": cold lexes text("x") + image(alt); the splice would
|
|
2140
2490
|
// keep "x!" + a styled link byte-run.
|
|
2141
2491
|
(deltaTabs.startsWith("[") && recipe.rowRaw.endsWith("!")));
|
|
@@ -2161,7 +2511,7 @@ export class Markdown implements Component {
|
|
|
2161
2511
|
: renderTextWithSwatches(
|
|
2162
2512
|
normalizeHtmlEntitiesForTerminal(deltaTabs),
|
|
2163
2513
|
applyText,
|
|
2164
|
-
this.#
|
|
2514
|
+
this.#symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
|
|
2165
2515
|
));
|
|
2166
2516
|
const wrapped = wrapTextWithAnsi(grown, contentWidth);
|
|
2167
2517
|
const fastPaddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
@@ -2214,8 +2564,14 @@ export class Markdown implements Component {
|
|
|
2214
2564
|
// removed): the guard-scan memo's checked region is no longer
|
|
2215
2565
|
// byte-identical, and a cached false verdict may have been based
|
|
2216
2566
|
// on the very CR/ref-def line that was deleted. Invalidate so the
|
|
2217
|
-
// next #lexTokens re-derives on the repaired buffer.
|
|
2567
|
+
// next #lexTokens re-derives on the repaired buffer. The text past
|
|
2568
|
+
// the frozen prefix is no append of what was frozen against either
|
|
2569
|
+
// (a fence line right after the prefix leaves the tail opening on
|
|
2570
|
+
// blank lines a one-pass lex joins to the ones above), so drop it,
|
|
2571
|
+
// and any prefix a rewind set aside.
|
|
2218
2572
|
this.#lastScanValid = false;
|
|
2573
|
+
this.#dropStreamPrefix();
|
|
2574
|
+
this.#streamRewound = undefined;
|
|
2219
2575
|
}
|
|
2220
2576
|
|
|
2221
2577
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
@@ -2327,6 +2683,7 @@ export class Markdown implements Component {
|
|
|
2327
2683
|
textSizing: TERMINAL.textSizing,
|
|
2328
2684
|
bgColorProbe,
|
|
2329
2685
|
headingProbe,
|
|
2686
|
+
symbolsProbe: this.#symbolsProbe,
|
|
2330
2687
|
};
|
|
2331
2688
|
}
|
|
2332
2689
|
// All-primitive signature — compare via the canonical render-cache encoding.
|
|
@@ -2335,7 +2692,7 @@ export class Markdown implements Component {
|
|
|
2335
2692
|
}
|
|
2336
2693
|
|
|
2337
2694
|
#renderCacheKey(normalizedText: string, signature: RenderSignature): string {
|
|
2338
|
-
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}`;
|
|
2695
|
+
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}\x00${signature.symbolsProbe}`;
|
|
2339
2696
|
}
|
|
2340
2697
|
|
|
2341
2698
|
#renderStreamingContentLines(
|
|
@@ -2356,10 +2713,15 @@ export class Markdown implements Component {
|
|
|
2356
2713
|
// of prefix + tail, so no array handed to a caller is ever mutated.
|
|
2357
2714
|
const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, stableText, signature);
|
|
2358
2715
|
let prefixLines: string[] = [];
|
|
2716
|
+
let marks: PrefixMark[] = [];
|
|
2359
2717
|
let renderedUntil = 0;
|
|
2360
2718
|
if (reusablePrefix && reusablePrefix.tokenCount <= stableTokenCount) {
|
|
2361
2719
|
prefixLines = reusablePrefix.lines;
|
|
2720
|
+
marks = reusablePrefix.marks;
|
|
2362
2721
|
renderedUntil = reusablePrefix.tokenCount;
|
|
2722
|
+
if (renderedUntil < stableTokenCount) {
|
|
2723
|
+
marks.push({ textEnd: reusablePrefix.text.length, tokenCount: renderedUntil, lineEnd: prefixLines.length });
|
|
2724
|
+
}
|
|
2363
2725
|
}
|
|
2364
2726
|
|
|
2365
2727
|
if (renderedUntil < stableTokenCount) {
|
|
@@ -2382,6 +2744,7 @@ export class Markdown implements Component {
|
|
|
2382
2744
|
text: stableText,
|
|
2383
2745
|
tokenCount: stableTokenCount,
|
|
2384
2746
|
lines: prefixLines,
|
|
2747
|
+
marks,
|
|
2385
2748
|
};
|
|
2386
2749
|
|
|
2387
2750
|
if (renderedUntil >= tokens.length) return prefixLines.slice();
|
|
@@ -3108,7 +3471,7 @@ export class Markdown implements Component {
|
|
|
3108
3471
|
|
|
3109
3472
|
/** Render a horizontal rule line themed to `width`, matching `sourceChar` when given. */
|
|
3110
3473
|
#renderHrLine(width: number, sourceChar = ""): string {
|
|
3111
|
-
const fillChar = getHrChar(sourceChar, this.#
|
|
3474
|
+
const fillChar = getHrChar(sourceChar, this.#symbols.hrChar);
|
|
3112
3475
|
return this.#theme.hr(fillChar.repeat(Math.min(width, 80)));
|
|
3113
3476
|
}
|
|
3114
3477
|
|
|
@@ -3140,7 +3503,7 @@ export class Markdown implements Component {
|
|
|
3140
3503
|
} else {
|
|
3141
3504
|
const styledLine = applyQuoteStyle(quoteLine.text);
|
|
3142
3505
|
for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
|
|
3143
|
-
lines.push(renderedLine(this.#theme.quoteBorder(`${this.#
|
|
3506
|
+
lines.push(renderedLine(this.#theme.quoteBorder(`${this.#symbols.quoteBorder} `) + wrappedLine));
|
|
3144
3507
|
}
|
|
3145
3508
|
}
|
|
3146
3509
|
}
|
|
@@ -3201,7 +3564,10 @@ export class Markdown implements Component {
|
|
|
3201
3564
|
const segments: string[] = text.split("\n");
|
|
3202
3565
|
return segments.map((segment: string) => (segment === "" ? "" : applyText(segment))).join("\n");
|
|
3203
3566
|
};
|
|
3204
|
-
const swatchGlyph = this.#
|
|
3567
|
+
const swatchGlyph = this.#symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
|
|
3568
|
+
// Set by a line break: the next line's own leading whitespace is dropped.
|
|
3569
|
+
// Every token consumes it, so the space after a styled span that starts
|
|
3570
|
+
// the line stays; a token that renders nothing passes it on.
|
|
3205
3571
|
let trimLeadingWhitespace = false;
|
|
3206
3572
|
const htmlState = createHtmlNormalizationState();
|
|
3207
3573
|
const markHtmlItemWhenContent = (text: string): void => {
|
|
@@ -3209,6 +3575,8 @@ export class Markdown implements Component {
|
|
|
3209
3575
|
};
|
|
3210
3576
|
|
|
3211
3577
|
for (const token of collapseInlineHtml(tokens)) {
|
|
3578
|
+
const lineStart: boolean = trimLeadingWhitespace;
|
|
3579
|
+
trimLeadingWhitespace = false;
|
|
3212
3580
|
if (isMathToken(token)) {
|
|
3213
3581
|
markHtmlItemWhenContent(token.text);
|
|
3214
3582
|
result += applyTextWithNewlines(renderMathToken(token.text));
|
|
@@ -3216,9 +3584,8 @@ export class Markdown implements Component {
|
|
|
3216
3584
|
}
|
|
3217
3585
|
switch (token.type) {
|
|
3218
3586
|
case "text": {
|
|
3219
|
-
const rawText =
|
|
3587
|
+
const rawText = lineStart ? token.text.replace(/^\s+/, "") : token.text;
|
|
3220
3588
|
const text = normalizeHtmlEntitiesForTerminal(rawText);
|
|
3221
|
-
trimLeadingWhitespace = false;
|
|
3222
3589
|
markHtmlItemWhenContent(text);
|
|
3223
3590
|
if (token.tokens) markHtmlItemWhenContent(plainInlineTokens(token.tokens));
|
|
3224
3591
|
// Text tokens in list items can have nested tokens for inline formatting
|
|
@@ -3299,8 +3666,8 @@ export class Markdown implements Component {
|
|
|
3299
3666
|
result += applyTextWithNewlines(cleaned);
|
|
3300
3667
|
if (cleaned.endsWith("\n")) {
|
|
3301
3668
|
trimLeadingWhitespace = true;
|
|
3302
|
-
} else if (cleaned.length
|
|
3303
|
-
trimLeadingWhitespace =
|
|
3669
|
+
} else if (cleaned.length === 0) {
|
|
3670
|
+
trimLeadingWhitespace = lineStart;
|
|
3304
3671
|
}
|
|
3305
3672
|
}
|
|
3306
3673
|
break;
|
|
@@ -3308,11 +3675,12 @@ export class Markdown implements Component {
|
|
|
3308
3675
|
default:
|
|
3309
3676
|
// Handle any other inline token types as plain text
|
|
3310
3677
|
if ("text" in token && typeof token.text === "string") {
|
|
3311
|
-
const rawText =
|
|
3678
|
+
const rawText = lineStart ? token.text.replace(/^\s+/, "") : token.text;
|
|
3312
3679
|
const text = normalizeHtmlEntitiesForTerminal(rawText);
|
|
3313
|
-
trimLeadingWhitespace = false;
|
|
3314
3680
|
markHtmlItemWhenContent(text);
|
|
3315
3681
|
result += applyTextWithNewlines(text);
|
|
3682
|
+
} else {
|
|
3683
|
+
trimLeadingWhitespace = lineStart;
|
|
3316
3684
|
}
|
|
3317
3685
|
}
|
|
3318
3686
|
}
|
|
@@ -3642,7 +4010,7 @@ export class Markdown implements Component {
|
|
|
3642
4010
|
}
|
|
3643
4011
|
}
|
|
3644
4012
|
|
|
3645
|
-
const t = this.#
|
|
4013
|
+
const t = this.#symbols.table;
|
|
3646
4014
|
const h = t.horizontal;
|
|
3647
4015
|
const v = t.vertical;
|
|
3648
4016
|
|