@sayknow-cli/tui 0.2.2

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