jeopi-tui 16.2.13

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 (75) hide show
  1. package/CHANGELOG.md +1861 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +99 -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 +155 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +23 -0
  10. package/dist/types/components/loader.d.ts +20 -0
  11. package/dist/types/components/markdown.d.ts +64 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +68 -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 +14 -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 +51 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +32 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +191 -0
  25. package/dist/types/keys.d.ts +208 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +79 -0
  28. package/dist/types/latex-block.d.ts +7 -0
  29. package/dist/types/latex-to-unicode.d.ts +33 -0
  30. package/dist/types/loop-watchdog.d.ts +39 -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 +284 -0
  35. package/dist/types/terminal.d.ts +107 -0
  36. package/dist/types/ttyid.d.ts +9 -0
  37. package/dist/types/tui.d.ts +423 -0
  38. package/dist/types/utils.d.ts +95 -0
  39. package/package.json +73 -0
  40. package/src/autocomplete.ts +1026 -0
  41. package/src/bracketed-paste.ts +123 -0
  42. package/src/components/box.ts +194 -0
  43. package/src/components/cancellable-loader.ts +40 -0
  44. package/src/components/editor.ts +3092 -0
  45. package/src/components/image.ts +444 -0
  46. package/src/components/input.ts +474 -0
  47. package/src/components/loader.ts +103 -0
  48. package/src/components/markdown.ts +2068 -0
  49. package/src/components/scroll-view.ts +227 -0
  50. package/src/components/select-list.ts +531 -0
  51. package/src/components/settings-list.ts +793 -0
  52. package/src/components/spacer.ts +32 -0
  53. package/src/components/tab-bar.ts +300 -0
  54. package/src/components/text.ts +122 -0
  55. package/src/components/truncated-text.ts +69 -0
  56. package/src/deccara.ts +314 -0
  57. package/src/desktop-notify.ts +186 -0
  58. package/src/editor-component.ts +74 -0
  59. package/src/fuzzy.ts +356 -0
  60. package/src/index.ts +51 -0
  61. package/src/keybindings.ts +337 -0
  62. package/src/keys.ts +561 -0
  63. package/src/kill-ring.ts +51 -0
  64. package/src/kitty-graphics.ts +171 -0
  65. package/src/latex-block.ts +461 -0
  66. package/src/latex-to-unicode.ts +1994 -0
  67. package/src/loop-watchdog.ts +106 -0
  68. package/src/mouse.ts +105 -0
  69. package/src/stdin-buffer.ts +669 -0
  70. package/src/symbols.ts +26 -0
  71. package/src/terminal-capabilities.ts +1152 -0
  72. package/src/terminal.ts +1463 -0
  73. package/src/ttyid.ts +84 -0
  74. package/src/tui.ts +3901 -0
  75. package/src/utils.ts +570 -0
