@fleetagent/pi-tui 0.1.9 → 0.2.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 (109) hide show
  1. package/README.md +47 -7
  2. package/dist/autocomplete.d.ts +17 -23
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +129 -195
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/alt-screen-flash.d.ts +13 -0
  7. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  8. package/dist/components/alt-screen-flash.js +37 -0
  9. package/dist/components/alt-screen-flash.js.map +1 -0
  10. package/dist/components/editor.d.ts +47 -4
  11. package/dist/components/editor.d.ts.map +1 -1
  12. package/dist/components/editor.js +615 -585
  13. package/dist/components/editor.js.map +1 -1
  14. package/dist/components/h-stack.d.ts +7 -0
  15. package/dist/components/h-stack.d.ts.map +1 -0
  16. package/dist/components/h-stack.js +43 -0
  17. package/dist/components/h-stack.js.map +1 -0
  18. package/dist/components/image.d.ts +4 -0
  19. package/dist/components/image.d.ts.map +1 -1
  20. package/dist/components/image.js +38 -51
  21. package/dist/components/image.js.map +1 -1
  22. package/dist/components/input.d.ts +9 -0
  23. package/dist/components/input.d.ts.map +1 -1
  24. package/dist/components/input.js +121 -147
  25. package/dist/components/input.js.map +1 -1
  26. package/dist/components/markdown.d.ts +34 -0
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +491 -429
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/scroll-view.d.ts +53 -0
  31. package/dist/components/scroll-view.d.ts.map +1 -0
  32. package/dist/components/scroll-view.js +173 -0
  33. package/dist/components/scroll-view.js.map +1 -0
  34. package/dist/components/select-list.d.ts.map +1 -1
  35. package/dist/components/select-list.js.map +1 -1
  36. package/dist/components/settings-list.d.ts +2 -0
  37. package/dist/components/settings-list.d.ts.map +1 -1
  38. package/dist/components/settings-list.js +31 -29
  39. package/dist/components/settings-list.js.map +1 -1
  40. package/dist/components/stack.d.ts +32 -0
  41. package/dist/components/stack.d.ts.map +1 -0
  42. package/dist/components/stack.js +122 -0
  43. package/dist/components/stack.js.map +1 -0
  44. package/dist/components/v-stack.d.ts +8 -0
  45. package/dist/components/v-stack.d.ts.map +1 -0
  46. package/dist/components/v-stack.js +27 -0
  47. package/dist/components/v-stack.js.map +1 -0
  48. package/dist/fuzzy.d.ts.map +1 -1
  49. package/dist/fuzzy.js +37 -46
  50. package/dist/fuzzy.js.map +1 -1
  51. package/dist/index.d.ts +10 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +11 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/keybindings.d.ts +54 -4
  56. package/dist/keybindings.d.ts.map +1 -1
  57. package/dist/keybindings.js +39 -4
  58. package/dist/keybindings.js.map +1 -1
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +359 -479
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts +6 -4
  63. package/dist/kill-ring.d.ts.map +1 -1
  64. package/dist/kill-ring.js +0 -7
  65. package/dist/kill-ring.js.map +1 -1
  66. package/dist/latex.d.ts +10 -0
  67. package/dist/latex.d.ts.map +1 -0
  68. package/dist/latex.js +1406 -0
  69. package/dist/latex.js.map +1 -0
  70. package/dist/layout-node.d.ts +43 -0
  71. package/dist/layout-node.d.ts.map +1 -0
  72. package/dist/layout-node.js +6 -0
  73. package/dist/layout-node.js.map +1 -0
  74. package/dist/layout.d.ts +40 -0
  75. package/dist/layout.d.ts.map +1 -0
  76. package/dist/layout.js +386 -0
  77. package/dist/layout.js.map +1 -0
  78. package/dist/stdin-buffer.d.ts +8 -0
  79. package/dist/stdin-buffer.d.ts.map +1 -1
  80. package/dist/stdin-buffer.js +116 -159
  81. package/dist/stdin-buffer.js.map +1 -1
  82. package/dist/terminal-image.d.ts +41 -19
  83. package/dist/terminal-image.d.ts.map +1 -1
  84. package/dist/terminal-image.js +171 -32
  85. package/dist/terminal-image.js.map +1 -1
  86. package/dist/terminal.d.ts +3 -0
  87. package/dist/terminal.d.ts.map +1 -1
  88. package/dist/terminal.js +27 -23
  89. package/dist/terminal.js.map +1 -1
  90. package/dist/tui-alt-screen.d.ts +114 -0
  91. package/dist/tui-alt-screen.d.ts.map +1 -0
  92. package/dist/tui-alt-screen.js +950 -0
  93. package/dist/tui-alt-screen.js.map +1 -0
  94. package/dist/tui-main-screen.d.ts +52 -0
  95. package/dist/tui-main-screen.d.ts.map +1 -0
  96. package/dist/tui-main-screen.js +511 -0
  97. package/dist/tui-main-screen.js.map +1 -0
  98. package/dist/tui.d.ts +112 -44
  99. package/dist/tui.d.ts.map +1 -1
  100. package/dist/tui.js +489 -699
  101. package/dist/tui.js.map +1 -1
  102. package/dist/utils.d.ts +29 -17
  103. package/dist/utils.d.ts.map +1 -1
  104. package/dist/utils.js +605 -528
  105. package/dist/utils.js.map +1 -1
  106. package/dist/word-navigation.d.ts.map +1 -1
  107. package/dist/word-navigation.js +37 -39
  108. package/dist/word-navigation.js.map +1 -1
  109. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { Marked, Tokenizer } from "marked";
2
+ import { renderLatex } from "../latex.js";
2
3
  import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
3
4
  import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
4
5
  const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
@@ -21,10 +22,115 @@ class StrictStrikethroughTokenizer extends Tokenizer {
21
22
  };
22
23
  }
23
24
  }
