@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.
@@ -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 { mathBlockAt, mathSpanInContext, mathStartIndex } from "@oh-my-pi/pi-utils/math-delimiters";
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
- const beginLineStart = bm.index === 0 ? 0 : bm.index + 1; // skip the matched leading `\n`
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 boundary), a trailing backslash
902
- // (it can become an escape once the delta supplies the next char — the `\\`
903
- // clause covers that escape-completion hazard), or a full/partial hex swatch
904
- // run (a `#` + 3-8 hex is a swatch glyph; the byte range may shift).
905
- const FAST_RUN_END_RE = /(?:[ \t\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
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-start block-kind gate. */
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
- const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
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`. A boundary qualifies only
1112
- * when splitting there is invisible to the lexer, i.e. `lex(head) ++ lex(tail)
1113
- * === lex(text)`:
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 character must start real block content. Whitespace means the
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
- startIndex = 0,
1133
- ): { end: number; count: number } {
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
- for (let i = startIndex; i < tokens.length; i++) {
1138
- const raw = tokens[i].raw;
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 (raw.endsWith("\n\n")) {
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 NO_BLOCK_BOUNDARY;
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 NO_BLOCK_BOUNDARY;
1152
- return { end, count };
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
- const LEX_WINDOW_BYTES = 2 * 1024;
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 doubles until it finds one or reaches the end. Probes
1175
- * never run inline tokenization (their inlineQueue is discarded) — a boundary
1176
- * is a property of block structure alone, and probe inline passes were the
1177
- * dominant cost of an earlier revision. Block tokenization runs per window
1178
- * while inline tokenization is deferred to the end — mirroring `Lexer.lex` —
1179
- * so a `[label]: dest` definition anywhere in the document still resolves for
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
- * A boundary requires some top-level token whose raw ends in `"\n\n"`, so a
1183
- * window that contains no blank line cannot cut: each round starts at the next
1184
- * `"\n\n"` (skipping straight to the end when there is none — e.g. a tail
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): Token[] {
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 segment = "";
1192
- const nextBlank = text.indexOf("\n\n", offset);
1193
- if (nextBlank === -1) {
1194
- segment = text.slice(offset);
1195
- } else {
1196
- const minSize = Math.max(LEX_WINDOW_BYTES, nextBlank + 2 - offset);
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(segment, lexer.tokens);
1209
- offset += segment.length;
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
- /** Lex a whole document, windowing anything large enough for the quadratic scan to bite. */
1217
- function lexDocument(text: string): Token[] {
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
- symbols: SymbolTheme;
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. Only meaningful while streaming (transient
1900
- * render cache on); grows monotonically under append-only `setText`.
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.#streamPrefixText ?? "") : "";
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
- // When a frozen prefix exists, it was already verified ref-def-free when
1946
- // frozen (#freezeStablePrefix only runs when canStream was true). The prefix
1947
- // ends at a "\n\n" block boundary (stableBlockBoundary), so the tail starts
1948
- // at a fresh line — scanning only the tail for ref defs is sufficient and
1949
- // avoids re-scanning the grown prefix every frame (O(n²) → O(n) overall).
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
- const tokens = [...prefixTokens, ...tailTokens];
2004
- if (retainPrefix) this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2005
- else this.#dropStreamPrefix();
2006
- return tokens;
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
- if (canStream && retainPrefix) {
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
- /** Drop the frozen lex prefix and the transient row caches keyed on it. */
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) so the next streaming
2027
- // render re-lexes only the unfrozen tail. Caller guarantees no CR / no
2028
- // reference definitions, so each token's `raw` is a verbatim slice of `text`
2029
- // and the summed offsets address `text` exactly.
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 frozen = stableBlockBoundary(
2041
- text,
2042
- skipPrefix > 0 ? (this.#streamPrefixText?.length ?? 0) : 0,
2043
- tokens,
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 (!opts.preserveExisting) this.#dropStreamPrefix();
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
- // An image marker (`x!` + `[a](u)`) re-pairs the same way.
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.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
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.#theme.symbols.hrChar);
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.#theme.symbols.quoteBorder} `) + wrappedLine));
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.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
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 = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
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 > 0) {
3303
- trimLeadingWhitespace = false;
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 = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
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.#theme.symbols.table;
4013
+ const t = this.#symbols.table;
3646
4014
  const h = t.horizontal;
3647
4015
  const v = t.vertical;
3648
4016