@@ -0,0 +1,2068 @@
1
+ import { LRUCache } from "lru-cache/raw";
2
+ import { Marked, type Token, Tokenizer, type TokenizerAndRendererExtension, type Tokens } from "marked";
3
+ import { latexToBlock } from "../latex-block";
4
+ import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
5
+ import type { SymbolTheme } from "../symbols";
6
+ import { TERMINAL } from "../terminal-capabilities";
7
+ import type { Component } from "../tui";
8
+ import {
9
+ applyBackgroundToLine,
10
+ Ellipsis,
11
+ encodeTextSized,
12
+ getPaddingX,
13
+ getSegmenter,
14
+ padding,
15
+ replaceTabs,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ wrapTextWithAnsi,
19
+ } from "../utils";
20
+
21
+ const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
22
+
23
+ // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
24
+ // unit by the H1 render path. Like image-protocol lines, they must bypass
25
+ // ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span
26
+ // (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC
27
+ // payload), and padding would append trailing cells past the doubled glyph.
28
+ const OSC66_LINE_PREFIX = "\x1b]66;";
29
+
30
+ function isOsc66Line(line: string): boolean {
31
+ return line.includes(OSC66_LINE_PREFIX);
32
+ }
33
+
34
+ function normalizeHtmlEntitiesForTerminal(raw: string): string {
35
+ const parseCodePoint = (value: number): string => {
36
+ if (Number.isFinite(value) && value >= 0 && value <= 0x10ffff) {
37
+ try {
38
+ return String.fromCodePoint(value);
39
+ } catch (_) {
40
+ // Fallback to empty string or original if invalid codepoint
41
+ }
42
+ }
43
+ return "";
44
+ };
45
+
46
+ return raw.replace(/&(amp|lt|gt|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);/gi, (match, entity) => {
47
+ const lower = entity.toLowerCase();
48
+ switch (lower) {
49
+ case "nbsp":
50
+ return " ";
51
+ case "lt":
52
+ return "<";
53
+ case "gt":
54
+ return ">";
55
+ case "quot":
56
+ return '"';
57
+ case "apos":
58
+ return "'";
59
+ case "amp":
60
+ return "&";
61
+ default: {
62
+ if (lower.startsWith("#x")) {
63
+ return parseCodePoint(Number.parseInt(lower.slice(2), 16));
64
+ }
65
+ if (lower.startsWith("#")) {
66
+ return parseCodePoint(Number(lower.slice(1)));
67
+ }
68
+ return match;
69
+ }
70
+ }
71
+ });
72
+ }
73
+
74
+ interface HtmlListState {
75
+ type: "ol" | "ul";
76
+ next: number;
77
+ }
78
+
79
+ interface HtmlNormalizationState {
80
+ lists: HtmlListState[];
81
+ openItems: boolean[];
82
+ itemHasContent: boolean[];
83
+ }
84
+
85
+ function createHtmlNormalizationState(): HtmlNormalizationState {
86
+ return { lists: [], openItems: [], itemHasContent: [] };
87
+ }
88
+
89
+ const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text|code|hr|blockquote)\b(?:\s[^>]*)?\s*\/?>/gi;
90
+ // Block-level HTML that needs structural (not just textual) rendering: standalone
91
+ // `<hr>` becomes a rule and balanced `<blockquote>…</blockquote>` renders with
92
+ // quote styling. Group 1 captures blockquote inner content; it is undefined for hr.
93
+ const BLOCK_HTML_REGEX = /<hr\b[^>]*\/?>|<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi;
94
+
95
+ function htmlTagName(tag: string): string {
96
+ const match = /^<\/?\s*([A-Za-z][A-Za-z0-9:-]*)/.exec(tag);
97
+ return match ? match[1].toLowerCase() : "";
98
+ }
99
+
100
+ function htmlOlStart(tag: string): number {
101
+ const match = /\bstart\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))/i.exec(tag);
102
+ if (!match) return 1;
103
+ return Number(match[1] ?? match[2] ?? match[3]);
104
+ }
105
+
106
+ function appendHtmlLineBreak(output: string, force: boolean = false): string {
107
+ const trimmed = output.replace(/[ \t]+$/u, "");
108
+ return !force && trimmed.endsWith("\n") ? trimmed : `${trimmed}\n`;
109
+ }
110
+
111
+ function htmlListIndent(state: HtmlNormalizationState): string {
112
+ return " ".repeat(Math.max(0, state.lists.length - 1));
113
+ }
114
+
115
+ function appendHtmlListBreak(output: string, state: HtmlNormalizationState): string {
116
+ const indent = htmlListIndent(state);
117
+ return output.endsWith(`${indent}\n`) ? output : appendHtmlLineBreak(output);
118
+ }
119
+
120
+ function markCurrentHtmlItemContent(state: HtmlNormalizationState, text: string): void {
121
+ if (text.trim() !== "" && state.itemHasContent.length > 0) {
122
+ state.itemHasContent[state.itemHasContent.length - 1] = true;
123
+ }
124
+ }
125
+
126
+ function isAtEmptyHtmlListItem(state: HtmlNormalizationState): boolean {
127
+ const itemIndex = state.itemHasContent.length - 1;
128
+ return state.openItems[itemIndex] === true && state.itemHasContent[itemIndex] !== true;
129
+ }
130
+
131
+ function normalizeHtmlForTerminal(
132
+ raw: string,
133
+ state: HtmlNormalizationState = createHtmlNormalizationState(),
134
+ codeHook?: (text: string) => string,
135
+ ): string {
136
+ let output = "";
137
+ let lastIndex = 0;
138
+ let inCode = false;
139
+
140
+ for (const match of raw.matchAll(HTML_TAG_REGEX)) {
141
+ const tag = match[0];
142
+ const index = match.index ?? 0;
143
+ const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index));
144
+ const name = htmlTagName(tag);
145
+ // Most tags handled here are block-level. Inline contexts — span, text, and
146
+ // the content inside a `<code>` run — keep their surrounding whitespace
147
+ // verbatim because it is significant. For block-level tags, HTML formatting
148
+ // whitespace between tags (e.g. the newlines and indentation in
149
+ // pretty-printed `<ul>\n <li>…`) is not rendered content; appending it
150
+ // literally would leak source indentation before bullets and blank rows
151
+ // between items, so a whitespace-only slice is dropped. Text inside a
152
+ // `<code>` run is routed through `codeHook` so the inline-code theme is
153
+ // applied without leaking the raw `<code>`/`</code>` tags.
154
+ const isInlineTag = name === "span" || name === "text";
155
+ if (isInlineTag || inCode || textBeforeTag.trim() !== "") {
156
+ output += inCode && codeHook ? codeHook(textBeforeTag) : textBeforeTag;
157
+ markCurrentHtmlItemContent(state, textBeforeTag);
158
+ }
159
+ lastIndex = index + tag.length;
160
+
161
+ const isClosing = /^<\//.test(tag);
162
+ const isSelfClosing = /\/\s*>$/.test(tag);
163
+
164
+ switch (name) {
165
+ case "span":
166
+ case "text":
167
+ break;
168
+ case "code":
169
+ if (isClosing) inCode = false;
170
+ else if (!isSelfClosing) inCode = true;
171
+ break;
172
+ case "br":
173
+ case "hr":
174
+ output = appendHtmlLineBreak(output, true);
175
+ break;
176
+ case "p":
177
+ case "blockquote":
178
+ if (isClosing) {
179
+ output = appendHtmlLineBreak(output);
180
+ } else if (output.trim() !== "" && !output.endsWith("\n") && !isAtEmptyHtmlListItem(state)) {
181
+ output = appendHtmlLineBreak(output);
182
+ }
183
+ break;
184
+ case "ol":
185
+ if (isClosing) {
186
+ state.lists.pop();
187
+ state.openItems.pop();
188
+ state.itemHasContent.pop();
189
+ } else if (!isSelfClosing) {
190
+ if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) {
191
+ output = appendHtmlListBreak(output, state);
192
+ }
193
+ state.lists.push({ type: "ol", next: htmlOlStart(tag) });
194
+ state.openItems.push(false);
195
+ state.itemHasContent.push(false);
196
+ }
197
+ break;
198
+ case "ul":
199
+ if (isClosing) {
200
+ state.lists.pop();
201
+ state.openItems.pop();
202
+ state.itemHasContent.pop();
203
+ } else if (!isSelfClosing) {
204
+ if (state.openItems.length > 0 && state.openItems[state.openItems.length - 1]) {
205
+ output = appendHtmlListBreak(output, state);
206
+ }
207
+ state.lists.push({ type: "ul", next: 1 });
208
+ state.openItems.push(false);
209
+ state.itemHasContent.push(false);
210
+ }
211
+ break;
212
+ case "li": {
213
+ if (isClosing) {
214
+ output = appendHtmlLineBreak(output);
215
+ break;
216
+ }
217
+ if (state.openItems.length > 0) {
218
+ const itemOpenIndex = state.openItems.length - 1;
219
+ if (state.openItems[itemOpenIndex]) output = appendHtmlListBreak(output, state);
220
+ state.openItems[itemOpenIndex] = true;
221
+ state.itemHasContent[itemOpenIndex] = false;
222
+ } else if (output.trim() !== "" && !output.endsWith("\n")) {
223
+ output = appendHtmlLineBreak(output);
224
+ }
225
+ const list = state.lists[state.lists.length - 1];
226
+ const indent = htmlListIndent(state);
227
+ if (list?.type === "ol") {
228
+ output += `${indent}${list.next}. `;
229
+ list.next++;
230
+ } else {
231
+ output += `${indent}• `;
232
+ }
233
+ break;
234
+ }
235
+ default:
236
+ output += tag;
237
+ break;
238
+ }
239
+ }
240
+
241
+ const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex));
242
+ markCurrentHtmlItemContent(state, remainingText);
243
+ return output + (inCode && codeHook ? codeHook(remainingText) : remainingText);
244
+ }
245
+
246
+ function splitTerminalLines(text: string): string[] {
247
+ const lines = text.split("\n");
248
+ while (lines.length > 1 && lines[lines.length - 1] === "") {
249
+ lines.pop();
250
+ }
251
+ return lines;
252
+ }
253
+
254
+ class StrictStrikethroughTokenizer extends Tokenizer {
255
+ override del(src: string): Tokens.Del | undefined {
256
+ const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
257
+ if (!match) {
258
+ return undefined;
259
+ }
260
+
261
+ const text = match[2];
262
+ return {
263
+ type: "del",
264
+ raw: match[0],
265
+ text,
266
+ tokens: this.lexer.inlineTokens(text),
267
+ };
268
+ }
269
+ }
270
+
271
+ const markdownParser = new Marked();
272
+ markdownParser.setOptions({
273
+ tokenizer: new StrictStrikethroughTokenizer(),
274
+ });
275
+
276
+ // Math spans (`$$…$$`, `\[…\]`, `$…$`, `\(…\)`) are tokenized as a dedicated
277
+ // `math` inline token before markdown's escape/emphasis/link rules run, so
278
+ // backslash commands (`\frac`, `\alpha`) and intraword underscores (`x_i`)
279
+ // survive intact instead of being mangled or split. The `$…$` form uses
280
+ // pandoc's anti-currency heuristic (`inlineMathSpanEnd`) so "$5 and $10" is
281
+ // never math. Inline extensions run before marked's escape tokenizer, so
282
+ // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
283
+ // renders as a literal dollar.
284
+ const CUSTOM_HR_START_REGEX = /(?:^|\n) {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
285
+ const CUSTOM_HR_TOKENIZER_REGEX = /^ {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
286
+
287
+ function getHrChar(char: string, hrChar: string): string {
288
+ const isAscii = hrChar === "-";
289
+ switch (char) {
290
+ case "=":
291
+ return "=";
292
+ case "═":
293
+ return isAscii ? "=" : "═";
294
+ case "━":
295
+ return isAscii ? "-" : "━";
296
+ case "─":
297
+ return isAscii ? "-" : "─";
298
+ case "–":
299
+ return isAscii ? "-" : "–";
300
+ case "—":
301
+ return isAscii ? "-" : "—";
302
+ default:
303
+ return hrChar;
304
+ }
305
+ }
306
+
307
+ const customHrExtension: TokenizerAndRendererExtension = {
308
+ name: "customHr",
309
+ level: "block",
310
+ start(src) {
311
+ const match = CUSTOM_HR_START_REGEX.exec(src);
312
+ if (!match) return undefined;
313
+ let idx = match.index;
314
+ if (src[idx] === "\n") {
315
+ idx += 1;
316
+ }
317
+ return idx;
318
+ },
319
+ tokenizer(src) {
320
+ const match = CUSTOM_HR_TOKENIZER_REGEX.exec(src);
321
+ if (match) {
322
+ return {
323
+ type: "hr",
324
+ raw: match[0],
325
+ };
326
+ }
327
+ return undefined;
328
+ },
329
+ renderer() {
330
+ return "";
331
+ },
332
+ };
333
+
334
+ const mathExtension: TokenizerAndRendererExtension = {
335
+ name: "math",
336
+ level: "inline",
337
+ start(src) {
338
+ const m = /\$|\\\(|\\\[/.exec(src);
339
+ return m ? m.index : undefined;
340
+ },
341
+ tokenizer(src) {
342
+ if (src.startsWith("$$")) {
343
+ const end = src.indexOf("$$", 2);
344
+ if (end !== -1 && src.slice(2, end).trim().length > 0) {
345
+ return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
346
+ }
347
+ return undefined;
348
+ }
349
+ if (src.startsWith("\\[")) {
350
+ const end = src.indexOf("\\]", 2);
351
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
352
+ return undefined;
353
+ }
354
+ if (src.startsWith("\\(")) {
355
+ const end = src.indexOf("\\)", 2);
356
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: false };
357
+ return undefined;
358
+ }
359
+ if (src.charCodeAt(0) === 0x24 /* $ */) {
360
+ const end = inlineMathSpanEnd(src, 0);
361
+ if (end !== -1) return { type: "math", raw: src.slice(0, end + 1), text: src.slice(1, end), display: false };
362
+ }
363
+ return undefined;
364
+ },
365
+ renderer(token) {
366
+ return (token as { text?: string }).text ?? "";
367
+ },
368
+ };
369
+
370
+ // Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
371
+ // their own line (≤3 leading spaces). Matched at the block level — before
372
+ // paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
373
+ // row breaks) renders across several lines instead of being collapsed onto one,
374
+ // and blank lines inside the block don't split it. The own-line requirement
375
+ // keeps inline `$$…$$` inside prose for the inline tokenizer above.
376
+ const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\n([\s\S]+?)\n {0,3}\$\$[ \t]*(?:\n|$)/;
377
+ const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\n([\s\S]+?)\n {0,3}\\\][ \t]*(?:\n|$)/;
378
+ const MATH_BLOCK_START = /(?:^|\n) {0,3}(?:\$\$|\\\[)[ \t]*\n/;
379
+ const mathBlockExtension: TokenizerAndRendererExtension = {
380
+ name: "mathBlock",
381
+ level: "block",
382
+ start(src) {
383
+ const m = MATH_BLOCK_START.exec(src);
384
+ return m ? m.index : undefined;
385
+ },
386
+ tokenizer(src) {
387
+ const m = MATH_BLOCK_DOLLAR.exec(src) ?? MATH_BLOCK_BRACKET.exec(src);
388
+ if (!m || m[1].trim().length === 0) return undefined;
389
+ return { type: "math", raw: m[0], text: m[1], display: true };
390
+ },
391
+ renderer(token) {
392
+ return (token as { text?: string }).text ?? "";
393
+ },
394
+ };
395
+
396
+ // Bare (delimiter-less) display-math environments: `\begin{<mathenv>}…\end{…}`
397
+ // written without `$$`/`\[` fences (common in raw model output). Captured at the
398
+ // block level as a whole unit — including any immediately preceding `lhs =`
399
+ // line — so marked never splits it on inline `\\` row breaks. Restricted to math
400
+ // environments (isBareMathEnvironment), and the `≤3 leading spaces` + "block
401
+ // starts at offset 0" guards keep fenced/indented `\begin{cases}` code blocks
402
+ // for marked's own code rules.
403
+ const BARE_ENV_BEGIN = /(?:^|\n)[ \t]{0,3}\\begin\{([A-Za-z]+\*?)\}/;
404
+ function bareMathEnvBlock(src: string): readonly [number, number] | null {
405
+ const bm = BARE_ENV_BEGIN.exec(src);
406
+ if (!bm || !isBareMathEnvironment(bm[1])) return null;
407
+ const beginLineStart = bm.index === 0 ? 0 : bm.index + 1; // skip the matched leading `\n`
408
+ const endToken = `\\end{${bm[1]}}`;
409
+ const endAt = src.indexOf(endToken, bm.index);
410
+ if (endAt === -1) return null;
411
+ // The `\end` must close before any blank line (i.e. within the same block).
412
+ if (/\n[ \t]*\n/.test(src.slice(beginLineStart, endAt))) return null;
413
+ let blockEnd = endAt + endToken.length;
414
+ while (src[blockEnd] === " " || src[blockEnd] === "\t") blockEnd++;
415
+ if (src[blockEnd] === "\n") blockEnd++;
416
+ // Pull in one immediately-preceding `lhs =`/open-delimiter line (e.g. `f(x) =`).
417
+ let start = beginLineStart;
418
+ if (start > 0 && src[start - 1] === "\n") {
419
+ const prevStart = src.lastIndexOf("\n", start - 2) + 1;
420
+ const prevLine = src.slice(prevStart, start - 1);
421
+ if (/[=([{]\s*$/.test(prevLine)) start = prevStart;
422
+ }
423
+ return [start, blockEnd];
424
+ }
425
+ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
426
+ name: "mathEnvBlock",
427
+ level: "block",
428
+ start(src) {
429
+ const r = bareMathEnvBlock(src);
430
+ return r ? r[0] : undefined;
431
+ },
432
+ tokenizer(src) {
433
+ const r = bareMathEnvBlock(src);
434
+ if (r?.[0] !== 0) return undefined; // only consume when the block starts at offset 0
435
+ const raw = src.slice(0, r[1]);
436
+ const text = raw.replace(/\n[ \t]*$/, "");
437
+ if (text.trim().length === 0) return undefined;
438
+ return { type: "math", raw, text, display: true };
439
+ },
440
+ renderer(token) {
441
+ return (token as { text?: string }).text ?? "";
442
+ },
443
+ };
444
+ markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension] });
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Module-level LRU render cache
448
+ // ---------------------------------------------------------------------------
449
+ // Each session-tree navigation discards and recreates Markdown component
450
+ // instances, so the per-instance #cachedLines field is always cold on first
451
+ // render of a fresh component. This module-level cache survives across
452
+ // component lifetimes and eliminates redundant marked.lexer + highlightCode
453
+ // (Rust FFI) work for content/layout combinations already seen this session.
454
+
455
+ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
456
+ const EMPTY_RENDER_LINES: readonly string[] = [];
457
+ const renderCache = new LRUCache<string, readonly string[]>({ max: RENDER_CACHE_MAX });
458
+
459
+ // A reference-link definition (`[label]: dest`) resolves across the whole
460
+ // document, so a split lex cannot reproduce it — disable the streaming fast path
461
+ // when one is present (rare in streamed output). The label may contain
462
+ // backslash-escaped characters (`[a\]b]: x`), so escapes are matched explicitly;
463
+ // over-matching is safe (it only costs the fast path), under-matching is not.
464
+ const HAS_REF_DEF = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/m;
465
+
466
+ /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
467
+ export function clearRenderCache(): void {
468
+ renderCache.clear();
469
+ }
470
+
471
+ // Stable numeric IDs for structural theme/style objects (no ID field on type).
472
+ // WeakMap-keyed so the ID matches strict object identity and doesn't get copied by spread/cloning.
473
+ const themeObjectIds = new WeakMap<object, number>();
474
+ let nextObjectId = 0;
475
+ function objectId(o: object): number {
476
+ let id = themeObjectIds.get(o);
477
+ if (id === undefined) {
478
+ id = nextObjectId++;
479
+ themeObjectIds.set(o, id);
480
+ }
481
+ return id;
482
+ }
483
+
484
+ /**
485
+ * Default text styling for markdown content.
486
+ * Applied to all text unless overridden by markdown formatting.
487
+ */
488
+ export interface DefaultTextStyle {
489
+ /** Foreground color function */
490
+ color?: (text: string) => string;
491
+ /** Background color function */
492
+ bgColor?: (text: string) => string;
493
+ /** Bold text */
494
+ bold?: boolean;
495
+ /** Italic text */
496
+ italic?: boolean;
497
+ /** Strikethrough text */
498
+ strikethrough?: boolean;
499
+ /** Underline text */
500
+ underline?: boolean;
501
+ }
502
+
503
+ /**
504
+ * Theme functions for markdown elements.
505
+ * Each function takes text and returns styled text with ANSI codes.
506
+ */
507
+ export interface MarkdownTheme {
508
+ heading: (text: string) => string;
509
+ link: (text: string) => string;
510
+ linkUrl: (text: string) => string;
511
+ code: (text: string) => string;
512
+ codeBlock: (text: string) => string;
513
+ codeBlockBorder: (text: string) => string;
514
+ quote: (text: string) => string;
515
+ quoteBorder: (text: string) => string;
516
+ hr: (text: string) => string;
517
+ listBullet: (text: string) => string;
518
+ bold: (text: string) => string;
519
+ italic: (text: string) => string;
520
+ strikethrough: (text: string) => string;
521
+ underline: (text: string) => string;
522
+ highlightCode?: (code: string, lang?: string) => string[];
523
+ /**
524
+ * Resolve a mermaid ASCII rendering by fenced block source text.
525
+ * Return null to fall back to fenced code rendering.
526
+ */
527
+ resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
528
+ symbols: SymbolTheme;
529
+ }
530
+
531
+ interface InlineStyleContext {
532
+ applyText: (text: string) => string;
533
+ stylePrefix: string;
534
+ }
535
+
536
+ type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
537
+ type TableCellToken = { tokens?: Token[] };
538
+ type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
539
+
540
+ function formatHyperlink(text: string, target: string): string {
541
+ if (!TERMINAL.hyperlinks || !target) {
542
+ return text;
543
+ }
544
+
545
+ const safeTarget = target.replaceAll("\x1b", "").replaceAll("\x07", "");
546
+ if (!safeTarget) {
547
+ return text;
548
+ }
549
+
550
+ return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
551
+ }
552
+
553
+ function isAsciiTextSizingPayload(text: string): boolean {
554
+ for (let i = 0; i < text.length; i++) {
555
+ const code = text.charCodeAt(i);
556
+ if (code < 0x20 || code > 0x7e) return false;
557
+ }
558
+ return true;
559
+ }
560
+
561
+ function encodeTextSizedHeading(text: string, scale: 1 | 2 | 3): string {
562
+ let out = "";
563
+ let asciiRun = "";
564
+ const flushAscii = () => {
565
+ if (asciiRun === "") return;
566
+ out += encodeTextSized(asciiRun, { scale });
567
+ asciiRun = "";
568
+ };
569
+
570
+ for (const { segment } of getSegmenter().segment(text)) {
571
+ if (isAsciiTextSizingPayload(segment)) {
572
+ asciiRun += segment;
573
+ continue;
574
+ }
575
+ flushAscii();
576
+ out += encodeTextSized(segment, { scale, widthCells: visibleWidth(segment) });
577
+ }
578
+ flushAscii();
579
+ return out;
580
+ }
581
+
582
+ const MATH_NEWLINES = /\n+/g;
583
+
584
+ /** True for the custom inline `math` token produced by the math extension. */
585
+ function isMathToken(token: Token): token is Token & { text: string; display: boolean } {
586
+ return (token as { type: string }).type === "math";
587
+ }
588
+
589
+ /** Convert a `math` token's LaTeX to single-line Unicode for inline rendering. */
590
+ function renderMathToken(text: string): string {
591
+ return latexToUnicode(text).replace(MATH_NEWLINES, " ");
592
+ }
593
+
594
+ /**
595
+ * When a paragraph's only meaningful content is a single display math token
596
+ * (`$$…$$` / `\[…\]`), return it so the paragraph can be stacked multi-line
597
+ * instead of flattened inline. Models routinely write display math on one line,
598
+ * which marked captures as an inline `display:true` math token inside a
599
+ * paragraph; without this it would flatten through `renderMathToken`.
600
+ */
601
+ function soleDisplayMath(tokens?: Token[]): (Token & { text: string }) | null {
602
+ if (!tokens) return null;
603
+ let math: (Token & { text: string; display: boolean }) | null = null;
604
+ for (const token of tokens) {
605
+ if (isMathToken(token) && token.display) {
606
+ if (math) return null;
607
+ math = token;
608
+ } else if (!(token.type === "text" && typeof token.text === "string" && token.text.trim() === "")) {
609
+ return null;
610
+ }
611
+ }
612
+ return math;
613
+ }
614
+
615
+ function plainInlineTokens(tokens: Token[]): string {
616
+ let result = "";
617
+ for (const token of tokens) {
618
+ if (isMathToken(token)) {
619
+ result += renderMathToken(token.text);
620
+ continue;
621
+ }
622
+ switch (token.type) {
623
+ case "text":
624
+ result += token.tokens && token.tokens.length > 0 ? plainInlineTokens(token.tokens) : token.text;
625
+ break;
626
+ case "strong":
627
+ case "em":
628
+ case "del":
629
+ case "link":
630
+ result += plainInlineTokens(token.tokens || []);
631
+ break;
632
+ case "codespan":
633
+ result += token.text;
634
+ break;
635
+ default:
636
+ if ("text" in token && typeof token.text === "string") result += token.text;
637
+ break;
638
+ }
639
+ }
640
+ return result;
641
+ }
642
+
643
+ /**
644
+ * Classify an inline `html` token by tag name and whether it is a closing tag.
645
+ * Returns null for non-html tokens or raw that isn't a recognizable HTML tag.
646
+ */
647
+ function inlineHtmlTag(token: Token): { name: string; closing: boolean } | null {
648
+ if ((token as { type: string }).type !== "html") return null;
649
+ const raw = (token as { raw?: unknown }).raw;
650
+ if (typeof raw !== "string") return null;
651
+ const name = htmlTagName(raw);
652
+ if (!name) return null;
653
+ return { name, closing: /^<\s*\//.test(raw) };
654
+ }
655
+
656
+ /**
657
+ * Collapse inline `<code>…</code>` runs — which marked emits as separate `html`
658
+ * open/close tokens around the literal content — into a single synthetic
659
+ * `codespan` token, so they render with the theme's inline-code styling instead
660
+ * of leaking the raw tags. HTML entities inside the run are decoded. Stray or
661
+ * unmatched code tags are dropped; other inline html tokens pass through for the
662
+ * `html` render path to normalize. Returns the original array when no `<code>`
663
+ * tag is present (the common case).
664
+ */
665
+ function collapseInlineHtml(tokens: Token[]): Token[] {
666
+ let hasCode = false;
667
+ for (const token of tokens) {
668
+ if (inlineHtmlTag(token)?.name === "code") {
669
+ hasCode = true;
670
+ break;
671
+ }
672
+ }
673
+ if (!hasCode) return tokens;
674
+
675
+ const out: Token[] = [];
676
+ for (let i = 0; i < tokens.length; i++) {
677
+ const tag = inlineHtmlTag(tokens[i]);
678
+ if (tag?.name === "code") {
679
+ if (tag.closing) continue; // stray `</code>` — drop it
680
+ let j = i + 1;
681
+ for (; j < tokens.length; j++) {
682
+ const close = inlineHtmlTag(tokens[j]);
683
+ if (close?.name === "code" && close.closing) break;
684
+ }
685
+ if (j >= tokens.length) continue; // unmatched `<code>` — drop it, render the rest normally
686
+ const text = normalizeHtmlEntitiesForTerminal(plainInlineTokens(tokens.slice(i + 1, j)));
687
+ out.push({ type: "codespan", raw: text, text } as Token);
688
+ i = j;
689
+ continue;
690
+ }
691
+ out.push(tokens[i]);
692
+ }
693
+ return out;
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // Inline hex-color swatches
698
+ // ---------------------------------------------------------------------------
699
+ // When prose/thinking mentions a CSS hex color (e.g. #C5FFD6 or `#C5FFD6`),
700
+ // render a small chip painted with that color just before the code. The chip
701
+ // glyph comes from the theme's symbol set (ASCII → Unicode → Nerd Font), so it
702
+ // degrades gracefully; the color itself is exact 24-bit on truecolor terminals
703
+ // and the nearest 256-color cell otherwise (Bun.color quantizes for us).
704
+
705
+ /** Fallback chip when the theme supplies no `colorSwatch` symbol (Unicode default). */
706
+ const DEFAULT_COLOR_SWATCH_GLYPH = "■";
707
+
708
+ // `#` + 3-8 hex digits, not glued to a surrounding word/`#`/`&` (avoids HTML
709
+ // entities like &#9731; and paths like foo#fff) and not trailed by more hex
710
+ // (so over-long runs never produce a misleading swatch). Length/letter rules
711
+ // are enforced in classifyHexColor since the alternation can't express "exactly
712
+ // 3, 6, or 8".
713
+ const HEX_COLOR_REGEX = /(?<![\w#&])#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
714
+ const HEX_COLOR_EXACT_REGEX = /^#([0-9a-fA-F]{3,8})$/;
715
+
716
+ /**
717
+ * Decide whether a run of hex digits denotes a renderable CSS color.
718
+ *
719
+ * Only the canonical CSS lengths (#RGB, #RRGGBB, #RRGGBBAA) qualify. The 4-digit
720
+ * #RGBA form is deliberately excluded: it collides with hashline `#TAG` snapshot
721
+ * tags (4 hex digits, e.g. #6C5E), which would otherwise sprout spurious swatches.
722
+ * In `strict` mode (bare prose) a 3-digit run must contain a hex letter, so the
723
+ * far more common short issue/PR references (#123, #1011) don't sprout swatches.
724
+ * Codespans opt out of strictness — the backticks already signal "this is a color".
725
+ */
726
+ function classifyHexColor(hex: string, strict: boolean): boolean {
727
+ const n = hex.length;
728
+ if (n !== 3 && n !== 6 && n !== 8) return false;
729
+ if (strict && n === 3 && !/[a-fA-F]/.test(hex)) return false;
730
+ return true;
731
+ }
732
+
733
+ /** ANSI-painted `glyph` for `#${hex}`, or "" when the color can't be encoded. */
734
+ function colorSwatch(hex: string, glyph: string): string {
735
+ const ansi = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
736
+ // Reset only the foreground (\x1b[39m) so an enclosing background/decoration
737
+ // applied later by the line renderer survives across the swatch.
738
+ return ansi ? `${ansi}${glyph}\x1b[39m ` : "";
739
+ }
740
+
741
+ /**
742
+ * Style a plain-text run, inserting a color swatch before each hex color it
743
+ * mentions. Non-color text (including the matched `#hex` itself) is routed
744
+ * through `applySegment` so the caller's base styling is preserved verbatim.
745
+ */
746
+ function renderTextWithSwatches(text: string, applySegment: (t: string) => string, glyph: string): string {
747
+ HEX_COLOR_REGEX.lastIndex = 0;
748
+ let result = "";
749
+ let last = 0;
750
+ for (;;) {
751
+ const match = HEX_COLOR_REGEX.exec(text);
752
+ if (match === null) break;
753
+ if (!classifyHexColor(match[1], true)) continue;
754
+ const swatch = colorSwatch(match[1], glyph);
755
+ if (!swatch) continue;
756
+ if (match.index > last) result += applySegment(text.slice(last, match.index));
757
+ result += swatch + applySegment(match[0]);
758
+ last = match.index + match[0].length;
759
+ }
760
+ if (last === 0) return applySegment(text);
761
+ if (last < text.length) result += applySegment(text.slice(last));
762
+ return result;
763
+ }
764
+
765
+ /** Swatch for a codespan whose entire content is a single hex color, else "". */
766
+ function codespanSwatch(code: string, glyph: string): string {
767
+ const match = HEX_COLOR_EXACT_REGEX.exec(code.trim());
768
+ if (!match || !classifyHexColor(match[1], false)) return "";
769
+ return colorSwatch(match[1], glyph);
770
+ }
771
+
772
+ interface RenderSignature {
773
+ width: number;
774
+ paddingX: number;
775
+ paddingY: number;
776
+ codeBlockIndent: number;
777
+ themeId: number;
778
+ defaultTextStyleId: number;
779
+ imageProtocol: string;
780
+ hyperlinks: boolean;
781
+ textSizing: boolean;
782
+ bgColorProbe: string;
783
+ headingProbe: string;
784
+ }
785
+
786
+ interface StreamPrefixLineCache extends RenderSignature {
787
+ text: string;
788
+ tokenCount: number;
789
+ lines: readonly string[];
790
+ }
791
+
792
+ export class Markdown implements Component {
793
+ #text: string;
794
+ #paddingX: number; // Left/right padding
795
+ #paddingY: number; // Top/bottom padding
796
+ #defaultTextStyle?: DefaultTextStyle;
797
+ #theme: MarkdownTheme;
798
+ #defaultStylePrefix?: string;
799
+ /** Number of spaces used to indent code block content. */
800
+ #codeBlockIndent: number;
801
+
802
+ // Cache for rendered output. Cached arrays are shared and returned by
803
+ // reference (render contract: results are component-owned and immutable to
804
+ // callers); the L2 LRU may hand the same array to multiple instances.
805
+ #cachedText?: string;
806
+ #cachedWidth?: number;
807
+ #cachedLines?: readonly string[];
808
+ #transientRenderCache = false;
809
+
810
+ // Streaming-lex cache: the largest blank-line-bounded prefix of #text whose
811
+ // block tokens are frozen, plus those tokens. marked has no resumable lexer,
812
+ // but block tokenization is local across a "\n\n" boundary with balanced
813
+ // fences, so lex(prefix) ++ lex(tail) === lex(prefix+tail). On append-only
814
+ // growth (the streaming path) this re-lexes only the grown tail instead of the
815
+ // whole buffer, turning O(N^2) reveal cost into O(N). Width/theme do not affect
816
+ // tokenization, so this cache is independent of the render caches above.
817
+ #streamPrefixText?: string;
818
+ #streamPrefixTokens?: Token[];
819
+ #streamPrefixLineCache?: StreamPrefixLineCache;
820
+
821
+ #ignoreTight = false;
822
+
823
+ setIgnoreTight(ignore: boolean): this {
824
+ this.#ignoreTight = ignore;
825
+ this.invalidate();
826
+ return this;
827
+ }
828
+
829
+ constructor(
830
+ text: string,
831
+ paddingX: number,
832
+ paddingY: number,
833
+ theme: MarkdownTheme,
834
+ defaultTextStyle?: DefaultTextStyle,
835
+ codeBlockIndent: number = 2,
836
+ ) {
837
+ this.#text = text;
838
+ this.#paddingX = paddingX;
839
+ this.#paddingY = paddingY;
840
+ this.#theme = theme;
841
+ this.#defaultTextStyle = defaultTextStyle;
842
+ this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
843
+ }
844
+
845
+ setText(text: string): void {
846
+ this.#text = text;
847
+ if (!text.trim()) {
848
+ // Blank replacement: render() early-returns before #lexTokens can see
849
+ // the non-append edit, so drop the frozen stream state here or it
850
+ // outlives the content it indexed.
851
+ this.#streamPrefixText = undefined;
852
+ this.#streamPrefixTokens = undefined;
853
+ this.#streamPrefixLineCache = undefined;
854
+ }
855
+ this.invalidate();
856
+ }
857
+
858
+ invalidate(): void {
859
+ this.#cachedText = undefined;
860
+ this.#cachedWidth = undefined;
861
+ this.#cachedLines = undefined;
862
+ }
863
+ get transientRenderCache(): boolean {
864
+ return this.#transientRenderCache;
865
+ }
866
+
867
+ set transientRenderCache(value: boolean) {
868
+ const next = value === true;
869
+ if (this.#transientRenderCache === next) return;
870
+ this.#transientRenderCache = next;
871
+ this.invalidate();
872
+ }
873
+
874
+ // Lex `text` into block tokens, reusing the frozen stable prefix when the text
875
+ // only grew (the streaming path). Falls back to a full lex whenever the prefix
876
+ // is no longer a prefix (non-append edit), the text carries reference-link
877
+ // definitions, or it contains CR (marked normalizes CRLF, which would desync
878
+ // raw-span offsets). Every fallback is correctness-preserving — only speed
879
+ // differs; the render loop sees the identical token list either way.
880
+ #lexTokens(text: string): Token[] {
881
+ const canStream = !HAS_REF_DEF.test(text) && !text.includes("\r");
882
+ const prefix = this.#streamPrefixText;
883
+ const prefixTokens = this.#streamPrefixTokens;
884
+ if (
885
+ canStream &&
886
+ prefix !== undefined &&
887
+ prefixTokens !== undefined &&
888
+ text.length > prefix.length &&
889
+ text.startsWith(prefix)
890
+ ) {
891
+ const tailTokens = markdownParser.lexer(text.slice(prefix.length));
892
+ const tokens = [...prefixTokens, ...tailTokens];
893
+ this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
894
+ return tokens;
895
+ }
896
+ const tokens = markdownParser.lexer(text);
897
+ if (canStream) {
898
+ this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
899
+ } else {
900
+ this.#streamPrefixText = undefined;
901
+ this.#streamPrefixTokens = undefined;
902
+ this.#streamPrefixLineCache = undefined;
903
+ }
904
+ return tokens;
905
+ }
906
+
907
+ // Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
908
+ // (complete and immutable under append-only growth) so the next streaming
909
+ // render re-lexes only the unfrozen tail. Caller guarantees no CR / no
910
+ // reference definitions, so each token's `raw` is a verbatim slice of `text`
911
+ // and the summed offsets address `text` exactly.
912
+ #freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
913
+ let pos = 0;
914
+ let frozenEnd = 0;
915
+ let frozenCount = 0;
916
+ for (let i = 0; i < tokens.length; i++) {
917
+ const raw = tokens[i].raw;
918
+ const end = pos + raw.length;
919
+ // A `space` token ending in "\n\n" closes the preceding block, but a
920
+ // `list` before it can still be extended by a following same-marker
921
+ // item across the blank line (CommonMark loose-list continuation),
922
+ // which marked merges into one renumbered loose list. Freezing across
923
+ // such a cut would keep the lists separate. Never freeze right after a
924
+ // list — it stays in the re-lexed tail.
925
+ if (raw.endsWith("\n\n") && tokens[i - 1]?.type !== "list") {
926
+ frozenEnd = end;
927
+ frozenCount = i + 1;
928
+ }
929
+ pos = end;
930
+ }
931
+ // Freeze only when the tail begins with real block content. If the next
932
+ // char is whitespace (an extra blank line, or an indented continuation),
933
+ // the block separator straddles the cut and lex(prefix)++lex(tail) would
934
+ // desync from a full lex — e.g. a fence followed by "\n\n\n- list". When
935
+ // frozenEnd is at end-of-text the next char is unknown, so defer.
936
+ if (frozenCount > 0 && frozenEnd < text.length) {
937
+ const next = text.charCodeAt(frozenEnd);
938
+ if (next !== 0x20 /* space */ && next !== 0x0a /* \n */) {
939
+ this.#streamPrefixText = text.slice(0, frozenEnd);
940
+ this.#streamPrefixTokens = tokens.slice(0, frozenCount);
941
+ return;
942
+ }
943
+ }
944
+
945
+ if (!opts.preserveExisting) {
946
+ this.#streamPrefixText = undefined;
947
+ this.#streamPrefixTokens = undefined;
948
+ this.#streamPrefixLineCache = undefined;
949
+ }
950
+ }
951
+
952
+ render(width: number): readonly string[] {
953
+ // L1: per-instance cache — fastest path for repeated renders of the same
954
+ // instance at the same width (e.g. resize debounce, repeated redraws).
955
+ // Returning the cached reference is load-bearing: parents memoize their
956
+ // concatenation on reference equality.
957
+ if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
958
+ return this.#cachedLines;
959
+ }
960
+
961
+ // Calculate available width for content (subtract horizontal padding)
962
+ const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
963
+ const contentWidth = Math.max(1, width - paddingX * 2);
964
+
965
+ // Don't render anything if there's no actual text
966
+ if (!this.#text || this.#text.trim() === "") {
967
+ this.#cachedText = this.#text;
968
+ this.#cachedWidth = width;
969
+ this.#cachedLines = EMPTY_RENDER_LINES;
970
+ return EMPTY_RENDER_LINES;
971
+ }
972
+
973
+ // Replace tabs with 3 spaces for consistent rendering
974
+ const normalizedText = replaceTabs(this.#text);
975
+ const signature = this.#renderSignature(width, paddingX);
976
+
977
+ // L2: module-level LRU — survives component disposal/recreation across
978
+ // session-tree navigations. Key encodes every dimension that affects the
979
+ // render output so different configurations never collide.
980
+ // Encode terminal capability state and theme/style function output samples
981
+ // so that capability shifts (image protocol changes, hyperlink toggle) or
982
+ // caller-supplied theme/bgColor functions that mutate their output without
983
+ // changing object identity invalidate the cache entry.
984
+ // bgColor probe uses \x01 (single non-printable byte): chalk/ANSI wrappers
985
+ // pass arbitrary bytes through verbatim, so this is safe and minimizes the
986
+ // risk of clashing with a function that returns text verbatim.
987
+ // theme.heading is used as the representative theme probe — it's required
988
+ // by MarkdownTheme and is one of the most styling-sensitive entries.
989
+ let cacheKey: string | undefined;
990
+ if (!this.transientRenderCache) {
991
+ cacheKey = this.#renderCacheKey(normalizedText, signature);
992
+ const cached = renderCache.get(cacheKey);
993
+ if (cached !== undefined) {
994
+ // Populate L1 so subsequent calls from this instance are O(1) map lookup.
995
+ this.#cachedText = this.#text;
996
+ this.#cachedWidth = width;
997
+ this.#cachedLines = cached;
998
+ return cached;
999
+ }
1000
+ }
1001
+
1002
+ // Parse markdown to HTML-like tokens
1003
+ const tokens = this.#lexTokens(normalizedText);
1004
+ const contentLines = this.transientRenderCache
1005
+ ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1006
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1007
+ const emptyLines = this.#renderEmptyPaddingLines(signature);
1008
+
1009
+ // Combine top padding, content, and bottom padding
1010
+ const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
1011
+ const result = rawResult.length > 0 ? rawResult : [""];
1012
+
1013
+ // Update caches and hand the array out by reference. Callers must not
1014
+ // mutate it (Component render contract); the L2 entry is shared across
1015
+ // instances keyed on identical inputs.
1016
+ this.#cachedText = this.#text;
1017
+ this.#cachedWidth = width;
1018
+ this.#cachedLines = result;
1019
+
1020
+ // Update L2 module-level LRU so future instances with the same key skip
1021
+ // the marked.lexer + highlightCode (Rust FFI) work entirely.
1022
+ if (cacheKey !== undefined) {
1023
+ renderCache.set(cacheKey, result);
1024
+ }
1025
+
1026
+ return result;
1027
+ }
1028
+
1029
+ #renderSignature(width: number, paddingX: number): RenderSignature {
1030
+ const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
1031
+ const headingProbe = this.#theme.heading("");
1032
+ return {
1033
+ width,
1034
+ paddingX,
1035
+ paddingY: this.#paddingY,
1036
+ codeBlockIndent: this.#codeBlockIndent,
1037
+ themeId: objectId(this.#theme),
1038
+ defaultTextStyleId: this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1,
1039
+ imageProtocol: TERMINAL.imageProtocol ?? "",
1040
+ hyperlinks: TERMINAL.hyperlinks,
1041
+ textSizing: TERMINAL.textSizing,
1042
+ bgColorProbe,
1043
+ headingProbe,
1044
+ };
1045
+ }
1046
+
1047
+ #renderCacheKey(normalizedText: string, signature: RenderSignature): string {
1048
+ 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}`;
1049
+ }
1050
+
1051
+ #renderStreamingContentLines(
1052
+ tokens: Token[],
1053
+ normalizedText: string,
1054
+ signature: RenderSignature,
1055
+ contentWidth: number,
1056
+ ): string[] {
1057
+ const frozenText = this.#streamPrefixText;
1058
+ const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
1059
+ if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
1060
+ return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1061
+ }
1062
+
1063
+ const contentLines: string[] = [];
1064
+ const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
1065
+ let renderedUntil = 0;
1066
+ if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
1067
+ contentLines.push(...reusablePrefix.lines);
1068
+ renderedUntil = reusablePrefix.tokenCount;
1069
+ }
1070
+
1071
+ if (renderedUntil < frozenTokenCount) {
1072
+ contentLines.push(
1073
+ ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
1074
+ );
1075
+ renderedUntil = frozenTokenCount;
1076
+ }
1077
+
1078
+ this.#streamPrefixLineCache = {
1079
+ ...signature,
1080
+ text: frozenText,
1081
+ tokenCount: frozenTokenCount,
1082
+ lines: contentLines.slice(),
1083
+ };
1084
+
1085
+ if (renderedUntil < tokens.length) {
1086
+ contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
1087
+ }
1088
+
1089
+ return contentLines;
1090
+ }
1091
+
1092
+ #matchingStreamPrefixLineCache(
1093
+ normalizedText: string,
1094
+ frozenText: string,
1095
+ signature: RenderSignature,
1096
+ ): StreamPrefixLineCache | undefined {
1097
+ const cache = this.#streamPrefixLineCache;
1098
+ if (!cache) return undefined;
1099
+ if (!normalizedText.startsWith(cache.text) || !frozenText.startsWith(cache.text)) return undefined;
1100
+ if (cache.width !== signature.width) return undefined;
1101
+ if (cache.paddingX !== signature.paddingX) return undefined;
1102
+ if (cache.paddingY !== signature.paddingY) return undefined;
1103
+ if (cache.codeBlockIndent !== signature.codeBlockIndent) return undefined;
1104
+ if (cache.themeId !== signature.themeId) return undefined;
1105
+ if (cache.defaultTextStyleId !== signature.defaultTextStyleId) return undefined;
1106
+ if (cache.imageProtocol !== signature.imageProtocol) return undefined;
1107
+ if (cache.hyperlinks !== signature.hyperlinks) return undefined;
1108
+ if (cache.textSizing !== signature.textSizing) return undefined;
1109
+ if (cache.bgColorProbe !== signature.bgColorProbe) return undefined;
1110
+ if (cache.headingProbe !== signature.headingProbe) return undefined;
1111
+ return cache;
1112
+ }
1113
+
1114
+ #renderContentLines(
1115
+ tokens: Token[],
1116
+ start: number,
1117
+ end: number,
1118
+ contentWidth: number,
1119
+ signature: RenderSignature,
1120
+ ): string[] {
1121
+ const renderedLines: string[] = [];
1122
+ for (let i = start; i < end; i++) {
1123
+ const token = tokens[i];
1124
+ const nextToken = tokens[i + 1];
1125
+ renderedLines.push(...this.#renderToken(token, contentWidth, nextToken?.type));
1126
+ }
1127
+
1128
+ const wrappedLines: string[] = [];
1129
+ for (const line of renderedLines) {
1130
+ // Skip wrapping for image protocol lines and OSC 66 sized headings
1131
+ // (would corrupt escape sequences / split the indivisible sized span).
1132
+ if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
1133
+ wrappedLines.push(line);
1134
+ } else {
1135
+ wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
1136
+ }
1137
+ }
1138
+
1139
+ const leftMargin = padding(signature.paddingX);
1140
+ const rightMargin = padding(signature.paddingX);
1141
+ const bgFn = this.#defaultTextStyle?.bgColor;
1142
+ const contentLines: string[] = [];
1143
+ let previousLineWasOsc66 = false;
1144
+
1145
+ for (const line of wrappedLines) {
1146
+ // The first empty row after a scale>1 OSC 66 heading is structural:
1147
+ // it reserves the lower cells occupied by the multicell glyphs. Do
1148
+ // not pad or background-fill it, because real spaces on that row can
1149
+ // interact with Kitty's multicell overwrite rules during the first
1150
+ // paint. Leave it as a cursor-only newline.
1151
+ if (previousLineWasOsc66 && line === "") {
1152
+ contentLines.push("");
1153
+ previousLineWasOsc66 = false;
1154
+ continue;
1155
+ }
1156
+
1157
+ // Image lines and OSC 66 sized headings must be output raw - no margins or background
1158
+ if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
1159
+ contentLines.push(line);
1160
+ previousLineWasOsc66 = isOsc66Line(line);
1161
+ continue;
1162
+ }
1163
+
1164
+ previousLineWasOsc66 = false;
1165
+ const lineWithMargins = leftMargin + line + rightMargin;
1166
+
1167
+ if (bgFn) {
1168
+ contentLines.push(applyBackgroundToLine(lineWithMargins, signature.width, bgFn));
1169
+ } else {
1170
+ // No background - just pad to width
1171
+ const visibleLen = visibleWidth(lineWithMargins);
1172
+ const paddingNeeded = Math.max(0, signature.width - visibleLen);
1173
+ contentLines.push(lineWithMargins + padding(paddingNeeded));
1174
+ }
1175
+ }
1176
+
1177
+ return contentLines;
1178
+ }
1179
+
1180
+ #renderEmptyPaddingLines(signature: RenderSignature): string[] {
1181
+ const emptyLine = padding(signature.width);
1182
+ const emptyLines: string[] = [];
1183
+ const bgFn = this.#defaultTextStyle?.bgColor;
1184
+ for (let i = 0; i < signature.paddingY; i++) {
1185
+ const line = bgFn ? applyBackgroundToLine(emptyLine, signature.width, bgFn) : emptyLine;
1186
+ emptyLines.push(line);
1187
+ }
1188
+ return emptyLines;
1189
+ }
1190
+
1191
+ /**
1192
+ * Apply default text style to a string.
1193
+ * This is the base styling applied to all text content.
1194
+ * NOTE: Background color is NOT applied here - it's applied at the padding stage
1195
+ * to ensure it extends to the full line width.
1196
+ */
1197
+ #applyDefaultStyle(text: string): string {
1198
+ if (!this.#defaultTextStyle) {
1199
+ return text;
1200
+ }
1201
+
1202
+ let styled = text;
1203
+
1204
+ // Apply foreground color (NOT background - that's applied at padding stage)
1205
+ if (this.#defaultTextStyle.color) {
1206
+ styled = this.#defaultTextStyle.color(styled);
1207
+ }
1208
+
1209
+ // Apply text decorations using this.#theme
1210
+ if (this.#defaultTextStyle.bold) {
1211
+ styled = this.#theme.bold(styled);
1212
+ }
1213
+ if (this.#defaultTextStyle.italic) {
1214
+ styled = this.#theme.italic(styled);
1215
+ }
1216
+ if (this.#defaultTextStyle.strikethrough) {
1217
+ styled = this.#theme.strikethrough(styled);
1218
+ }
1219
+ if (this.#defaultTextStyle.underline) {
1220
+ styled = this.#theme.underline(styled);
1221
+ }
1222
+
1223
+ return styled;
1224
+ }
1225
+
1226
+ #getDefaultStylePrefix(): string {
1227
+ if (!this.#defaultTextStyle) {
1228
+ return "";
1229
+ }
1230
+
1231
+ if (this.#defaultStylePrefix !== undefined) {
1232
+ return this.#defaultStylePrefix;
1233
+ }
1234
+
1235
+ const sentinel = "\u0000";
1236
+ let styled = sentinel;
1237
+
1238
+ if (this.#defaultTextStyle.color) {
1239
+ styled = this.#defaultTextStyle.color(styled);
1240
+ }
1241
+
1242
+ if (this.#defaultTextStyle.bold) {
1243
+ styled = this.#theme.bold(styled);
1244
+ }
1245
+ if (this.#defaultTextStyle.italic) {
1246
+ styled = this.#theme.italic(styled);
1247
+ }
1248
+ if (this.#defaultTextStyle.strikethrough) {
1249
+ styled = this.#theme.strikethrough(styled);
1250
+ }
1251
+ if (this.#defaultTextStyle.underline) {
1252
+ styled = this.#theme.underline(styled);
1253
+ }
1254
+
1255
+ const sentinelIndex = styled.indexOf(sentinel);
1256
+ this.#defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
1257
+ return this.#defaultStylePrefix;
1258
+ }
1259
+
1260
+ #getStylePrefix(styleFn: (text: string) => string): string {
1261
+ const sentinel = "\u0000";
1262
+ const styled = styleFn(sentinel);
1263
+ const sentinelIndex = styled.indexOf(sentinel);
1264
+ return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
1265
+ }
1266
+
1267
+ #getDefaultInlineStyleContext(): InlineStyleContext {
1268
+ return {
1269
+ applyText: (text: string) => this.#applyDefaultStyle(text),
1270
+ stylePrefix: this.#getDefaultStylePrefix(),
1271
+ };
1272
+ }
1273
+
1274
+ #renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] {
1275
+ const lines: string[] = [];
1276
+
1277
+ // Display math block (own-line `$$…$$` / `\[…\]`): stack `\frac` vertically
1278
+ // and keep `\\` row breaks, so fractions and matrices span multiple lines.
1279
+ if (isMathToken(token)) {
1280
+ for (const mathLine of latexToBlock(token.text)) lines.push(this.#applyDefaultStyle(mathLine));
1281
+ if (nextTokenType && nextTokenType !== "space") lines.push("");
1282
+ return lines;
1283
+ }
1284
+
1285
+ switch (token.type) {
1286
+ case "heading": {
1287
+ const headingLevel = token.depth;
1288
+ const headingPrefix = `${"#".repeat(headingLevel)} `;
1289
+ const headingText = this.#renderInlineTokens(token.tokens || [], styleContext);
1290
+ const headingPlainText = plainInlineTokens(token.tokens || []);
1291
+ let styledHeading: string;
1292
+ if (headingLevel === 1 && TERMINAL.textSizing) {
1293
+ const plainWidth = visibleWidth(headingPlainText);
1294
+ if (plainWidth > 0 && 2 * plainWidth <= width) {
1295
+ const sizedHeading = encodeTextSizedHeading(headingPlainText, 2);
1296
+ lines.push(this.#theme.heading(this.#theme.bold(this.#theme.underline(sizedHeading))));
1297
+ lines.push(""); // reserve the heading's second visual row
1298
+ if (nextTokenType && nextTokenType !== "space") {
1299
+ lines.push(""); // Add spacing after headings (unless space token follows)
1300
+ }
1301
+ break;
1302
+ }
1303
+ }
1304
+ if (headingLevel === 1) {
1305
+ styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText)));
1306
+ } else if (headingLevel === 2) {
1307
+ styledHeading = this.#theme.heading(this.#theme.bold(headingText));
1308
+ } else {
1309
+ styledHeading = this.#theme.heading(this.#theme.bold(headingPrefix + headingText));
1310
+ }
1311
+ lines.push(styledHeading);
1312
+ if (nextTokenType && nextTokenType !== "space") {
1313
+ lines.push(""); // Add spacing after headings (unless space token follows)
1314
+ }
1315
+ break;
1316
+ }
1317
+
1318
+ case "paragraph": {
1319
+ const displayMath = soleDisplayMath(token.tokens);
1320
+ if (displayMath) {
1321
+ for (const mathLine of latexToBlock(displayMath.text)) lines.push(this.#applyDefaultStyle(mathLine));
1322
+ if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") lines.push("");
1323
+ break;
1324
+ }
1325
+ const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
1326
+ lines.push(paragraphText);
1327
+ // Don't add spacing if next token is space or list
1328
+ if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
1329
+ lines.push("");
1330
+ }
1331
+ break;
1332
+ }
1333
+
1334
+ case "code": {
1335
+ // Mermaid diagrams render as ASCII art when the theme supplies a
1336
+ // resolver. The art is preformatted, so clip each row to the content
1337
+ // width: the later wrap pass would otherwise fragment the box-drawing
1338
+ // canvas. truncateToWidth is ANSI- and wide-char-aware, and the
1339
+ // resolver already re-fits over-wide horizontal graphs top-down.
1340
+ if (token.lang === "mermaid" && this.#theme.resolveMermaidAscii) {
1341
+ const ascii = this.#theme.resolveMermaidAscii(token.text, width);
1342
+ if (ascii) {
1343
+ for (const asciiLine of ascii.split("\n")) {
1344
+ lines.push(
1345
+ visibleWidth(asciiLine) > width ? truncateToWidth(asciiLine, width, Ellipsis.Omit) : asciiLine,
1346
+ );
1347
+ }
1348
+ if (nextTokenType && nextTokenType !== "space") {
1349
+ lines.push("");
1350
+ }
1351
+ break;
1352
+ }
1353
+ }
1354
+
1355
+ const codeIndent = padding(this.#codeBlockIndent);
1356
+ lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
1357
+ if (this.#theme.highlightCode && !this.transientRenderCache) {
1358
+ const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1359
+ for (const hlLine of highlightedLines) {
1360
+ lines.push(`${codeIndent}${hlLine}`);
1361
+ }
1362
+ } else {
1363
+ // Split code by newlines and style each line
1364
+ const codeLines = token.text.split("\n");
1365
+ for (const codeLine of codeLines) {
1366
+ lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1367
+ }
1368
+ }
1369
+ lines.push(this.#theme.codeBlockBorder("```"));
1370
+ if (nextTokenType && nextTokenType !== "space") {
1371
+ lines.push(""); // Add spacing after code blocks (unless space token follows)
1372
+ }
1373
+ break;
1374
+ }
1375
+
1376
+ case "list": {
1377
+ const listLines = this.#renderList(token as ListToken, 0, styleContext);
1378
+ lines.push(...listLines);
1379
+ // Don't add spacing after lists if a space token follows
1380
+ // (the space token will handle it)
1381
+ break;
1382
+ }
1383
+
1384
+ case "table": {
1385
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
1386
+ lines.push(...tableLines);
1387
+ break;
1388
+ }
1389
+
1390
+ case "blockquote": {
1391
+ const quoteInlineStyleContext: InlineStyleContext = {
1392
+ applyText: (text: string) => text,
1393
+ stylePrefix: "",
1394
+ };
1395
+ const quoteContentWidth = Math.max(1, width - 2);
1396
+ const quoteTokens = token.tokens || [];
1397
+ const renderedQuoteLines: string[] = [];
1398
+
1399
+ for (let i = 0; i < quoteTokens.length; i++) {
1400
+ const quoteToken = quoteTokens[i];
1401
+ const nextQuoteToken = quoteTokens[i + 1];
1402
+ renderedQuoteLines.push(
1403
+ ...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
1404
+ );
1405
+ }
1406
+
1407
+ while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
1408
+ renderedQuoteLines.pop();
1409
+ }
1410
+
1411
+ lines.push(...this.#applyQuoteBorder(renderedQuoteLines, width));
1412
+ if (nextTokenType && nextTokenType !== "space") {
1413
+ lines.push(""); // Add spacing after blockquotes (unless space token follows)
1414
+ }
1415
+ break;
1416
+ }
1417
+
1418
+ case "hr": {
1419
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw.trim() : "";
1420
+ lines.push(this.#renderHrLine(width, raw[0] || ""));
1421
+ if (nextTokenType && nextTokenType !== "space") {
1422
+ lines.push(""); // Add spacing after horizontal rules (unless space token follows)
1423
+ }
1424
+ break;
1425
+ }
1426
+
1427
+ case "html":
1428
+ if ("raw" in token && typeof token.raw === "string") {
1429
+ lines.push(...this.#renderHtmlBlock(token.raw, width));
1430
+ }
1431
+ break;
1432
+
1433
+ case "space":
1434
+ // Space tokens represent blank lines in markdown
1435
+ lines.push("");
1436
+ break;
1437
+
1438
+ default:
1439
+ // Handle any other token types as plain text
1440
+ if ("text" in token && typeof token.text === "string") {
1441
+ lines.push(token.text);
1442
+ }
1443
+ }
1444
+
1445
+ return lines;
1446
+ }
1447
+
1448
+ /** Render a horizontal rule line themed to `width`, matching `sourceChar` when given. */
1449
+ #renderHrLine(width: number, sourceChar = ""): string {
1450
+ const fillChar = getHrChar(sourceChar, this.#theme.symbols.hrChar);
1451
+ return this.#theme.hr(fillChar.repeat(Math.min(width, 80)));
1452
+ }
1453
+
1454
+ /**
1455
+ * Wrap already-rendered lines in the blockquote border and quote styling.
1456
+ * `width` is the full content width; the border reserves two cells.
1457
+ */
1458
+ #applyQuoteBorder(renderedLines: string[], width: number): string[] {
1459
+ const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
1460
+ const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
1461
+ const applyQuoteStyle = (line: string): string => {
1462
+ if (!quoteStylePrefix) {
1463
+ return quoteStyle(line);
1464
+ }
1465
+ const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
1466
+ return quoteStyle(lineWithReappliedStyle);
1467
+ };
1468
+ const quoteContentWidth = Math.max(1, width - 2);
1469
+ const lines: string[] = [];
1470
+ for (const quoteLine of renderedLines) {
1471
+ const styledLine = applyQuoteStyle(quoteLine);
1472
+ for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
1473
+ lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
1474
+ }
1475
+ }
1476
+ return lines;
1477
+ }
1478
+
1479
+ /**
1480
+ * Render a block-level `html` token to styled lines. Standalone `<hr>` tags
1481
+ * become rules and balanced `<blockquote>…</blockquote>` regions render with
1482
+ * quote styling; the remaining markup is normalized to terminal text (entities
1483
+ * decoded, `<code>` themed, lists/`<br>`/`<p>` laid out).
1484
+ */
1485
+ #renderHtmlBlock(raw: string, width: number): string[] {
1486
+ const lines: string[] = [];
1487
+ const state = createHtmlNormalizationState();
1488
+ const codeHook = (text: string): string => this.#theme.code(text) + this.#getDefaultStylePrefix();
1489
+ const flushText = (chunk: string): void => {
1490
+ const cleaned = normalizeHtmlForTerminal(chunk, state, codeHook);
1491
+ if (cleaned.trim() === "") return;
1492
+ for (const line of splitTerminalLines(cleaned)) {
1493
+ const trimmed = line.trimEnd();
1494
+ lines.push(trimmed.trim() === "" ? "" : this.#applyDefaultStyle(trimmed));
1495
+ }
1496
+ };
1497
+ let lastIndex = 0;
1498
+ BLOCK_HTML_REGEX.lastIndex = 0;
1499
+ for (let match = BLOCK_HTML_REGEX.exec(raw); match !== null; match = BLOCK_HTML_REGEX.exec(raw)) {
1500
+ flushText(raw.slice(lastIndex, match.index));
1501
+ lastIndex = match.index + match[0].length;
1502
+ if (match[1] !== undefined) {
1503
+ lines.push(...this.#renderHtmlBlockquote(match[1], width));
1504
+ } else {
1505
+ lines.push(this.#renderHrLine(width));
1506
+ }
1507
+ }
1508
+ flushText(raw.slice(lastIndex));
1509
+ return lines;
1510
+ }
1511
+
1512
+ /** Render the inner content of an HTML `<blockquote>` with quote styling. */
1513
+ #renderHtmlBlockquote(inner: string, width: number): string[] {
1514
+ const cleaned = normalizeHtmlForTerminal(inner, createHtmlNormalizationState(), text => this.#theme.code(text));
1515
+ const innerLines = splitTerminalLines(cleaned).map(line => line.trimEnd());
1516
+ while (innerLines.length > 0 && innerLines[innerLines.length - 1] === "") innerLines.pop();
1517
+ return this.#applyQuoteBorder(innerLines, width);
1518
+ }
1519
+
1520
+ #renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
1521
+ let result = "";
1522
+ const resolvedStyleContext = styleContext ?? this.#getDefaultInlineStyleContext();
1523
+ const { applyText, stylePrefix } = resolvedStyleContext;
1524
+ const applyTextWithNewlines = (text: string): string => {
1525
+ const segments: string[] = text.split("\n");
1526
+ return segments.map((segment: string) => (segment === "" ? "" : applyText(segment))).join("\n");
1527
+ };
1528
+ const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
1529
+ let trimLeadingWhitespace = false;
1530
+ const htmlState = createHtmlNormalizationState();
1531
+ const markHtmlItemWhenContent = (text: string): void => {
1532
+ markCurrentHtmlItemContent(htmlState, text);
1533
+ };
1534
+
1535
+ for (const token of collapseInlineHtml(tokens)) {
1536
+ if (isMathToken(token)) {
1537
+ markHtmlItemWhenContent(token.text);
1538
+ result += applyTextWithNewlines(renderMathToken(token.text));
1539
+ continue;
1540
+ }
1541
+ switch (token.type) {
1542
+ case "text": {
1543
+ const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
1544
+ const text = normalizeHtmlEntitiesForTerminal(rawText);
1545
+ trimLeadingWhitespace = false;
1546
+ markHtmlItemWhenContent(text);
1547
+ if (token.tokens) markHtmlItemWhenContent(plainInlineTokens(token.tokens));
1548
+ // Text tokens in list items can have nested tokens for inline formatting
1549
+ if (token.tokens && token.tokens.length > 0) {
1550
+ result += this.#renderInlineTokens(token.tokens, resolvedStyleContext);
1551
+ } else {
1552
+ result += renderTextWithSwatches(text, applyTextWithNewlines, swatchGlyph);
1553
+ }
1554
+ break;
1555
+ }
1556
+
1557
+ case "paragraph":
1558
+ // Paragraph tokens contain nested inline tokens
1559
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
1560
+ result += this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
1561
+ break;
1562
+
1563
+ case "strong": {
1564
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
1565
+ const boldContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
1566
+ result += this.#theme.bold(boldContent) + stylePrefix;
1567
+ break;
1568
+ }
1569
+
1570
+ case "em": {
1571
+ const italicContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
1572
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
1573
+ result += this.#theme.italic(italicContent) + stylePrefix;
1574
+ break;
1575
+ }
1576
+
1577
+ case "codespan": {
1578
+ markHtmlItemWhenContent(token.text);
1579
+ result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
1580
+ break;
1581
+ }
1582
+
1583
+ case "link": {
1584
+ markHtmlItemWhenContent(token.text);
1585
+ const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
1586
+ const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
1587
+ const clickableLinkText = formatHyperlink(styledLinkText, token.href);
1588
+ // If link text matches href, only show the link once
1589
+ // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
1590
+ // For mailto: links, strip the prefix before comparing (autolinked emails have
1591
+ // text="foo@bar.com" but href="mailto:foo@bar.com")
1592
+ const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
1593
+ if (token.text === token.href || token.text === hrefForComparison)
1594
+ result += clickableLinkText + stylePrefix;
1595
+ else {
1596
+ const styledLinkUrl = this.#theme.linkUrl(` (${token.href})`);
1597
+ result += clickableLinkText + formatHyperlink(styledLinkUrl, token.href) + stylePrefix;
1598
+ }
1599
+ break;
1600
+ }
1601
+
1602
+ case "br":
1603
+ result += "\n";
1604
+ trimLeadingWhitespace = true;
1605
+ break;
1606
+
1607
+ case "del": {
1608
+ const delContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
1609
+ markHtmlItemWhenContent(plainInlineTokens(token.tokens || []));
1610
+ result += this.#theme.strikethrough(delContent) + stylePrefix;
1611
+ break;
1612
+ }
1613
+
1614
+ case "html":
1615
+ if ("raw" in token && typeof token.raw === "string") {
1616
+ const cleaned = normalizeHtmlForTerminal(token.raw, htmlState);
1617
+ result += applyTextWithNewlines(cleaned);
1618
+ if (cleaned.endsWith("\n")) {
1619
+ trimLeadingWhitespace = true;
1620
+ } else if (cleaned.length > 0) {
1621
+ trimLeadingWhitespace = false;
1622
+ }
1623
+ }
1624
+ break;
1625
+
1626
+ default:
1627
+ // Handle any other inline token types as plain text
1628
+ if ("text" in token && typeof token.text === "string") {
1629
+ const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
1630
+ const text = normalizeHtmlEntitiesForTerminal(rawText);
1631
+ trimLeadingWhitespace = false;
1632
+ markHtmlItemWhenContent(text);
1633
+ result += applyTextWithNewlines(text);
1634
+ }
1635
+ }
1636
+ }
1637
+
1638
+ // Strip dangling re-opened-default SGR prefix left over from the last inline
1639
+ // token (strong/em/codespan/link/del/etc.) so the emitted line self-terminates
1640
+ // at its last styled segment instead of carrying an unmatched SGR open into
1641
+ // the next line. Matches upstream behavior.
1642
+ while (stylePrefix && result.endsWith(stylePrefix)) {
1643
+ result = result.slice(0, -stylePrefix.length);
1644
+ }
1645
+
1646
+ return result;
1647
+ }
1648
+
1649
+ /**
1650
+ * Render a list with proper nesting support
1651
+ */
1652
+ #renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] {
1653
+ const lines: string[] = [];
1654
+ const indent = " ".repeat(depth);
1655
+ // Use the list's start property (defaults to 1 for ordered lists)
1656
+ const startNumber = token.start ?? 1;
1657
+
1658
+ for (let i = 0; i < token.items.length; i++) {
1659
+ const item = token.items[i];
1660
+ const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
1661
+ // Continuation rows align under the item text, so the hang matches the
1662
+ // actual bullet width (`10. ` is 4 cells, not 2).
1663
+ const continuationIndent = indent + padding(bullet.length);
1664
+
1665
+ // Process item tokens; nested-list lines arrive structurally tagged and
1666
+ // already carry their own full indent.
1667
+ const itemLines = this.#renderListItem(item.tokens || [], depth, styleContext);
1668
+
1669
+ if (itemLines.length > 0) {
1670
+ const firstLine = itemLines[0]!;
1671
+ if (firstLine.nested) {
1672
+ // Nested list first - keep as-is (already has full indent)
1673
+ lines.push(firstLine.text);
1674
+ } else {
1675
+ // Regular text content - add indent and bullet
1676
+ lines.push(indent + this.#theme.listBullet(bullet) + firstLine.text);
1677
+ }
1678
+
1679
+ // Rest of the lines
1680
+ for (let j = 1; j < itemLines.length; j++) {
1681
+ const line = itemLines[j]!;
1682
+ if (line.nested) {
1683
+ // Nested list line - already has full indent
1684
+ lines.push(line.text);
1685
+ } else {
1686
+ // Regular content - hang under the item text
1687
+ lines.push(continuationIndent + line.text);
1688
+ }
1689
+ }
1690
+ } else {
1691
+ lines.push(indent + this.#theme.listBullet(bullet));
1692
+ }
1693
+ }
1694
+
1695
+ return lines;
1696
+ }
1697
+
1698
+ /**
1699
+ * Render list item tokens, handling nested lists.
1700
+ * Returns lines WITHOUT the parent indent (renderList adds it); lines that
1701
+ * belong to a nested list are tagged `nested` so the caller never has to
1702
+ * sniff theme-dependent ANSI bytes to recognize them.
1703
+ */
1704
+ #renderListItem(
1705
+ tokens: Token[],
1706
+ parentDepth: number,
1707
+ styleContext?: InlineStyleContext,
1708
+ ): Array<{ text: string; nested: boolean }> {
1709
+ const lines: Array<{ text: string; nested: boolean }> = [];
1710
+
1711
+ for (const token of tokens) {
1712
+ if (token.type === "list") {
1713
+ // Nested list - render with one additional indent level
1714
+ // These lines carry their own indent, so tag them for pass-through
1715
+ const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, styleContext);
1716
+ for (const nestedLine of nestedLines) {
1717
+ lines.push({ text: nestedLine, nested: true });
1718
+ }
1719
+ } else if (token.type === "text") {
1720
+ // Text content (may have inline tokens, or a sole display-math token)
1721
+ const displayMath = soleDisplayMath(token.tokens);
1722
+ if (displayMath) {
1723
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
1724
+ for (const mathLine of latexToBlock(displayMath.text))
1725
+ lines.push({ text: apply(mathLine), nested: false });
1726
+ } else {
1727
+ const text =
1728
+ token.tokens && token.tokens.length > 0
1729
+ ? this.#renderInlineTokens(token.tokens, styleContext)
1730
+ : token.text || "";
1731
+ lines.push({ text, nested: false });
1732
+ }
1733
+ } else if (token.type === "paragraph") {
1734
+ // Paragraph in list item
1735
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
1736
+ const displayMath = soleDisplayMath(token.tokens);
1737
+ if (displayMath) {
1738
+ for (const mathLine of latexToBlock(displayMath.text))
1739
+ lines.push({ text: apply(mathLine), nested: false });
1740
+ } else {
1741
+ lines.push({ text: this.#renderInlineTokens(token.tokens || [], styleContext), nested: false });
1742
+ }
1743
+ } else if (token.type === "code") {
1744
+ // Code block in list item
1745
+ const codeIndent = padding(this.#codeBlockIndent);
1746
+ lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
1747
+ if (this.#theme.highlightCode && !this.transientRenderCache) {
1748
+ const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1749
+ for (const hlLine of highlightedLines) {
1750
+ lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
1751
+ }
1752
+ } else {
1753
+ const codeLines = token.text.split("\n");
1754
+ for (const codeLine of codeLines) {
1755
+ lines.push({ text: `${codeIndent}${this.#theme.codeBlock(codeLine)}`, nested: false });
1756
+ }
1757
+ }
1758
+ lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
1759
+ } else if (isMathToken(token)) {
1760
+ // Display math block inside a list item: stack fractions / matrix rows.
1761
+ const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
1762
+ for (const mathLine of latexToBlock(token.text)) lines.push({ text: apply(mathLine), nested: false });
1763
+ } else {
1764
+ // Other token types - try to render as inline
1765
+ const text = this.#renderInlineTokens([token], styleContext);
1766
+ if (text) {
1767
+ lines.push({ text, nested: false });
1768
+ }
1769
+ }
1770
+ }
1771
+
1772
+ return lines;
1773
+ }
1774
+
1775
+ /**
1776
+ * Get the visible width of the longest word in a string.
1777
+ */
1778
+ #getLongestWordWidth(text: string, maxWidth?: number): number {
1779
+ const words = text.split(/\s+/).filter(word => word.length > 0);
1780
+ let longest = 0;
1781
+ for (const word of words) {
1782
+ longest = Math.max(longest, visibleWidth(word));
1783
+ }
1784
+ if (maxWidth === undefined) {
1785
+ return longest;
1786
+ }
1787
+ return Math.min(longest, maxWidth);
1788
+ }
1789
+
1790
+ #terminalLineWidths(text: string): number[] {
1791
+ return splitTerminalLines(text).map(line => visibleWidth(line));
1792
+ }
1793
+
1794
+ /**
1795
+ * Wrap a table cell to fit into a column.
1796
+ *
1797
+ * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
1798
+ * consistently with the rest of the renderer.
1799
+ */
1800
+ #wrapCellText(text: string, maxWidth: number): string[] {
1801
+ const cellWidth = Math.max(1, maxWidth);
1802
+ return splitTerminalLines(text).flatMap(line => wrapTextWithAnsi(line, cellWidth));
1803
+ }
1804
+
1805
+ /**
1806
+ * Render a table with width-aware cell wrapping.
1807
+ * Cells that don't fit are wrapped to multiple lines.
1808
+ */
1809
+ #renderTable(
1810
+ token: TableToken,
1811
+ availableWidth: number,
1812
+ nextTokenType?: string,
1813
+ styleContext?: InlineStyleContext,
1814
+ ): string[] {
1815
+ const lines: string[] = [];
1816
+ const numCols = token.header.length;
1817
+
1818
+ if (numCols === 0) {
1819
+ return lines;
1820
+ }
1821
+
1822
+ // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
1823
+ // = 2 + (n-1) * 3 + 2 = 3n + 1
1824
+ const borderOverhead = 3 * numCols + 1;
1825
+ const availableForCells = availableWidth - borderOverhead;
1826
+ if (availableForCells < numCols) {
1827
+ // Too narrow to render a stable table. Fall back to raw markdown.
1828
+ const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
1829
+ if (nextTokenType && nextTokenType !== "space") {
1830
+ fallbackLines.push("");
1831
+ }
1832
+ return fallbackLines;
1833
+ }
1834
+
1835
+ const maxUnbrokenWordWidth = 30;
1836
+
1837
+ // Calculate natural column widths (what each column needs without constraints)
1838
+ const naturalWidths: number[] = [];
1839
+ const minWordWidths: number[] = [];
1840
+ for (let i = 0; i < numCols; i++) {
1841
+ const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext);
1842
+ const headerLineWidths = this.#terminalLineWidths(headerText);
1843
+ naturalWidths[i] = Math.max(...headerLineWidths, 0);
1844
+ minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth));
1845
+ }
1846
+ for (const row of token.rows) {
1847
+ for (let i = 0; i < row.length; i++) {
1848
+ const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext);
1849
+ const cellLineWidths = this.#terminalLineWidths(cellText);
1850
+ naturalWidths[i] = Math.max(naturalWidths[i] || 0, ...cellLineWidths);
1851
+ minWordWidths[i] = Math.max(
1852
+ minWordWidths[i] || 1,
1853
+ this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth),
1854
+ );
1855
+ }
1856
+ }
1857
+
1858
+ let minColumnWidths = minWordWidths;
1859
+ let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
1860
+
1861
+ if (minCellsWidth > availableForCells) {
1862
+ minColumnWidths = new Array(numCols).fill(1);
1863
+ const remaining = availableForCells - numCols;
1864
+
1865
+ if (remaining > 0) {
1866
+ const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
1867
+ const growth = minWordWidths.map(width => {
1868
+ const weight = Math.max(0, width - 1);
1869
+ return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
1870
+ });
1871
+
1872
+ for (let i = 0; i < numCols; i++) {
1873
+ minColumnWidths[i] += growth[i] ?? 0;
1874
+ }
1875
+
1876
+ const allocated = growth.reduce((total, width) => total + width, 0);
1877
+ let leftover = remaining - allocated;
1878
+ for (let i = 0; leftover > 0 && i < numCols; i++) {
1879
+ minColumnWidths[i]++;
1880
+ leftover--;
1881
+ }
1882
+ }
1883
+
1884
+ minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
1885
+ }
1886
+
1887
+ // Calculate column widths that fit within available width
1888
+ const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
1889
+ let columnWidths: number[];
1890
+
1891
+ if (totalNaturalWidth <= availableWidth) {
1892
+ // Everything fits naturally
1893
+ columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
1894
+ } else {
1895
+ // Need to shrink columns to fit
1896
+ const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
1897
+ return total + Math.max(0, width - minColumnWidths[index]);
1898
+ }, 0);
1899
+ const extraWidth = Math.max(0, availableForCells - minCellsWidth);
1900
+ columnWidths = minColumnWidths.map((minWidth, index) => {
1901
+ const naturalWidth = naturalWidths[index];
1902
+ const minWidthDelta = Math.max(0, naturalWidth - minWidth);
1903
+ let grow = 0;
1904
+ if (totalGrowPotential > 0) {
1905
+ grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
1906
+ }
1907
+ return minWidth + grow;
1908
+ });
1909
+
1910
+ // Adjust for rounding errors - distribute remaining space
1911
+ const allocated = columnWidths.reduce((a, b) => a + b, 0);
1912
+ let remaining = availableForCells - allocated;
1913
+ while (remaining > 0) {
1914
+ let grew = false;
1915
+ for (let i = 0; i < numCols && remaining > 0; i++) {
1916
+ if (columnWidths[i] < naturalWidths[i]) {
1917
+ columnWidths[i]++;
1918
+ remaining--;
1919
+ grew = true;
1920
+ }
1921
+ }
1922
+ if (!grew) {
1923
+ break;
1924
+ }
1925
+ }
1926
+ }
1927
+
1928
+ const t = this.#theme.symbols.table;
1929
+ const h = t.horizontal;
1930
+ const v = t.vertical;
1931
+
1932
+ // Render top border
1933
+ const topBorderCells = columnWidths.map(w => h.repeat(w));
1934
+ lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
1935
+
1936
+ // Render header with wrapping
1937
+ const headerCellLines: string[][] = token.header.map((cell, i) => {
1938
+ const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
1939
+ return this.#wrapCellText(text, columnWidths[i]);
1940
+ });
1941
+ const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
1942
+
1943
+ for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
1944
+ const rowParts = headerCellLines.map((cellLines, colIdx) => {
1945
+ const text = cellLines[lineIdx] || "";
1946
+ const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
1947
+ return this.#theme.bold(padded);
1948
+ });
1949
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
1950
+ }
1951
+
1952
+ // Render separator
1953
+ const separatorCells = columnWidths.map(w => h.repeat(w));
1954
+ const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
1955
+ lines.push(separatorLine);
1956
+
1957
+ // Render rows with wrapping
1958
+ for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
1959
+ const row = token.rows[rowIndex];
1960
+ const rowCellLines: string[][] = row.map((cell, i) => {
1961
+ const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
1962
+ return this.#wrapCellText(text, columnWidths[i]);
1963
+ });
1964
+ const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
1965
+
1966
+ for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
1967
+ const rowParts = rowCellLines.map((cellLines, colIdx) => {
1968
+ const text = cellLines[lineIdx] || "";
1969
+ return text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
1970
+ });
1971
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
1972
+ }
1973
+
1974
+ if (rowIndex < token.rows.length - 1) {
1975
+ lines.push(separatorLine);
1976
+ }
1977
+ }
1978
+
1979
+ // Render bottom border
1980
+ const bottomBorderCells = columnWidths.map(w => h.repeat(w));
1981
+ lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
1982
+
1983
+ if (nextTokenType && nextTokenType !== "space") {
1984
+ lines.push(""); // Add spacing after table
1985
+ }
1986
+ return lines;
1987
+ }
1988
+ }
1989
+
1990
+ /**
1991
+ * Render inline markdown (bold, italic, code, links, strikethrough) to a styled string.
1992
+ * Unlike the full Markdown component, this produces a single line with no block-level elements.
1993
+ */
1994
+ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string {
1995
+ // Guard against undefined/null during streaming — partial JSON can leave fields unpopulated.
1996
+ if (typeof text !== "string") return (baseColor ?? (t => t))(text != null ? String(text) : "");
1997
+ const tokens = markdownParser.lexer(text);
1998
+ const applyText = baseColor ?? ((t: string) => t);
1999
+ let result = "";
2000
+ for (const token of tokens) {
2001
+ if (isMathToken(token)) {
2002
+ result += applyText(renderMathToken(token.text));
2003
+ continue;
2004
+ }
2005
+ if (token.type === "paragraph" && token.tokens) {
2006
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
2007
+ } else if (token.type === "list") {
2008
+ result += token.items
2009
+ .map((item: Tokens.ListItem, index: number) => {
2010
+ const prefix = token.ordered ? `${(token.start || 1) + index}. ` : "• ";
2011
+ const content = item.tokens ? renderInlineTokens(item.tokens, mdTheme, applyText) : applyText(item.text);
2012
+ return `${applyText(prefix)}${content}`;
2013
+ })
2014
+ .join(applyText(" "));
2015
+ } else if ("text" in token && typeof token.text === "string") {
2016
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
2017
+ }
2018
+ }
2019
+ return result;
2020
+ }
2021
+
2022
+ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText: (t: string) => string): string {
2023
+ let result = "";
2024
+ const styleReset = applyText("");
2025
+ for (const token of collapseInlineHtml(tokens)) {
2026
+ if (isMathToken(token)) {
2027
+ result += applyText(renderMathToken(token.text));
2028
+ continue;
2029
+ }
2030
+ switch (token.type) {
2031
+ case "text":
2032
+ if (token.tokens && token.tokens.length > 0) {
2033
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
2034
+ } else {
2035
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
2036
+ }
2037
+ break;
2038
+ case "strong":
2039
+ result += mdTheme.bold(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
2040
+ break;
2041
+ case "em":
2042
+ result += mdTheme.italic(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
2043
+ break;
2044
+ case "codespan":
2045
+ result += mdTheme.code(token.text) + styleReset;
2046
+ break;
2047
+ case "del":
2048
+ result += mdTheme.strikethrough(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
2049
+ break;
2050
+ case "link": {
2051
+ const linkText = renderInlineTokens(token.tokens || [], mdTheme, applyText);
2052
+ result += mdTheme.link(mdTheme.underline(linkText)) + styleReset;
2053
+ break;
2054
+ }
2055
+ case "html":
2056
+ if ("raw" in token && typeof token.raw === "string") {
2057
+ result += applyText(normalizeHtmlForTerminal(token.raw));
2058
+ }
2059
+ break;
2060
+ default:
2061
+ if ("text" in token && typeof token.text === "string") {
2062
+ result += applyText(normalizeHtmlEntitiesForTerminal(token.text));
2063
+ }
2064
+ break;
2065
+ }
2066
+ }
2067
+ return result;
2068
+ }