@linxiraos/pi-tui 1.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,3119 @@
1
+ import { LRUCache } from "@linxiraos/pi-utils/lru";
2
+ import {
3
+ Lexer,
4
+ Marked,
5
+ type Token,
6
+ Tokenizer,
7
+ type TokenizerAndRendererExtension,
8
+ type Tokens,
9
+ } from "@linxiraos/pi-utils/marked";
10
+ import { latexToBlock } from "../latex-block";
11
+ import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
+ import type { SymbolTheme } from "../symbols";
13
+ import { TERMINAL } from "../terminal-capabilities";
14
+ import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui";
15
+ import {
16
+ applyBackgroundToLine,
17
+ Ellipsis,
18
+ encodeTextSized,
19
+ getPaddingX,
20
+ getSegmenter,
21
+ padding,
22
+ replaceTabs,
23
+ truncateToWidth,
24
+ visibleWidth,
25
+ wrapTextWithAnsi,
26
+ } from "../utils";
27
+
28
+ const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
29
+
30
+ // Marked treats the backslash in an ST-terminated OSC 8 sequence (`ESC \\`) as
31
+ // Markdown punctuation when it is immediately followed by markup such as a
32
+ // codespan backtick. Normalize well-formed OSC 8 prefixes to the equivalent BEL
33
+ // terminator before lexing so the control sequence stays opaque to Markdown.
34
+ const OSC8_ST_PREFIX_REGEX = /(\x1b\]8;[^\x07\x1b]*)\x1b\\/g;
35
+
36
+ function normalizeOsc8Terminators(text: string): string {
37
+ return text.replace(OSC8_ST_PREFIX_REGEX, "$1\x07");
38
+ }
39
+
40
+ // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
41
+ // unit by the H1 render path. Like image-protocol lines, they must bypass
42
+ // ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span
43
+ // (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC
44
+ // payload), and padding would append trailing cells past the doubled glyph.
45
+ const OSC66_LINE_PREFIX = "\x1b]66;";
46
+
47
+ function isOsc66Line(line: string): boolean {
48
+ return line.includes(OSC66_LINE_PREFIX);
49
+ }
50
+
51
+ function normalizeHtmlEntitiesForTerminal(raw: string): string {
52
+ const parseCodePoint = (value: number): string => {
53
+ if (Number.isFinite(value) && value >= 0 && value <= 0x10ffff) {
54
+ try {
55
+ return String.fromCodePoint(value);
56
+ } catch (_) {
57
+ // Fallback to empty string or original if invalid codepoint
58
+ }
59
+ }
60
+ return "";
61
+ };
62
+
63
+ return raw.replace(/&(amp|lt|gt|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);/gi, (match, entity) => {
64
+ const lower = entity.toLowerCase();
65
+ switch (lower) {
66
+ case "nbsp":
67
+ return " ";
68
+ case "lt":
69
+ return "<";
70
+ case "gt":
71
+ return ">";
72
+ case "quot":
73
+ return '"';
74
+ case "apos":
75
+ return "'";
76
+ case "amp":
77
+ return "&";
78
+ default: {
79
+ if (lower.startsWith("#x")) {
80
+ return parseCodePoint(Number.parseInt(lower.slice(2), 16));
81
+ }
82
+ if (lower.startsWith("#")) {
83
+ return parseCodePoint(Number(lower.slice(1)));
84
+ }
85
+ return match;
86
+ }
87
+ }
88
+ });
89
+ }
90
+
91
+ interface HtmlListState {
92
+ type: "ol" | "ul";
93
+ next: number;
94
+ }
95
+
96
+ interface HtmlNormalizationState {
97
+ lists: HtmlListState[];
98
+ openItems: boolean[];
99
+ itemHasContent: boolean[];
100
+ }
101
+
102
+ function createHtmlNormalizationState(): HtmlNormalizationState {
103
+ return { lists: [], openItems: [], itemHasContent: [] };
104
+ }
105
+
106
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
107
+ const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text|code|hr|blockquote)\b(?:\s[^>]*)?\s*\/?>/gi;
108
+ // Block-level HTML that needs structural (not just textual) rendering: standalone
109
+ // `<hr>` becomes a rule and balanced `<blockquote>…</blockquote>` renders with
110
+ // quote styling. Group 1 captures blockquote inner content; it is undefined for hr.
111
+ const BLOCK_HTML_REGEX = /<hr\b[^>]*\/?>|<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi;
112
+
113
+ function htmlTagName(tag: string): string {
114
+ const match = /^<\/?\s*([A-Za-z][A-Za-z0-9:-]*)/.exec(tag);
115
+ return match ? match[1].toLowerCase() : "";
116
+ }
117
+
118
+ function htmlOlStart(tag: string): number {
119
+ const match = /\bstart\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))/i.exec(tag);
120
+ if (!match) return 1;
121
+ return Number(match[1] ?? match[2] ?? match[3]);
122
+ }
123
+
124
+ function appendHtmlLineBreak(output: string, force: boolean = false): string {
125
+ const trimmed = output.replace(/[ \t]+$/u, "");
126
+ return !force && trimmed.endsWith("\n") ? trimmed : `${trimmed}\n`;
127
+ }
128
+
129
+ function htmlListIndent(state: HtmlNormalizationState): string {
130
+ return " ".repeat(Math.max(0, state.lists.length - 1));
131
+ }
132
+
133
+ function appendHtmlListBreak(output: string, state: HtmlNormalizationState): string {
134
+ const indent = htmlListIndent(state);
135
+ return output.endsWith(`${indent}\n`) ? output : appendHtmlLineBreak(output);
136
+ }
137
+
138
+ function markCurrentHtmlItemContent(state: HtmlNormalizationState, text: string): void {
139
+ if (text.trim() !== "" && state.itemHasContent.length > 0) {
140
+ state.itemHasContent[state.itemHasContent.length - 1] = true;
141
+ }
142
+ }
143
+
144
+ function isAtEmptyHtmlListItem(state: HtmlNormalizationState): boolean {
145
+ const itemIndex = state.itemHasContent.length - 1;
146
+ return state.openItems[itemIndex] === true && state.itemHasContent[itemIndex] !== true;
147
+ }
148
+
149
+ function normalizeHtmlForTerminal(
150
+ raw: string,
151
+ state: HtmlNormalizationState = createHtmlNormalizationState(),
152
+ codeHook?: (text: string) => string,
153
+ ): string {
154
+ let output = "";
155
+ let lastIndex = 0;
156
+ let inCode = false;
157
+ const withoutComments = raw.replace(HTML_COMMENT_REGEX, "");
158
+
159
+ for (const match of withoutComments.matchAll(HTML_TAG_REGEX)) {
160
+ const tag = match[0];
161
+ const index = match.index ?? 0;
162
+ const textBeforeTag = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex, index));
163
+ const name = htmlTagName(tag);
164
+ // Most tags handled here are block-level. Inline contexts — span, text, and
165
+ // the content inside a `<code>` run — keep their surrounding whitespace
166
+ // verbatim because it is significant. For block-level tags, HTML formatting
167
+ // whitespace between tags (e.g. the newlines and indentation in
168
+ // pretty-printed `<ul>\n <li>…`) is not rendered content; appending it
169
+ // literally would leak source indentation before bullets and blank rows
170
+ // between items, so a whitespace-only slice is dropped. Text inside a
171
+ // `<code>` run is routed through `codeHook` so the inline-code theme is
172
+ // applied without leaking the raw `<code>`/`</code>` tags.
173
+ const isInlineTag = name === "span" || name === "text";
174
+ if (isInlineTag || inCode || textBeforeTag.trim() !== "") {
175
+ output += inCode && codeHook ? codeHook(textBeforeTag) : textBeforeTag;
176
+ markCurrentHtmlItemContent(state, textBeforeTag);
177
+ }
178
+ lastIndex = index + tag.length;
179
+
180
+ const isClosing = /^<\//.test(tag);
181
+ const isSelfClosing = /\/\s*>$/.test(tag);
182
+
183
+ switch (name) {
184
+ case "span":
185
+ case "text":
186
+ break;
187
+ case "code":
188
+ if (isClosing) inCode = false;
189
+ else if (!isSelfClosing) inCode = true;
190
+ break;
191
+ case "br":
192
+ case "hr":
193
+ output = appendHtmlLineBreak(output, true);
194
+ break;
195
+ case "p":
196
+ case "blockquote":
197
+ if (isClosing) {
198
+ output = appendHtmlLineBreak(output);
199
+ } else if (output.trim() !== "" && !output.endsWith("\n") && !isAtEmptyHtmlListItem(state)) {
200
+ output = appendHtmlLineBreak(output);
201
+ }
202
+ break;
203
+ case "ol":
204
+ if (isClosing) {
205
+ state.lists.pop();
206
+ state.openItems.pop();
207
+ state.itemHasContent.pop();
208
+ } else if (!isSelfClosing) {
209
+ if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) {
210
+ output = appendHtmlListBreak(output, state);
211
+ }
212
+ state.lists.push({ type: "ol", next: htmlOlStart(tag) });
213
+ state.openItems.push(false);
214
+ state.itemHasContent.push(false);
215
+ }
216
+ break;
217
+ case "ul":
218
+ if (isClosing) {
219
+ state.lists.pop();
220
+ state.openItems.pop();
221
+ state.itemHasContent.pop();
222
+ } else if (!isSelfClosing) {
223
+ if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) {
224
+ output = appendHtmlListBreak(output, state);
225
+ }
226
+ state.lists.push({ type: "ul", next: 1 });
227
+ state.openItems.push(false);
228
+ state.itemHasContent.push(false);
229
+ }
230
+ break;
231
+ case "li": {
232
+ if (isClosing) {
233
+ output = appendHtmlLineBreak(output);
234
+ break;
235
+ }
236
+ if (state.openItems.length > 0) {
237
+ const itemOpenIndex = state.openItems.length - 1;
238
+ if (state.openItems[itemOpenIndex]) output = appendHtmlListBreak(output, state);
239
+ state.openItems[itemOpenIndex] = true;
240
+ state.itemHasContent[itemOpenIndex] = false;
241
+ } else if (output.trim() !== "" && !output.endsWith("\n")) {
242
+ output = appendHtmlLineBreak(output);
243
+ }
244
+ const list = state.lists[state.lists.length - 1];
245
+ const indent = htmlListIndent(state);
246
+ if (list?.type === "ol") {
247
+ output += `${indent}${list.next}. `;
248
+ list.next++;
249
+ } else {
250
+ output += `${indent}• `;
251
+ }
252
+ break;
253
+ }
254
+ default:
255
+ output += tag;
256
+ break;
257
+ }
258
+ }
259
+
260
+ const remainingText = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex));
261
+ markCurrentHtmlItemContent(state, remainingText);
262
+ return output + (inCode && codeHook ? codeHook(remainingText) : remainingText);
263
+ }
264
+
265
+ function splitTerminalLines(text: string): string[] {
266
+ const lines = text.split("\n");
267
+ while (lines.length > 1 && lines[lines.length - 1] === "") {
268
+ lines.pop();
269
+ }
270
+ return lines;
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Tree-guide hanging wrap
275
+ //
276
+ // Models routinely emit box-drawing trees ("├── item") inside plain
277
+ // paragraphs — directory layouts, decision trees. The lexer sees those lines
278
+ // as ordinary prose, so the generic wrap pass restarts wrapped continuations
279
+ // at column 0 and visually shears the tree apart (doubly fast for CJK text,
280
+ // where every glyph is two cells wide). Mirror the guide semantics of
281
+ // `tree(1)` / rich.tree instead: wrap the node text within the cells that
282
+ // remain after the guide prefix, and indent every continuation row under the
283
+ // node text — branch glyphs swap to their pass-through form (`├` → `│`,
284
+ // `└` → blank) so the rails of still-open ancestors stay visually joined.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /** Continuation glyph for each guide character a tree prefix may contain. */
288
+ const TREE_GUIDE_CONTINUATION: Record<string, string> = {
289
+ "│": "│",
290
+ "┃": "┃",
291
+ "║": "║",
292
+ "├": "│",
293
+ "┣": "┃",
294
+ "╠": "║",
295
+ "└": " ",
296
+ "┗": " ",
297
+ "╚": " ",
298
+ "╰": " ",
299
+ "─": " ",
300
+ "━": " ",
301
+ "═": " ",
302
+ " ": " ",
303
+ };
304
+
305
+ /** Cheap pre-gate: any guide glyph at all. The structural test is TREE_BRANCH_CONNECTOR_RE. */
306
+ const TREE_GUIDE_ANCHOR_RE = /[│┃║├┣╠└┗╚╰]/;
307
+
308
+ /**
309
+ * A prefix qualifies as tree-shaped only when a branch/corner glyph is
310
+ * immediately followed by a horizontal connector (`├──`, `└─`, `╰──`, …).
311
+ * A lone rail or branch glyph used as prose ("│ is the Unicode vertical box
312
+ * drawing glyph…") never qualifies, so such paragraphs keep the plain wrap.
313
+ */
314
+ const TREE_BRANCH_CONNECTOR_RE = /[├┣╠└┗╚╰][─━═]/;
315
+
316
+ /** Below this many content cells a hanging wrap degenerates; keep the plain wrap. */
317
+ const MIN_TREE_CONTENT_WIDTH = 8;
318
+
319
+ const SGR_SEQUENCE_STICKY = /\x1b\[[0-9;:]*m/y;
320
+ const SGR_SEQUENCE_GLOBAL = /\x1b\[[0-9;:]*m/g;
321
+
322
+ /**
323
+ * Everything before the last full SGR reset is dead state — drop it so the
324
+ * re-played `carry` stays bounded by the paragraph's live style run instead
325
+ * of its whole code history.
326
+ */
327
+ function compactSgrCarry(carry: string): string {
328
+ const shortReset = carry.lastIndexOf("\x1b[m");
329
+ const longReset = carry.lastIndexOf("\x1b[0m");
330
+ const cut = Math.max(shortReset === -1 ? -1 : shortReset + 3, longReset === -1 ? -1 : longReset + 4);
331
+ return cut === -1 ? carry : carry.slice(cut);
332
+ }
333
+
334
+ interface TreeGuidePrefix {
335
+ /** Index of the first char past the guide run (start of the node text). */
336
+ end: number;
337
+ /** SGR sequences interleaved with the guides, in order (zero visible width). */
338
+ codes: string;
339
+ /** Guide characters with SGR stripped, exactly as they appear on screen. */
340
+ guides: string;
341
+ }
342
+
343
+ /**
344
+ * Match the leading box-drawing guide run of a rendered line (e.g. `│ ├── `),
345
+ * tolerating interleaved SGR styling. Returns undefined unless the run
346
+ * contains a branch glyph joined to a horizontal connector and node text
347
+ * follows, so dash art, indented prose, and lone glyphs used as prose are
348
+ * never treated as a tree.
349
+ */
350
+ function matchTreeGuidePrefix(line: string): TreeGuidePrefix | undefined {
351
+ let codes = "";
352
+ let guides = "";
353
+ let i = 0;
354
+ while (i < line.length) {
355
+ if (line.charCodeAt(i) === 0x1b) {
356
+ SGR_SEQUENCE_STICKY.lastIndex = i;
357
+ const match = SGR_SEQUENCE_STICKY.exec(line);
358
+ if (!match) break;
359
+ codes += match[0];
360
+ i = SGR_SEQUENCE_STICKY.lastIndex;
361
+ continue;
362
+ }
363
+ const char = line[i]!;
364
+ if (!(char in TREE_GUIDE_CONTINUATION)) break;
365
+ guides += char;
366
+ i++;
367
+ }
368
+ if (i >= line.length || !TREE_BRANCH_CONNECTOR_RE.test(guides)) return undefined;
369
+ return { end: i, codes, guides };
370
+ }
371
+
372
+ /**
373
+ * Hanging wrap for box-drawing tree lines inside prose block text.
374
+ *
375
+ * Returns undefined when no line needs the treatment, so paragraphs without
376
+ * overflowing tree lines keep their exact current render. When a paragraph
377
+ * does hang, its lines are returned pre-split and style-self-contained: the
378
+ * SGR state open at each line start is re-played onto that line (`carry`),
379
+ * because the caller's wrap pass — which normally carries SGR state across
380
+ * the newlines of a single entry — no longer sees them as one entry.
381
+ */
382
+ function hangWrapTreeGuideLines(text: string, width: number): string[] | undefined {
383
+ if (width < MIN_TREE_CONTENT_WIDTH || !TREE_GUIDE_ANCHOR_RE.test(text)) return undefined;
384
+
385
+ const sourceLines = text.split("\n");
386
+ const hangs = (line: string): TreeGuidePrefix | undefined => {
387
+ if (visibleWidth(line) <= width) return undefined;
388
+ const prefix = matchTreeGuidePrefix(line);
389
+ if (!prefix) return undefined;
390
+ if (width - visibleWidth(prefix.guides) < MIN_TREE_CONTENT_WIDTH) return undefined;
391
+ return prefix;
392
+ };
393
+ if (!sourceLines.some(line => hangs(line) !== undefined)) return undefined;
394
+
395
+ const out: string[] = [];
396
+ let carry = "";
397
+ for (const line of sourceLines) {
398
+ const prefix = hangs(line);
399
+ if (!prefix) {
400
+ out.push(carry ? carry + line : line);
401
+ carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
402
+ continue;
403
+ }
404
+ // Re-play the SGR state ahead of the node text so the wrapper carries
405
+ // it onto every continuation row; the codes are zero-width, so measured
406
+ // row widths are unaffected.
407
+ const activeCodes = carry + prefix.codes;
408
+ const rows = wrapTextWithAnsi(activeCodes + line.slice(prefix.end), width - visibleWidth(prefix.guides));
409
+ let hang = "";
410
+ for (const guide of prefix.guides) hang += TREE_GUIDE_CONTINUATION[guide] ?? " ";
411
+ const hangShortfall = visibleWidth(prefix.guides) - visibleWidth(hang);
412
+ if (hangShortfall > 0) hang += padding(hangShortfall);
413
+ out.push(carry + line.slice(0, prefix.end) + rows[0]!.slice(activeCodes.length));
414
+ for (let i = 1; i < rows.length; i++) {
415
+ out.push(activeCodes + hang + rows[i]!);
416
+ }
417
+ carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
418
+ }
419
+ return out;
420
+ }
421
+
422
+ class StrictStrikethroughTokenizer extends Tokenizer {
423
+ override del(src: string): Tokens.Del | undefined {
424
+ const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
425
+ if (!match) {
426
+ return undefined;
427
+ }
428
+
429
+ const text = match[2];
430
+ return {
431
+ type: "del",
432
+ raw: match[0],
433
+ text,
434
+ tokens: this.lexer.inlineTokens(text),
435
+ };
436
+ }
437
+ }
438
+
439
+ const markdownParser = new Marked();
440
+ markdownParser.setOptions({
441
+ tokenizer: new StrictStrikethroughTokenizer(),
442
+ });
443
+
444
+ // Math spans (`$$…$$`, `\[…\]`, `$…$`, `\(…\)`) are tokenized as a dedicated
445
+ // `math` inline token before markdown's escape/emphasis/link rules run, so
446
+ // backslash commands (`\frac`, `\alpha`) and intraword underscores (`x_i`)
447
+ // survive intact instead of being mangled or split. The `$…$` form uses
448
+ // pandoc's anti-currency heuristic (`inlineMathSpanEnd`) so "$5 and $10" is
449
+ // never math. Inline extensions run before marked's escape tokenizer, so
450
+ // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
451
+ // renders as a literal dollar.
452
+ const CUSTOM_HR_START_REGEX = /(?:^|\n) {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
453
+ const CUSTOM_HR_TOKENIZER_REGEX = /^ {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
454
+
455
+ function getHrChar(char: string, hrChar: string): string {
456
+ const isAscii = hrChar === "-";
457
+ switch (char) {
458
+ case "=":
459
+ return "=";
460
+ case "═":
461
+ return isAscii ? "=" : "═";
462
+ case "━":
463
+ return isAscii ? "-" : "━";
464
+ case "─":
465
+ return isAscii ? "-" : "─";
466
+ case "–":
467
+ return isAscii ? "-" : "–";
468
+ case "—":
469
+ return isAscii ? "-" : "—";
470
+ default:
471
+ return hrChar;
472
+ }
473
+ }
474
+
475
+ const customHrExtension: TokenizerAndRendererExtension = {
476
+ name: "customHr",
477
+ level: "block",
478
+ start(src) {
479
+ const match = CUSTOM_HR_START_REGEX.exec(src);
480
+ if (!match) return undefined;
481
+ let idx = match.index;
482
+ if (src[idx] === "\n") {
483
+ idx += 1;
484
+ }
485
+ return idx;
486
+ },
487
+ tokenizer(src) {
488
+ const match = CUSTOM_HR_TOKENIZER_REGEX.exec(src);
489
+ if (match) {
490
+ return {
491
+ type: "hr",
492
+ raw: match[0],
493
+ };
494
+ }
495
+ return undefined;
496
+ },
497
+ renderer() {
498
+ return "";
499
+ },
500
+ };
501
+
502
+ // Leftmost-match scan replacing /\$|\\\(|\\\[/ in mathExtension.start —
503
+ // marked calls start() on the remaining source at every inline position, so
504
+ // the regex alternation showed up in CPU profiles (part of a ~4.3% start()
505
+ // tail). Three indexOf scans yield the identical leftmost index.
506
+ /** @internal exported for tests — must stay index-identical to the old regex scan. */
507
+ export function mathStartIndex(src: string): number | undefined {
508
+ let best = src.indexOf("$");
509
+ const paren = src.indexOf("\\(");
510
+ if (paren !== -1 && (best === -1 || paren < best)) best = paren;
511
+ const bracket = src.indexOf("\\[");
512
+ if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
513
+ return best === -1 ? undefined : best;
514
+ }
515
+
516
+ const mathExtension: TokenizerAndRendererExtension = {
517
+ name: "math",
518
+ level: "inline",
519
+ start(src) {
520
+ return mathStartIndex(src);
521
+ },
522
+ tokenizer(src) {
523
+ if (src.startsWith("$$")) {
524
+ const end = src.indexOf("$$", 2);
525
+ if (end !== -1 && src.slice(2, end).trim().length > 0) {
526
+ return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
527
+ }
528
+ return undefined;
529
+ }
530
+ if (src.startsWith("\\[")) {
531
+ const end = src.indexOf("\\]", 2);
532
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
533
+ return undefined;
534
+ }
535
+ if (src.startsWith("\\(")) {
536
+ const end = src.indexOf("\\)", 2);
537
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: false };
538
+ return undefined;
539
+ }
540
+ if (src.charCodeAt(0) === 0x24 /* $ */) {
541
+ const end = inlineMathSpanEnd(src, 0);
542
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 1), text: src.slice(1, end), display: false };
543
+ }
544
+ return undefined;
545
+ },
546
+ renderer(token) {
547
+ return (token as { text?: string }).text ?? "";
548
+ },
549
+ };
550
+
551
+ // Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
552
+ // their own line (≤3 leading spaces). Matched at the block level — before
553
+ // paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
554
+ // row breaks) renders across several lines instead of being collapsed onto one,
555
+ // and blank lines inside the block don't split it. The own-line requirement
556
+ // keeps inline `$$…$$` inside prose for the inline tokenizer above.
557
+ const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\n([\s\S]+?)\n {0,3}\$\$[ \t]*(?:\n|$)/;
558
+ const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\n([\s\S]+?)\n {0,3}\\\][ \t]*(?:\n|$)/;
559
+ const MATH_BLOCK_START = /(?:^|\n) {0,3}(?:\$\$|\\\[)[ \t]*\n/;
560
+ const mathBlockExtension: TokenizerAndRendererExtension = {
561
+ name: "mathBlock",
562
+ level: "block",
563
+ start(src) {
564
+ const m = MATH_BLOCK_START.exec(src);
565
+ return m ? m.index : undefined;
566
+ },
567
+ tokenizer(src) {
568
+ const m = MATH_BLOCK_DOLLAR.exec(src) ?? MATH_BLOCK_BRACKET.exec(src);
569
+ if (!m || m[1].trim().length === 0) return undefined;
570
+ return { type: "math", raw: m[0], text: m[1], display: true };
571
+ },
572
+ renderer(token) {
573
+ return (token as { text?: string }).text ?? "";
574
+ },
575
+ };
576
+
577
+ // Bare (delimiter-less) display-math environments: `\begin{<mathenv>}…\end{…}`
578
+ // written without `$$`/`\[` fences (common in raw model output). Captured at the
579
+ // block level as a whole unit — including any immediately preceding `lhs =`
580
+ // line — so marked never splits it on inline `\\` row breaks. Restricted to math
581
+ // environments (isBareMathEnvironment), and the `≤3 leading spaces` + "block
582
+ // starts at offset 0" guards keep fenced/indented `\begin{cases}` code blocks
583
+ // for marked's own code rules.
584
+ const BARE_ENV_BEGIN = /(?:^|\n)[ \t]{0,3}\\begin\{([A-Za-z]+\*?)\}/;
585
+ function bareMathEnvBlock(src: string): readonly [number, number] | null {
586
+ const bm = BARE_ENV_BEGIN.exec(src);
587
+ if (!bm || !isBareMathEnvironment(bm[1])) return null;
588
+ const beginLineStart = bm.index === 0 ? 0 : bm.index + 1; // skip the matched leading `\n`
589
+ const endToken = `\\end{${bm[1]}}`;
590
+ const endAt = src.indexOf(endToken, bm.index);
591
+ if (endAt === -1) return null;
592
+ // The `\end` must close before any blank line (i.e. within the same block).
593
+ if (/\n[ \t]*\n/.test(src.slice(beginLineStart, endAt))) return null;
594
+ let blockEnd = endAt + endToken.length;
595
+ while (src[blockEnd] === " " || src[blockEnd] === "\t") blockEnd++;
596
+ if (src[blockEnd] === "\n") blockEnd++;
597
+ // Pull in one immediately-preceding `lhs =`/open-delimiter line (e.g. `f(x) =`).
598
+ let start = beginLineStart;
599
+ if (start > 0 && src[start - 1] === "\n") {
600
+ const prevStart = src.lastIndexOf("\n", start - 2) + 1;
601
+ const prevLine = src.slice(prevStart, start - 1);
602
+ if (/[=([{]\s*$/.test(prevLine)) start = prevStart;
603
+ }
604
+ return [start, blockEnd];
605
+ }
606
+ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
607
+ name: "mathEnvBlock",
608
+ level: "block",
609
+ start(src) {
610
+ const r = bareMathEnvBlock(src);
611
+ return r ? r[0] : undefined;
612
+ },
613
+ tokenizer(src) {
614
+ const r = bareMathEnvBlock(src);
615
+ if (r?.[0] !== 0) return undefined; // only consume when the block starts at offset 0
616
+ const raw = src.slice(0, r[1]);
617
+ const text = raw.replace(/\n[ \t]*$/, "");
618
+ if (text.trim().length === 0) return undefined;
619
+ return { type: "math", raw, text, display: true };
620
+ },
621
+ renderer(token) {
622
+ return (token as { text?: string }).text ?? "";
623
+ },
624
+ };
625
+
626
+ // GFM's extended autolinks (`www.`, `http://`, `https://`, `ftp://`) may only
627
+ // begin at a valid left boundary: start of line, whitespace, or one of `* _ ~ (`
628
+ // (https://github.github.com/gfm/#autolinks-extension-). marked's bundled `url`
629
+ // tokenizer instead fires after ANY character, so a local path such as
630
+ // `~/meta/www.share/blog/index.dj` is mangled into a `http://www.share/...`
631
+ // link. This inline extension runs before the built-in tokenizer: when an
632
+ // autolink candidate is glued to an invalid preceding character it emits the
633
+ // bare scheme prefix as literal text, so the remainder never reaches the `url`
634
+ // tokenizer at a valid start. Candidates at a legal boundary fall through
635
+ // (return undefined) to marked's own autolink handling unchanged.
636
+ const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
637
+ // Case-insensitive scheme scan replacing /www\.|https?:\/\/|ftp:\/\//i in
638
+ // boundedAutolinkExtension.start — like mathStartIndex above, this runs on the
639
+ // remaining source at every inline position (part of a ~4.3% CPU start() scan
640
+ // tail in profiles). charCode-only: no allocation, no toLowerCase copies.
641
+ // `| 32` lower-cases ASCII letters; `.`/`:`/`/` are compared exactly, matching
642
+ // the regex's ASCII-only `i` semantics. charCodeAt past the end returns NaN,
643
+ // which fails every comparison, so no explicit bounds checks are needed.
644
+ function isAutolinkSchemeAt(src: string, i: number): boolean {
645
+ const c = src.charCodeAt(i) | 32;
646
+ if (c === 119 /* w */) {
647
+ // www.
648
+ return (
649
+ (src.charCodeAt(i + 1) | 32) === 119 &&
650
+ (src.charCodeAt(i + 2) | 32) === 119 &&
651
+ src.charCodeAt(i + 3) === 46 /* . */
652
+ );
653
+ }
654
+ if (c === 104 /* h */) {
655
+ // http:// | https://
656
+ if (
657
+ (src.charCodeAt(i + 1) | 32) !== 116 /* t */ ||
658
+ (src.charCodeAt(i + 2) | 32) !== 116 /* t */ ||
659
+ (src.charCodeAt(i + 3) | 32) !== 112 /* p */
660
+ ) {
661
+ return false;
662
+ }
663
+ let j = i + 4;
664
+ if ((src.charCodeAt(j) | 32) === 115 /* s */) j++;
665
+ return src.charCodeAt(j) === 58 /* : */ && src.charCodeAt(j + 1) === 47 /* / */ && src.charCodeAt(j + 2) === 47;
666
+ }
667
+ if (c === 102 /* f */) {
668
+ // ftp://
669
+ return (
670
+ (src.charCodeAt(i + 1) | 32) === 116 /* t */ &&
671
+ (src.charCodeAt(i + 2) | 32) === 112 /* p */ &&
672
+ src.charCodeAt(i + 3) === 58 /* : */ &&
673
+ src.charCodeAt(i + 4) === 47 /* / */ &&
674
+ src.charCodeAt(i + 5) === 47 /* / */
675
+ );
676
+ }
677
+ return false;
678
+ }
679
+
680
+ /** @internal exported for tests — must stay index-identical to the old regex scan. */
681
+ export function autolinkSchemeScanIndex(src: string): number | undefined {
682
+ for (let i = 0; i < src.length; i++) {
683
+ const c = src.charCodeAt(i) | 32;
684
+ if ((c === 119 || c === 104 || c === 102) && isAutolinkSchemeAt(src, i)) return i;
685
+ }
686
+ return undefined;
687
+ }
688
+ const VALID_AUTOLINK_LEFT_BOUNDARY = /[\s*_~(]/;
689
+ const boundedAutolinkExtension: TokenizerAndRendererExtension = {
690
+ name: "boundedAutolink",
691
+ level: "inline",
692
+ start(src) {
693
+ return autolinkSchemeScanIndex(src);
694
+ },
695
+ tokenizer(src, tokens) {
696
+ const match = AUTOLINK_SCHEME_REGEX.exec(src);
697
+ if (!match) return undefined;
698
+ const prevChar = tokens.at(-1)?.raw?.at(-1);
699
+ // Start of line or a legal delimiter → let marked autolink it.
700
+ if (prevChar === undefined || VALID_AUTOLINK_LEFT_BOUNDARY.test(prevChar)) return undefined;
701
+ // Glued to an invalid character (e.g. `/`, a letter, `.`): consume only
702
+ // the scheme prefix as text so the built-in `url` tokenizer cannot match.
703
+ const raw = match[0];
704
+ return { type: "text", raw, text: raw };
705
+ },
706
+ };
707
+ markdownParser.use({
708
+ extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension, boundedAutolinkExtension],
709
+ });
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // GFM `url` tokenizer gate
713
+ // ---------------------------------------------------------------------------
714
+ // marked tries the bundled GFM `url` tokenizer at every inline tokenization
715
+ // step, and its regex is expensive to FAIL: the email alternative
716
+ // `^[A-Za-z0-9._+-]+(@)…` linearly consumes an identifier run, then backtracks
717
+ // it one character at a time when no `@` follows. A 71414-sample / 1ms CPU
718
+ // profile of the TUI put 73.3% of total CPU (74.9s of a 102s capture) inside
719
+ // this single regex. The override below runs an O(bounded) charCode gate first
720
+ // and only falls through to the built-in tokenizer — by returning `false`,
721
+ // marked's tokenizer-override fallback contract — when a match is possible.
722
+ //
723
+ // Conservativeness argument. The built-in rule (no flags) is
724
+ // /^((?:[hH][tT][tT][pP][sS]?|[fF][tT][pP]):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*
725
+ // |^[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/
726
+ // Both alternatives are anchored, so any match constrains the head of src:
727
+ // • Branch 1 requires src to start with `http://`, `https://`, `ftp://`
728
+ // (scheme letters in any case) or lowercase `www.`. The gate accepts all of
729
+ // these via isAutolinkSchemeAt(src, 0); it also over-accepts `WWW.`, a
730
+ // harmless false positive (the built-in regex simply fails to match).
731
+ // • Branch 2 requires src to start with one-or-more chars from
732
+ // `[A-Za-z0-9._+-]` immediately followed by `@`. The gate scans that exact
733
+ // class: if the run ends within URL_GATE_EMAIL_SCAN_LIMIT chars it accepts
734
+ // iff the terminator is `@`; a run reaching the limit is accepted
735
+ // unconditionally. Every src branch 2 can match is therefore accepted —
736
+ // the gate never rejects a src the built-in regex would match.
737
+ const URL_GATE_EMAIL_SCAN_LIMIT = 320;
738
+
739
+ /** @internal exported for tests — must never return false for a src the built-in url regex matches. */
740
+ export function urlTokenPossible(src: string): boolean {
741
+ if (isAutolinkSchemeAt(src, 0)) return true;
742
+ let i = 0;
743
+ while (i < URL_GATE_EMAIL_SCAN_LIMIT) {
744
+ const c = src.charCodeAt(i);
745
+ const isLocalChar =
746
+ (c >= 97 && c <= 122) /* a-z */ ||
747
+ (c >= 65 && c <= 90) /* A-Z */ ||
748
+ (c >= 48 && c <= 57) /* 0-9 */ ||
749
+ c === 46 /* . */ ||
750
+ c === 95 /* _ */ ||
751
+ c === 43 /* + */ ||
752
+ c === 45; /* - */
753
+ if (!isLocalChar) break;
754
+ i++;
755
+ }
756
+ if (i === 0) return false;
757
+ if (i >= URL_GATE_EMAIL_SCAN_LIMIT) return true; // over-long run: give up conservatively
758
+ return src.charCodeAt(i) === 64 /* @ */;
759
+ }
760
+
761
+ // Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
762
+ // `((?:.|\n(?!<block-start>))+?)` re-runs its block-start lookahead while
763
+ // expanding character by character, so even a FAILING attempt at offset 0
764
+ // costs O(len × lookahead) — ~26µs per 200-char list-item body, and marked's
765
+ // list tokenizer block-tokenizes every item's content (47.8% of a streaming
766
+ // bench profile). A match REQUIRES the setext underline `\n {0,3}(=+|-+)`
767
+ // somewhere in src, so this O(n) charCode scan never rejects a src the
768
+ // built-in rule would match; single-line srcs (every tight list item) reject
769
+ // on the first indexOf.
770
+ function lheadingPossible(src: string): boolean {
771
+ let i = src.indexOf("\n");
772
+ while (i !== -1) {
773
+ let j = i + 1;
774
+ const limit = j + 3; // underline allows up to 3 leading spaces
775
+ while (j < limit && src.charCodeAt(j) === 0x20 /* space */) j++;
776
+ const c = src.charCodeAt(j); // NaN past the end fails both comparisons
777
+ if (c === 0x3d /* = */ || c === 0x2d /* - */) return true;
778
+ i = src.indexOf("\n", j);
779
+ }
780
+ return false;
781
+ }
782
+
783
+ markdownParser.use({
784
+ tokenizer: {
785
+ // `false` → marked falls back to the built-in tokenizer;
786
+ // `undefined` → no token here, built-in never runs.
787
+ url(src: string): Tokens.Link | undefined | false {
788
+ return urlTokenPossible(src) ? false : undefined;
789
+ },
790
+ lheading(src: string): Tokens.Heading | undefined | false {
791
+ return lheadingPossible(src) ? false : undefined;
792
+ },
793
+ },
794
+ });
795
+
796
+ // ---------------------------------------------------------------------------
797
+ // Sticky clones of marked's pathological block rules
798
+ // ---------------------------------------------------------------------------
799
+ // Bun's (JSC) regex engine skips the start-anchor fast-fail for several of
800
+ // marked's `^`-anchored block rules — `hr`, `lheading`, `table` and `html` are
801
+ // anchored alternations of quantified branches, and a failing `exec`/`test`
802
+ // rescans the entire remaining source instead of stopping after offset 0.
803
+ // marked's list tokenizer runs `hr.test` and `lheading` per list line against
804
+ // the remaining source, so lexing a long list is quadratic (66% of a streaming
805
+ // bench profile sat in these two regexes). A sticky (`y`) clone with
806
+ // `lastIndex` pinned to 0 attempts the match at offset 0 only.
807
+ //
808
+ // Equivalence: for a flagless rule whose source is `^`-anchored, a sticky
809
+ // clone at `lastIndex = 0` matches exactly when the original matches (same
810
+ // match object, same captures) — `^` already restricted matches to offset 0
811
+ // (no `m` flag), and stickiness only removes the futile later attempts. The
812
+ // flags/anchor guard below skips any rule a future marked version changes.
813
+ class AnchoredAtZero extends RegExp {
814
+ override exec(str: string): RegExpExecArray | null {
815
+ this.lastIndex = 0; // sticky matches set lastIndex; rules are shared
816
+ return super.exec(str);
817
+ }
818
+ override test(str: string): boolean {
819
+ this.lastIndex = 0;
820
+ return super.test(str);
821
+ }
822
+ }
823
+
824
+ for (const table of [Lexer.rules.block.normal, Lexer.rules.block.gfm]) {
825
+ for (const name of ["hr", "lheading", "table", "html"] as const) {
826
+ const rule = table[name];
827
+ if (rule.flags === "" && rule.source.startsWith("^")) {
828
+ table[name] = new AnchoredAtZero(rule.source, "y");
829
+ }
830
+ }
831
+ }
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // Module-level LRU render cache
835
+ // ---------------------------------------------------------------------------
836
+ // Each session-tree navigation discards and recreates Markdown component
837
+ // instances, so the per-instance #cachedLines field is always cold on first
838
+ // render of a fresh component. This module-level cache survives across
839
+ // component lifetimes and eliminates redundant marked.lexer + highlightCode
840
+ // (Rust FFI) work for content/layout combinations already seen this session.
841
+
842
+ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
843
+ const RENDER_CACHE_MAX_SIZE = 4 * 1024 * 1024;
844
+ const RENDER_CACHE_MAX_ENTRY_SIZE = 256 * 1024;
845
+ const EMPTY_RENDER_LINES: readonly string[] = [];
846
+
847
+ interface RenderedLine {
848
+ text: string;
849
+ literalCode?: true;
850
+ }
851
+
852
+ interface RenderedListItemLine extends RenderedLine {
853
+ nested: boolean;
854
+ }
855
+
856
+ function renderedLine(text: string, literalCode?: boolean): RenderedLine {
857
+ return literalCode ? { text, literalCode: true } : { text };
858
+ }
859
+
860
+ interface RenderCacheEntry {
861
+ lines: readonly string[];
862
+ tables: readonly RenderedTableLayout[];
863
+ }
864
+
865
+ const renderCache = new LRUCache<string, RenderCacheEntry>({
866
+ max: RENDER_CACHE_MAX,
867
+ maxSize: RENDER_CACHE_MAX_SIZE,
868
+ maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
869
+ sizeCalculation: renderCacheEntrySize,
870
+ });
871
+
872
+ function renderedLinesCacheSize(lines: readonly string[]): number {
873
+ let size = lines.length;
874
+ for (let i = 0; i < lines.length; i++) size += lines[i]!.length;
875
+ return Math.max(1, size);
876
+ }
877
+
878
+ function renderCacheEntrySize(entry: RenderCacheEntry): number {
879
+ let size = renderedLinesCacheSize(entry.lines);
880
+ for (const table of entry.tables) size += table.key.length + table.columnWidths.length + 4;
881
+ return size;
882
+ }
883
+
884
+ // A reference-link definition (`[label]: dest`) resolves across the whole
885
+ // document, so a split lex cannot reproduce it — disable the streaming fast path
886
+ // when one is present (rare in streamed output). The label may contain
887
+ // backslash-escaped characters (`[a\]b]: x`), so escapes are matched explicitly;
888
+ // over-matching is safe (it only costs the fast path), under-matching is not.
889
+ const HAS_REF_DEF = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/m;
890
+
891
+ // marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
892
+ // blank lines only when the remaining source matches
893
+ // `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
894
+ // where `marker` is the exact bullet char for unordered lists (`\${char}`) or
895
+ // 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
896
+ // The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
897
+ // sits at the start of a top-level list token's raw:
898
+ const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
899
+
900
+ // Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
901
+ // lex(full text) — for the CURRENT text and for every append-only extension of
902
+ // it, because a frozen prefix is sticky (it keeps being reused while the text
903
+ // grows). At a blank-line (`\n\n`) cut directly after a top-level `list`
904
+ // token, the only construct that can straddle the cut is a continuation item
905
+ // of that list: marked consumed the blank line into the last item's raw and
906
+ // re-ran `listItemRegex` at exactly `tailStart`, merging a same-marker item
907
+ // into one renumbered loose list. The cut is safe only when that regex can
908
+ // NEVER match at `tailStart`, no matter what is appended later.
909
+ //
910
+ // Append-only growth means existing characters are immutable while new ones
911
+ // may appear after them, so "closed" may only be concluded from a present
912
+ // character that contradicts every possible continuation (e.g. tail "1x" can
913
+ // never grow into an ordered item, but tail "1" can become "1. c"). Running
914
+ // out of text mid-marker therefore answers "may continue".
915
+ //
916
+ // Returns true when the tail could still continue the list (or the list's
917
+ // marker is unrecognizable) — the conservative "don't freeze" answer. marked
918
+ // may break the list anyway when the matching line is also an hr (`- - -`);
919
+ // treating that as "may continue" merely skips a freeze, never corrupts one.
920
+ function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
921
+ const marker = LIST_MARKER_RE.exec(listRaw);
922
+ if (marker === null) return true; // unrecognized list shape — stay conservative
923
+ const n = text.length;
924
+ let i = tailStart;
925
+ // `listItemRegex` allows up to 3 leading spaces (the caller's next-char
926
+ // guard rejects whitespace at the final cut, but mirror the rule exactly).
927
+ while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
928
+ if (i >= n) return true;
929
+ const bullet = marker[1];
930
+ if (bullet !== undefined) {
931
+ if (text[i] !== bullet) return false; // wrong marker char — closed forever
932
+ i++;
933
+ } else {
934
+ // Ordered: 1-9 digits, then the same `.`/`)` delimiter.
935
+ let digits = 0;
936
+ while (i < n && digits < 10) {
937
+ const c = text.charCodeAt(i);
938
+ if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
939
+ digits++;
940
+ i++;
941
+ }
942
+ if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
943
+ if (i >= n) return true; // delimiter (or more digits) may still arrive
944
+ if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
945
+ i++;
946
+ }
947
+ // After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
948
+ // bare newline, or end-of-input (which appends can still extend).
949
+ if (i >= n) return true;
950
+ const after = text.charCodeAt(i);
951
+ return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a /* \n */;
952
+ }
953
+
954
+ const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
955
+
956
+ /**
957
+ * Offset just past the last token in `tokens` that closes a block on a hard
958
+ * `"\n\n"` break, together with the number of tokens up to and including it.
959
+ * `count === 0` means the run holds no usable boundary.
960
+ *
961
+ * `base` is where `tokens[0]` starts inside `text`. A boundary qualifies only
962
+ * when splitting there is invisible to the lexer, i.e. `lex(head) ++ lex(tail)
963
+ * === lex(text)`:
964
+ * - The break must sit inside `text`. At end-of-text the next character is
965
+ * unknown (and, while streaming, may still arrive), so the cut is deferred.
966
+ * - The next character must start real block content. Whitespace means the
967
+ * block separator straddles the cut — e.g. a fence followed by
968
+ * `"\n\n\n- list"` — and the two lexes desync.
969
+ * - A preceding `list` must be provably closed: CommonMark lets a same-marker
970
+ * item continue the list across the blank line, and marked merges both into
971
+ * one renumbered loose list (`listMayContinueAt`).
972
+ */
973
+ function stableBlockBoundary(text: string, base: number, tokens: Token[]): { end: number; count: number } {
974
+ let pos = base;
975
+ let end = 0;
976
+ let count = 0;
977
+ for (let i = 0; i < tokens.length; i++) {
978
+ const raw = tokens[i].raw;
979
+ const tokenEnd = pos + raw.length;
980
+ if (raw.endsWith("\n\n")) {
981
+ const prev = i > 0 ? tokens[i - 1] : undefined;
982
+ if (prev === undefined || prev.type !== "list" || !listMayContinueAt(text, tokenEnd, prev.raw)) {
983
+ end = tokenEnd;
984
+ count = i + 1;
985
+ }
986
+ }
987
+ pos = tokenEnd;
988
+ }
989
+ if (count === 0 || end >= text.length) return NO_BLOCK_BOUNDARY;
990
+ const next = text.charCodeAt(end);
991
+ if (next === 0x20 /* space */ || next === 0x0a /* \n */) return NO_BLOCK_BOUNDARY;
992
+ return { end, count };
993
+ }
994
+
995
+ // Bun's regex engine skips the start-anchor optimization for several of marked's
996
+ // block rules — `hr`, `lheading`, `table` and `html` are `^`-anchored
997
+ // alternations of quantified branches — so each failing `exec` rescans the whole
998
+ // remaining source instead of stopping at offset 0. Lexing is then quadratic in
999
+ // document length: an 800 KB message costs ~41 s under Bun where Node/V8 needs
1000
+ // ~60 ms, and it runs on the render path, freezing the UI. Bounded windows keep
1001
+ // every scan short and restore linear behavior (~0.7 s for that same message).
1002
+ const LEX_WINDOW_BYTES = 2 * 1024;
1003
+ // Under this size a single pass beats probing for window boundaries; the
1004
+ // crossover measured on pathological Markdown sits around 16 KB.
1005
+ const WINDOWED_LEX_MIN_BYTES = 16 * 1024;
1006
+
1007
+ /**
1008
+ * Lex `text` in bounded windows, producing the exact token stream
1009
+ * `markdownParser.lexer(text)` would.
1010
+ *
1011
+ * Window cuts come from marked itself: a throwaway BLOCK-ONLY probe lex of the
1012
+ * window reports its last stable block boundary ({@link stableBlockBoundary})
1013
+ * and only that confirmed segment is handed to the real lexer; a window
1014
+ * holding no boundary doubles until it finds one or reaches the end. Probes
1015
+ * never run inline tokenization (their inlineQueue is discarded) — a boundary
1016
+ * is a property of block structure alone, and probe inline passes were the
1017
+ * dominant cost of an earlier revision. Block tokenization runs per window
1018
+ * while inline tokenization is deferred to the end — mirroring `Lexer.lex` —
1019
+ * so a `[label]: dest` definition anywhere in the document still resolves for
1020
+ * every inline span.
1021
+ *
1022
+ * A boundary requires some top-level token whose raw ends in `"\n\n"`, so a
1023
+ * window that contains no blank line cannot cut: each round starts at the next
1024
+ * `"\n\n"` (skipping straight to the end when there is none — e.g. a tail
1025
+ * that is one long tight list) instead of probing sizes that cannot succeed.
1026
+ */
1027
+ function lexWindowed(text: string): Token[] {
1028
+ const lexer = new Lexer(markdownParser.defaults);
1029
+ let offset = 0;
1030
+ while (offset < text.length) {
1031
+ let segment = "";
1032
+ const nextBlank = text.indexOf("\n\n", offset);
1033
+ if (nextBlank === -1) {
1034
+ segment = text.slice(offset);
1035
+ } else {
1036
+ const minSize = Math.max(LEX_WINDOW_BYTES, nextBlank + 2 - offset);
1037
+ for (let size = minSize; segment.length === 0; size *= 2) {
1038
+ if (offset + size >= text.length) {
1039
+ segment = text.slice(offset);
1040
+ break;
1041
+ }
1042
+ const probe = new Lexer(markdownParser.defaults);
1043
+ probe.blockTokens(text.slice(offset, offset + size), probe.tokens);
1044
+ const boundary = stableBlockBoundary(text, offset, probe.tokens);
1045
+ if (boundary.count > 0) segment = text.slice(offset, boundary.end);
1046
+ }
1047
+ }
1048
+ lexer.blockTokens(segment, lexer.tokens);
1049
+ offset += segment.length;
1050
+ }
1051
+ for (const queued of lexer.inlineQueue) lexer.inlineTokens(queued.src, queued.tokens);
1052
+ lexer.inlineQueue = [];
1053
+ return lexer.tokens;
1054
+ }
1055
+
1056
+ /** Lex a whole document, windowing anything large enough for the quadratic scan to bite. */
1057
+ function lexDocument(text: string): Token[] {
1058
+ // A CR shifts every `raw` span (marked normalizes CRLF before tokenizing), so
1059
+ // window offsets would address the wrong characters — lex those in one pass.
1060
+ if (text.length < WINDOWED_LEX_MIN_BYTES || text.includes("\r")) return markdownParser.lexer(text);
1061
+ return lexWindowed(text);
1062
+ }
1063
+
1064
+ /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
1065
+ export function clearRenderCache(): void {
1066
+ renderCache.clear();
1067
+ }
1068
+
1069
+ // Stable numeric IDs for structural theme/style objects (no ID field on type).
1070
+ // WeakMap-keyed so the ID matches strict object identity and doesn't get copied by spread/cloning.
1071
+ const themeObjectIds = new WeakMap<object, number>();
1072
+ let nextObjectId = 0;
1073
+ function objectId(o: object): number {
1074
+ let id = themeObjectIds.get(o);
1075
+ if (id === undefined) {
1076
+ id = nextObjectId++;
1077
+ themeObjectIds.set(o, id);
1078
+ }
1079
+ return id;
1080
+ }
1081
+
1082
+ /**
1083
+ * Default text styling for markdown content.
1084
+ * Applied to all text unless overridden by markdown formatting.
1085
+ */
1086
+ export interface DefaultTextStyle {
1087
+ /** Foreground color function */
1088
+ color?: (text: string) => string;
1089
+ /** Background color function */
1090
+ bgColor?: (text: string) => string;
1091
+ /** Bold text */
1092
+ bold?: boolean;
1093
+ /** Italic text */
1094
+ italic?: boolean;
1095
+ /** Strikethrough text */
1096
+ strikethrough?: boolean;
1097
+ /** Underline text */
1098
+ underline?: boolean;
1099
+ }
1100
+
1101
+ /**
1102
+ * Theme functions for markdown elements.
1103
+ * Each function takes text and returns styled text with ANSI codes.
1104
+ */
1105
+ export interface MarkdownTheme {
1106
+ heading: (text: string) => string;
1107
+ link: (text: string) => string;
1108
+ linkUrl: (text: string) => string;
1109
+ code: (text: string) => string;
1110
+ codeBlock: (text: string) => string;
1111
+ codeBlockBorder: (text: string) => string;
1112
+ quote: (text: string) => string;
1113
+ quoteBorder: (text: string) => string;
1114
+ hr: (text: string) => string;
1115
+ listBullet: (text: string) => string;
1116
+ bold: (text: string) => string;
1117
+ italic: (text: string) => string;
1118
+ strikethrough: (text: string) => string;
1119
+ underline: (text: string) => string;
1120
+ highlightCode?: (code: string, lang?: string) => string[];
1121
+ /**
1122
+ * Resolve a mermaid ASCII rendering by fenced block source text.
1123
+ * Return null to fall back to fenced code rendering.
1124
+ */
1125
+ resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
1126
+ symbols: SymbolTheme;
1127
+ }
1128
+
1129
+ interface InlineStyleContext {
1130
+ applyText: (text: string) => string;
1131
+ stylePrefix: string;
1132
+ }
1133
+
1134
+ type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
1135
+ type TableCellToken = { tokens?: Token[] };
1136
+ type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
1137
+
1138
+ function formatHyperlink(text: string, target: string): string {
1139
+ if (!TERMINAL.hyperlinks || !target) {
1140
+ return text;
1141
+ }
1142
+
1143
+ const safeTarget = target.replaceAll("\x1b", "").replaceAll("\x07", "");
1144
+ if (!safeTarget) {
1145
+ return text;
1146
+ }
1147
+
1148
+ return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
1149
+ }
1150
+
1151
+ function isAsciiTextSizingPayload(text: string): boolean {
1152
+ for (let i = 0; i < text.length; i++) {
1153
+ const code = text.charCodeAt(i);
1154
+ if (code < 0x20 || code > 0x7e) return false;
1155
+ }
1156
+ return true;
1157
+ }
1158
+
1159
+ function encodeTextSizedHeading(text: string, scale: 1 | 2 | 3): string {
1160
+ let out = "";
1161
+ let asciiRun = "";
1162
+ const flushAscii = () => {
1163
+ if (asciiRun === "") return;
1164
+ out += encodeTextSized(asciiRun, { scale });
1165
+ asciiRun = "";
1166
+ };
1167
+
1168
+ for (const { segment } of getSegmenter().segment(text)) {
1169
+ if (isAsciiTextSizingPayload(segment)) {
1170
+ asciiRun += segment;
1171
+ continue;
1172
+ }
1173
+ flushAscii();
1174
+ out += encodeTextSized(segment, { scale, widthCells: visibleWidth(segment) });
1175
+ }
1176
+ flushAscii();
1177
+ return out;
1178
+ }
1179
+
1180
+ const MATH_NEWLINES = /\n+/g;
1181
+
1182
+ /** True for the custom inline `math` token produced by the math extension. */
1183
+ function isMathToken(token: Token): token is Token & { text: string; display: boolean } {
1184
+ return (token as { type: string }).type === "math";
1185
+ }
1186
+
1187
+ /** Convert a `math` token's LaTeX to single-line Unicode for inline rendering. */
1188
+ function renderMathToken(text: string): string {
1189
+ return latexToUnicode(text).replace(MATH_NEWLINES, " ");
1190
+ }
1191
+
1192
+ /**
1193
+ * When a paragraph's only meaningful content is a single display math token
1194
+ * (`$$…$$` / `\[…\]`), return it so the paragraph can be stacked multi-line
1195
+ * instead of flattened inline. Models routinely write display math on one line,
1196
+ * which marked captures as an inline `display:true` math token inside a
1197
+ * paragraph; without this it would flatten through `renderMathToken`.
1198
+ */
1199
+ function soleDisplayMath(tokens?: Token[]): (Token & { text: string }) | null {
1200
+ if (!tokens) return null;
1201
+ let math: (Token & { text: string; display: boolean }) | null = null;
1202
+ for (const token of tokens) {
1203
+ if (isMathToken(token) && token.display) {
1204
+ if (math) return null;
1205
+ math = token;
1206
+ } else if (!(token.type === "text" && typeof token.text === "string" && token.text.trim() === "")) {
1207
+ return null;
1208
+ }
1209
+ }
1210
+ return math;
1211
+ }
1212
+
1213
+ function plainInlineTokens(tokens: Token[]): string {
1214
+ let result = "";
1215
+ for (const token of tokens) {
1216
+ if (isMathToken(token)) {
1217
+ result += renderMathToken(token.text);
1218
+ continue;
1219
+ }
1220
+ switch (token.type) {
1221
+ case "text":
1222
+ result += token.tokens && token.tokens.length > 0 ? plainInlineTokens(token.tokens) : token.text;
1223
+ break;
1224
+ case "strong":
1225
+ case "em":
1226
+ case "del":
1227
+ case "link":
1228
+ result += plainInlineTokens(token.tokens || []);
1229
+ break;
1230
+ case "codespan":
1231
+ result += token.text;
1232
+ break;
1233
+ default:
1234
+ if ("text" in token && typeof token.text === "string") result += token.text;
1235
+ break;
1236
+ }
1237
+ }
1238
+ return result;
1239
+ }
1240
+
1241
+ /**
1242
+ * Classify an inline `html` token by tag name and whether it is a closing tag.
1243
+ * Returns null for non-html tokens or raw that isn't a recognizable HTML tag.
1244
+ */
1245
+ function inlineHtmlTag(token: Token): { name: string; closing: boolean } | null {
1246
+ if ((token as { type: string }).type !== "html") return null;
1247
+ const raw = (token as { raw?: unknown }).raw;
1248
+ if (typeof raw !== "string") return null;
1249
+ const name = htmlTagName(raw);
1250
+ if (!name) return null;
1251
+ return { name, closing: /^<\s*\//.test(raw) };
1252
+ }
1253
+
1254
+ /**
1255
+ * Collapse inline `<code>…</code>` runs — which marked emits as separate `html`
1256
+ * open/close tokens around the literal content — into a single synthetic
1257
+ * `codespan` token, so they render with the theme's inline-code styling instead
1258
+ * of leaking the raw tags. HTML entities inside the run are decoded. Stray or
1259
+ * unmatched code tags are dropped; other inline html tokens pass through for the
1260
+ * `html` render path to normalize. Returns the original array when no `<code>`
1261
+ * tag is present (the common case).
1262
+ */
1263
+ function collapseInlineHtml(tokens: Token[]): Token[] {
1264
+ let hasCode = false;
1265
+ for (const token of tokens) {
1266
+ if (inlineHtmlTag(token)?.name === "code") {
1267
+ hasCode = true;
1268
+ break;
1269
+ }
1270
+ }
1271
+ if (!hasCode) return tokens;
1272
+
1273
+ const out: Token[] = [];
1274
+ for (let i = 0; i < tokens.length; i++) {
1275
+ const tag = inlineHtmlTag(tokens[i]);
1276
+ if (tag?.name === "code") {
1277
+ if (tag.closing) continue; // stray `</code>` — drop it
1278
+ let j = i + 1;
1279
+ for (; j < tokens.length; j++) {
1280
+ const close = inlineHtmlTag(tokens[j]);
1281
+ if (close?.name === "code" && close.closing) break;
1282
+ }
1283
+ if (j >= tokens.length) continue; // unmatched `<code>` — drop it, render the rest normally
1284
+ const text = normalizeHtmlEntitiesForTerminal(plainInlineTokens(tokens.slice(i + 1, j)));
1285
+ out.push({ type: "codespan", raw: text, text } as Token);
1286
+ i = j;
1287
+ continue;
1288
+ }
1289
+ out.push(tokens[i]);
1290
+ }
1291
+ return out;
1292
+ }
1293
+
1294
+ // ---------------------------------------------------------------------------
1295
+ // Inline hex-color swatches
1296
+ // ---------------------------------------------------------------------------
1297
+ // When prose/thinking mentions a CSS hex color (e.g. #C5FFD6 or `#C5FFD6`),
1298
+ // render a small chip painted with that color just before the code. The chip
1299
+ // glyph comes from the theme's symbol set (ASCII → Unicode → Nerd Font), so it
1300
+ // degrades gracefully; the color itself is exact 24-bit on truecolor terminals
1301
+ // and the nearest 256-color cell otherwise (Bun.color quantizes for us).
1302
+
1303
+ /** Fallback chip when the theme supplies no `colorSwatch` symbol (Unicode default). */
1304
+ const DEFAULT_COLOR_SWATCH_GLYPH = "■";
1305
+
1306
+ // `#` + 3-8 hex digits, not glued to a surrounding word/`#`/`&` (avoids HTML
1307
+ // entities like &#9731; and paths like foo#fff), not the start of a canonical
1308
+ // UUID, and not trailed by more hex (so over-long runs never produce a
1309
+ // misleading swatch). Length/letter rules are enforced in classifyHexColor
1310
+ // since the alternation can't express "exactly 3, 6, or 8".
1311
+ const HEX_COLOR_REGEX =
1312
+ /(?<![\w#&])#(?![0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
1313
+ const HEX_COLOR_EXACT_REGEX = /^#([0-9a-fA-F]{3,8})$/;
1314
+
1315
+ /**
1316
+ * Decide whether a run of hex digits denotes a renderable CSS color.
1317
+ *
1318
+ * Only the canonical CSS lengths (#RGB, #RRGGBB, #RRGGBBAA) qualify. The 4-digit
1319
+ * #RGBA form is deliberately excluded: it collides with hashline `#TAG` snapshot
1320
+ * tags (4 hex digits, e.g. #6C5E), which would otherwise sprout spurious swatches.
1321
+ * In `strict` mode (bare prose) a 3-digit run must contain a hex letter, so the
1322
+ * far more common short issue/PR references (#123, #1011) don't sprout swatches.
1323
+ * Codespans opt out of strictness — the backticks already signal "this is a color".
1324
+ */
1325
+ function classifyHexColor(hex: string, strict: boolean): boolean {
1326
+ const n = hex.length;
1327
+ if (n !== 3 && n !== 6 && n !== 8) return false;
1328
+ if (strict && n === 3 && !/[a-fA-F]/.test(hex)) return false;
1329
+ return true;
1330
+ }
1331
+
1332
+ /** ANSI-painted `glyph` for `#${hex}`, or "" when the color can't be encoded. */
1333
+ function colorSwatch(hex: string, glyph: string): string {
1334
+ const ansi = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
1335
+ // Reset only the foreground (\x1b[39m) so an enclosing background/decoration
1336
+ // applied later by the line renderer survives across the swatch.
1337
+ return ansi ? `${ansi}${glyph}\x1b[39m ` : "";
1338
+ }
1339
+
1340
+ /**
1341
+ * Style a plain-text run, inserting a color swatch before each hex color it
1342
+ * mentions. Non-color text (including the matched `#hex` itself) is routed
1343
+ * through `applySegment` so the caller's base styling is preserved verbatim.
1344
+ */
1345
+ function renderTextWithSwatches(text: string, applySegment: (t: string) => string, glyph: string): string {
1346
+ HEX_COLOR_REGEX.lastIndex = 0;
1347
+ let result = "";
1348
+ let last = 0;
1349
+ for (;;) {
1350
+ const match = HEX_COLOR_REGEX.exec(text);
1351
+ if (match === null) break;
1352
+ if (!classifyHexColor(match[1], true)) continue;
1353
+ const swatch = colorSwatch(match[1], glyph);
1354
+ if (!swatch) continue;
1355
+ if (match.index > last) result += applySegment(text.slice(last, match.index));
1356
+ result += swatch + applySegment(match[0]);
1357
+ last = match.index + match[0].length;
1358
+ }
1359
+ if (last === 0) return applySegment(text);
1360
+ if (last < text.length) result += applySegment(text.slice(last));
1361
+ return result;
1362
+ }
1363
+
1364
+ /** Swatch for a codespan whose entire content is a single hex color, else "". */
1365
+ function codespanSwatch(code: string, glyph: string): string {
1366
+ const match = HEX_COLOR_EXACT_REGEX.exec(code.trim());
1367
+ if (!match || !classifyHexColor(match[1], false)) return "";
1368
+ return colorSwatch(match[1], glyph);
1369
+ }
1370
+
1371
+ interface RenderSignature {
1372
+ width: number;
1373
+ paddingX: number;
1374
+ paddingY: number;
1375
+ codeBlockIndent: number;
1376
+ themeId: number;
1377
+ defaultTextStyleId: number;
1378
+ imageProtocol: string;
1379
+ hyperlinks: boolean;
1380
+ textSizing: boolean;
1381
+ bgColorProbe: string;
1382
+ headingProbe: string;
1383
+ }
1384
+
1385
+ interface StreamPrefixLineCache extends RenderSignature {
1386
+ text: string;
1387
+ tokenCount: number;
1388
+ lines: readonly string[];
1389
+ tables: readonly TableRenderSpec[];
1390
+ }
1391
+ interface StreamingDiffLineCache extends RenderSignature {
1392
+ lang: string | undefined;
1393
+ text: string;
1394
+ lines: readonly string[];
1395
+ }
1396
+
1397
+ interface TableLayoutLock {
1398
+ availableWidth: number;
1399
+ columnWidths: readonly number[];
1400
+ }
1401
+
1402
+ interface TableRenderSpec extends TableLayoutLock {
1403
+ key: string;
1404
+ lineCount: number;
1405
+ startRow: number;
1406
+ endRow: number;
1407
+ }
1408
+
1409
+ interface RenderedTableLayout extends TableLayoutLock {
1410
+ key: string;
1411
+ startRow: number;
1412
+ endRow: number;
1413
+ }
1414
+
1415
+ export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
1416
+ #text: string;
1417
+ #paddingX: number; // Left/right padding
1418
+ #paddingY: number; // Top/bottom padding
1419
+ #defaultTextStyle?: DefaultTextStyle;
1420
+ #theme: MarkdownTheme;
1421
+ #defaultStylePrefix?: string;
1422
+ /** Number of spaces used to indent code block content. */
1423
+ #codeBlockIndent: number;
1424
+
1425
+ // Cache for rendered output. Cached arrays are shared and returned by
1426
+ // reference (render contract: results are component-owned and immutable to
1427
+ // callers); the L2 LRU may hand the same array to multiple instances.
1428
+ #cachedText?: string;
1429
+ #cachedWidth?: number;
1430
+ #cachedLines?: readonly string[];
1431
+ #transientRenderCache = false;
1432
+
1433
+ // Streaming-lex cache: the largest blank-line-bounded prefix of #text whose
1434
+ // block tokens are frozen, plus those tokens. marked has no resumable lexer,
1435
+ // but block tokenization is local across a "\n\n" boundary with balanced
1436
+ // fences, so lex(prefix) ++ lex(tail) === lex(prefix+tail). On append-only
1437
+ // growth (the streaming path) this re-lexes only the grown tail instead of the
1438
+ // whole buffer, turning O(N^2) reveal cost into O(N). Width/theme do not affect
1439
+ // tokenization, so this cache is independent of the render caches above.
1440
+ #streamPrefixText?: string;
1441
+ #streamPrefixTokens?: Token[];
1442
+ #streamPrefixLineCache?: StreamPrefixLineCache;
1443
+ // Rows of the most recent render() that are settled — top padding plus the
1444
+ // rendered frozen token prefix — exposed via getLastRenderSettledRows()
1445
+ // for native-scrollback commit gating.
1446
+ #lastRenderSettledRows = 0;
1447
+ // Frozen-prefix text backing the last non-zero settled exposure. Settled
1448
+ // rows are declared final downstream, so a render whose frozen text no
1449
+ // longer extends this prefix (a rewind / wholesale rewrite) resets the
1450
+ // exposure to 0 and re-earns it — the exposure is hard-monotone within a
1451
+ // text lineage.
1452
+ #settledExposedText?: string;
1453
+ // True while #renderStreamingContentLines renders the frozen token range:
1454
+ // frozen code blocks highlight even in transient mode so their bytes match
1455
+ // the finalized render (they render once into the prefix line cache, so
1456
+ // the FFI cost is amortized). The volatile tail normally stays
1457
+ // unhighlighted; streaming diff fences line-highlight completed rows so
1458
+ // semantic colors reach native scrollback before rows leave the viewport.
1459
+ #renderingFrozenPrefix = false;
1460
+ #streamingDiffLineCache?: StreamingDiffLineCache;
1461
+ #activeRenderSignature?: RenderSignature;
1462
+ // Streaming tables may grow naturally while wholly repaintable. Once any
1463
+ // physical row of a table enters native scrollback, its current column widths
1464
+ // are locked for the rest of this append-only text lineage: future wider cells
1465
+ // wrap inside those columns instead of reflowing immutable history above.
1466
+ #tableLayoutWidth?: number;
1467
+ #lockedTableLayouts = new Map<string, TableLayoutLock>();
1468
+ #lastRenderedTableLayouts: RenderedTableLayout[] = [];
1469
+ #activeTableRenderSpecs?: TableRenderSpec[];
1470
+
1471
+ #ignoreTight = false;
1472
+
1473
+ setIgnoreTight(ignore: boolean): this {
1474
+ if (this.#ignoreTight !== ignore) this.#clearTableLayouts();
1475
+ this.#ignoreTight = ignore;
1476
+ this.invalidate();
1477
+ return this;
1478
+ }
1479
+
1480
+ constructor(
1481
+ text: string,
1482
+ paddingX: number,
1483
+ paddingY: number,
1484
+ theme: MarkdownTheme,
1485
+ defaultTextStyle?: DefaultTextStyle,
1486
+ codeBlockIndent: number = 2,
1487
+ ) {
1488
+ this.#text = normalizeOsc8Terminators(text);
1489
+ this.#paddingX = paddingX;
1490
+ this.#paddingY = paddingY;
1491
+ this.#theme = theme;
1492
+ this.#defaultTextStyle = defaultTextStyle;
1493
+ this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
1494
+ }
1495
+
1496
+ setText(text: string): boolean {
1497
+ text = normalizeOsc8Terminators(text);
1498
+ // Equality guard: streaming re-emits identical text on ticks that carried
1499
+ // no delta (throttled provider frames, reconciled tool-execution updates).
1500
+ // Without this, the caller-side `#cachedLines` gets thrown away and the
1501
+ // full lex + wrap runs per re-emit — one of the top CPU hotspots during
1502
+ // streaming (issue #4353). Mirrors `Text.setText`'s guard.
1503
+ if (text === this.#text) return false;
1504
+ if (!text.startsWith(this.#text)) this.#clearTableLayouts();
1505
+ this.#text = text;
1506
+ if (!text.trim()) {
1507
+ // Blank replacement: render() early-returns before #lexTokens can see
1508
+ // the non-append edit, so drop the frozen stream state here or it
1509
+ // outlives the content it indexed.
1510
+ this.#streamPrefixText = undefined;
1511
+ this.#streamPrefixTokens = undefined;
1512
+ this.#streamPrefixLineCache = undefined;
1513
+ this.#settledExposedText = undefined;
1514
+ }
1515
+ this.invalidate();
1516
+ return true;
1517
+ }
1518
+
1519
+ invalidate(): void {
1520
+ this.#cachedText = undefined;
1521
+ this.#cachedWidth = undefined;
1522
+ this.#cachedLines = undefined;
1523
+ }
1524
+ get transientRenderCache(): boolean {
1525
+ return this.#transientRenderCache;
1526
+ }
1527
+
1528
+ set transientRenderCache(value: boolean) {
1529
+ const next = value === true;
1530
+ if (this.#transientRenderCache === next) return;
1531
+ this.#transientRenderCache = next;
1532
+ this.invalidate();
1533
+ }
1534
+
1535
+ /**
1536
+ * Rows at the top of the most recent render() (top padding + rendered
1537
+ * frozen-token prefix) whose bytes are settled: byte-stable at this
1538
+ * width/theme for as long as the text keeps growing append-only. Hosts
1539
+ * feed this to transcript commit gating (see the coding agent's
1540
+ * `FinalizableBlock.getTranscriptBlockSettledRows`). 0 outside streaming
1541
+ * (`transientRenderCache`) mode, after a text rewind (re-earned on the new
1542
+ * lineage), and on cache-served non-streaming renders.
1543
+ */
1544
+ getLastRenderSettledRows(): number {
1545
+ return this.#lastRenderSettledRows;
1546
+ }
1547
+
1548
+ /**
1549
+ * Freeze every table whose first physical row is already part of the native
1550
+ * scrollback prefix. The recorded widths came from the exact frame that was
1551
+ * just emitted, so the next streamed delta cannot retroactively widen it.
1552
+ */
1553
+ setNativeScrollbackCommittedRows(rows: number): void {
1554
+ const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
1555
+ let changed = false;
1556
+ for (const table of this.#lastRenderedTableLayouts) {
1557
+ if (table.startRow >= committed || this.#lockedTableLayouts.has(table.key)) continue;
1558
+ this.#lockedTableLayouts.set(table.key, {
1559
+ availableWidth: table.availableWidth,
1560
+ columnWidths: table.columnWidths.slice(),
1561
+ });
1562
+ changed = true;
1563
+ }
1564
+ if (changed) this.invalidate();
1565
+ }
1566
+
1567
+ /** A destructive replay removes the immutable tape this layout was guarding. */
1568
+ prepareNativeScrollbackReplay(): void {
1569
+ this.#clearTableLayouts();
1570
+ this.#tableLayoutWidth = undefined;
1571
+ this.invalidate();
1572
+ }
1573
+
1574
+ #clearTableLayouts(): void {
1575
+ this.#lockedTableLayouts.clear();
1576
+ this.#lastRenderedTableLayouts = [];
1577
+ this.#activeTableRenderSpecs = undefined;
1578
+ // Same-width replay/non-append rewrites could otherwise reuse physical
1579
+ // prefix lines rendered with the retired locked widths.
1580
+ this.#streamPrefixLineCache = undefined;
1581
+ }
1582
+
1583
+ // Lex `text` into block tokens, reusing the frozen stable prefix when the text
1584
+ // only grew (the streaming path). Falls back to a full lex whenever the prefix
1585
+ // is no longer a prefix (non-append edit), the text carries reference-link
1586
+ // definitions, or it contains CR (marked normalizes CRLF, which would desync
1587
+ // raw-span offsets). Every fallback is correctness-preserving — only speed
1588
+ // differs; the render loop sees the identical token list either way.
1589
+ #lexTokens(text: string): Token[] {
1590
+ const canStream = !HAS_REF_DEF.test(text) && !text.includes("\r");
1591
+ const prefix = this.#streamPrefixText;
1592
+ const prefixTokens = this.#streamPrefixTokens;
1593
+ if (
1594
+ canStream &&
1595
+ prefix !== undefined &&
1596
+ prefixTokens !== undefined &&
1597
+ text.length > prefix.length &&
1598
+ text.startsWith(prefix)
1599
+ ) {
1600
+ const tailTokens = lexDocument(text.slice(prefix.length));
1601
+ const tokens = [...prefixTokens, ...tailTokens];
1602
+ this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
1603
+ return tokens;
1604
+ }
1605
+ const tokens = lexDocument(text);
1606
+ if (canStream) {
1607
+ this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
1608
+ } else {
1609
+ this.#streamPrefixText = undefined;
1610
+ this.#streamPrefixTokens = undefined;
1611
+ this.#streamPrefixLineCache = undefined;
1612
+ }
1613
+ return tokens;
1614
+ }
1615
+
1616
+ // Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
1617
+ // (complete and immutable under append-only growth) so the next streaming
1618
+ // render re-lexes only the unfrozen tail. Caller guarantees no CR / no
1619
+ // reference definitions, so each token's `raw` is a verbatim slice of `text`
1620
+ // and the summed offsets address `text` exactly.
1621
+ #freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
1622
+ const frozen = stableBlockBoundary(text, 0, tokens);
1623
+ if (frozen.count > 0) {
1624
+ this.#streamPrefixText = text.slice(0, frozen.end);
1625
+ this.#streamPrefixTokens = tokens.slice(0, frozen.count);
1626
+ return;
1627
+ }
1628
+
1629
+ if (!opts.preserveExisting) {
1630
+ this.#streamPrefixText = undefined;
1631
+ this.#streamPrefixTokens = undefined;
1632
+ this.#streamPrefixLineCache = undefined;
1633
+ }
1634
+ }
1635
+
1636
+ render(width: number): readonly string[] {
1637
+ if (this.#tableLayoutWidth !== undefined && this.#tableLayoutWidth !== width) {
1638
+ this.#clearTableLayouts();
1639
+ this.invalidate();
1640
+ }
1641
+ this.#tableLayoutWidth = width;
1642
+ // L1: per-instance cache — fastest path for repeated renders of the same
1643
+ // instance at the same width (e.g. resize debounce, repeated redraws).
1644
+ // Returning the cached reference is load-bearing: parents memoize their
1645
+ // concatenation on reference equality.
1646
+ if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
1647
+ return this.#cachedLines;
1648
+ }
1649
+
1650
+ // Recomputed below by the streaming path; every other path (cache-served,
1651
+ // empty text, non-streaming full render) exposes no settled rows.
1652
+ this.#lastRenderSettledRows = 0;
1653
+
1654
+ // Calculate available width for content (subtract horizontal padding)
1655
+ const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
1656
+ const contentWidth = Math.max(1, width - paddingX * 2);
1657
+
1658
+ // Don't render anything if there's no actual text
1659
+ if (!this.#text || this.#text.trim() === "") {
1660
+ this.#cachedText = this.#text;
1661
+ this.#cachedWidth = width;
1662
+ this.#cachedLines = EMPTY_RENDER_LINES;
1663
+ return EMPTY_RENDER_LINES;
1664
+ }
1665
+
1666
+ // Replace tabs with 3 spaces for consistent rendering
1667
+ const normalizedText = replaceTabs(this.#text);
1668
+ const signature = this.#renderSignature(width, paddingX);
1669
+
1670
+ // L2: module-level LRU — survives component disposal/recreation across
1671
+ // session-tree navigations. Key encodes every dimension that affects the
1672
+ // render output so different configurations never collide.
1673
+ // Encode terminal capability state and theme/style function output samples
1674
+ // so that capability shifts (image protocol changes, hyperlink toggle) or
1675
+ // caller-supplied theme/bgColor functions that mutate their output without
1676
+ // changing object identity invalidate the cache entry.
1677
+ // bgColor probe uses \x01 (single non-printable byte): chalk/ANSI wrappers
1678
+ // pass arbitrary bytes through verbatim, so this is safe and minimizes the
1679
+ // risk of clashing with a function that returns text verbatim.
1680
+ // theme.heading is used as the representative theme probe — it's required
1681
+ // by MarkdownTheme and is one of the most styling-sensitive entries.
1682
+ let cacheKey: string | undefined;
1683
+ if (!this.transientRenderCache && this.#lockedTableLayouts.size === 0) {
1684
+ cacheKey = this.#renderCacheKey(normalizedText, signature);
1685
+ const cached = renderCache.get(cacheKey);
1686
+ if (cached !== undefined) {
1687
+ // Restore both the rendered rows and the geometry metadata that produced
1688
+ // them. A later scrollback publication must never lock widths from an
1689
+ // older transient frame against rows served from this cache entry.
1690
+ this.#lastRenderedTableLayouts = cached.tables.map(table => ({
1691
+ ...table,
1692
+ columnWidths: table.columnWidths.slice(),
1693
+ }));
1694
+ // Populate L1 so subsequent calls from this instance are O(1) map lookup.
1695
+ this.#cachedText = this.#text;
1696
+ this.#cachedWidth = width;
1697
+ this.#cachedLines = cached.lines;
1698
+ return cached.lines;
1699
+ }
1700
+ }
1701
+
1702
+ // Parse markdown to HTML-like tokens
1703
+ const tokens = this.#lexTokens(normalizedText);
1704
+ let contentLines: string[];
1705
+ const tableRenderSpecs: TableRenderSpec[] = [];
1706
+ this.#activeTableRenderSpecs = tableRenderSpecs;
1707
+ this.#activeRenderSignature = signature;
1708
+ try {
1709
+ contentLines = this.transientRenderCache
1710
+ ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1711
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1712
+ } finally {
1713
+ this.#activeRenderSignature = undefined;
1714
+ this.#activeTableRenderSpecs = undefined;
1715
+ }
1716
+ this.#lastRenderedTableLayouts = this.#resolveRenderedTableLayouts(tableRenderSpecs, signature.paddingY);
1717
+ const emptyLines = this.#renderEmptyPaddingLines(signature);
1718
+
1719
+ // Combine top padding, content, and bottom padding
1720
+ const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
1721
+ const result = rawResult.length > 0 ? rawResult : [""];
1722
+
1723
+ // Update caches and hand the array out by reference. Callers must not
1724
+ // mutate it (Component render contract); the L2 entry is shared across
1725
+ // instances keyed on identical inputs.
1726
+ this.#cachedText = this.#text;
1727
+ this.#cachedWidth = width;
1728
+ this.#cachedLines = result;
1729
+
1730
+ // Update L2 module-level LRU so future instances with the same key skip
1731
+ // the marked.lexer + highlightCode (Rust FFI) work entirely.
1732
+ if (cacheKey !== undefined) {
1733
+ renderCache.set(cacheKey, {
1734
+ lines: result,
1735
+ tables: this.#lastRenderedTableLayouts.map(table => ({
1736
+ ...table,
1737
+ columnWidths: table.columnWidths.slice(),
1738
+ })),
1739
+ });
1740
+ }
1741
+
1742
+ return result;
1743
+ }
1744
+
1745
+ #renderSignature(width: number, paddingX: number): RenderSignature {
1746
+ const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
1747
+ const headingProbe = this.#theme.heading("");
1748
+ return {
1749
+ width,
1750
+ paddingX,
1751
+ paddingY: this.#paddingY,
1752
+ codeBlockIndent: this.#codeBlockIndent,
1753
+ themeId: objectId(this.#theme),
1754
+ defaultTextStyleId: this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1,
1755
+ imageProtocol: TERMINAL.imageProtocol ?? "",
1756
+ hyperlinks: TERMINAL.hyperlinks,
1757
+ textSizing: TERMINAL.textSizing,
1758
+ bgColorProbe,
1759
+ headingProbe,
1760
+ };
1761
+ }
1762
+
1763
+ #renderCacheKey(normalizedText: string, signature: RenderSignature): string {
1764
+ 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}`;
1765
+ }
1766
+
1767
+ #renderStreamingContentLines(
1768
+ tokens: Token[],
1769
+ normalizedText: string,
1770
+ signature: RenderSignature,
1771
+ contentWidth: number,
1772
+ ): string[] {
1773
+ const frozenText = this.#streamPrefixText;
1774
+ const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
1775
+ if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
1776
+ return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1777
+ }
1778
+
1779
+ const contentLines: string[] = [];
1780
+ const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
1781
+ let renderedUntil = 0;
1782
+ let renderedSourceOffset = 0;
1783
+ if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
1784
+ contentLines.push(...reusablePrefix.lines);
1785
+ this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
1786
+ renderedUntil = reusablePrefix.tokenCount;
1787
+ renderedSourceOffset = reusablePrefix.text.length;
1788
+ }
1789
+
1790
+ if (renderedUntil < frozenTokenCount) {
1791
+ // Frozen tokens render with full fidelity (syntax highlighting on)
1792
+ // so these cached rows byte-match the finalized render.
1793
+ this.#renderingFrozenPrefix = true;
1794
+ try {
1795
+ contentLines.push(
1796
+ ...this.#renderContentLines(
1797
+ tokens,
1798
+ renderedUntil,
1799
+ frozenTokenCount,
1800
+ contentWidth,
1801
+ signature,
1802
+ contentLines.length,
1803
+ renderedSourceOffset,
1804
+ ),
1805
+ );
1806
+ } finally {
1807
+ this.#renderingFrozenPrefix = false;
1808
+ }
1809
+ renderedUntil = frozenTokenCount;
1810
+ }
1811
+
1812
+ this.#streamPrefixLineCache = {
1813
+ ...signature,
1814
+ text: frozenText,
1815
+ tokenCount: frozenTokenCount,
1816
+ lines: contentLines.slice(),
1817
+ tables: this.#activeTableRenderSpecs?.slice() ?? [],
1818
+ };
1819
+
1820
+ // Settled exposure (hard-monotone): these rows are declared final to
1821
+ // the host, so expose them only while the frozen text still extends
1822
+ // the previously exposed prefix; a rewind resets to 0 and re-earns on
1823
+ // the rewritten lineage.
1824
+ if (contentLines.length > 0) {
1825
+ if (this.#settledExposedText === undefined || frozenText.startsWith(this.#settledExposedText)) {
1826
+ this.#settledExposedText = frozenText;
1827
+ this.#lastRenderSettledRows = signature.paddingY + contentLines.length;
1828
+ } else {
1829
+ this.#settledExposedText = undefined;
1830
+ }
1831
+ }
1832
+
1833
+ if (renderedUntil < tokens.length) {
1834
+ contentLines.push(
1835
+ ...this.#renderContentLines(
1836
+ tokens,
1837
+ renderedUntil,
1838
+ tokens.length,
1839
+ contentWidth,
1840
+ signature,
1841
+ contentLines.length,
1842
+ frozenText.length,
1843
+ ),
1844
+ );
1845
+ }
1846
+
1847
+ return contentLines;
1848
+ }
1849
+
1850
+ #matchingStreamPrefixLineCache(
1851
+ normalizedText: string,
1852
+ frozenText: string,
1853
+ signature: RenderSignature,
1854
+ ): StreamPrefixLineCache | undefined {
1855
+ const cache = this.#streamPrefixLineCache;
1856
+ if (!cache) return undefined;
1857
+ if (!normalizedText.startsWith(cache.text) || !frozenText.startsWith(cache.text)) return undefined;
1858
+ if (cache.width !== signature.width) return undefined;
1859
+ if (cache.paddingX !== signature.paddingX) return undefined;
1860
+ if (cache.paddingY !== signature.paddingY) return undefined;
1861
+ if (cache.codeBlockIndent !== signature.codeBlockIndent) return undefined;
1862
+ if (cache.themeId !== signature.themeId) return undefined;
1863
+ if (cache.defaultTextStyleId !== signature.defaultTextStyleId) return undefined;
1864
+ if (cache.imageProtocol !== signature.imageProtocol) return undefined;
1865
+ if (cache.hyperlinks !== signature.hyperlinks) return undefined;
1866
+ if (cache.textSizing !== signature.textSizing) return undefined;
1867
+ if (cache.bgColorProbe !== signature.bgColorProbe) return undefined;
1868
+ if (cache.headingProbe !== signature.headingProbe) return undefined;
1869
+ return cache;
1870
+ }
1871
+
1872
+ #renderContentLines(
1873
+ tokens: Token[],
1874
+ start: number,
1875
+ end: number,
1876
+ contentWidth: number,
1877
+ signature: RenderSignature,
1878
+ rowOffset: number,
1879
+ startingSourceOffset: number,
1880
+ ): string[] {
1881
+ const wrappedLines: RenderedLine[] = [];
1882
+ let sourceOffset = startingSourceOffset;
1883
+ for (let i = start; i < end; i++) {
1884
+ const token = tokens[i];
1885
+ const nextToken = tokens[i + 1];
1886
+ const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
1887
+ const tokenWrappedRowStart = wrappedLines.length;
1888
+ const tokenRowStart = rowOffset + tokenWrappedRowStart;
1889
+ const renderedTokenLines = this.#renderToken(
1890
+ token,
1891
+ contentWidth,
1892
+ nextToken?.type,
1893
+ undefined,
1894
+ `offset:${sourceOffset}`,
1895
+ );
1896
+ const tokenLineOffsets = [0];
1897
+ for (const renderedRow of renderedTokenLines) {
1898
+ // Lists wrap while their structural prefixes are still available, so
1899
+ // continuation rows retain the correct hanging indent. Re-wrapping the
1900
+ // flattened rows here would discard that structure.
1901
+ if (token.type === "list" || TERMINAL.isImageLine(renderedRow.text) || isOsc66Line(renderedRow.text)) {
1902
+ wrappedLines.push(renderedRow);
1903
+ } else {
1904
+ const wrappedRows = wrapTextWithAnsi(renderedRow.text, contentWidth);
1905
+ if (wrappedRows.length === 1 && wrappedRows[0] === renderedRow.text) {
1906
+ wrappedLines.push(renderedRow);
1907
+ } else {
1908
+ for (const wrappedLine of wrappedRows) {
1909
+ wrappedLines.push(renderedLine(wrappedLine, renderedRow.literalCode));
1910
+ }
1911
+ }
1912
+ }
1913
+ tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
1914
+ }
1915
+ const tableSpecs = this.#activeTableRenderSpecs;
1916
+ if (tableSpecs !== undefined) {
1917
+ for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
1918
+ const spec = tableSpecs[specIndex]!;
1919
+ let relativeStart: number;
1920
+ let relativeEnd: number;
1921
+ if (token.type === "table") {
1922
+ // Exclude the optional inter-block blank from a top-level table's span.
1923
+ relativeStart = 0;
1924
+ relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
1925
+ } else {
1926
+ // Container renderers express nested table spans relative to their
1927
+ // returned lines. Preserve that exact span through this final wrap.
1928
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1929
+ relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
1930
+ relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
1931
+ }
1932
+ spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
1933
+ spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
1934
+ }
1935
+ }
1936
+ sourceOffset += token.raw.length;
1937
+ }
1938
+
1939
+ const leftMargin = padding(signature.paddingX);
1940
+ const rightMargin = padding(signature.paddingX);
1941
+ const bgFn = this.#defaultTextStyle?.bgColor;
1942
+ const contentLines: string[] = [];
1943
+ let previousLineWasOsc66 = false;
1944
+ for (const renderedLine of wrappedLines) {
1945
+ const literalCodeRow = renderedLine.literalCode === true;
1946
+ const line = renderedLine.text;
1947
+ // The first empty row after a scale>1 OSC 66 heading is structural:
1948
+ // it reserves the lower cells occupied by the multicell glyphs. Do
1949
+ // not pad or background-fill it, because real spaces on that row can
1950
+ // interact with Kitty's multicell overwrite rules during the first
1951
+ // paint. Leave it as a cursor-only newline.
1952
+ if (previousLineWasOsc66 && line === "") {
1953
+ contentLines.push("");
1954
+ previousLineWasOsc66 = false;
1955
+ continue;
1956
+ }
1957
+
1958
+ // Image lines and OSC 66 sized headings must be output raw - no margins or background
1959
+ if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
1960
+ contentLines.push(line);
1961
+ previousLineWasOsc66 = isOsc66Line(line);
1962
+ continue;
1963
+ }
1964
+
1965
+ previousLineWasOsc66 = false;
1966
+ if (literalCodeRow) {
1967
+ contentLines.push(line);
1968
+ continue;
1969
+ }
1970
+ const lineWithMargins = leftMargin + line + rightMargin;
1971
+
1972
+ if (bgFn) {
1973
+ contentLines.push(applyBackgroundToLine(lineWithMargins, signature.width, bgFn));
1974
+ } else {
1975
+ // No background - just pad to width
1976
+ const visibleLen = visibleWidth(lineWithMargins);
1977
+ const paddingNeeded = Math.max(0, signature.width - visibleLen);
1978
+ contentLines.push(lineWithMargins + padding(paddingNeeded));
1979
+ }
1980
+ }
1981
+
1982
+ return contentLines;
1983
+ }
1984
+
1985
+ #resolveRenderedTableLayouts(specs: readonly TableRenderSpec[], topPadding: number): RenderedTableLayout[] {
1986
+ const layouts: RenderedTableLayout[] = [];
1987
+ for (const spec of specs) {
1988
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1989
+ layouts.push({
1990
+ key: spec.key,
1991
+ availableWidth: spec.availableWidth,
1992
+ columnWidths: spec.columnWidths.slice(),
1993
+ startRow: topPadding + spec.startRow,
1994
+ endRow: topPadding + spec.endRow,
1995
+ });
1996
+ }
1997
+ return layouts;
1998
+ }
1999
+
2000
+ #renderCodeBodyLines(token: Token, codeIndent: string): RenderedLine[] {
2001
+ const literalCode = this.#codeBlockIndent === 0;
2002
+ const bodyLines: RenderedLine[] = [];
2003
+ const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
2004
+ const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined;
2005
+ const normalizedLang = lang?.toLowerCase();
2006
+ const canStreamDiff =
2007
+ this.transientRenderCache &&
2008
+ !this.#renderingFrozenPrefix &&
2009
+ this.#theme.highlightCode &&
2010
+ (normalizedLang === "diff" || normalizedLang === "patch" || normalizedLang === "udiff");
2011
+ const addBodyLine = (line: string): void => {
2012
+ bodyLines.push(renderedLine(literalCode ? line : codeIndent + line, literalCode));
2013
+ };
2014
+
2015
+ if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
2016
+ const highlightedLines = this.#theme.highlightCode(tokenText, lang);
2017
+ for (const hlLine of highlightedLines) {
2018
+ addBodyLine(hlLine);
2019
+ }
2020
+ return bodyLines;
2021
+ }
2022
+
2023
+ if (canStreamDiff) {
2024
+ const closedFence = this.#codeTokenHasClosingFence(token);
2025
+ const lineEnd = tokenText.lastIndexOf("\n");
2026
+ if (closedFence || lineEnd >= 0) {
2027
+ const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd);
2028
+ for (const hlLine of this.#highlightStreamingDiffLines(completedText, lang)) {
2029
+ addBodyLine(hlLine);
2030
+ }
2031
+ if (!closedFence) {
2032
+ for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
2033
+ addBodyLine(this.#theme.codeBlock(codeLine));
2034
+ }
2035
+ }
2036
+ return bodyLines;
2037
+ }
2038
+ }
2039
+
2040
+ for (const codeLine of tokenText.split("\n")) {
2041
+ addBodyLine(this.#theme.codeBlock(codeLine));
2042
+ }
2043
+ return bodyLines;
2044
+ }
2045
+
2046
+ #codeTokenHasClosingFence(token: Token): boolean {
2047
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
2048
+ const firstLineEnd = raw.indexOf("\n");
2049
+ if (firstLineEnd < 0) return false;
2050
+ const openingLine = raw.slice(0, firstLineEnd);
2051
+ const openingTrimmed = openingLine.trimStart();
2052
+ const openingIndent = openingLine.length - openingTrimmed.length;
2053
+ if (openingIndent > 3) return false;
2054
+ const fenceChar = openingTrimmed.charAt(0);
2055
+ if (fenceChar !== "`" && fenceChar !== "~") return false;
2056
+ let fenceLength = 0;
2057
+ while (openingTrimmed.charAt(fenceLength) === fenceChar) fenceLength++;
2058
+ if (fenceLength < 3) return false;
2059
+
2060
+ let lineStart = firstLineEnd + 1;
2061
+ while (lineStart <= raw.length) {
2062
+ const lineEnd = raw.indexOf("\n", lineStart);
2063
+ const line = lineEnd >= 0 ? raw.slice(lineStart, lineEnd) : raw.slice(lineStart);
2064
+ const trimmed = line.trimStart();
2065
+ const indent = line.length - trimmed.length;
2066
+ let closingLength = 0;
2067
+ while (trimmed.charAt(closingLength) === fenceChar) closingLength++;
2068
+ if (indent <= 3 && closingLength >= fenceLength && trimmed.slice(closingLength).trim().length === 0) {
2069
+ return true;
2070
+ }
2071
+ if (lineEnd < 0) break;
2072
+ lineStart = lineEnd + 1;
2073
+ }
2074
+ return false;
2075
+ }
2076
+
2077
+ #highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] {
2078
+ const highlightCode = this.#theme.highlightCode;
2079
+ if (!highlightCode) return [];
2080
+ const signature = this.#activeRenderSignature;
2081
+ const cache = this.#streamingDiffLineCache;
2082
+ if (
2083
+ signature &&
2084
+ cache &&
2085
+ completedText.startsWith(cache.text) &&
2086
+ (cache.text.length === completedText.length || completedText.charCodeAt(cache.text.length) === 0x0a) &&
2087
+ cache.lang === lang &&
2088
+ cache.width === signature.width &&
2089
+ cache.paddingX === signature.paddingX &&
2090
+ cache.paddingY === signature.paddingY &&
2091
+ cache.codeBlockIndent === signature.codeBlockIndent &&
2092
+ cache.themeId === signature.themeId &&
2093
+ cache.defaultTextStyleId === signature.defaultTextStyleId &&
2094
+ cache.imageProtocol === signature.imageProtocol &&
2095
+ cache.hyperlinks === signature.hyperlinks &&
2096
+ cache.textSizing === signature.textSizing &&
2097
+ cache.bgColorProbe === signature.bgColorProbe &&
2098
+ cache.headingProbe === signature.headingProbe
2099
+ ) {
2100
+ if (completedText.length === cache.text.length) return cache.lines;
2101
+ const lines = cache.lines.slice();
2102
+ const addedText = completedText.slice(cache.text.length + 1);
2103
+ for (const codeLine of addedText.split("\n")) {
2104
+ lines.push(...highlightCode(codeLine, lang));
2105
+ }
2106
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
2107
+ return lines;
2108
+ }
2109
+
2110
+ const lines: string[] = [];
2111
+ for (const codeLine of completedText.split("\n")) {
2112
+ lines.push(...highlightCode(codeLine, lang));
2113
+ }
2114
+ if (signature) {
2115
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
2116
+ }
2117
+ return lines;
2118
+ }
2119
+
2120
+ #renderEmptyPaddingLines(signature: RenderSignature): string[] {
2121
+ const emptyLine = padding(signature.width);
2122
+ const emptyLines: string[] = [];
2123
+ const bgFn = this.#defaultTextStyle?.bgColor;
2124
+ for (let i = 0; i < signature.paddingY; i++) {
2125
+ const line = bgFn ? applyBackgroundToLine(emptyLine, signature.width, bgFn) : emptyLine;
2126
+ emptyLines.push(line);
2127
+ }
2128
+ return emptyLines;
2129
+ }
2130
+
2131
+ /**
2132
+ * Apply default text style to a string.
2133
+ * This is the base styling applied to all text content.
2134
+ * NOTE: Background color is NOT applied here - it's applied at the padding stage
2135
+ * to ensure it extends to the full line width.
2136
+ */
2137
+ #applyDefaultStyle(text: string): string {
2138
+ if (!this.#defaultTextStyle) {
2139
+ return text;
2140
+ }
2141
+
2142
+ let styled = text;
2143
+
2144
+ // Apply foreground color (NOT background - that's applied at padding stage)
2145
+ if (this.#defaultTextStyle.color) {
2146
+ styled = this.#defaultTextStyle.color(styled);
2147
+ }
2148
+
2149
+ // Apply text decorations using this.#theme
2150
+ if (this.#defaultTextStyle.bold) {
2151
+ styled = this.#theme.bold(styled);
2152
+ }
2153
+ if (this.#defaultTextStyle.italic) {
2154
+ styled = this.#theme.italic(styled);
2155
+ }
2156
+ if (this.#defaultTextStyle.strikethrough) {
2157
+ styled = this.#theme.strikethrough(styled);
2158
+ }
2159
+ if (this.#defaultTextStyle.underline) {
2160
+ styled = this.#theme.underline(styled);
2161
+ }
2162
+
2163
+ return styled;
2164
+ }
2165
+
2166
+ #getDefaultStylePrefix(): string {
2167
+ if (!this.#defaultTextStyle) {
2168
+ return "";
2169
+ }
2170
+
2171
+ if (this.#defaultStylePrefix !== undefined) {
2172
+ return this.#defaultStylePrefix;
2173
+ }
2174
+
2175
+ const sentinel = "\u0000";
2176
+ let styled = sentinel;
2177
+
2178
+ if (this.#defaultTextStyle.color) {
2179
+ styled = this.#defaultTextStyle.color(styled);
2180
+ }
2181
+
2182
+ if (this.#defaultTextStyle.bold) {
2183
+ styled = this.#theme.bold(styled);
2184
+ }
2185
+ if (this.#defaultTextStyle.italic) {
2186
+ styled = this.#theme.italic(styled);
2187
+ }
2188
+ if (this.#defaultTextStyle.strikethrough) {
2189
+ styled = this.#theme.strikethrough(styled);
2190
+ }
2191
+ if (this.#defaultTextStyle.underline) {
2192
+ styled = this.#theme.underline(styled);
2193
+ }
2194
+
2195
+ const sentinelIndex = styled.indexOf(sentinel);
2196
+ this.#defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
2197
+ return this.#defaultStylePrefix;
2198
+ }
2199
+
2200
+ #getStylePrefix(styleFn: (text: string) => string): string {
2201
+ const sentinel = "\u0000";
2202
+ const styled = styleFn(sentinel);
2203
+ const sentinelIndex = styled.indexOf(sentinel);
2204
+ return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
2205
+ }
2206
+
2207
+ #getDefaultInlineStyleContext(): InlineStyleContext {
2208
+ return {
2209
+ applyText: (text: string) => this.#applyDefaultStyle(text),
2210
+ stylePrefix: this.#getDefaultStylePrefix(),
2211
+ };
2212
+ }
2213
+
2214
+ #renderToken(
2215
+ token: Token,
2216
+ width: number,
2217
+ nextTokenType?: string,
2218
+ styleContext?: InlineStyleContext,
2219
+ tokenKey = "root",
2220
+ ): RenderedLine[] {
2221
+ const lines: RenderedLine[] = [];
2222
+
2223
+ // Display math block (own-line `$$…$$` / `\[…\]`): stack `\frac` vertically
2224
+ // and keep `\\` row breaks, so fractions and matrices span multiple lines.
2225
+ if (isMathToken(token)) {
2226
+ for (const mathLine of latexToBlock(token.text)) lines.push(renderedLine(this.#applyDefaultStyle(mathLine)));
2227
+ if (nextTokenType && nextTokenType !== "space") lines.push(renderedLine(""));
2228
+ return lines;
2229
+ }
2230
+
2231
+ switch (token.type) {
2232
+ case "heading": {
2233
+ const headingLevel = token.depth;
2234
+ const headingPrefix = `${"#".repeat(headingLevel)} `;
2235
+ const headingText = this.#renderInlineTokens(token.tokens || [], styleContext);
2236
+ const headingPlainText = plainInlineTokens(token.tokens || []);
2237
+ let styledHeading: string;
2238
+ if (headingLevel === 1 && TERMINAL.textSizing) {
2239
+ const plainWidth = visibleWidth(headingPlainText);
2240
+ if (plainWidth > 0 && 2 * plainWidth <= width) {
2241
+ const sizedHeading = encodeTextSizedHeading(headingPlainText, 2);
2242
+ lines.push(renderedLine(this.#theme.heading(this.#theme.bold(this.#theme.underline(sizedHeading)))));
2243
+ lines.push(renderedLine("")); // reserve the heading's second visual row
2244
+ if (nextTokenType && nextTokenType !== "space") {
2245
+ lines.push(renderedLine("")); // Add spacing after headings (unless space token follows)
2246
+ }
2247
+ break;
2248
+ }
2249
+ }
2250
+ if (headingLevel === 1) {
2251
+ styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText)));
2252
+ } else if (headingLevel === 2) {
2253
+ styledHeading = this.#theme.heading(this.#theme.bold(headingText));
2254
+ } else {
2255
+ styledHeading = this.#theme.heading(this.#theme.bold(headingPrefix + headingText));
2256
+ }
2257
+ lines.push(renderedLine(styledHeading));
2258
+ if (nextTokenType && nextTokenType !== "space") {
2259
+ lines.push(renderedLine("")); // Add spacing after headings (unless space token follows)
2260
+ }
2261
+ break;
2262
+ }
2263
+
2264
+ case "paragraph": {
2265
+ const displayMath = soleDisplayMath(token.tokens);
2266
+ if (displayMath) {
2267
+ for (const mathLine of latexToBlock(displayMath.text))
2268
+ lines.push(renderedLine(this.#applyDefaultStyle(mathLine)));
2269
+ if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") lines.push(renderedLine(""));
2270
+ break;
2271
+ }
2272
+ const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
2273
+ for (const paragraphLine of hangWrapTreeGuideLines(paragraphText, width) ?? [paragraphText]) {
2274
+ lines.push(renderedLine(paragraphLine));
2275
+ }
2276
+ // Don't add spacing if next token is space or list
2277
+ if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
2278
+ lines.push(renderedLine(""));
2279
+ }
2280
+ break;
2281
+ }
2282
+
2283
+ case "code": {
2284
+ // Mermaid diagrams render as ASCII art when the theme supplies a
2285
+ // resolver. The art is preformatted, so clip each row to the content
2286
+ // width: the later wrap pass would otherwise fragment the box-drawing
2287
+ // canvas. truncateToWidth is ANSI- and wide-char-aware, and the
2288
+ // resolver already re-fits over-wide horizontal graphs top-down.
2289
+ if (token.lang === "mermaid" && this.#theme.resolveMermaidAscii) {
2290
+ const ascii = this.#theme.resolveMermaidAscii(token.text, width);
2291
+ if (ascii) {
2292
+ for (const asciiLine of ascii.split("\n")) {
2293
+ lines.push(
2294
+ renderedLine(
2295
+ visibleWidth(asciiLine) > width
2296
+ ? truncateToWidth(asciiLine, width, Ellipsis.Omit)
2297
+ : asciiLine,
2298
+ ),
2299
+ );
2300
+ }
2301
+ if (nextTokenType && nextTokenType !== "space") {
2302
+ lines.push(renderedLine(""));
2303
+ }
2304
+ break;
2305
+ }
2306
+ }
2307
+
2308
+ const codeIndent = padding(this.#codeBlockIndent);
2309
+ lines.push(renderedLine(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)));
2310
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
2311
+ lines.push(bodyLine);
2312
+ }
2313
+ lines.push(renderedLine(this.#theme.codeBlockBorder("```")));
2314
+ if (nextTokenType && nextTokenType !== "space") {
2315
+ lines.push(renderedLine("")); // Add spacing after code blocks (unless space token follows)
2316
+ }
2317
+ break;
2318
+ }
2319
+
2320
+ case "list": {
2321
+ const listLines = this.#renderList(token as ListToken, 0, width, styleContext);
2322
+ lines.push(...listLines);
2323
+ // Don't add spacing after lists if a space token follows
2324
+ // (the space token will handle it)
2325
+ break;
2326
+ }
2327
+
2328
+ case "table": {
2329
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, tokenKey);
2330
+ for (const tableLine of tableLines) lines.push(renderedLine(tableLine));
2331
+ break;
2332
+ }
2333
+
2334
+ case "blockquote": {
2335
+ const quoteInlineStyleContext: InlineStyleContext = {
2336
+ applyText: (text: string) => text,
2337
+ stylePrefix: "",
2338
+ };
2339
+ const quoteContentWidth = Math.max(1, width - 2);
2340
+ const quoteTokens = token.tokens || [];
2341
+ const renderedQuoteLines: RenderedLine[] = [];
2342
+ const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2343
+
2344
+ for (let i = 0; i < quoteTokens.length; i++) {
2345
+ const quoteToken = quoteTokens[i];
2346
+ const nextQuoteToken = quoteTokens[i + 1];
2347
+ const quoteTokenRowStart = renderedQuoteLines.length;
2348
+ const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
2349
+ const quoteTokenLines = this.#renderToken(
2350
+ quoteToken,
2351
+ quoteContentWidth,
2352
+ nextQuoteToken?.type,
2353
+ quoteInlineStyleContext,
2354
+ `${tokenKey}/quote:${i}`,
2355
+ );
2356
+ renderedQuoteLines.push(...quoteTokenLines);
2357
+
2358
+ const tableSpecs = this.#activeTableRenderSpecs;
2359
+ if (tableSpecs !== undefined) {
2360
+ for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
2361
+ const spec = tableSpecs[specIndex]!;
2362
+ if (spec.startRow < 0) {
2363
+ // Direct child tables initially have no row coordinates. Their
2364
+ // structural line count excludes any inter-block blank.
2365
+ spec.startRow = quoteTokenRowStart;
2366
+ spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
2367
+ } else {
2368
+ // A nested blockquote already mapped the table into its own
2369
+ // returned rows; translate those rows into this quote's input.
2370
+ spec.startRow += quoteTokenRowStart;
2371
+ spec.endRow += quoteTokenRowStart;
2372
+ }
2373
+ }
2374
+ }
2375
+ }
2376
+
2377
+ while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1]!.text === "") {
2378
+ renderedQuoteLines.pop();
2379
+ }
2380
+
2381
+ const quoteRowOffsets: number[] = [];
2382
+ const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
2383
+ const tableSpecs = this.#activeTableRenderSpecs;
2384
+ if (tableSpecs !== undefined) {
2385
+ for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
2386
+ const spec = tableSpecs[specIndex]!;
2387
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
2388
+ const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
2389
+ const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
2390
+ spec.startRow = quoteRowOffsets[relativeStart]!;
2391
+ spec.endRow = quoteRowOffsets[relativeEnd]!;
2392
+ }
2393
+ }
2394
+ lines.push(...borderedQuoteLines);
2395
+ if (nextTokenType && nextTokenType !== "space") {
2396
+ lines.push(renderedLine("")); // Add spacing after blockquotes (unless space token follows)
2397
+ }
2398
+ break;
2399
+ }
2400
+
2401
+ case "hr": {
2402
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw.trim() : "";
2403
+ lines.push(renderedLine(this.#renderHrLine(width, raw[0] || "")));
2404
+ if (nextTokenType && nextTokenType !== "space") {
2405
+ lines.push(renderedLine("")); // Add spacing after horizontal rules (unless space token follows)
2406
+ }
2407
+ break;
2408
+ }
2409
+
2410
+ case "html":
2411
+ if ("raw" in token && typeof token.raw === "string") {
2412
+ lines.push(...this.#renderHtmlBlock(token.raw, width));
2413
+ }
2414
+ break;
2415
+
2416
+ case "space":
2417
+ // Space tokens represent blank lines in markdown
2418
+ lines.push(renderedLine(""));
2419
+ break;
2420
+
2421
+ default:
2422
+ // Handle any other token types as plain text
2423
+ if ("text" in token && typeof token.text === "string") {
2424
+ lines.push(renderedLine(token.text));
2425
+ }
2426
+ }
2427
+
2428
+ return lines;
2429
+ }
2430
+
2431
+ /** Render a horizontal rule line themed to `width`, matching `sourceChar` when given. */
2432
+ #renderHrLine(width: number, sourceChar = ""): string {
2433
+ const fillChar = getHrChar(sourceChar, this.#theme.symbols.hrChar);
2434
+ return this.#theme.hr(fillChar.repeat(Math.min(width, 80)));
2435
+ }
2436
+
2437
+ /**
2438
+ * Wrap already-rendered lines in the blockquote border and quote styling.
2439
+ * `width` is the full content width; the border reserves two cells.
2440
+ */
2441
+ #applyQuoteBorder(renderedLines: RenderedLine[], width: number, sourceRowOffsets?: number[]): RenderedLine[] {
2442
+ const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
2443
+ const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
2444
+ const applyQuoteStyle = (line: string): string => {
2445
+ if (!quoteStylePrefix) {
2446
+ return quoteStyle(line);
2447
+ }
2448
+ const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
2449
+ return quoteStyle(lineWithReappliedStyle);
2450
+ };
2451
+ const quoteContentWidth = Math.max(1, width - 2);
2452
+ const lines: RenderedLine[] = [];
2453
+ sourceRowOffsets?.push(0);
2454
+ for (const quoteLine of renderedLines) {
2455
+ if (quoteLine.literalCode) {
2456
+ const wrappedLiteralRows = wrapTextWithAnsi(quoteLine.text, quoteContentWidth);
2457
+ if (wrappedLiteralRows.length === 0) {
2458
+ lines.push(renderedLine("", true));
2459
+ } else {
2460
+ for (const wrappedLine of wrappedLiteralRows) {
2461
+ lines.push(renderedLine(wrappedLine, true));
2462
+ }
2463
+ }
2464
+ } else {
2465
+ const styledLine = applyQuoteStyle(quoteLine.text);
2466
+ for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
2467
+ lines.push(renderedLine(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine));
2468
+ }
2469
+ }
2470
+ sourceRowOffsets?.push(lines.length);
2471
+ }
2472
+ return lines;
2473
+ }
2474
+
2475
+ /**
2476
+ * Render a block-level `html` token to styled lines. Standalone `<hr>` tags
2477
+ * become rules and balanced `<blockquote>…</blockquote>` regions render with
2478
+ * quote styling; the remaining markup is normalized to terminal text (entities
2479
+ * decoded, `<code>` themed, lists/`<br>`/`<p>` laid out).
2480
+ */
2481
+ #renderHtmlBlock(raw: string, width: number): RenderedLine[] {
2482
+ const lines: RenderedLine[] = [];
2483
+ const state = createHtmlNormalizationState();
2484
+ const codeHook = (text: string): string => this.#theme.code(text) + this.#getDefaultStylePrefix();
2485
+ const flushText = (chunk: string): void => {
2486
+ const cleaned = normalizeHtmlForTerminal(chunk, state, codeHook);
2487
+ if (cleaned.trim() === "") return;
2488
+ for (const line of splitTerminalLines(cleaned)) {
2489
+ const trimmed = line.trimEnd();
2490
+ lines.push(renderedLine(trimmed.trim() === "" ? "" : this.#applyDefaultStyle(trimmed)));
2491
+ }
2492
+ };
2493
+ let lastIndex = 0;
2494
+ BLOCK_HTML_REGEX.lastIndex = 0;
2495
+ for (let match = BLOCK_HTML_REGEX.exec(raw); match !== null; match = BLOCK_HTML_REGEX.exec(raw)) {
2496
+ flushText(raw.slice(lastIndex, match.index));
2497
+ lastIndex = match.index + match[0].length;
2498
+ if (match[1] !== undefined) {
2499
+ lines.push(...this.#renderHtmlBlockquote(match[1], width));
2500
+ } else {
2501
+ lines.push(renderedLine(this.#renderHrLine(width)));
2502
+ }
2503
+ }
2504
+ flushText(raw.slice(lastIndex));
2505
+ return lines;
2506
+ }
2507
+
2508
+ /** Render the inner content of an HTML `<blockquote>` with quote styling. */
2509
+ #renderHtmlBlockquote(inner: string, width: number): RenderedLine[] {
2510
+ const cleaned = normalizeHtmlForTerminal(inner, createHtmlNormalizationState(), text => this.#theme.code(text));
2511
+ const innerLines = splitTerminalLines(cleaned).map(line => renderedLine(line.trimEnd()));
2512
+ while (innerLines.length > 0 && innerLines[innerLines.length - 1].text === "") innerLines.pop();
2513
+ return this.#applyQuoteBorder(innerLines, width);
2514
+ }
2515
+
2516
+ #renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
2517
+ let result = "";
2518
+ const resolvedStyleContext = styleContext ?? this.#getDefaultInlineStyleContext();
2519
+ const { applyText, stylePrefix } = resolvedStyleContext;
2520
+ const applyTextWithNewlines = (text: string): string => {
2521
+ const segments: string[] = text.split("\n");
2522
+ return segments.map((segment: string) => (segment === "" ? "" : applyText(segment))).join("\n");
2523
+ };
2524
+ const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
2525
+ let trimLeadingWhitespace = false;
2526
+ const htmlState = createHtmlNormalizationState();
2527
+ const markHtmlItemWhenContent = (text: string): void => {
2528
+ markCurrentHtmlItemContent(htmlState, text);
2529
+ };
2530
+
2531
+ for (const token of collapseInlineHtml(tokens)) {
2532
+ if (isMathToken(token)) {
2533
+ markHtmlItemWhenContent(token.text);
2534
+ result += applyTextWithNewlines(renderMathToken(token.text));
2535
+ continue;
2536
+ }
2537
+ switch (token.type) {
2538
+ case "text": {
2539
+ const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
2540
+ const text = normalizeHtmlEntitiesForTerminal(rawText);
2541
+ trimLeadingWhitespace = false;
2542
+ markHtmlItemWhenContent(text);
2543
+ if (token.tokens) markHtmlItemWhenContent(plainInlineTokens(token.tokens));
2544
+ // Text tokens in list items can have nested tokens for inline formatting
2545
+ if (token.tokens && token.tokens.length > 0) {
2546
+ result += this.#renderInlineTokens(token.tokens, resolvedStyleContext);
2547
+ } else {
2548
+ result += renderTextWithSwatches(text, applyTextWithNewlines, swatchGlyph);
2549
+ }
2550
+ break;
2551
+ }
2552
+
2553
+ case "paragraph":
2554
+ // Paragraph tokens contain nested inline tokens
2555
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
2556
+ result += this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
2557
+ break;
2558
+
2559
+ case "strong": {
2560
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
2561
+ const boldContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
2562
+ result += this.#theme.bold(boldContent) + stylePrefix;
2563
+ break;
2564
+ }
2565
+
2566
+ case "em": {
2567
+ const italicContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
2568
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
2569
+ result += this.#theme.italic(italicContent) + stylePrefix;
2570
+ break;
2571
+ }
2572
+
2573
+ case "codespan": {
2574
+ markHtmlItemWhenContent(token.text);
2575
+ result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
2576
+ break;
2577
+ }
2578
+
2579
+ case "link": {
2580
+ markHtmlItemWhenContent(token.text);
2581
+ const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
2582
+ const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
2583
+ const clickableLinkText = formatHyperlink(styledLinkText, token.href);
2584
+ // If link text matches href, only show the link once
2585
+ // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
2586
+ // For mailto: links, strip the prefix before comparing (autolinked emails have
2587
+ // text="foo@bar.com" but href="mailto:foo@bar.com")
2588
+ const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
2589
+ if (token.text === token.href || token.text === hrefForComparison)
2590
+ result += clickableLinkText + stylePrefix;
2591
+ else {
2592
+ const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
2593
+ result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
2594
+ }
2595
+ break;
2596
+ }
2597
+
2598
+ case "br":
2599
+ result += "\n";
2600
+ trimLeadingWhitespace = true;
2601
+ break;
2602
+
2603
+ case "del": {
2604
+ const delContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
2605
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
2606
+ result += this.#theme.strikethrough(delContent) + stylePrefix;
2607
+ break;
2608
+ }
2609
+
2610
+ case "html":
2611
+ if ("raw" in token && typeof token.raw === "string") {
2612
+ const cleaned = normalizeHtmlForTerminal(token.raw, htmlState);
2613
+ result += applyTextWithNewlines(cleaned);
2614
+ if (cleaned.endsWith("\n")) {
2615
+ trimLeadingWhitespace = true;
2616
+ } else if (cleaned.length > 0) {
2617
+ trimLeadingWhitespace = false;
2618
+ }
2619
+ }
2620
+ break;
2621
+
2622
+ default:
2623
+ // Handle any other inline token types as plain text
2624
+ if ("text" in token && typeof token.text === "string") {
2625
+ const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
2626
+ const text = normalizeHtmlEntitiesForTerminal(rawText);
2627
+ trimLeadingWhitespace = false;
2628
+ markHtmlItemWhenContent(text);
2629
+ result += applyTextWithNewlines(text);
2630
+ }
2631
+ }
2632
+ }
2633
+
2634
+ // Strip dangling re-opened-default SGR prefix left over from the last inline
2635
+ // token (strong/em/codespan/link/del/etc.) so the emitted line self-terminates
2636
+ // at its last styled segment instead of carrying an unmatched SGR open into
2637
+ // the next line. Matches upstream behavior.
2638
+ while (stylePrefix && result.endsWith(stylePrefix)) {
2639
+ result = result.slice(0, -stylePrefix.length);
2640
+ }
2641
+
2642
+ return result;
2643
+ }
2644
+
2645
+ /**
2646
+ * Render a list with proper nesting support
2647
+ */
2648
+ #renderList(token: ListToken, depth: number, width: number, styleContext?: InlineStyleContext): RenderedLine[] {
2649
+ const lines: RenderedLine[] = [];
2650
+ const indent = " ".repeat(depth);
2651
+ // Use the list's start property (defaults to 1 for ordered lists)
2652
+ const startNumber = token.start ?? 1;
2653
+ const pushWrapped = (line: RenderedLine, firstPrefix: string, continuationPrefix: string): void => {
2654
+ if (line.literalCode) {
2655
+ const wrappedLiteralRows = wrapTextWithAnsi(line.text, Math.max(1, width));
2656
+ if (wrappedLiteralRows.length === 0) {
2657
+ lines.push(renderedLine("", true));
2658
+ } else {
2659
+ for (const wrappedLine of wrappedLiteralRows) {
2660
+ lines.push(renderedLine(wrappedLine, true));
2661
+ }
2662
+ }
2663
+ return;
2664
+ }
2665
+
2666
+ const prefixWidth = visibleWidth(firstPrefix);
2667
+ if (prefixWidth >= width) {
2668
+ lines.push(renderedLine(truncateToWidth(firstPrefix, width, Ellipsis.Omit)));
2669
+ for (const wrappedLine of wrapTextWithAnsi(line.text, Math.max(1, width))) {
2670
+ lines.push(renderedLine(wrappedLine));
2671
+ }
2672
+ return;
2673
+ }
2674
+ const bodyWidth = width - prefixWidth;
2675
+ const wrapped = wrapTextWithAnsi(line.text, bodyWidth);
2676
+ if (wrapped.length === 0) {
2677
+ lines.push(renderedLine(firstPrefix));
2678
+ return;
2679
+ }
2680
+ lines.push(renderedLine(firstPrefix + wrapped[0]));
2681
+ for (let lineIndex = 1; lineIndex < wrapped.length; lineIndex++) {
2682
+ lines.push(renderedLine(continuationPrefix + wrapped[lineIndex]));
2683
+ }
2684
+ };
2685
+
2686
+ for (let i = 0; i < token.items.length; i++) {
2687
+ const item = token.items[i];
2688
+ const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
2689
+ const firstPrefix = indent + this.#theme.listBullet(bullet);
2690
+ // Continuation rows align under the item text, so the hang matches the
2691
+ // actual bullet width (`10. ` is 4 cells, not 2).
2692
+ const continuationIndent = indent + padding(visibleWidth(bullet));
2693
+
2694
+ // Process item tokens; nested-list lines arrive structurally tagged and
2695
+ // already carry their own full indent.
2696
+ const itemLines = this.#renderListItem(item.tokens || [], depth, width, styleContext);
2697
+
2698
+ if (itemLines.length > 0) {
2699
+ const firstLine = itemLines[0]!;
2700
+ if (firstLine.nested) {
2701
+ lines.push(firstLine);
2702
+ } else {
2703
+ pushWrapped(firstLine, firstPrefix, continuationIndent);
2704
+ }
2705
+
2706
+ for (let j = 1; j < itemLines.length; j++) {
2707
+ const line = itemLines[j]!;
2708
+ if (line.nested) {
2709
+ lines.push(line);
2710
+ } else {
2711
+ pushWrapped(line, continuationIndent, continuationIndent);
2712
+ }
2713
+ }
2714
+ } else {
2715
+ lines.push(renderedLine(firstPrefix));
2716
+ }
2717
+ }
2718
+
2719
+ return lines;
2720
+ }
2721
+
2722
+ /**
2723
+ * Render list item tokens, handling nested lists.
2724
+ * Returns lines WITHOUT the parent indent (renderList adds it); lines that
2725
+ * belong to a nested list are tagged `nested` so the caller never has to
2726
+ * sniff theme-dependent ANSI bytes to recognize them.
2727
+ */
2728
+ #renderListItem(
2729
+ tokens: Token[],
2730
+ parentDepth: number,
2731
+ width: number,
2732
+ styleContext?: InlineStyleContext,
2733
+ ): RenderedListItemLine[] {
2734
+ const lines: RenderedListItemLine[] = [];
2735
+
2736
+ for (const token of tokens) {
2737
+ if (token.type === "list") {
2738
+ // Nested list - render with one additional indent level
2739
+ // These lines carry their own indent, so tag them for pass-through
2740
+ const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, width, styleContext);
2741
+ for (const nestedLine of nestedLines) {
2742
+ lines.push({ ...nestedLine, nested: true });
2743
+ }
2744
+ } else if (token.type === "text") {
2745
+ // Text content (may have inline tokens, or a sole display-math token)
2746
+ const displayMath = soleDisplayMath(token.tokens);
2747
+ if (displayMath) {
2748
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
2749
+ for (const mathLine of latexToBlock(displayMath.text))
2750
+ lines.push({ text: apply(mathLine), nested: false });
2751
+ } else {
2752
+ const text =
2753
+ token.tokens && token.tokens.length > 0
2754
+ ? this.#renderInlineTokens(token.tokens, styleContext)
2755
+ : token.text || "";
2756
+ lines.push({ text, nested: false });
2757
+ }
2758
+ } else if (token.type === "paragraph") {
2759
+ // Paragraph in list item
2760
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
2761
+ const displayMath = soleDisplayMath(token.tokens);
2762
+ if (displayMath) {
2763
+ for (const mathLine of latexToBlock(displayMath.text))
2764
+ lines.push({ text: apply(mathLine), nested: false });
2765
+ } else {
2766
+ lines.push({ text: this.#renderInlineTokens(token.tokens || [], styleContext), nested: false });
2767
+ }
2768
+ } else if (token.type === "code") {
2769
+ // Code block in list item
2770
+ const codeIndent = padding(this.#codeBlockIndent);
2771
+ lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
2772
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
2773
+ lines.push({ ...bodyLine, nested: false });
2774
+ }
2775
+ lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
2776
+ } else if (isMathToken(token)) {
2777
+ // Display math block inside a list item: stack fractions / matrix rows.
2778
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
2779
+ for (const mathLine of latexToBlock(token.text)) lines.push({ text: apply(mathLine), nested: false });
2780
+ } else {
2781
+ // Other token types - try to render as inline
2782
+ const text = this.#renderInlineTokens([token], styleContext);
2783
+ if (text) {
2784
+ lines.push({ text, nested: false });
2785
+ }
2786
+ }
2787
+ }
2788
+
2789
+ return lines;
2790
+ }
2791
+
2792
+ /**
2793
+ * Get the visible width of the longest word in a string.
2794
+ */
2795
+ #getLongestWordWidth(text: string, maxWidth?: number): number {
2796
+ const words = text.split(/\s+/).filter(word => word.length > 0);
2797
+ let longest = 0;
2798
+ for (const word of words) {
2799
+ longest = Math.max(longest, visibleWidth(word));
2800
+ }
2801
+ if (maxWidth === undefined) {
2802
+ return longest;
2803
+ }
2804
+ return Math.min(longest, maxWidth);
2805
+ }
2806
+
2807
+ #terminalLineWidths(text: string): number[] {
2808
+ return splitTerminalLines(text).map(line => visibleWidth(line));
2809
+ }
2810
+
2811
+ /**
2812
+ * Wrap a table cell to fit into a column.
2813
+ *
2814
+ * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
2815
+ * consistently with the rest of the renderer.
2816
+ */
2817
+ #wrapCellText(text: string, maxWidth: number): string[] {
2818
+ const cellWidth = Math.max(1, maxWidth);
2819
+ // Wrap the whole cell in one call so wrapTextWithAnsi() balances OSC 8
2820
+ // hyperlink state across explicit newlines (e.g. `<br>` rendered as \n);
2821
+ // per-fragment wrapping would drop the reopened link on later rows.
2822
+ const wrapped = wrapTextWithAnsi(text, cellWidth);
2823
+ while (wrapped.length > 1 && wrapped[wrapped.length - 1] === "") {
2824
+ wrapped.pop();
2825
+ }
2826
+ // The native wrap deliberately leaves fg color and bold/italic open at
2827
+ // line ends so continuation lines can re-open them. Table rows splice
2828
+ // every cell line between unstyled border glyphs, so an open style
2829
+ // (e.g. mdCode) would bleed into the "│" and the following cells.
2830
+ // Terminate each line at default fg, clearing bold/italic but keeping
2831
+ // any ambient background (message-bg rendering) intact.
2832
+ return wrapped.map(line => `${line}\x1b[22m\x1b[23m\x1b[39m`);
2833
+ }
2834
+
2835
+ /**
2836
+ * Render a table with width-aware cell wrapping.
2837
+ * Cells that don't fit are wrapped to multiple lines.
2838
+ */
2839
+ #renderTable(
2840
+ token: TableToken,
2841
+ availableWidth: number,
2842
+ nextTokenType?: string,
2843
+ styleContext?: InlineStyleContext,
2844
+ tableKey = "table",
2845
+ ): string[] {
2846
+ const lines: string[] = [];
2847
+ const numCols = token.header.length;
2848
+
2849
+ if (numCols === 0) {
2850
+ return lines;
2851
+ }
2852
+
2853
+ // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
2854
+ // = 2 + (n-1) * 3 + 2 = 3n + 1
2855
+ const borderOverhead = 3 * numCols + 1;
2856
+ const availableForCells = availableWidth - borderOverhead;
2857
+ if (availableForCells < numCols) {
2858
+ // Too narrow to render a stable table. Fall back to raw markdown.
2859
+ const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
2860
+ if (nextTokenType && nextTokenType !== "space") {
2861
+ fallbackLines.push("");
2862
+ }
2863
+ return fallbackLines;
2864
+ }
2865
+
2866
+ const maxUnbrokenWordWidth = 30;
2867
+
2868
+ // Calculate natural column widths (what each column needs without constraints)
2869
+ const naturalWidths: number[] = [];
2870
+ const minWordWidths: number[] = [];
2871
+ for (let i = 0; i < numCols; i++) {
2872
+ const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext);
2873
+ const headerLineWidths = this.#terminalLineWidths(headerText);
2874
+ naturalWidths[i] = Math.max(...headerLineWidths, 0);
2875
+ minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth));
2876
+ }
2877
+ for (const row of token.rows) {
2878
+ for (let i = 0; i < row.length; i++) {
2879
+ const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext);
2880
+ const cellLineWidths = this.#terminalLineWidths(cellText);
2881
+ naturalWidths[i] = Math.max(naturalWidths[i] || 0, ...cellLineWidths);
2882
+ minWordWidths[i] = Math.max(
2883
+ minWordWidths[i] || 1,
2884
+ this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth),
2885
+ );
2886
+ }
2887
+ }
2888
+
2889
+ let minColumnWidths = minWordWidths;
2890
+ let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
2891
+
2892
+ if (minCellsWidth > availableForCells) {
2893
+ minColumnWidths = new Array(numCols).fill(1);
2894
+ const remaining = availableForCells - numCols;
2895
+
2896
+ if (remaining > 0) {
2897
+ const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
2898
+ const growth = minWordWidths.map(width => {
2899
+ const weight = Math.max(0, width - 1);
2900
+ return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
2901
+ });
2902
+
2903
+ for (let i = 0; i < numCols; i++) {
2904
+ minColumnWidths[i] += growth[i] ?? 0;
2905
+ }
2906
+
2907
+ const allocated = growth.reduce((total, width) => total + width, 0);
2908
+ let leftover = remaining - allocated;
2909
+ for (let i = 0; leftover > 0 && i < numCols; i++) {
2910
+ minColumnWidths[i]++;
2911
+ leftover--;
2912
+ }
2913
+ }
2914
+
2915
+ minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
2916
+ }
2917
+
2918
+ // Calculate column widths that fit within available width
2919
+ const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
2920
+ let columnWidths: number[];
2921
+
2922
+ if (totalNaturalWidth <= availableWidth) {
2923
+ // Everything fits naturally
2924
+ columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
2925
+ } else {
2926
+ // Need to shrink columns to fit
2927
+ const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
2928
+ return total + Math.max(0, width - minColumnWidths[index]);
2929
+ }, 0);
2930
+ const extraWidth = Math.max(0, availableForCells - minCellsWidth);
2931
+ columnWidths = minColumnWidths.map((minWidth, index) => {
2932
+ const naturalWidth = naturalWidths[index];
2933
+ const minWidthDelta = Math.max(0, naturalWidth - minWidth);
2934
+ let grow = 0;
2935
+ if (totalGrowPotential > 0) {
2936
+ grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
2937
+ }
2938
+ return minWidth + grow;
2939
+ });
2940
+
2941
+ // Adjust for rounding errors - distribute remaining space
2942
+ const allocated = columnWidths.reduce((a, b) => a + b, 0);
2943
+ let remaining = availableForCells - allocated;
2944
+ while (remaining > 0) {
2945
+ let grew = false;
2946
+ for (let i = 0; i < numCols && remaining > 0; i++) {
2947
+ if (columnWidths[i] < naturalWidths[i]) {
2948
+ columnWidths[i]++;
2949
+ remaining--;
2950
+ grew = true;
2951
+ }
2952
+ }
2953
+ if (!grew) {
2954
+ break;
2955
+ }
2956
+ }
2957
+ }
2958
+
2959
+ const lockedLayout = this.#lockedTableLayouts.get(tableKey);
2960
+ if (
2961
+ lockedLayout !== undefined &&
2962
+ lockedLayout.availableWidth === availableWidth &&
2963
+ lockedLayout.columnWidths.length === numCols &&
2964
+ lockedLayout.columnWidths.every(width => Number.isFinite(width) && width >= 1) &&
2965
+ lockedLayout.columnWidths.reduce((total, width) => total + width, borderOverhead) <= availableWidth
2966
+ ) {
2967
+ columnWidths = lockedLayout.columnWidths.slice();
2968
+ }
2969
+
2970
+ const t = this.#theme.symbols.table;
2971
+ const h = t.horizontal;
2972
+ const v = t.vertical;
2973
+
2974
+ // Render top border
2975
+ const topBorderCells = columnWidths.map(w => h.repeat(w));
2976
+ lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
2977
+
2978
+ // Render header with wrapping
2979
+ const headerCellLines: string[][] = token.header.map((cell, i) => {
2980
+ const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
2981
+ return this.#wrapCellText(text, columnWidths[i]);
2982
+ });
2983
+ const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
2984
+
2985
+ for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
2986
+ const rowParts = headerCellLines.map((cellLines, colIdx) => {
2987
+ const text = cellLines[lineIdx] || "";
2988
+ const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
2989
+ return this.#theme.bold(padded);
2990
+ });
2991
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
2992
+ }
2993
+
2994
+ // Render separator
2995
+ const separatorCells = columnWidths.map(w => h.repeat(w));
2996
+ const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
2997
+ lines.push(separatorLine);
2998
+
2999
+ // Render rows with wrapping
3000
+ for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
3001
+ const row = token.rows[rowIndex];
3002
+ const rowCellLines: string[][] = row.map((cell, i) => {
3003
+ const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
3004
+ return this.#wrapCellText(text, columnWidths[i]);
3005
+ });
3006
+ const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
3007
+
3008
+ for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
3009
+ const rowParts = rowCellLines.map((cellLines, colIdx) => {
3010
+ const text = cellLines[lineIdx] || "";
3011
+ return text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
3012
+ });
3013
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
3014
+ }
3015
+
3016
+ if (rowIndex < token.rows.length - 1) {
3017
+ lines.push(separatorLine);
3018
+ }
3019
+ }
3020
+
3021
+ // Render bottom border
3022
+ const bottomBorderCells = columnWidths.map(w => h.repeat(w));
3023
+ const bottomBorder = `${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`;
3024
+ lines.push(bottomBorder);
3025
+ this.#activeTableRenderSpecs?.push({
3026
+ key: tableKey,
3027
+ availableWidth,
3028
+ columnWidths: columnWidths.slice(),
3029
+ lineCount: lines.length,
3030
+ startRow: -1,
3031
+ endRow: -1,
3032
+ });
3033
+
3034
+ if (nextTokenType && nextTokenType !== "space") {
3035
+ lines.push(""); // Add spacing after table
3036
+ }
3037
+ return lines;
3038
+ }
3039
+ }
3040
+
3041
+ /**
3042
+ * Render inline markdown (bold, italic, code, links, strikethrough) to a styled string.
3043
+ * Unlike the full Markdown component, this produces a single line with no block-level elements.
3044
+ */
3045
+ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string {
3046
+ // Guard against undefined/null during streaming — partial JSON can leave fields unpopulated.
3047
+ if (typeof text !== "string") return (baseColor ?? (t => t))(text != null ? String(text) : "");
3048
+ const tokens = markdownParser.lexer(normalizeOsc8Terminators(text));
3049
+ const applyText = baseColor ?? ((t: string) => t);
3050
+ let result = "";
3051
+ for (const token of tokens) {
3052
+ if (isMathToken(token)) {
3053
+ result += applyText(renderMathToken(token.text));
3054
+ continue;
3055
+ }
3056
+ if (token.type === "paragraph" && token.tokens) {
3057
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
3058
+ } else if (token.type === "list") {
3059
+ result += token.items
3060
+ .map((item: Tokens.ListItem, index: number) => {
3061
+ const prefix = token.ordered ? `${(token.start || 1) + index}. ` : "• ";
3062
+ const content = item.tokens ? renderInlineTokens(item.tokens, mdTheme, applyText) : applyText(item.text);
3063
+ return `${applyText(prefix)}${content}`;
3064
+ })
3065
+ .join(applyText(" "));
3066
+ } else if ("text" in token && typeof token.text === "string") {
3067
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
3068
+ }
3069
+ }
3070
+ return result;
3071
+ }
3072
+
3073
+ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText: (t: string) => string): string {
3074
+ let result = "";
3075
+ const styleReset = applyText("");
3076
+ for (const token of collapseInlineHtml(tokens)) {
3077
+ if (isMathToken(token)) {
3078
+ result += applyText(renderMathToken(token.text));
3079
+ continue;
3080
+ }
3081
+ switch (token.type) {
3082
+ case "text":
3083
+ if (token.tokens && token.tokens.length > 0) {
3084
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
3085
+ } else {
3086
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
3087
+ }
3088
+ break;
3089
+ case "strong":
3090
+ result += mdTheme.bold(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
3091
+ break;
3092
+ case "em":
3093
+ result += mdTheme.italic(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
3094
+ break;
3095
+ case "codespan":
3096
+ result += mdTheme.code(token.text) + styleReset;
3097
+ break;
3098
+ case "del":
3099
+ result += mdTheme.strikethrough(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
3100
+ break;
3101
+ case "link": {
3102
+ const linkText = renderInlineTokens(token.tokens || [], mdTheme, applyText);
3103
+ result += mdTheme.link(mdTheme.underline(linkText)) + styleReset;
3104
+ break;
3105
+ }
3106
+ case "html":
3107
+ if ("raw" in token && typeof token.raw === "string") {
3108
+ result += applyText(normalizeHtmlForTerminal(token.raw));
3109
+ }
3110
+ break;
3111
+ default:
3112
+ if ("text" in token && typeof token.text === "string") {
3113
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
3114
+ }
3115
+ break;
3116
+ }
3117
+ }
3118
+ return result;
3119
+ }