25
+ function isEscaped(source, index) {
26
+ let backslashes = 0;
27
+ for (let position = index - 1; position >= 0 && source[position] === "\\"; position--) {
28
+ backslashes++;
29
+ }
30
+ return backslashes % 2 === 1;
31
+ }
32
+ function findClosingDelimiter(source, closing, start) {
33
+ let index = source.indexOf(closing, start);
34
+ while (index >= 0 && isEscaped(source, index)) {
35
+ index = source.indexOf(closing, index + closing.length);
36
+ }
37
+ return index;
38
+ }
39
+ function looksLikePendingDollarMath(source) {
40
+ return /\\[A-Za-z]+|[_^=+*/<>()[\]|±≤≥≠≈∈→⇒∞∫∑√-]/.test(source);
41
+ }
42
+ function looksLikeShellVariableChain(content, following) {
43
+ return /^[A-Z_][A-Z0-9_]*(?:[/:.-][A-Za-z0-9_./:-]*)*$/.test(content) && /^[A-Za-z_][A-Za-z0-9_]*/.test(following);
44
+ }
45
+ function tokenizeInlineLatex(source) {
46
+ let opening = "";
47
+ let closing = "";
48
+ if (source.startsWith("$$")) {
49
+ opening = "$$";
50
+ closing = "$$";
51
+ }
52
+ else if (source.startsWith("\\(")) {
53
+ opening = "\\(";
54
+ closing = "\\)";
55
+ }
56
+ else if (source.startsWith("\\[")) {
57
+ opening = "\\[";
58
+ closing = "\\]";
59
+ }
60
+ else if (source.startsWith("$") && !/^\$\s/.test(source)) {
61
+ opening = "$";
62
+ closing = "$";
63
+ }
64
+ else {
65
+ return undefined;
66
+ }
67
+ const closingIndex = findClosingDelimiter(source, closing, opening.length);
68
+ if (closingIndex >= 0 &&
69
+ opening === "$" &&
70
+ (/\s$/.test(source.slice(opening.length, closingIndex)) ||
71
+ /^\d/.test(source.slice(closingIndex + 1)) ||
72
+ looksLikeShellVariableChain(source.slice(opening.length, closingIndex), source.slice(closingIndex + closing.length)) ||
73
+ source.slice(opening.length, closingIndex).includes("`"))) {
74
+ return undefined;
75
+ }
76
+ if (closingIndex < 0) {
77
+ const pendingSource = source.slice(opening.length);
78
+ if (opening.startsWith("\\") || looksLikePendingDollarMath(pendingSource)) {
79
+ return { type: "latex", raw: source, text: pendingSource, pending: true };
80
+ }
81
+ return undefined;
82
+ }
83
+ const text = source.slice(opening.length, closingIndex);
84
+ if (!text || text.includes("\n")) {
85
+ return undefined;
86
+ }
87
+ const raw = source.slice(0, closingIndex + closing.length);
88
+ return { type: "latex", raw, text };
89
+ }
90
+ function tokenizeBlockLatex(source) {
91
+ const dollarMatch = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*?)\$\$[ \t]*(?:\n|$)/.exec(source);
92
+ if (dollarMatch?.[1]) {
93
+ return { type: "latexBlock", raw: dollarMatch[0], text: dollarMatch[1].trim() };
94
+ }
95
+ const bracketMatch = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*?)\\\][ \t]*(?:\n|$)/.exec(source);
96
+ if (bracketMatch?.[1]) {
97
+ return { type: "latexBlock", raw: bracketMatch[0], text: bracketMatch[1].trim() };
98
+ }
99
+ const pendingBracket = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*)$/.exec(source);
100
+ if (pendingBracket) {
101
+ return { type: "latexBlock", raw: pendingBracket[0], text: pendingBracket[1], pending: true };
102
+ }
103
+ const pendingDollar = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*)$/.exec(source);
104
+ if (pendingDollar?.[1] && looksLikePendingDollarMath(pendingDollar[1])) {
105
+ return { type: "latexBlock", raw: pendingDollar[0], text: pendingDollar[1], pending: true };
106
+ }
107
+ return undefined;
108
+ }
109
+ const LATEX_MARKDOWN_EXTENSIONS = [
110
+ {
111
+ name: "latexBlock",
112
+ level: "block",
113
+ start(source) {
114
+ const match = /(?:^|\n) {0,3}(?:\$\$|\\\[)/.exec(source);
115
+ return match ? match.index + (match[0].startsWith("\n") ? 1 : 0) : undefined;
116
+ },
117
+ tokenizer: tokenizeBlockLatex,
118
+ },
119
+ {
120
+ name: "latex",
121
+ level: "inline",
122
+ start(source) {
123
+ const indices = [source.indexOf("$"), source.indexOf("\\("), source.indexOf("\\[")].filter((index) => index >= 0);
124
+ return indices.length > 0 ? Math.min(...indices) : undefined;
125
+ },
126
+ tokenizer: tokenizeInlineLatex,
127
+ },
128
+ ];
24
129
  const markdownParser = new Marked();
25
130
  markdownParser.setOptions({
26
131
  tokenizer: new StrictStrikethroughTokenizer(),
27
132
  });
133
+ markdownParser.use({ extensions: [...LATEX_MARKDOWN_EXTENSIONS] });
28
134
  export class Markdown {
29
135
  text;
30
136
  paddingX; // Left/right padding
@@ -54,82 +160,80 @@ export class Markdown {
54
160
  this.cachedWidth = undefined;
55
161
  this.cachedLines = undefined;
56
162
  }
57
- render(width) {
58
- // Check cache
59
- if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
60
- return this.cachedLines;
61
- }
62
- // Calculate available width for content (subtract horizontal padding)
63
- const contentWidth = Math.max(1, width - this.paddingX * 2);
64
- // Don't render anything if there's no actual text
65
- if (!this.text || this.text.trim() === "") {
66
- const result = [];
67
- // Update cache
68
- this.cachedText = this.text;
69
- this.cachedWidth = width;
70
- this.cachedLines = result;
71
- return result;
72
- }
73
- // Replace tabs with 3 spaces for consistent rendering
74
- const normalizedText = this.text.replace(/\t/g, " ");
75
- // Parse markdown to HTML-like tokens
76
- const tokens = markdownParser.lexer(normalizedText);
77
- // Convert tokens to styled terminal output
163
+ renderMarkdownTokens(text, contentWidth) {
164
+ const tokens = markdownParser.lexer(text);
78
165
  const renderedLines = [];
79
- for (let i = 0; i < tokens.length; i++) {
80
- const token = tokens[i];
81
- const nextToken = tokens[i + 1];
82
- const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
83
- for (const tokenLine of tokenLines) {
166
+ for (let index = 0; index < tokens.length; index++) {
167
+ const tokenLines = this.renderToken(tokens[index], contentWidth, tokens[index + 1]?.type);
168
+ for (const tokenLine of tokenLines)
84
169
  renderedLines.push(tokenLine);
85
- }
86
170
  }
87
- // Wrap lines (NO padding, NO background yet)
171
+ return renderedLines;
172
+ }
173
+ wrapRenderedLines(renderedLines, contentWidth) {
88
174
  const wrappedLines = [];
89
175
  for (const line of renderedLines) {
90
176
  if (isImageLine(line)) {
91
177
  wrappedLines.push(line);
178
+ continue;
92
179
  }
93
- else {
94
- for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) {
95
- wrappedLines.push(wrappedLine);
96
- }
97
- }
180
+ for (const wrappedLine of wrapTextWithAnsi(line, contentWidth))
181
+ wrappedLines.push(wrappedLine);
98
182
  }
99
- // Add margins and background to each wrapped line
100
- const leftMargin = " ".repeat(this.paddingX);
101
- const rightMargin = " ".repeat(this.paddingX);
183
+ return wrappedLines;
184
+ }
185
+ padRenderedLines(lines, width, paddingX) {
186
+ const margin = " ".repeat(paddingX);
102
187
  const bgFn = this.defaultTextStyle?.bgColor;
103
188
  const contentLines = [];
104
- for (const line of wrappedLines) {
189
+ for (const line of lines) {
105
190
  if (isImageLine(line)) {
106
191
  contentLines.push(line);
107
192
  continue;
108
193
  }
109
- const lineWithMargins = leftMargin + line + rightMargin;
194
+ const lineWithMargins = margin + line + margin;
110
195
  if (bgFn) {
111
196
  contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
197
+ continue;
112
198
  }
113
- else {
114
- // No background - just pad to width
115
- const visibleLen = visibleWidth(lineWithMargins);
116
- const paddingNeeded = Math.max(0, width - visibleLen);
117
- contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
118
- }
199
+ const paddingNeeded = Math.max(0, width - visibleWidth(lineWithMargins));
200
+ contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
119
201
  }
120
- // Add top/bottom padding (empty lines)
202
+ return contentLines;
203
+ }
204
+ createVerticalPadding(width) {
205
+ const bgFn = this.defaultTextStyle?.bgColor;
121
206
  const emptyLine = " ".repeat(width);
122
207
  const emptyLines = [];
123
- for (let i = 0; i < this.paddingY; i++) {
124
- const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
125
- emptyLines.push(line);
208
+ for (let index = 0; index < this.paddingY; index++) {
209
+ emptyLines.push(bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine);
126
210
  }
127
- // Combine top padding, content, and bottom padding
128
- const result = emptyLines.concat(contentLines, emptyLines);
129
- // Update cache
211
+ return emptyLines;
212
+ }
213
+ cacheRenderedLines(width, lines) {
130
214
  this.cachedText = this.text;
131
215
  this.cachedWidth = width;
132
- this.cachedLines = result;
216
+ this.cachedLines = lines;
217
+ }
218
+ render(width) {
219
+ if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
220
+ return this.cachedLines;
221
+ }
222
+ const effectivePaddingX = Math.min(this.paddingX, Math.floor(Math.max(0, width - 1) / 2));
223
+ const contentWidth = Math.max(1, width - effectivePaddingX * 2);
224
+ const text = this.options.transform?.(this.text, contentWidth) ?? this.text;
225
+ if (!text || text.trim() === "") {
226
+ const result = [];
227
+ this.cacheRenderedLines(width, result);
228
+ return result;
229
+ }
230
+ const normalizedText = text.replace(/\t/g, " ");
231
+ const renderedLines = this.renderMarkdownTokens(normalizedText, contentWidth);
232
+ const wrappedLines = this.wrapRenderedLines(renderedLines, contentWidth);
233
+ const contentLines = this.padRenderedLines(wrappedLines, width, effectivePaddingX);
234
+ const verticalPadding = this.createVerticalPadding(width);
235
+ const result = verticalPadding.concat(contentLines, verticalPadding);
236
+ this.cacheRenderedLines(width, result);
133
237
  return result.length > 0 ? result : [""];
134
238
  }
135
239
  /**
@@ -202,235 +306,210 @@ export class Markdown {
202
306
  stylePrefix: this.getDefaultStylePrefix(),
203
307
  };
204
308
  }
205
- renderToken(token, width, nextTokenType, styleContext) {
206
- const lines = [];
207
- switch (token.type) {
208
- case "heading": {
209
- const headingLevel = token.depth;
210
- const headingPrefix = `${"#".repeat(headingLevel)} `;
211
- // Build a heading-specific style context so inline tokens (codespan, bold, etc.)
212
- // restore heading styling after their own ANSI resets instead of falling back to
213
- // the default text style.
214
- let headingStyleFn;
215
- if (headingLevel === 1) {
216
- headingStyleFn = (text) => this.theme.heading(this.theme.bold(this.theme.underline(text)));
217
- }
218
- else {
219
- headingStyleFn = (text) => this.theme.heading(this.theme.bold(text));
220
- }
221
- const headingStyleContext = {
222
- applyText: headingStyleFn,
223
- stylePrefix: this.getStylePrefix(headingStyleFn),
224
- };
225
- const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext);
226
- const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText;
227
- lines.push(styledHeading);
228
- if (nextTokenType && nextTokenType !== "space") {
229
- lines.push(""); // Add spacing after headings (unless space token follows)
230
- }
231
- break;
232
- }
233
- case "paragraph": {
234
- const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext);
235
- lines.push(paragraphText);
236
- // Don't add spacing if next token is space or list
237
- if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
238
- lines.push("");
239
- }
240
- break;
241
- }
242
- case "text":
243
- lines.push(this.renderInlineTokens([token], styleContext));
244
- break;
245
- case "code": {
246
- const indent = this.theme.codeBlockIndent ?? " ";
247
- lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
248
- if (this.theme.highlightCode) {
249
- const highlightedLines = this.theme.highlightCode(token.text, token.lang);
250
- for (const hlLine of highlightedLines) {
251
- lines.push(`${indent}${hlLine}`);
252
- }
253
- }
254
- else {
255
- // Split code by newlines and style each line
256
- const codeLines = token.text.split("\n");
257
- for (const codeLine of codeLines) {
258
- lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
259
- }
260
- }
261
- lines.push(this.theme.codeBlockBorder("```"));
262
- if (nextTokenType && nextTokenType !== "space") {
263
- lines.push(""); // Add spacing after code blocks (unless space token follows)
264
- }
265
- break;
266
- }
267
- case "list": {
268
- const listLines = this.renderList(token, 0, width, styleContext);
269
- lines.push(...listLines);
270
- // Don't add spacing after lists if a space token follows
271
- // (the space token will handle it)
272
- break;
309
+ shouldAppendBlockSpacing(nextTokenType) {
310
+ return Boolean(nextTokenType && nextTokenType !== "space");
311
+ }
312
+ renderHeadingToken(token, nextTokenType) {
313
+ const headingPrefix = `${"#".repeat(token.depth)} `;
314
+ const headingStyle = token.depth === 1
315
+ ? (text) => this.theme.heading(this.theme.bold(this.theme.underline(text)))
316
+ : (text) => this.theme.heading(this.theme.bold(text));
317
+ const styleContext = {
318
+ applyText: headingStyle,
319
+ stylePrefix: this.getStylePrefix(headingStyle),
320
+ };
321
+ const headingText = this.renderInlineTokens(token.tokens || [], styleContext);
322
+ const styledHeading = token.depth >= 3 ? headingStyle(headingPrefix) + headingText : headingText;
323
+ return this.shouldAppendBlockSpacing(nextTokenType) ? [styledHeading, ""] : [styledHeading];
324
+ }
325
+ renderParagraphToken(token, nextTokenType, styleContext) {
326
+ const lines = [this.renderInlineTokens(token.tokens || [], styleContext)];
327
+ if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space")
328
+ lines.push("");
329
+ return lines;
330
+ }
331
+ renderLatexBlockToken(token, width, nextTokenType) {
332
+ const candidate = !token.pending && this.options.renderLatex !== false ? renderLatex(token.text, { display: true }) : undefined;
333
+ const rendered = candidate?.split("\n").every((line) => visibleWidth(line) <= width)
334
+ ? candidate
335
+ : token.raw.trim();
336
+ const lines = rendered.split("\n").map((line) => this.applyDefaultStyle(line));
337
+ if (this.shouldAppendBlockSpacing(nextTokenType))
338
+ lines.push("");
339
+ return lines;
340
+ }
341
+ renderCodeToken(token, nextTokenType) {
342
+ const indent = this.theme.codeBlockIndent ?? " ";
343
+ const lines = [this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)];
344
+ if (this.theme.highlightCode) {
345
+ for (const highlightedLine of this.theme.highlightCode(token.text, token.lang)) {
346
+ lines.push(`${indent}${highlightedLine}`);
273
347
  }
274
- case "table": {
275
- const tableLines = this.renderTable(token, width, nextTokenType, styleContext);
276
- lines.push(...tableLines);
277
- break;
348
+ }
349
+ else {
350
+ for (const codeLine of token.text.split("\n")) {
351
+ lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
278
352
  }
279
- case "blockquote": {
280
- const quoteStyle = (text) => this.theme.quote(this.theme.italic(text));
281
- const quoteStylePrefix = this.getStylePrefix(quoteStyle);
282
- const applyQuoteStyle = (line) => {
283
- if (!quoteStylePrefix) {
284
- return quoteStyle(line);
285
- }
286
- const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
287
- return quoteStyle(lineWithReappliedStyle);
288
- };
289
- // Calculate available width for quote content (subtract border "│ " = 2 chars)
290
- const quoteContentWidth = Math.max(1, width - 2);
291
- // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
292
- // children with renderToken() instead of renderInlineTokens().
293
- // Default message style should not apply inside blockquotes.
294
- const quoteInlineStyleContext = {
295
- applyText: (text) => text,
296
- stylePrefix: quoteStylePrefix,
297
- };
298
- const quoteTokens = token.tokens || [];
299
- const renderedQuoteLines = [];
300
- for (let i = 0; i < quoteTokens.length; i++) {
301
- const quoteToken = quoteTokens[i];
302
- const nextQuoteToken = quoteTokens[i + 1];
303
- renderedQuoteLines.push(...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext));
304
- }
305
- // Avoid rendering an extra empty quote line before the outer blockquote spacing.
306
- while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
307
- renderedQuoteLines.pop();
308
- }
309
- for (const quoteLine of renderedQuoteLines) {
310
- const styledLine = applyQuoteStyle(quoteLine);
311
- const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
312
- for (const wrappedLine of wrappedLines) {
313
- lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
314
- }
315
- }
316
- if (nextTokenType && nextTokenType !== "space") {
317
- lines.push(""); // Add spacing after blockquotes (unless space token follows)
318
- }
319
- break;
353
+ }
354
+ lines.push(this.theme.codeBlockBorder("```"));
355
+ if (this.shouldAppendBlockSpacing(nextTokenType))
356
+ lines.push("");
357
+ return lines;
358
+ }
359
+ renderBlockquoteToken(token, width, nextTokenType) {
360
+ const quoteStyle = (text) => this.theme.quote(this.theme.italic(text));
361
+ const quoteStylePrefix = this.getStylePrefix(quoteStyle);
362
+ const quoteContentWidth = Math.max(1, width - 2);
363
+ const quoteStyleContext = {
364
+ applyText: (text) => text,
365
+ stylePrefix: quoteStylePrefix,
366
+ };
367
+ const renderedQuoteLines = [];
368
+ const quoteTokens = token.tokens || [];
369
+ for (let index = 0; index < quoteTokens.length; index++) {
370
+ renderedQuoteLines.push(...this.renderToken(quoteTokens[index], quoteContentWidth, quoteTokens[index + 1]?.type, quoteStyleContext));
371
+ }
372
+ while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
373
+ renderedQuoteLines.pop();
374
+ }
375
+ const lines = [];
376
+ for (const quoteLine of renderedQuoteLines) {
377
+ const lineWithReappliedStyle = quoteStylePrefix
378
+ ? quoteLine.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`)
379
+ : quoteLine;
380
+ for (const wrappedLine of wrapTextWithAnsi(quoteStyle(lineWithReappliedStyle), quoteContentWidth)) {
381
+ lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
320
382
  }
383
+ }
384
+ if (this.shouldAppendBlockSpacing(nextTokenType))
385
+ lines.push("");
386
+ return lines;
387
+ }
388
+ renderHorizontalRule(width, nextTokenType) {
389
+ const lines = [this.theme.hr("─".repeat(Math.min(width, 80)))];
390
+ if (this.shouldAppendBlockSpacing(nextTokenType))
391
+ lines.push("");
392
+ return lines;
393
+ }
394
+ renderHtmlBlock(token) {
395
+ if (!("raw" in token) || typeof token.raw !== "string")
396
+ return [];
397
+ const visibleHtml = stripHtmlComments(token.raw).trim();
398
+ return visibleHtml ? [this.applyDefaultStyle(visibleHtml)] : [];
399
+ }
400
+ renderFallbackToken(token) {
401
+ return "text" in token && typeof token.text === "string" ? [token.text] : [];
402
+ }
403
+ renderToken(token, width, nextTokenType, styleContext) {
404
+ switch (token.type) {
405
+ case "heading":
406
+ return this.renderHeadingToken(token, nextTokenType);
407
+ case "paragraph":
408
+ return this.renderParagraphToken(token, nextTokenType, styleContext);
409
+ case "text":
410
+ return [this.renderInlineTokens([token], styleContext)];
411
+ case "latexBlock":
412
+ return this.renderLatexBlockToken(token, width, nextTokenType);
413
+ case "code":
414
+ return this.renderCodeToken(token, nextTokenType);
415
+ case "list":
416
+ return this.renderList(token, 0, width, styleContext);
417
+ case "table":
418
+ return this.renderTable(token, width, nextTokenType, styleContext);
419
+ case "blockquote":
420
+ return this.renderBlockquoteToken(token, width, nextTokenType);
321
421
  case "hr":
322
- lines.push(this.theme.hr("─".repeat(Math.min(width, 80))));
323
- if (nextTokenType && nextTokenType !== "space") {
324
- lines.push(""); // Add spacing after horizontal rules (unless space token follows)
325
- }
326
- break;
422
+ return this.renderHorizontalRule(width, nextTokenType);
327
423
  case "html":
328
- // HTML comments are invisible in Markdown; render other HTML as plain text.
329
- if ("raw" in token && typeof token.raw === "string") {
330
- const visibleHtml = stripHtmlComments(token.raw).trim();
331
- if (visibleHtml)
332
- lines.push(this.applyDefaultStyle(visibleHtml));
333
- }
334
- break;
424
+ return this.renderHtmlBlock(token);
335
425
  case "space":
336
- // Space tokens represent blank lines in markdown
337
- lines.push("");
338
- break;
426
+ return [""];
339
427
  default:
340
- // Handle any other token types as plain text
341
- if ("text" in token && typeof token.text === "string") {
342
- lines.push(token.text);
343
- }
428
+ return this.renderFallbackToken(token);
429
+ }
430
+ }
431
+ applyInlineText(text, styleContext) {
432
+ return text
433
+ .split("\n")
434
+ .map((segment) => styleContext.applyText(segment))
435
+ .join("\n");
436
+ }
437
+ renderInlineLinkToken(token, styleContext) {
438
+ const linkText = this.renderInlineTokens(token.tokens || [], styleContext);
439
+ const styledLink = this.theme.link(this.theme.underline(linkText));
440
+ if (getCapabilities().hyperlinks) {
441
+ // OSC 8: render as a clickable hyperlink. The URL is not printed inline,
442
+ // so we always show only the link text regardless of whether it matches href.
443
+ return hyperlink(styledLink, token.href) + styleContext.stylePrefix;
444
+ }
445
+ // Fallback: print URL in parentheses when text differs from href.
446
+ // Compare raw token.text (not styled) against href for the equality check.
447
+ // For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
448
+ // but href="mailto:foo@bar.com").
449
+ const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
450
+ if (token.text === token.href || token.text === hrefForComparison) {
451
+ return styledLink + styleContext.stylePrefix;
452
+ }
453
+ return styledLink + this.theme.linkUrl(` (${token.href})`) + styleContext.stylePrefix;
454
+ }
455
+ renderInlineLatexToken(token, styleContext) {
456
+ const rendered = !token.pending && this.options.renderLatex !== false ? (renderLatex(token.text) ?? token.raw) : token.raw;
457
+ return this.applyInlineText(rendered, styleContext);
458
+ }
459
+ renderInlineTextToken(token, styleContext) {
460
+ // Text tokens in list items can have nested tokens for inline formatting.
461
+ return token.tokens?.length
462
+ ? this.renderInlineTokens(token.tokens, styleContext)
463
+ : this.applyInlineText(token.text, styleContext);
464
+ }
465
+ renderInlineHtmlToken(token, styleContext) {
466
+ if (!("raw" in token) || typeof token.raw !== "string")
467
+ return "";
468
+ const visibleHtml = stripHtmlComments(token.raw);
469
+ return visibleHtml ? this.applyInlineText(visibleHtml, styleContext) : "";
470
+ }
471
+ renderInlineFallbackToken(token, styleContext) {
472
+ if (!("text" in token) || typeof token.text !== "string")
473
+ return "";
474
+ return this.applyInlineText(token.text, styleContext);
475
+ }
476
+ renderInlineToken(token, styleContext) {
477
+ const { stylePrefix } = styleContext;
478
+ switch (token.type) {
479
+ case "latex":
480
+ return this.renderInlineLatexToken(token, styleContext);
481
+ case "escape":
482
+ return this.applyInlineText(this.options.preserveBackslashEscapes ? token.raw : token.text, styleContext);
483
+ case "text":
484
+ return this.renderInlineTextToken(token, styleContext);
485
+ case "paragraph":
486
+ return this.renderInlineTokens(token.tokens || [], styleContext);
487
+ case "strong":
488
+ return this.theme.bold(this.renderInlineTokens(token.tokens || [], styleContext)) + stylePrefix;
489
+ case "em":
490
+ return this.theme.italic(this.renderInlineTokens(token.tokens || [], styleContext)) + stylePrefix;
491
+ case "codespan":
492
+ return this.theme.code(token.text) + stylePrefix;
493
+ case "link":
494
+ return this.renderInlineLinkToken(token, styleContext);
495
+ case "br":
496
+ return "\n";
497
+ case "del":
498
+ return this.theme.strikethrough(this.renderInlineTokens(token.tokens || [], styleContext)) + stylePrefix;
499
+ case "html":
500
+ return this.renderInlineHtmlToken(token, styleContext);
501
+ default:
502
+ return this.renderInlineFallbackToken(token, styleContext);
344
503
  }
345
- return lines;
346
504
  }
347
505
  renderInlineTokens(tokens, styleContext) {
348
- let result = "";
349
506
  const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext();
350
- const { applyText, stylePrefix } = resolvedStyleContext;
351
- const applyTextWithNewlines = (text) => {
352
- const segments = text.split("\n");
353
- return segments.map((segment) => applyText(segment)).join("\n");
354
- };
507
+ let result = "";
355
508
  for (const token of tokens) {
356
- switch (token.type) {
357
- case "escape":
358
- result += applyTextWithNewlines(this.options.preserveBackslashEscapes ? token.raw : token.text);
359
- break;
360
- case "text":
361
- // Text tokens in list items can have nested tokens for inline formatting
362
- if (token.tokens && token.tokens.length > 0) {
363
- result += this.renderInlineTokens(token.tokens, resolvedStyleContext);
364
- }
365
- else {
366
- result += applyTextWithNewlines(token.text);
367
- }
368
- break;
369
- case "paragraph":
370
- // Paragraph tokens contain nested inline tokens
371
- result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
372
- break;
373
- case "strong": {
374
- const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
375
- result += this.theme.bold(boldContent) + stylePrefix;
376
- break;
377
- }
378
- case "em": {
379
- const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
380
- result += this.theme.italic(italicContent) + stylePrefix;
381
- break;
382
- }
383
- case "codespan":
384
- result += this.theme.code(token.text) + stylePrefix;
385
- break;
386
- case "link": {
387
- const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
388
- const styledLink = this.theme.link(this.theme.underline(linkText));
389
- if (getCapabilities().hyperlinks) {
390
- // OSC 8: render as a clickable hyperlink. The URL is not printed inline,
391
- // so we always show only the link text regardless of whether it matches href.
392
- result += hyperlink(styledLink, token.href) + stylePrefix;
393
- }
394
- else {
395
- // Fallback: print URL in parentheses when text differs from href.
396
- // Compare raw token.text (not styled) against href for the equality check.
397
- // For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
398
- // but href="mailto:foo@bar.com").
399
- const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
400
- if (token.text === token.href || token.text === hrefForComparison) {
401
- result += styledLink + stylePrefix;
402
- }
403
- else {
404
- result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;
405
- }
406
- }
407
- break;
408
- }
409
- case "br":
410
- result += "\n";
411
- break;
412
- case "del": {
413
- const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
414
- result += this.theme.strikethrough(delContent) + stylePrefix;
415
- break;
416
- }
417
- case "html":
418
- // HTML comments are invisible in Markdown; render other inline HTML as plain text.
419
- if ("raw" in token && typeof token.raw === "string") {
420
- const visibleHtml = stripHtmlComments(token.raw);
421
- if (visibleHtml)
422
- result += applyTextWithNewlines(visibleHtml);
423
- }
424
- break;
425
- default:
426
- // Handle any other inline token types as plain text
427
- if ("text" in token && typeof token.text === "string") {
428
- result += applyTextWithNewlines(token.text);
429
- }
430
- }
509
+ result += this.renderInlineToken(token, resolvedStyleContext);
431
510
  }
432
- while (stylePrefix && result.endsWith(stylePrefix)) {
433
- result = result.slice(0, -stylePrefix.length);
511
+ while (resolvedStyleContext.stylePrefix && result.endsWith(resolvedStyleContext.stylePrefix)) {
512
+ result = result.slice(0, -resolvedStyleContext.stylePrefix.length);
434
513
  }
435
514
  return result;
436
515
  }
@@ -438,45 +517,50 @@ export class Markdown {
438
517
  const match = /^(?: {0,3})(\d{1,9}[.)])[ \t]+/.exec(item.raw);
439
518
  return match ? `${match[1]} ` : undefined;
440
519
  }
520
+ getListItemMarker(token, item, index, startNumber) {
521
+ if (!token.ordered)
522
+ return "- ";
523
+ if (this.options.preserveOrderedListMarkers) {
524
+ return this.getOrderedListMarker(item) ?? `${startNumber + index}. `;
525
+ }
526
+ return `${startNumber + index}. `;
527
+ }
528
+ renderListItem(token, item, index, startNumber, depth, width, styleContext) {
529
+ const indent = " ".repeat(depth);
530
+ const bullet = this.getListItemMarker(token, item, index, startNumber);
531
+ const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : "";
532
+ const marker = bullet + taskMarker;
533
+ const firstPrefix = indent + this.theme.listBullet(marker);
534
+ const continuationPrefix = indent + " ".repeat(visibleWidth(marker));
535
+ const itemWidth = Math.max(1, width - visibleWidth(firstPrefix));
536
+ const lines = [];
537
+ let renderedAnyLine = false;
538
+ for (const itemToken of item.tokens) {
539
+ if (itemToken.type === "list") {
540
+ lines.push(...this.renderList(itemToken, depth + 1, width, styleContext));
541
+ renderedAnyLine = true;
542
+ continue;
543
+ }
544
+ const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext);
545
+ const wrappedItemLines = itemLines.flatMap((line) => wrapTextWithAnsi(line, itemWidth));
546
+ for (const wrappedLine of wrappedItemLines) {
547
+ const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix;
548
+ lines.push(linePrefix + wrappedLine);
549
+ renderedAnyLine = true;
550
+ }
551
+ }
552
+ if (!renderedAnyLine)
553
+ lines.push(firstPrefix);
554
+ return lines;
555
+ }
441
556
  /**
442
557
  * Render a list with proper nesting support
443
558
  */
444
559
  renderList(token, depth, width, styleContext) {
445
560
  const lines = [];
446
- const indent = " ".repeat(depth);
447
- // Use the list's start property (defaults to 1 for ordered lists)
448
561
  const startNumber = typeof token.start === "number" ? token.start : 1;
449
- for (let i = 0; i < token.items.length; i++) {
450
- const item = token.items[i];
451
- const bullet = token.ordered
452
- ? this.options.preserveOrderedListMarkers
453
- ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `)
454
- : `${startNumber + i}. `
455
- : "- ";
456
- const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : "";
457
- const marker = bullet + taskMarker;
458
- const firstPrefix = indent + this.theme.listBullet(marker);
459
- const continuationPrefix = indent + " ".repeat(visibleWidth(marker));
460
- const itemWidth = Math.max(1, width - visibleWidth(firstPrefix));
461
- let renderedAnyLine = false;
462
- for (const itemToken of item.tokens) {
463
- if (itemToken.type === "list") {
464
- lines.push(...this.renderList(itemToken, depth + 1, width, styleContext));
465
- renderedAnyLine = true;
466
- continue;
467
- }
468
- const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext);
469
- for (const line of itemLines) {
470
- for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) {
471
- const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix;
472
- lines.push(linePrefix + wrappedLine);
473
- renderedAnyLine = true;
474
- }
475
- }
476
- }
477
- if (!renderedAnyLine) {
478
- lines.push(firstPrefix);
479
- }
562
+ for (let index = 0; index < token.items.length; index++) {
563
+ lines.push(...this.renderListItem(token, token.items[index], index, startNumber, depth, width, styleContext));
480
564
  }
481
565
  return lines;
482
566
  }
@@ -507,148 +591,126 @@ export class Markdown {
507
591
  * Render a table with width-aware cell wrapping.
508
592
  * Cells that don't fit are wrapped to multiple lines.
509
593
  */
510
- renderTable(token, availableWidth, nextTokenType, styleContext) {
511
- const lines = [];
512
- const numCols = token.header.length;
513
- if (numCols === 0) {
514
- return lines;
515
- }
516
- // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
517
- // = 2 + (n-1) * 3 + 2 = 3n + 1
518
- const borderOverhead = 3 * numCols + 1;
519
- const availableForCells = availableWidth - borderOverhead;
520
- if (availableForCells < numCols) {
521
- // Too narrow to render a stable table. Fall back to raw markdown.
522
- const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
523
- if (nextTokenType && nextTokenType !== "space") {
524
- fallbackLines.push("");
525
- }
526
- return fallbackLines;
527
- }
528
- const maxUnbrokenWordWidth = 30;
529
- // Calculate natural column widths (what each column needs without constraints)
594
+ measureTableColumns(token, styleContext) {
530
595
  const naturalWidths = [];
531
- const minWordWidths = [];
532
- for (let i = 0; i < numCols; i++) {
533
- const headerText = this.renderInlineTokens(token.header[i].tokens || [], styleContext);
534
- naturalWidths[i] = visibleWidth(headerText);
535
- minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth));
596
+ const minimumWidths = [];
597
+ const maxUnbrokenWordWidth = 30;
598
+ for (let column = 0; column < token.header.length; column++) {
599
+ const headerText = this.renderInlineTokens(token.header[column].tokens || [], styleContext);
600
+ naturalWidths[column] = visibleWidth(headerText);
601
+ minimumWidths[column] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth));
536
602
  }
537
603
  for (const row of token.rows) {
538
- for (let i = 0; i < row.length; i++) {
539
- const cellText = this.renderInlineTokens(row[i].tokens || [], styleContext);
540
- naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
541
- minWordWidths[i] = Math.max(minWordWidths[i] || 1, this.getLongestWordWidth(cellText, maxUnbrokenWordWidth));
542
- }
543
- }
544
- let minColumnWidths = minWordWidths;
545
- let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
546
- if (minCellsWidth > availableForCells) {
547
- minColumnWidths = new Array(numCols).fill(1);
548
- const remaining = availableForCells - numCols;
549
- if (remaining > 0) {
550
- const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
551
- const growth = minWordWidths.map((width) => {
552
- const weight = Math.max(0, width - 1);
553
- return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
554
- });
555
- for (let i = 0; i < numCols; i++) {
556
- minColumnWidths[i] += growth[i] ?? 0;
557
- }
558
- const allocated = growth.reduce((total, width) => total + width, 0);
559
- let leftover = remaining - allocated;
560
- for (let i = 0; leftover > 0 && i < numCols; i++) {
561
- minColumnWidths[i]++;
562
- leftover--;
563
- }
604
+ for (let column = 0; column < row.length; column++) {
605
+ const cellText = this.renderInlineTokens(row[column].tokens || [], styleContext);
606
+ naturalWidths[column] = Math.max(naturalWidths[column] || 0, visibleWidth(cellText));
607
+ minimumWidths[column] = Math.max(minimumWidths[column] || 1, this.getLongestWordWidth(cellText, maxUnbrokenWordWidth));
564
608
  }
565
- minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
566
609
  }
567
- // Calculate column widths that fit within available width
568
- const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
569
- let columnWidths;
570
- if (totalNaturalWidth <= availableWidth) {
571
- // Everything fits naturally
572
- columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
573
- }
574
- else {
575
- // Need to shrink columns to fit
576
- const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
577
- return total + Math.max(0, width - minColumnWidths[index]);
578
- }, 0);
579
- const extraWidth = Math.max(0, availableForCells - minCellsWidth);
580
- columnWidths = minColumnWidths.map((minWidth, index) => {
581
- const naturalWidth = naturalWidths[index];
582
- const minWidthDelta = Math.max(0, naturalWidth - minWidth);
583
- let grow = 0;
584
- if (totalGrowPotential > 0) {
585
- grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
586
- }
587
- return minWidth + grow;
588
- });
589
- // Adjust for rounding errors - distribute remaining space
590
- const allocated = columnWidths.reduce((a, b) => a + b, 0);
591
- let remaining = availableForCells - allocated;
592
- while (remaining > 0) {
593
- let grew = false;
594
- for (let i = 0; i < numCols && remaining > 0; i++) {
595
- if (columnWidths[i] < naturalWidths[i]) {
596
- columnWidths[i]++;
597
- remaining--;
598
- grew = true;
599
- }
600
- }
601
- if (!grew) {
602
- break;
603
- }
610
+ return { naturalWidths, minimumWidths };
611
+ }
612
+ fitMinimumTableColumnWidths(minimumWidths, availableForCells) {
613
+ if (minimumWidths.reduce((total, width) => total + width, 0) <= availableForCells)
614
+ return minimumWidths;
615
+ const fittedWidths = new Array(minimumWidths.length).fill(1);
616
+ const remaining = availableForCells - minimumWidths.length;
617
+ if (remaining <= 0)
618
+ return fittedWidths;
619
+ const totalWeight = minimumWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
620
+ const growth = minimumWidths.map((width) => {
621
+ const weight = Math.max(0, width - 1);
622
+ return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
623
+ });
624
+ for (let column = 0; column < minimumWidths.length; column++)
625
+ fittedWidths[column] += growth[column] ?? 0;
626
+ const allocated = growth.reduce((total, width) => total + width, 0);
627
+ let leftover = remaining - allocated;
628
+ for (let column = 0; leftover > 0 && column < minimumWidths.length; column++) {
629
+ fittedWidths[column]++;
630
+ leftover--;
631
+ }
632
+ return fittedWidths;
633
+ }
634
+ distributeRemainingTableWidth(columnWidths, naturalWidths, remainingWidth) {
635
+ let remaining = remainingWidth;
636
+ while (remaining > 0) {
637
+ let grew = false;
638
+ for (let column = 0; column < columnWidths.length && remaining > 0; column++) {
639
+ if (columnWidths[column] >= naturalWidths[column])
640
+ continue;
641
+ columnWidths[column]++;
642
+ remaining--;
643
+ grew = true;
604
644
  }
645
+ if (!grew)
646
+ break;
605
647
  }
606
- // Render top border
607
- const topBorderCells = columnWidths.map((w) => "─".repeat(w));
608
- lines.push(`┌─${topBorderCells.join("─┬─")}─┐`);
609
- // Render header with wrapping
610
- const headerCellLines = token.header.map((cell, i) => {
648
+ }
649
+ calculateTableColumnWidths(naturalWidths, minimumWidths, availableForCells, borderOverhead) {
650
+ const fittedMinimumWidths = this.fitMinimumTableColumnWidths(minimumWidths, availableForCells);
651
+ const totalNaturalWidth = naturalWidths.reduce((total, width) => total + width, 0) + borderOverhead;
652
+ if (totalNaturalWidth <= availableForCells + borderOverhead) {
653
+ return naturalWidths.map((width, column) => Math.max(width, fittedMinimumWidths[column]));
654
+ }
655
+ const minimumCellsWidth = fittedMinimumWidths.reduce((total, width) => total + width, 0);
656
+ const totalGrowPotential = naturalWidths.reduce((total, width, column) => total + Math.max(0, width - fittedMinimumWidths[column]), 0);
657
+ const extraWidth = Math.max(0, availableForCells - minimumCellsWidth);
658
+ const columnWidths = fittedMinimumWidths.map((minimumWidth, column) => {
659
+ const growthPotential = Math.max(0, naturalWidths[column] - minimumWidth);
660
+ const growth = totalGrowPotential > 0 ? Math.floor((growthPotential / totalGrowPotential) * extraWidth) : 0;
661
+ return minimumWidth + growth;
662
+ });
663
+ const allocated = columnWidths.reduce((total, width) => total + width, 0);
664
+ this.distributeRemainingTableWidth(columnWidths, naturalWidths, availableForCells - allocated);
665
+ return columnWidths;
666
+ }
667
+ renderTableBorder(columnWidths, left, junction, right) {
668
+ return `${left}─${columnWidths.map((width) => "─".repeat(width)).join(`─${junction}─`)}─${right}`;
669
+ }
670
+ renderTableCellLines(cells, columnWidths, styleContext) {
671
+ return cells.map((cell, column) => {
611
672
  const text = this.renderInlineTokens(cell.tokens || [], styleContext);
612
- return this.wrapCellText(text, columnWidths[i]);
673
+ return this.wrapCellText(text, columnWidths[column]);
613
674
  });
614
- const headerLineCount = Math.max(...headerCellLines.map((c) => c.length));
615
- for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
616
- const rowParts = headerCellLines.map((cellLines, colIdx) => {
617
- const text = cellLines[lineIdx] || "";
618
- const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
619
- return this.theme.bold(padded);
675
+ }
676
+ // pi-ignore noExcessiveCollectionIterations: Every rendered table line must format each column once, so this rectangular traversal is linear in the emitted cell slots.
677
+ appendTableRow(lines, cellLines, columnWidths, bold) {
678
+ const lineCount = Math.max(...cellLines.map((cell) => cell.length));
679
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) {
680
+ const rowParts = cellLines.map((columnLines, column) => {
681
+ const text = columnLines[lineIndex] || "";
682
+ const padded = text + " ".repeat(Math.max(0, columnWidths[column] - visibleWidth(text)));
683
+ return bold ? this.theme.bold(padded) : padded;
620
684
  });
621
685
  lines.push(`│ ${rowParts.join(" │ ")} │`);
622
686
  }
623
- // Render separator
624
- const separatorCells = columnWidths.map((w) => "─".repeat(w));
625
- const separatorLine = `├─${separatorCells.join("─┼─")}─┤`;
626
- lines.push(separatorLine);
627
- // Render rows with wrapping
628
- for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
629
- const row = token.rows[rowIndex];
630
- const rowCellLines = row.map((cell, i) => {
631
- const text = this.renderInlineTokens(cell.tokens || [], styleContext);
632
- return this.wrapCellText(text, columnWidths[i]);
633
- });
634
- const rowLineCount = Math.max(...rowCellLines.map((c) => c.length));
635
- for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
636
- const rowParts = rowCellLines.map((cellLines, colIdx) => {
637
- const text = cellLines[lineIdx] || "";
638
- return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
639
- });
640
- lines.push(`│ ${rowParts.join(" │ ")} │`);
641
- }
642
- if (rowIndex < token.rows.length - 1) {
643
- lines.push(separatorLine);
644
- }
687
+ }
688
+ renderTable(token, availableWidth, nextTokenType, styleContext) {
689
+ const columnCount = token.header.length;
690
+ if (columnCount === 0)
691
+ return [];
692
+ const borderOverhead = 3 * columnCount + 1;
693
+ const availableForCells = availableWidth - borderOverhead;
694
+ if (availableForCells < columnCount) {
695
+ const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
696
+ if (nextTokenType && nextTokenType !== "space")
697
+ fallbackLines.push("");
698
+ return fallbackLines;
645
699
  }
646
- // Render bottom border
647
- const bottomBorderCells = columnWidths.map((w) => "─".repeat(w));
648
- lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`);
649
- if (nextTokenType && nextTokenType !== "space") {
650
- lines.push(""); // Add spacing after table
700
+ const measurements = this.measureTableColumns(token, styleContext);
701
+ const columnWidths = this.calculateTableColumnWidths(measurements.naturalWidths, measurements.minimumWidths, availableForCells, borderOverhead);
702
+ const lines = [this.renderTableBorder(columnWidths, "", "┬", "┐")];
703
+ this.appendTableRow(lines, this.renderTableCellLines(token.header, columnWidths, styleContext), columnWidths, true);
704
+ const separator = this.renderTableBorder(columnWidths, "", "┼", "┤");
705
+ lines.push(separator);
706
+ for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
707
+ this.appendTableRow(lines, this.renderTableCellLines(token.rows[rowIndex], columnWidths, styleContext), columnWidths, false);
708
+ if (rowIndex < token.rows.length - 1)
709
+ lines.push(separator);
651
710
  }
711
+ lines.push(this.renderTableBorder(columnWidths, "└", "┴", "┘"));
712
+ if (nextTokenType && nextTokenType !== "space")
713
+ lines.push("");
652
714
  return lines;
653
715
  }
654
716
  }