@gajae-code/tui 0.1.1

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