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.
- package/CHANGELOG.md +1861 -0
- package/README.md +705 -0
- package/dist/types/autocomplete.d.ts +99 -0
- package/dist/types/bracketed-paste.d.ts +51 -0
- package/dist/types/components/box.d.ts +31 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +155 -0
- package/dist/types/components/image.d.ts +112 -0
- package/dist/types/components/input.d.ts +23 -0
- package/dist/types/components/loader.d.ts +20 -0
- package/dist/types/components/markdown.d.ts +64 -0
- package/dist/types/components/scroll-view.d.ts +62 -0
- package/dist/types/components/select-list.d.ts +68 -0
- package/dist/types/components/settings-list.d.ts +123 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +89 -0
- package/dist/types/components/text.d.ts +14 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/desktop-notify.d.ts +51 -0
- package/dist/types/editor-component.d.ts +38 -0
- package/dist/types/fuzzy.d.ts +32 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/keybindings.d.ts +191 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +20 -0
- package/dist/types/kitty-graphics.d.ts +79 -0
- package/dist/types/latex-block.d.ts +7 -0
- package/dist/types/latex-to-unicode.d.ts +33 -0
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +67 -0
- package/dist/types/stdin-buffer.d.ts +60 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +284 -0
- package/dist/types/terminal.d.ts +107 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +423 -0
- package/dist/types/utils.d.ts +95 -0
- package/package.json +73 -0
- package/src/autocomplete.ts +1026 -0
- package/src/bracketed-paste.ts +123 -0
- package/src/components/box.ts +194 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +3092 -0
- package/src/components/image.ts +444 -0
- package/src/components/input.ts +474 -0
- package/src/components/loader.ts +103 -0
- package/src/components/markdown.ts +2068 -0
- package/src/components/scroll-view.ts +227 -0
- package/src/components/select-list.ts +531 -0
- package/src/components/settings-list.ts +793 -0
- package/src/components/spacer.ts +32 -0
- package/src/components/tab-bar.ts +300 -0
- package/src/components/text.ts +122 -0
- package/src/components/truncated-text.ts +69 -0
- package/src/deccara.ts +314 -0
- package/src/desktop-notify.ts +186 -0
- package/src/editor-component.ts +74 -0
- package/src/fuzzy.ts +356 -0
- package/src/index.ts +51 -0
- package/src/keybindings.ts +337 -0
- package/src/keys.ts +561 -0
- package/src/kill-ring.ts +51 -0
- package/src/kitty-graphics.ts +171 -0
- package/src/latex-block.ts +461 -0
- package/src/latex-to-unicode.ts +1994 -0
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +105 -0
- package/src/stdin-buffer.ts +669 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1152 -0
- package/src/terminal.ts +1463 -0
- package/src/ttyid.ts +84 -0
- package/src/tui.ts +3901 -0
- 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 ☃ 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
|
+
}
|