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