@prometheus-ai/tui 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
@@ -0,0 +1,1189 @@
1
+ import { LRUCache } from "lru-cache/raw";
2
+ import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
3
+ import type { SymbolTheme } from "../symbols";
4
+ import { TERMINAL } from "../terminal-capabilities";
5
+ import type { Component } from "../tui";
6
+ import {
7
+ applyBackgroundToLine,
8
+ encodeTextSized,
9
+ getSegmenter,
10
+ padding,
11
+ replaceTabs,
12
+ visibleWidth,
13
+ wrapTextWithAnsi,
14
+ } from "../utils";
15
+
16
+ const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
17
+
18
+ // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible
19
+ // unit by the H1 render path. Like image-protocol lines, they must bypass
20
+ // ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span
21
+ // (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC
22
+ // payload), and padding would append trailing cells past the doubled glyph.
23
+ const OSC66_LINE_PREFIX = "\x1b]66;";
24
+
25
+ function isOsc66Line(line: string): boolean {
26
+ return line.includes(OSC66_LINE_PREFIX);
27
+ }
28
+
29
+ class StrictStrikethroughTokenizer extends Tokenizer {
30
+ override del(src: string): Tokens.Del | undefined {
31
+ const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
32
+ if (!match) {
33
+ return undefined;
34
+ }
35
+
36
+ const text = match[2];
37
+ return {
38
+ type: "del",
39
+ raw: match[0],
40
+ text,
41
+ tokens: this.lexer.inlineTokens(text),
42
+ };
43
+ }
44
+ }
45
+
46
+ const markdownParser = new Marked();
47
+ markdownParser.setOptions({
48
+ tokenizer: new StrictStrikethroughTokenizer(),
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Module-level LRU render cache
53
+ // ---------------------------------------------------------------------------
54
+ // Each session-tree navigation discards and recreates Markdown component
55
+ // instances, so the per-instance #cachedLines field is always cold on first
56
+ // render of a fresh component. This module-level cache survives across
57
+ // component lifetimes and eliminates redundant marked.lexer + highlightCode
58
+ // (Rust FFI) work for content/layout combinations already seen this session.
59
+
60
+ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
61
+ const renderCache = new LRUCache<string, string[]>({ max: RENDER_CACHE_MAX });
62
+
63
+ /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
64
+ export function clearRenderCache(): void {
65
+ renderCache.clear();
66
+ }
67
+
68
+ // Stable numeric IDs for structural theme/style objects (no ID field on type).
69
+ // Symbol-keyed so the id travels with the object and is invisible to consumers.
70
+ const kObjectId = Symbol("markdown.objectId");
71
+ type WithObjectId = object & { [kObjectId]?: number };
72
+ let nextObjectId = 0;
73
+ function objectId(o: object): number {
74
+ const tagged = o as WithObjectId;
75
+ let id = tagged[kObjectId];
76
+ if (id === undefined) {
77
+ id = nextObjectId++;
78
+ tagged[kObjectId] = id;
79
+ }
80
+ return id;
81
+ }
82
+
83
+ /**
84
+ * Default text styling for markdown content.
85
+ * Applied to all text unless overridden by markdown formatting.
86
+ */
87
+ export interface DefaultTextStyle {
88
+ /** Foreground color function */
89
+ color?: (text: string) => string;
90
+ /** Background color function */
91
+ bgColor?: (text: string) => string;
92
+ /** Bold text */
93
+ bold?: boolean;
94
+ /** Italic text */
95
+ italic?: boolean;
96
+ /** Strikethrough text */
97
+ strikethrough?: boolean;
98
+ /** Underline text */
99
+ underline?: boolean;
100
+ }
101
+
102
+ /**
103
+ * Theme functions for markdown elements.
104
+ * Each function takes text and returns styled text with ANSI codes.
105
+ */
106
+ export interface MarkdownTheme {
107
+ heading: (text: string) => string;
108
+ link: (text: string) => string;
109
+ linkUrl: (text: string) => string;
110
+ code: (text: string) => string;
111
+ codeBlock: (text: string) => string;
112
+ codeBlockBorder: (text: string) => string;
113
+ quote: (text: string) => string;
114
+ quoteBorder: (text: string) => string;
115
+ hr: (text: string) => string;
116
+ listBullet: (text: string) => string;
117
+ bold: (text: string) => string;
118
+ italic: (text: string) => string;
119
+ strikethrough: (text: string) => string;
120
+ underline: (text: string) => string;
121
+ highlightCode?: (code: string, lang?: string) => string[];
122
+ /**
123
+ * Resolve a mermaid ASCII rendering by fenced block source text.
124
+ * Return null to fall back to fenced code rendering.
125
+ */
126
+ resolveMermaidAscii?: (source: string) => string | null;
127
+ symbols: SymbolTheme;
128
+ }
129
+
130
+ interface InlineStyleContext {
131
+ applyText: (text: string) => string;
132
+ stylePrefix: string;
133
+ }
134
+
135
+ type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
136
+ type TableCellToken = { tokens?: Token[] };
137
+ type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
138
+
139
+ function formatHyperlink(text: string, target: string): string {
140
+ if (!TERMINAL.hyperlinks || !target) {
141
+ return text;
142
+ }
143
+
144
+ const safeTarget = target.replaceAll("\x1b", "").replaceAll("\x07", "");
145
+ if (!safeTarget) {
146
+ return text;
147
+ }
148
+
149
+ return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
150
+ }
151
+
152
+ function isAsciiTextSizingPayload(text: string): boolean {
153
+ for (let i = 0; i < text.length; i++) {
154
+ const code = text.charCodeAt(i);
155
+ if (code < 0x20 || code > 0x7e) return false;
156
+ }
157
+ return true;
158
+ }
159
+
160
+ function encodeTextSizedHeading(text: string, scale: 1 | 2 | 3): string {
161
+ let out = "";
162
+ let asciiRun = "";
163
+ const flushAscii = () => {
164
+ if (asciiRun === "") return;
165
+ out += encodeTextSized(asciiRun, { scale });
166
+ asciiRun = "";
167
+ };
168
+
169
+ for (const { segment } of getSegmenter().segment(text)) {
170
+ if (isAsciiTextSizingPayload(segment)) {
171
+ asciiRun += segment;
172
+ continue;
173
+ }
174
+ flushAscii();
175
+ out += encodeTextSized(segment, { scale, widthCells: visibleWidth(segment) });
176
+ }
177
+ flushAscii();
178
+ return out;
179
+ }
180
+
181
+ function plainInlineTokens(tokens: Token[]): string {
182
+ let result = "";
183
+ for (const token of tokens) {
184
+ switch (token.type) {
185
+ case "text":
186
+ result += token.tokens && token.tokens.length > 0 ? plainInlineTokens(token.tokens) : token.text;
187
+ break;
188
+ case "strong":
189
+ case "em":
190
+ case "del":
191
+ case "link":
192
+ result += plainInlineTokens(token.tokens || []);
193
+ break;
194
+ case "codespan":
195
+ result += token.text;
196
+ break;
197
+ default:
198
+ if ("text" in token && typeof token.text === "string") result += token.text;
199
+ break;
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Inline hex-color swatches
207
+ // ---------------------------------------------------------------------------
208
+ // When prose/thinking mentions a CSS hex color (e.g. #C5FFD6 or `#C5FFD6`),
209
+ // render a small chip painted with that color just before the code. The chip
210
+ // glyph comes from the theme's symbol set (ASCII → Unicode → Nerd Font), so it
211
+ // degrades gracefully; the color itself is exact 24-bit on truecolor terminals
212
+ // and the nearest 256-color cell otherwise (Bun.color quantizes for us).
213
+
214
+ /** Fallback chip when the theme supplies no `colorSwatch` symbol (Unicode default). */
215
+ const DEFAULT_COLOR_SWATCH_GLYPH = "■";
216
+
217
+ // `#` + 3-8 hex digits, not glued to a surrounding word/`#`/`&` (avoids HTML
218
+ // entities like &#9731; and paths like foo#fff) and not trailed by more hex
219
+ // (so over-long runs never produce a misleading swatch). Length/letter rules
220
+ // are enforced in classifyHexColor since the alternation can't express "exactly
221
+ // 3, 6, or 8".
222
+ const HEX_COLOR_REGEX = /(?<![\w#&])#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
223
+ const HEX_COLOR_EXACT_REGEX = /^#([0-9a-fA-F]{3,8})$/;
224
+
225
+ /**
226
+ * Decide whether a run of hex digits denotes a renderable CSS color.
227
+ *
228
+ * Only the canonical CSS lengths (#RGB, #RRGGBB, #RRGGBBAA) qualify. The 4-digit
229
+ * #RGBA form is deliberately excluded: it collides with hashline `#TAG` snapshot
230
+ * tags (4 hex digits, e.g. #6C5E), which would otherwise sprout spurious swatches.
231
+ * In `strict` mode (bare prose) a 3-digit run must contain a hex letter, so the
232
+ * far more common short issue/PR references (#123, #1011) don't sprout swatches.
233
+ * Codespans opt out of strictness — the backticks already signal "this is a color".
234
+ */
235
+ function classifyHexColor(hex: string, strict: boolean): boolean {
236
+ const n = hex.length;
237
+ if (n !== 3 && n !== 6 && n !== 8) return false;
238
+ if (strict && n === 3 && !/[a-fA-F]/.test(hex)) return false;
239
+ return true;
240
+ }
241
+
242
+ /** ANSI-painted `glyph` for `#${hex}`, or "" when the color can't be encoded. */
243
+ function colorSwatch(hex: string, glyph: string): string {
244
+ const ansi = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
245
+ // Reset only the foreground (\x1b[39m) so an enclosing background/decoration
246
+ // applied later by the line renderer survives across the swatch.
247
+ return ansi ? `${ansi}${glyph}\x1b[39m ` : "";
248
+ }
249
+
250
+ /**
251
+ * Style a plain-text run, inserting a color swatch before each hex color it
252
+ * mentions. Non-color text (including the matched `#hex` itself) is routed
253
+ * through `applySegment` so the caller's base styling is preserved verbatim.
254
+ */
255
+ function renderTextWithSwatches(text: string, applySegment: (t: string) => string, glyph: string): string {
256
+ HEX_COLOR_REGEX.lastIndex = 0;
257
+ let result = "";
258
+ let last = 0;
259
+ for (;;) {
260
+ const match = HEX_COLOR_REGEX.exec(text);
261
+ if (match === null) break;
262
+ if (!classifyHexColor(match[1], true)) continue;
263
+ const swatch = colorSwatch(match[1], glyph);
264
+ if (!swatch) continue;
265
+ if (match.index > last) result += applySegment(text.slice(last, match.index));
266
+ result += swatch + applySegment(match[0]);
267
+ last = match.index + match[0].length;
268
+ }
269
+ if (last === 0) return applySegment(text);
270
+ if (last < text.length) result += applySegment(text.slice(last));
271
+ return result;
272
+ }
273
+
274
+ /** Swatch for a codespan whose entire content is a single hex color, else "". */
275
+ function codespanSwatch(code: string, glyph: string): string {
276
+ const match = HEX_COLOR_EXACT_REGEX.exec(code.trim());
277
+ if (!match || !classifyHexColor(match[1], false)) return "";
278
+ return colorSwatch(match[1], glyph);
279
+ }
280
+
281
+ export class Markdown implements Component {
282
+ #text: string;
283
+ #paddingX: number; // Left/right padding
284
+ #paddingY: number; // Top/bottom padding
285
+ #defaultTextStyle?: DefaultTextStyle;
286
+ #theme: MarkdownTheme;
287
+ #defaultStylePrefix?: string;
288
+ /** Number of spaces used to indent code block content. */
289
+ #codeBlockIndent: number;
290
+
291
+ // Cache for rendered output
292
+ #cachedText?: string;
293
+ #cachedWidth?: number;
294
+ #cachedLines?: string[];
295
+
296
+ constructor(
297
+ text: string,
298
+ paddingX: number,
299
+ paddingY: number,
300
+ theme: MarkdownTheme,
301
+ defaultTextStyle?: DefaultTextStyle,
302
+ codeBlockIndent: number = 2,
303
+ ) {
304
+ this.#text = text;
305
+ this.#paddingX = paddingX;
306
+ this.#paddingY = paddingY;
307
+ this.#theme = theme;
308
+ this.#defaultTextStyle = defaultTextStyle;
309
+ this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
310
+ }
311
+
312
+ setText(text: string): void {
313
+ this.#text = text;
314
+ this.invalidate();
315
+ }
316
+
317
+ invalidate(): void {
318
+ this.#cachedText = undefined;
319
+ this.#cachedWidth = undefined;
320
+ this.#cachedLines = undefined;
321
+ }
322
+
323
+ render(width: number): string[] {
324
+ // L1: per-instance cache — fastest path for repeated renders of the same
325
+ // instance at the same width (e.g. resize debounce, repeated redraws).
326
+ if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
327
+ return this.#cachedLines;
328
+ }
329
+
330
+ // Calculate available width for content (subtract horizontal padding)
331
+ const contentWidth = Math.max(1, width - this.#paddingX * 2);
332
+
333
+ // Don't render anything if there's no actual text
334
+ if (!this.#text || this.#text.trim() === "") {
335
+ const result: string[] = [];
336
+ // Update per-instance cache
337
+ this.#cachedText = this.#text;
338
+ this.#cachedWidth = width;
339
+ this.#cachedLines = result;
340
+ return result;
341
+ }
342
+
343
+ // Replace tabs with 3 spaces for consistent rendering
344
+ const normalizedText = replaceTabs(this.#text);
345
+
346
+ // L2: module-level LRU — survives component disposal/recreation across
347
+ // session-tree navigations. Key encodes every dimension that affects the
348
+ // render output so different configurations never collide.
349
+ // Encode terminal capability state and theme/style function output samples
350
+ // so that capability shifts (image protocol changes, hyperlink toggle) or
351
+ // caller-supplied theme/bgColor functions that mutate their output without
352
+ // changing object identity invalidate the cache entry.
353
+ // bgColor probe uses \x01 (single non-printable byte): chalk/ANSI wrappers
354
+ // pass arbitrary bytes through verbatim, so this is safe and minimizes the
355
+ // risk of clashing with a function that returns text verbatim.
356
+ // theme.heading is used as the representative theme probe — it's required
357
+ // by MarkdownTheme and is one of the most styling-sensitive entries.
358
+ const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
359
+ const headingProbe = this.#theme.heading("");
360
+ const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${TERMINAL.textSizing ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
361
+ const cached = renderCache.get(cacheKey);
362
+ if (cached !== undefined) {
363
+ // Populate L1 so subsequent calls from this instance are O(1) map lookup.
364
+ this.#cachedText = this.#text;
365
+ this.#cachedWidth = width;
366
+ this.#cachedLines = cached;
367
+ return cached;
368
+ }
369
+
370
+ // Parse markdown to HTML-like tokens
371
+ const tokens = markdownParser.lexer(normalizedText);
372
+
373
+ // Convert tokens to styled terminal output
374
+ const renderedLines: string[] = [];
375
+
376
+ for (let i = 0; i < tokens.length; i++) {
377
+ const token = tokens[i];
378
+ const nextToken = tokens[i + 1];
379
+ const tokenLines = this.#renderToken(token, contentWidth, nextToken?.type);
380
+ renderedLines.push(...tokenLines);
381
+ }
382
+
383
+ // Wrap lines (NO padding, NO background yet)
384
+ const wrappedLines: string[] = [];
385
+ for (const line of renderedLines) {
386
+ // Skip wrapping for image protocol lines and OSC 66 sized headings
387
+ // (would corrupt escape sequences / split the indivisible sized span).
388
+ if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
389
+ wrappedLines.push(line);
390
+ } else {
391
+ wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
392
+ }
393
+ }
394
+
395
+ // Add margins and background to each wrapped line
396
+ const leftMargin = padding(this.#paddingX);
397
+ const rightMargin = padding(this.#paddingX);
398
+ const bgFn = this.#defaultTextStyle?.bgColor;
399
+ const contentLines: string[] = [];
400
+
401
+ let previousLineWasOsc66 = false;
402
+
403
+ for (const line of wrappedLines) {
404
+ // The first empty row after a scale>1 OSC 66 heading is structural:
405
+ // it reserves the lower cells occupied by the multicell glyphs. Do
406
+ // not pad or background-fill it, because real spaces on that row can
407
+ // interact with Kitty's multicell overwrite rules during the first
408
+ // paint. Leave it as a cursor-only newline.
409
+ if (previousLineWasOsc66 && line === "") {
410
+ contentLines.push("");
411
+ previousLineWasOsc66 = false;
412
+ continue;
413
+ }
414
+
415
+ // Image lines and OSC 66 sized headings must be output raw - no margins or background
416
+ if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
417
+ contentLines.push(line);
418
+ previousLineWasOsc66 = isOsc66Line(line);
419
+ continue;
420
+ }
421
+
422
+ previousLineWasOsc66 = false;
423
+
424
+ const lineWithMargins = leftMargin + line + rightMargin;
425
+
426
+ if (bgFn) {
427
+ contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
428
+ } else {
429
+ // No background - just pad to width
430
+ const visibleLen = visibleWidth(lineWithMargins);
431
+ const paddingNeeded = Math.max(0, width - visibleLen);
432
+ contentLines.push(lineWithMargins + padding(paddingNeeded));
433
+ }
434
+ }
435
+
436
+ // Add top/bottom padding (empty lines)
437
+ const emptyLine = padding(width);
438
+ const emptyLines: string[] = [];
439
+ for (let i = 0; i < this.#paddingY; i++) {
440
+ const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
441
+ emptyLines.push(line);
442
+ }
443
+
444
+ // Combine top padding, content, and bottom padding
445
+ const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
446
+ const result = rawResult.length > 0 ? rawResult : [""];
447
+
448
+ // Update L1 per-instance cache
449
+ this.#cachedText = this.#text;
450
+ this.#cachedWidth = width;
451
+ this.#cachedLines = result;
452
+
453
+ // Update L2 module-level LRU so future instances with the same key skip
454
+ // the marked.lexer + highlightCode (Rust FFI) work entirely.
455
+ renderCache.set(cacheKey, result);
456
+
457
+ return result;
458
+ }
459
+
460
+ /**
461
+ * Apply default text style to a string.
462
+ * This is the base styling applied to all text content.
463
+ * NOTE: Background color is NOT applied here - it's applied at the padding stage
464
+ * to ensure it extends to the full line width.
465
+ */
466
+ #applyDefaultStyle(text: string): string {
467
+ if (!this.#defaultTextStyle) {
468
+ return text;
469
+ }
470
+
471
+ let styled = text;
472
+
473
+ // Apply foreground color (NOT background - that's applied at padding stage)
474
+ if (this.#defaultTextStyle.color) {
475
+ styled = this.#defaultTextStyle.color(styled);
476
+ }
477
+
478
+ // Apply text decorations using this.#theme
479
+ if (this.#defaultTextStyle.bold) {
480
+ styled = this.#theme.bold(styled);
481
+ }
482
+ if (this.#defaultTextStyle.italic) {
483
+ styled = this.#theme.italic(styled);
484
+ }
485
+ if (this.#defaultTextStyle.strikethrough) {
486
+ styled = this.#theme.strikethrough(styled);
487
+ }
488
+ if (this.#defaultTextStyle.underline) {
489
+ styled = this.#theme.underline(styled);
490
+ }
491
+
492
+ return styled;
493
+ }
494
+
495
+ #getDefaultStylePrefix(): string {
496
+ if (!this.#defaultTextStyle) {
497
+ return "";
498
+ }
499
+
500
+ if (this.#defaultStylePrefix !== undefined) {
501
+ return this.#defaultStylePrefix;
502
+ }
503
+
504
+ const sentinel = "\u0000";
505
+ let styled = sentinel;
506
+
507
+ if (this.#defaultTextStyle.color) {
508
+ styled = this.#defaultTextStyle.color(styled);
509
+ }
510
+
511
+ if (this.#defaultTextStyle.bold) {
512
+ styled = this.#theme.bold(styled);
513
+ }
514
+ if (this.#defaultTextStyle.italic) {
515
+ styled = this.#theme.italic(styled);
516
+ }
517
+ if (this.#defaultTextStyle.strikethrough) {
518
+ styled = this.#theme.strikethrough(styled);
519
+ }
520
+ if (this.#defaultTextStyle.underline) {
521
+ styled = this.#theme.underline(styled);
522
+ }
523
+
524
+ const sentinelIndex = styled.indexOf(sentinel);
525
+ this.#defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
526
+ return this.#defaultStylePrefix;
527
+ }
528
+
529
+ #getStylePrefix(styleFn: (text: string) => string): string {
530
+ const sentinel = "\u0000";
531
+ const styled = styleFn(sentinel);
532
+ const sentinelIndex = styled.indexOf(sentinel);
533
+ return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
534
+ }
535
+
536
+ #getDefaultInlineStyleContext(): InlineStyleContext {
537
+ return {
538
+ applyText: (text: string) => this.#applyDefaultStyle(text),
539
+ stylePrefix: this.#getDefaultStylePrefix(),
540
+ };
541
+ }
542
+
543
+ #renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] {
544
+ const lines: string[] = [];
545
+
546
+ switch (token.type) {
547
+ case "heading": {
548
+ const headingLevel = token.depth;
549
+ const headingPrefix = `${"#".repeat(headingLevel)} `;
550
+ const headingText = this.#renderInlineTokens(token.tokens || [], styleContext);
551
+ const headingPlainText = plainInlineTokens(token.tokens || []);
552
+ let styledHeading: string;
553
+ if (headingLevel === 1 && TERMINAL.textSizing) {
554
+ const plainWidth = visibleWidth(headingPlainText);
555
+ if (plainWidth > 0 && 2 * plainWidth <= width) {
556
+ const sizedHeading = encodeTextSizedHeading(headingPlainText, 2);
557
+ lines.push(this.#theme.heading(this.#theme.bold(this.#theme.underline(sizedHeading))));
558
+ lines.push(""); // reserve the heading's second visual row
559
+ if (nextTokenType && nextTokenType !== "space") {
560
+ lines.push(""); // Add spacing after headings (unless space token follows)
561
+ }
562
+ break;
563
+ }
564
+ }
565
+ if (headingLevel === 1) {
566
+ styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText)));
567
+ } else if (headingLevel === 2) {
568
+ styledHeading = this.#theme.heading(this.#theme.bold(headingText));
569
+ } else {
570
+ styledHeading = this.#theme.heading(this.#theme.bold(headingPrefix + headingText));
571
+ }
572
+ lines.push(styledHeading);
573
+ if (nextTokenType && nextTokenType !== "space") {
574
+ lines.push(""); // Add spacing after headings (unless space token follows)
575
+ }
576
+ break;
577
+ }
578
+
579
+ case "paragraph": {
580
+ const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
581
+ lines.push(paragraphText);
582
+ // Don't add spacing if next token is space or list
583
+ if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
584
+ lines.push("");
585
+ }
586
+ break;
587
+ }
588
+
589
+ case "code": {
590
+ // Handle mermaid diagrams with ASCII rendering when available
591
+ if (token.lang === "mermaid" && this.#theme.resolveMermaidAscii) {
592
+ const ascii = this.#theme.resolveMermaidAscii(token.text);
593
+
594
+ if (ascii) {
595
+ for (const asciiLine of Bun.stripANSI(ascii).split("\n")) {
596
+ lines.push(asciiLine);
597
+ }
598
+ if (nextTokenType && nextTokenType !== "space") {
599
+ lines.push("");
600
+ }
601
+ break;
602
+ }
603
+ }
604
+
605
+ const codeIndent = padding(this.#codeBlockIndent);
606
+ lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
607
+ if (this.#theme.highlightCode) {
608
+ const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
609
+ for (const hlLine of highlightedLines) {
610
+ lines.push(`${codeIndent}${hlLine}`);
611
+ }
612
+ } else {
613
+ // Split code by newlines and style each line
614
+ const codeLines = token.text.split("\n");
615
+ for (const codeLine of codeLines) {
616
+ lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
617
+ }
618
+ }
619
+ lines.push(this.#theme.codeBlockBorder("```"));
620
+ if (nextTokenType && nextTokenType !== "space") {
621
+ lines.push(""); // Add spacing after code blocks (unless space token follows)
622
+ }
623
+ break;
624
+ }
625
+
626
+ case "list": {
627
+ const listLines = this.#renderList(token as ListToken, 0, styleContext);
628
+ lines.push(...listLines);
629
+ // Don't add spacing after lists if a space token follows
630
+ // (the space token will handle it)
631
+ break;
632
+ }
633
+
634
+ case "table": {
635
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
636
+ lines.push(...tableLines);
637
+ break;
638
+ }
639
+
640
+ case "blockquote": {
641
+ const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
642
+ const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
643
+ const applyQuoteStyle = (line: string): string => {
644
+ if (!quoteStylePrefix) {
645
+ return quoteStyle(line);
646
+ }
647
+
648
+ const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
649
+ return quoteStyle(lineWithReappliedStyle);
650
+ };
651
+
652
+ // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
653
+ // children recursively and keep default message styling out of nested content.
654
+ const quoteInlineStyleContext: InlineStyleContext = {
655
+ applyText: (text: string) => text,
656
+ stylePrefix: "",
657
+ };
658
+ const quoteContentWidth = Math.max(1, width - 2);
659
+ const quoteTokens = token.tokens || [];
660
+ const renderedQuoteLines: string[] = [];
661
+
662
+ for (let i = 0; i < quoteTokens.length; i++) {
663
+ const quoteToken = quoteTokens[i];
664
+ const nextQuoteToken = quoteTokens[i + 1];
665
+ renderedQuoteLines.push(
666
+ ...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
667
+ );
668
+ }
669
+
670
+ while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
671
+ renderedQuoteLines.pop();
672
+ }
673
+
674
+ for (const quoteLine of renderedQuoteLines) {
675
+ const styledLine = applyQuoteStyle(quoteLine);
676
+ const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
677
+ for (const wrappedLine of wrappedLines) {
678
+ lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
679
+ }
680
+ }
681
+ if (nextTokenType && nextTokenType !== "space") {
682
+ lines.push(""); // Add spacing after blockquotes (unless space token follows)
683
+ }
684
+ break;
685
+ }
686
+
687
+ case "hr":
688
+ lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80))));
689
+ if (nextTokenType && nextTokenType !== "space") {
690
+ lines.push(""); // Add spacing after horizontal rules (unless space token follows)
691
+ }
692
+ break;
693
+
694
+ case "html":
695
+ // Render HTML as plain text (escaped for terminal)
696
+ if ("raw" in token && typeof token.raw === "string") {
697
+ lines.push(this.#applyDefaultStyle(token.raw.trim()));
698
+ }
699
+ break;
700
+
701
+ case "space":
702
+ // Space tokens represent blank lines in markdown
703
+ lines.push("");
704
+ break;
705
+
706
+ default:
707
+ // Handle any other token types as plain text
708
+ if ("text" in token && typeof token.text === "string") {
709
+ lines.push(token.text);
710
+ }
711
+ }
712
+
713
+ return lines;
714
+ }
715
+
716
+ #renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
717
+ let result = "";
718
+ const resolvedStyleContext = styleContext ?? this.#getDefaultInlineStyleContext();
719
+ const { applyText, stylePrefix } = resolvedStyleContext;
720
+ const applyTextWithNewlines = (text: string): string => {
721
+ const segments: string[] = text.split("\n");
722
+ return segments.map((segment: string) => applyText(segment)).join("\n");
723
+ };
724
+ const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
725
+
726
+ for (const token of tokens) {
727
+ switch (token.type) {
728
+ case "text":
729
+ // Text tokens in list items can have nested tokens for inline formatting
730
+ if (token.tokens && token.tokens.length > 0) {
731
+ result += this.#renderInlineTokens(token.tokens, resolvedStyleContext);
732
+ } else {
733
+ result += renderTextWithSwatches(token.text, applyTextWithNewlines, swatchGlyph);
734
+ }
735
+ break;
736
+
737
+ case "paragraph":
738
+ // Paragraph tokens contain nested inline tokens
739
+ result += this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
740
+ break;
741
+
742
+ case "strong": {
743
+ const boldContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
744
+ result += this.#theme.bold(boldContent) + stylePrefix;
745
+ break;
746
+ }
747
+
748
+ case "em": {
749
+ const italicContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
750
+ result += this.#theme.italic(italicContent) + stylePrefix;
751
+ break;
752
+ }
753
+
754
+ case "codespan": {
755
+ result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
756
+ break;
757
+ }
758
+
759
+ case "link": {
760
+ const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
761
+ const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
762
+ const clickableLinkText = formatHyperlink(styledLinkText, token.href);
763
+ // If link text matches href, only show the link once
764
+ // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
765
+ // For mailto: links, strip the prefix before comparing (autolinked emails have
766
+ // text="foo@bar.com" but href="mailto:foo@bar.com")
767
+ const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
768
+ if (token.text === token.href || token.text === hrefForComparison)
769
+ result += clickableLinkText + stylePrefix;
770
+ else {
771
+ const styledLinkUrl = this.#theme.linkUrl(` (${token.href})`);
772
+ result += clickableLinkText + formatHyperlink(styledLinkUrl, token.href) + stylePrefix;
773
+ }
774
+ break;
775
+ }
776
+
777
+ case "br":
778
+ result += "\n";
779
+ break;
780
+
781
+ case "del": {
782
+ const delContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
783
+ result += this.#theme.strikethrough(delContent) + stylePrefix;
784
+ break;
785
+ }
786
+
787
+ case "html":
788
+ // Render inline HTML as plain text
789
+ if ("raw" in token && typeof token.raw === "string") {
790
+ result += applyTextWithNewlines(token.raw);
791
+ }
792
+ break;
793
+
794
+ default:
795
+ // Handle any other inline token types as plain text
796
+ if ("text" in token && typeof token.text === "string") {
797
+ result += applyTextWithNewlines(token.text);
798
+ }
799
+ }
800
+ }
801
+
802
+ // Strip dangling re-opened-default SGR prefix left over from the last inline
803
+ // token (strong/em/codespan/link/del/etc.) so the emitted line self-terminates
804
+ // at its last styled segment instead of carrying an unmatched SGR open into
805
+ // the next line. Matches upstream behavior.
806
+ while (stylePrefix && result.endsWith(stylePrefix)) {
807
+ result = result.slice(0, -stylePrefix.length);
808
+ }
809
+
810
+ return result;
811
+ }
812
+
813
+ /**
814
+ * Render a list with proper nesting support
815
+ */
816
+ #renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] {
817
+ const lines: string[] = [];
818
+ const indent = " ".repeat(depth);
819
+ // Use the list's start property (defaults to 1 for ordered lists)
820
+ const startNumber = token.start ?? 1;
821
+
822
+ for (let i = 0; i < token.items.length; i++) {
823
+ const item = token.items[i];
824
+ const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
825
+
826
+ // Process item tokens to handle nested lists
827
+ const itemLines = this.#renderListItem(item.tokens || [], depth, styleContext);
828
+
829
+ if (itemLines.length > 0) {
830
+ // First line - check if it's a nested list
831
+ // A nested list will start with indent (spaces) followed by cyan bullet
832
+ const firstLine = itemLines[0];
833
+ const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char
834
+
835
+ if (isNestedList) {
836
+ // This is a nested list, just add it as-is (already has full indent)
837
+ lines.push(firstLine);
838
+ } else {
839
+ // Regular text content - add indent and bullet
840
+ lines.push(indent + this.#theme.listBullet(bullet) + firstLine);
841
+ }
842
+
843
+ // Rest of the lines
844
+ for (let j = 1; j < itemLines.length; j++) {
845
+ const line = itemLines[j];
846
+ const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
847
+
848
+ if (isNestedListLine) {
849
+ // Nested list line - already has full indent
850
+ lines.push(line);
851
+ } else {
852
+ // Regular content - add parent indent + 2 spaces for continuation
853
+ lines.push(`${indent} ${line}`);
854
+ }
855
+ }
856
+ } else {
857
+ lines.push(indent + this.#theme.listBullet(bullet));
858
+ }
859
+ }
860
+
861
+ return lines;
862
+ }
863
+
864
+ /**
865
+ * Render list item tokens, handling nested lists
866
+ * Returns lines WITHOUT the parent indent (renderList will add it)
867
+ */
868
+ #renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] {
869
+ const lines: string[] = [];
870
+
871
+ for (const token of tokens) {
872
+ if (token.type === "list") {
873
+ // Nested list - render with one additional indent level
874
+ // These lines will have their own indent, so we just add them as-is
875
+ const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, styleContext);
876
+ lines.push(...nestedLines);
877
+ } else if (token.type === "text") {
878
+ // Text content (may have inline tokens)
879
+ const text =
880
+ token.tokens && token.tokens.length > 0
881
+ ? this.#renderInlineTokens(token.tokens, styleContext)
882
+ : token.text || "";
883
+ lines.push(text);
884
+ } else if (token.type === "paragraph") {
885
+ // Paragraph in list item
886
+ const text = this.#renderInlineTokens(token.tokens || [], styleContext);
887
+ lines.push(text);
888
+ } else if (token.type === "code") {
889
+ // Code block in list item
890
+ const codeIndent = padding(this.#codeBlockIndent);
891
+ lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
892
+ if (this.#theme.highlightCode) {
893
+ const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
894
+ for (const hlLine of highlightedLines) {
895
+ lines.push(`${codeIndent}${hlLine}`);
896
+ }
897
+ } else {
898
+ const codeLines = token.text.split("\n");
899
+ for (const codeLine of codeLines) {
900
+ lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
901
+ }
902
+ }
903
+ lines.push(this.#theme.codeBlockBorder("```"));
904
+ } else {
905
+ // Other token types - try to render as inline
906
+ const text = this.#renderInlineTokens([token], styleContext);
907
+ if (text) {
908
+ lines.push(text);
909
+ }
910
+ }
911
+ }
912
+
913
+ return lines;
914
+ }
915
+
916
+ /**
917
+ * Get the visible width of the longest word in a string.
918
+ */
919
+ #getLongestWordWidth(text: string, maxWidth?: number): number {
920
+ const words = text.split(/\s+/).filter(word => word.length > 0);
921
+ let longest = 0;
922
+ for (const word of words) {
923
+ longest = Math.max(longest, visibleWidth(word));
924
+ }
925
+ if (maxWidth === undefined) {
926
+ return longest;
927
+ }
928
+ return Math.min(longest, maxWidth);
929
+ }
930
+
931
+ /**
932
+ * Wrap a table cell to fit into a column.
933
+ *
934
+ * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
935
+ * consistently with the rest of the renderer.
936
+ */
937
+ #wrapCellText(text: string, maxWidth: number): string[] {
938
+ return wrapTextWithAnsi(text, Math.max(1, maxWidth));
939
+ }
940
+
941
+ /**
942
+ * Render a table with width-aware cell wrapping.
943
+ * Cells that don't fit are wrapped to multiple lines.
944
+ */
945
+ #renderTable(
946
+ token: TableToken,
947
+ availableWidth: number,
948
+ nextTokenType?: string,
949
+ styleContext?: InlineStyleContext,
950
+ ): string[] {
951
+ const lines: string[] = [];
952
+ const numCols = token.header.length;
953
+
954
+ if (numCols === 0) {
955
+ return lines;
956
+ }
957
+
958
+ // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
959
+ // = 2 + (n-1) * 3 + 2 = 3n + 1
960
+ const borderOverhead = 3 * numCols + 1;
961
+ const availableForCells = availableWidth - borderOverhead;
962
+ if (availableForCells < numCols) {
963
+ // Too narrow to render a stable table. Fall back to raw markdown.
964
+ const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
965
+ if (nextTokenType && nextTokenType !== "space") {
966
+ fallbackLines.push("");
967
+ }
968
+ return fallbackLines;
969
+ }
970
+
971
+ const maxUnbrokenWordWidth = 30;
972
+
973
+ // Calculate natural column widths (what each column needs without constraints)
974
+ const naturalWidths: number[] = [];
975
+ const minWordWidths: number[] = [];
976
+ for (let i = 0; i < numCols; i++) {
977
+ const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext);
978
+ naturalWidths[i] = visibleWidth(headerText);
979
+ minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth));
980
+ }
981
+ for (const row of token.rows) {
982
+ for (let i = 0; i < row.length; i++) {
983
+ const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext);
984
+ naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
985
+ minWordWidths[i] = Math.max(
986
+ minWordWidths[i] || 1,
987
+ this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth),
988
+ );
989
+ }
990
+ }
991
+
992
+ let minColumnWidths = minWordWidths;
993
+ let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
994
+
995
+ if (minCellsWidth > availableForCells) {
996
+ minColumnWidths = new Array(numCols).fill(1);
997
+ const remaining = availableForCells - numCols;
998
+
999
+ if (remaining > 0) {
1000
+ const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
1001
+ const growth = minWordWidths.map(width => {
1002
+ const weight = Math.max(0, width - 1);
1003
+ return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
1004
+ });
1005
+
1006
+ for (let i = 0; i < numCols; i++) {
1007
+ minColumnWidths[i] += growth[i] ?? 0;
1008
+ }
1009
+
1010
+ const allocated = growth.reduce((total, width) => total + width, 0);
1011
+ let leftover = remaining - allocated;
1012
+ for (let i = 0; leftover > 0 && i < numCols; i++) {
1013
+ minColumnWidths[i]++;
1014
+ leftover--;
1015
+ }
1016
+ }
1017
+
1018
+ minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
1019
+ }
1020
+
1021
+ // Calculate column widths that fit within available width
1022
+ const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
1023
+ let columnWidths: number[];
1024
+
1025
+ if (totalNaturalWidth <= availableWidth) {
1026
+ // Everything fits naturally
1027
+ columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
1028
+ } else {
1029
+ // Need to shrink columns to fit
1030
+ const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
1031
+ return total + Math.max(0, width - minColumnWidths[index]);
1032
+ }, 0);
1033
+ const extraWidth = Math.max(0, availableForCells - minCellsWidth);
1034
+ columnWidths = minColumnWidths.map((minWidth, index) => {
1035
+ const naturalWidth = naturalWidths[index];
1036
+ const minWidthDelta = Math.max(0, naturalWidth - minWidth);
1037
+ let grow = 0;
1038
+ if (totalGrowPotential > 0) {
1039
+ grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
1040
+ }
1041
+ return minWidth + grow;
1042
+ });
1043
+
1044
+ // Adjust for rounding errors - distribute remaining space
1045
+ const allocated = columnWidths.reduce((a, b) => a + b, 0);
1046
+ let remaining = availableForCells - allocated;
1047
+ while (remaining > 0) {
1048
+ let grew = false;
1049
+ for (let i = 0; i < numCols && remaining > 0; i++) {
1050
+ if (columnWidths[i] < naturalWidths[i]) {
1051
+ columnWidths[i]++;
1052
+ remaining--;
1053
+ grew = true;
1054
+ }
1055
+ }
1056
+ if (!grew) {
1057
+ break;
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ const t = this.#theme.symbols.table;
1063
+ const h = t.horizontal;
1064
+ const v = t.vertical;
1065
+
1066
+ // Render top border
1067
+ const topBorderCells = columnWidths.map(w => h.repeat(w));
1068
+ lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
1069
+
1070
+ // Render header with wrapping
1071
+ const headerCellLines: string[][] = token.header.map((cell, i) => {
1072
+ const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
1073
+ return this.#wrapCellText(text, columnWidths[i]);
1074
+ });
1075
+ const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
1076
+
1077
+ for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
1078
+ const rowParts = headerCellLines.map((cellLines, colIdx) => {
1079
+ const text = cellLines[lineIdx] || "";
1080
+ const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
1081
+ return this.#theme.bold(padded);
1082
+ });
1083
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
1084
+ }
1085
+
1086
+ // Render separator
1087
+ const separatorCells = columnWidths.map(w => h.repeat(w));
1088
+ const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
1089
+ lines.push(separatorLine);
1090
+
1091
+ // Render rows with wrapping
1092
+ for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
1093
+ const row = token.rows[rowIndex];
1094
+ const rowCellLines: string[][] = row.map((cell, i) => {
1095
+ const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
1096
+ return this.#wrapCellText(text, columnWidths[i]);
1097
+ });
1098
+ const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
1099
+
1100
+ for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
1101
+ const rowParts = rowCellLines.map((cellLines, colIdx) => {
1102
+ const text = cellLines[lineIdx] || "";
1103
+ return text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
1104
+ });
1105
+ lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
1106
+ }
1107
+
1108
+ if (rowIndex < token.rows.length - 1) {
1109
+ lines.push(separatorLine);
1110
+ }
1111
+ }
1112
+
1113
+ // Render bottom border
1114
+ const bottomBorderCells = columnWidths.map(w => h.repeat(w));
1115
+ lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
1116
+
1117
+ if (nextTokenType && nextTokenType !== "space") {
1118
+ lines.push(""); // Add spacing after table
1119
+ }
1120
+ return lines;
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Render inline markdown (bold, italic, code, links, strikethrough) to a styled string.
1126
+ * Unlike the full Markdown component, this produces a single line with no block-level elements.
1127
+ */
1128
+ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string {
1129
+ // Guard against undefined/null during streaming — partial JSON can leave fields unpopulated.
1130
+ if (typeof text !== "string") return (baseColor ?? (t => t))(text != null ? String(text) : "");
1131
+ const tokens = marked.lexer(text);
1132
+ const applyText = baseColor ?? ((t: string) => t);
1133
+ let result = "";
1134
+ for (const token of tokens) {
1135
+ if (token.type === "paragraph" && token.tokens) {
1136
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
1137
+ } else if (token.type === "list") {
1138
+ result += token.items
1139
+ .map((item: Tokens.ListItem, index: number) => {
1140
+ const prefix = token.ordered ? `${(token.start || 1) + index}. ` : "• ";
1141
+ const content = item.tokens ? renderInlineTokens(item.tokens, mdTheme, applyText) : applyText(item.text);
1142
+ return `${applyText(prefix)}${content}`;
1143
+ })
1144
+ .join(applyText(" "));
1145
+ } else if ("text" in token && typeof token.text === "string") {
1146
+ result += applyText(token.text);
1147
+ }
1148
+ }
1149
+ return result;
1150
+ }
1151
+
1152
+ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText: (t: string) => string): string {
1153
+ let result = "";
1154
+ const styleReset = applyText("");
1155
+ for (const token of tokens) {
1156
+ switch (token.type) {
1157
+ case "text":
1158
+ if (token.tokens && token.tokens.length > 0) {
1159
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
1160
+ } else {
1161
+ result += applyText(token.text);
1162
+ }
1163
+ break;
1164
+ case "strong":
1165
+ result += mdTheme.bold(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
1166
+ break;
1167
+ case "em":
1168
+ result += mdTheme.italic(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
1169
+ break;
1170
+ case "codespan":
1171
+ result += mdTheme.code(token.text) + styleReset;
1172
+ break;
1173
+ case "del":
1174
+ result += mdTheme.strikethrough(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
1175
+ break;
1176
+ case "link": {
1177
+ const linkText = renderInlineTokens(token.tokens || [], mdTheme, applyText);
1178
+ result += mdTheme.link(mdTheme.underline(linkText)) + styleReset;
1179
+ break;
1180
+ }
1181
+ default:
1182
+ if ("text" in token && typeof token.text === "string") {
1183
+ result += applyText(token.text);
1184
+ }
1185
+ break;
1186
+ }
1187
+ }
1188
+ return result;
1189
+ }