phi-code-tui 0.56.3 → 0.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +29 -11
  2. package/dist/autocomplete.d.ts +18 -14
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +151 -112
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts.map +1 -1
  7. package/dist/components/box.js +6 -1
  8. package/dist/components/box.js.map +1 -1
  9. package/dist/components/cancellable-loader.d.ts.map +1 -1
  10. package/dist/components/cancellable-loader.js +6 -7
  11. package/dist/components/cancellable-loader.js.map +1 -1
  12. package/dist/components/editor.d.ts +45 -1
  13. package/dist/components/editor.d.ts.map +1 -1
  14. package/dist/components/editor.js +505 -221
  15. package/dist/components/editor.js.map +1 -1
  16. package/dist/components/image.d.ts.map +1 -1
  17. package/dist/components/image.js +22 -7
  18. package/dist/components/image.js.map +1 -1
  19. package/dist/components/input.d.ts.map +1 -1
  20. package/dist/components/input.js +57 -74
  21. package/dist/components/input.js.map +1 -1
  22. package/dist/components/loader.d.ts +12 -2
  23. package/dist/components/loader.d.ts.map +1 -1
  24. package/dist/components/loader.js +36 -13
  25. package/dist/components/loader.js.map +1 -1
  26. package/dist/components/markdown.d.ts +0 -5
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +101 -114
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/select-list.d.ts +19 -1
  31. package/dist/components/select-list.d.ts.map +1 -1
  32. package/dist/components/select-list.js +82 -71
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts.map +1 -1
  35. package/dist/components/settings-list.js +18 -10
  36. package/dist/components/settings-list.js.map +1 -1
  37. package/dist/components/spacer.d.ts.map +1 -1
  38. package/dist/components/spacer.js +1 -0
  39. package/dist/components/spacer.js.map +1 -1
  40. package/dist/components/text.d.ts.map +1 -1
  41. package/dist/components/text.js +8 -0
  42. package/dist/components/text.js.map +1 -1
  43. package/dist/components/truncated-text.d.ts.map +1 -1
  44. package/dist/components/truncated-text.js +3 -0
  45. package/dist/components/truncated-text.js.map +1 -1
  46. package/dist/editor-component.d.ts.map +1 -1
  47. package/dist/fuzzy.d.ts.map +1 -1
  48. package/dist/fuzzy.js +3 -0
  49. package/dist/fuzzy.js.map +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/keybindings.d.ts +187 -33
  55. package/dist/keybindings.d.ts.map +1 -1
  56. package/dist/keybindings.js +156 -95
  57. package/dist/keybindings.js.map +1 -1
  58. package/dist/keys.d.ts +21 -12
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +270 -112
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts.map +1 -1
  63. package/dist/kill-ring.js +1 -3
  64. package/dist/kill-ring.js.map +1 -1
  65. package/dist/stdin-buffer.d.ts +2 -0
  66. package/dist/stdin-buffer.d.ts.map +1 -1
  67. package/dist/stdin-buffer.js +31 -8
  68. package/dist/stdin-buffer.js.map +1 -1
  69. package/dist/terminal-image.d.ts +17 -0
  70. package/dist/terminal-image.d.ts.map +1 -1
  71. package/dist/terminal-image.js +41 -5
  72. package/dist/terminal-image.js.map +1 -1
  73. package/dist/terminal.d.ts +4 -0
  74. package/dist/terminal.d.ts.map +1 -1
  75. package/dist/terminal.js +56 -8
  76. package/dist/terminal.js.map +1 -1
  77. package/dist/tui.d.ts +21 -5
  78. package/dist/tui.d.ts.map +1 -1
  79. package/dist/tui.js +234 -118
  80. package/dist/tui.js.map +1 -1
  81. package/dist/undo-stack.d.ts.map +1 -1
  82. package/dist/undo-stack.js +1 -3
  83. package/dist/undo-stack.js.map +1 -1
  84. package/dist/utils.d.ts +1 -0
  85. package/dist/utils.d.ts.map +1 -1
  86. package/dist/utils.js +281 -81
  87. package/dist/utils.js.map +1 -1
  88. package/package.json +3 -3
@@ -1,29 +1,33 @@
1
1
  import { Text } from "./text.js";
2
+ const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ const DEFAULT_INTERVAL_MS = 80;
2
4
  /**
3
- * Loader component that updates every 80ms with spinning animation
5
+ * Loader component that updates with an optional spinning animation.
4
6
  */
5
7
  export class Loader extends Text {
6
- constructor(ui, spinnerColorFn, messageColorFn, message = "Loading...") {
8
+ spinnerColorFn;
9
+ messageColorFn;
10
+ message;
11
+ frames = [...DEFAULT_FRAMES];
12
+ intervalMs = DEFAULT_INTERVAL_MS;
13
+ currentFrame = 0;
14
+ intervalId = null;
15
+ ui = null;
16
+ renderIndicatorVerbatim = false;
17
+ constructor(ui, spinnerColorFn, messageColorFn, message = "Loading...", indicator) {
7
18
  super("", 1, 0);
8
19
  this.spinnerColorFn = spinnerColorFn;
9
20
  this.messageColorFn = messageColorFn;
10
21
  this.message = message;
11
- this.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
12
- this.currentFrame = 0;
13
- this.intervalId = null;
14
- this.ui = null;
15
22
  this.ui = ui;
16
- this.start();
23
+ this.setIndicator(indicator);
17
24
  }
18
25
  render(width) {
19
26
  return ["", ...super.render(width)];
20
27
  }
21
28
  start() {
22
29
  this.updateDisplay();
23
- this.intervalId = setInterval(() => {
24
- this.currentFrame = (this.currentFrame + 1) % this.frames.length;
25
- this.updateDisplay();
26
- }, 80);
30
+ this.restartAnimation();
27
31
  }
28
32
  stop() {
29
33
  if (this.intervalId) {
@@ -35,9 +39,28 @@ export class Loader extends Text {
35
39
  this.message = message;
36
40
  this.updateDisplay();
37
41
  }
42
+ setIndicator(indicator) {
43
+ this.renderIndicatorVerbatim = indicator !== undefined;
44
+ this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES];
45
+ this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;
46
+ this.currentFrame = 0;
47
+ this.start();
48
+ }
49
+ restartAnimation() {
50
+ this.stop();
51
+ if (this.frames.length <= 1) {
52
+ return;
53
+ }
54
+ this.intervalId = setInterval(() => {
55
+ this.currentFrame = (this.currentFrame + 1) % this.frames.length;
56
+ this.updateDisplay();
57
+ }, this.intervalMs);
58
+ }
38
59
  updateDisplay() {
39
- const frame = this.frames[this.currentFrame];
40
- this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
60
+ const frame = this.frames[this.currentFrame] ?? "";
61
+ const renderedFrame = this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);
62
+ const indicator = frame.length > 0 ? `${renderedFrame} ` : "";
63
+ this.setText(`${indicator}${this.messageColorFn(this.message)}`);
41
64
  if (this.ui) {
42
65
  this.ui.requestRender();
43
66
  }
@@ -1 +1 @@
1
- {"version":3,"file":"loader.js","sourceRoot":"","sources":["../../src/components/loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC;;GAEG;AACH,MAAM,OAAO,MAAO,SAAQ,IAAI;IAM/B,YACC,EAAO,EACC,cAAuC,EACvC,cAAuC,EACvC,UAAkB,YAAY;QAEtC,KAAK,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QAJR,mBAAc,GAAd,cAAc,CAAyB;QACvC,mBAAc,GAAd,cAAc,CAAyB;QACvC,YAAO,GAAP,OAAO,CAAuB;QAT/B,WAAM,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;QAC5D,iBAAY,GAAG,CAAC,CAAC;QACjB,eAAU,GAA0B,IAAI,CAAC;QACzC,OAAE,GAAe,IAAI,CAAC;QAS7B,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,KAAK,EAAE,CAAC;IACd,CAAC;IAED,MAAM,CAAC,KAAa;QACnB,OAAO,CAAC,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACrC,CAAC;IAED,KAAK;QACJ,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,IAAI,CAAC,UAAU,GAAG,WAAW,CAAC,GAAG,EAAE;YAClC,IAAI,CAAC,YAAY,GAAG,CAAC,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;YACjE,IAAI,CAAC,aAAa,EAAE,CAAC;QACtB,CAAC,EAAE,EAAE,CAAC,CAAC;IACR,CAAC;IAED,IAAI;QACH,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACrB,aAAa,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YAC/B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC;QACxB,CAAC;IACF,CAAC;IAED,UAAU,CAAC,OAAe;QACzB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,aAAa,EAAE,CAAC;IACtB,CAAC;IAEO,aAAa;QACpB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAC7C,IAAI,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QACnF,IAAI,IAAI,CAAC,EAAE,EAAE,CAAC;YACb,IAAI,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC;QACzB,CAAC;IACF,CAAC;CACD","sourcesContent":["import type { TUI } from \"../tui.js\";\nimport { Text } from \"./text.js\";\n\n/**\n * Loader component that updates every 80ms with spinning animation\n */\nexport class Loader extends Text {\n\tprivate frames = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\n\tprivate currentFrame = 0;\n\tprivate intervalId: NodeJS.Timeout | null = null;\n\tprivate ui: TUI | null = null;\n\n\tconstructor(\n\t\tui: TUI,\n\t\tprivate spinnerColorFn: (str: string) => string,\n\t\tprivate messageColorFn: (str: string) => string,\n\t\tprivate message: string = \"Loading...\",\n\t) {\n\t\tsuper(\"\", 1, 0);\n\t\tthis.ui = ui;\n\t\tthis.start();\n\t}\n\n\trender(width: number): string[] {\n\t\treturn [\"\", ...super.render(width)];\n\t}\n\n\tstart() {\n\t\tthis.updateDisplay();\n\t\tthis.intervalId = setInterval(() => {\n\t\t\tthis.currentFrame = (this.currentFrame + 1) % this.frames.length;\n\t\t\tthis.updateDisplay();\n\t\t}, 80);\n\t}\n\n\tstop() {\n\t\tif (this.intervalId) {\n\t\t\tclearInterval(this.intervalId);\n\t\t\tthis.intervalId = null;\n\t\t}\n\t}\n\n\tsetMessage(message: string) {\n\t\tthis.message = message;\n\t\tthis.updateDisplay();\n\t}\n\n\tprivate updateDisplay() {\n\t\tconst frame = this.frames[this.currentFrame];\n\t\tthis.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);\n\t\tif (this.ui) {\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"loader.js","sourceRoot":"","sources":["../../src/components/loader.ts"],"names":[],"mappings":"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","sourcesContent":["import type { TUI } from \"../tui.js\";\nimport { Text } from \"./text.js\";\n\nexport interface LoaderIndicatorOptions {\n\t/** Animation frames. Use an empty array to hide the indicator. */\n\tframes?: string[];\n\t/** Frame interval in milliseconds for animated indicators. */\n\tintervalMs?: number;\n}\n\nconst DEFAULT_FRAMES = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\nconst DEFAULT_INTERVAL_MS = 80;\n\n/**\n * Loader component that updates with an optional spinning animation.\n */\nexport class Loader extends Text {\n\tprivate frames = [...DEFAULT_FRAMES];\n\tprivate intervalMs = DEFAULT_INTERVAL_MS;\n\tprivate currentFrame = 0;\n\tprivate intervalId: NodeJS.Timeout | null = null;\n\tprivate ui: TUI | null = null;\n\tprivate renderIndicatorVerbatim = false;\n\n\tconstructor(\n\t\tui: TUI,\n\t\tprivate spinnerColorFn: (str: string) => string,\n\t\tprivate messageColorFn: (str: string) => string,\n\t\tprivate message: string = \"Loading...\",\n\t\tindicator?: LoaderIndicatorOptions,\n\t) {\n\t\tsuper(\"\", 1, 0);\n\t\tthis.ui = ui;\n\t\tthis.setIndicator(indicator);\n\t}\n\n\trender(width: number): string[] {\n\t\treturn [\"\", ...super.render(width)];\n\t}\n\n\tstart(): void {\n\t\tthis.updateDisplay();\n\t\tthis.restartAnimation();\n\t}\n\n\tstop(): void {\n\t\tif (this.intervalId) {\n\t\t\tclearInterval(this.intervalId);\n\t\t\tthis.intervalId = null;\n\t\t}\n\t}\n\n\tsetMessage(message: string): void {\n\t\tthis.message = message;\n\t\tthis.updateDisplay();\n\t}\n\n\tsetIndicator(indicator?: LoaderIndicatorOptions): void {\n\t\tthis.renderIndicatorVerbatim = indicator !== undefined;\n\t\tthis.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES];\n\t\tthis.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;\n\t\tthis.currentFrame = 0;\n\t\tthis.start();\n\t}\n\n\tprivate restartAnimation(): void {\n\t\tthis.stop();\n\t\tif (this.frames.length <= 1) {\n\t\t\treturn;\n\t\t}\n\t\tthis.intervalId = setInterval(() => {\n\t\t\tthis.currentFrame = (this.currentFrame + 1) % this.frames.length;\n\t\t\tthis.updateDisplay();\n\t\t}, this.intervalMs);\n\t}\n\n\tprivate updateDisplay(): void {\n\t\tconst frame = this.frames[this.currentFrame] ?? \"\";\n\t\tconst renderedFrame = this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);\n\t\tconst indicator = frame.length > 0 ? `${renderedFrame} ` : \"\";\n\t\tthis.setText(`${indicator}${this.messageColorFn(this.message)}`);\n\t\tif (this.ui) {\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n}\n"]}
@@ -70,11 +70,6 @@ export declare class Markdown implements Component {
70
70
  * Render a list with proper nesting support
71
71
  */
72
72
  private renderList;
73
- /**
74
- * Render list item tokens, handling nested lists
75
- * Returns lines WITHOUT the parent indent (renderList will add it)
76
- */
77
- private renderListItem;
78
73
  /**
79
74
  * Get the visible width of the longest word in a string.
80
75
  */
@@ -1 +1 @@
1
- {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/components/markdown.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../src/components/markdown.ts"],"names":[],"mappings":"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","sourcesContent":["import { Marked, type Token, Tokenizer, type Tokens } from \"marked\";\nimport { getCapabilities, hyperlink, isImageLine } from \"../terminal-image.js\";\nimport type { Component } from \"../tui.js\";\nimport { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from \"../utils.js\";\n\nconst STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\\s~])((?:\\\\.|[^\\\\])*?(?:\\\\.|[^\\s~\\\\]))\\1(?=[^~]|$)/;\n\nclass StrictStrikethroughTokenizer extends Tokenizer {\n\toverride del(src: string): Tokens.Del | undefined {\n\t\tconst match = STRICT_STRIKETHROUGH_REGEX.exec(src);\n\t\tif (!match) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst text = match[2];\n\t\treturn {\n\t\t\ttype: \"del\",\n\t\t\traw: match[0],\n\t\t\ttext,\n\t\t\ttokens: this.lexer.inlineTokens(text),\n\t\t};\n\t}\n}\n\nconst markdownParser = new Marked();\nmarkdownParser.setOptions({\n\ttokenizer: new StrictStrikethroughTokenizer(),\n});\n\n/**\n * Default text styling for markdown content.\n * Applied to all text unless overridden by markdown formatting.\n */\nexport interface DefaultTextStyle {\n\t/** Foreground color function */\n\tcolor?: (text: string) => string;\n\t/** Background color function */\n\tbgColor?: (text: string) => string;\n\t/** Bold text */\n\tbold?: boolean;\n\t/** Italic text */\n\titalic?: boolean;\n\t/** Strikethrough text */\n\tstrikethrough?: boolean;\n\t/** Underline text */\n\tunderline?: boolean;\n}\n\n/**\n * Theme functions for markdown elements.\n * Each function takes text and returns styled text with ANSI codes.\n */\nexport interface MarkdownTheme {\n\theading: (text: string) => string;\n\tlink: (text: string) => string;\n\tlinkUrl: (text: string) => string;\n\tcode: (text: string) => string;\n\tcodeBlock: (text: string) => string;\n\tcodeBlockBorder: (text: string) => string;\n\tquote: (text: string) => string;\n\tquoteBorder: (text: string) => string;\n\thr: (text: string) => string;\n\tlistBullet: (text: string) => string;\n\tbold: (text: string) => string;\n\titalic: (text: string) => string;\n\tstrikethrough: (text: string) => string;\n\tunderline: (text: string) => string;\n\thighlightCode?: (code: string, lang?: string) => string[];\n\t/** Prefix applied to each rendered code block line (default: \" \") */\n\tcodeBlockIndent?: string;\n}\n\ninterface InlineStyleContext {\n\tapplyText: (text: string) => string;\n\tstylePrefix: string;\n}\n\nexport class Markdown implements Component {\n\tprivate text: string;\n\tprivate paddingX: number; // Left/right padding\n\tprivate paddingY: number; // Top/bottom padding\n\tprivate defaultTextStyle?: DefaultTextStyle;\n\tprivate theme: MarkdownTheme;\n\tprivate defaultStylePrefix?: string;\n\n\t// Cache for rendered output\n\tprivate cachedText?: string;\n\tprivate cachedWidth?: number;\n\tprivate cachedLines?: string[];\n\n\tconstructor(\n\t\ttext: string,\n\t\tpaddingX: number,\n\t\tpaddingY: number,\n\t\ttheme: MarkdownTheme,\n\t\tdefaultTextStyle?: DefaultTextStyle,\n\t) {\n\t\tthis.text = text;\n\t\tthis.paddingX = paddingX;\n\t\tthis.paddingY = paddingY;\n\t\tthis.theme = theme;\n\t\tthis.defaultTextStyle = defaultTextStyle;\n\t}\n\n\tsetText(text: string): void {\n\t\tthis.text = text;\n\t\tthis.invalidate();\n\t}\n\n\tinvalidate(): void {\n\t\tthis.cachedText = undefined;\n\t\tthis.cachedWidth = undefined;\n\t\tthis.cachedLines = undefined;\n\t}\n\n\trender(width: number): string[] {\n\t\t// Check cache\n\t\tif (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {\n\t\t\treturn this.cachedLines;\n\t\t}\n\n\t\t// Calculate available width for content (subtract horizontal padding)\n\t\tconst contentWidth = Math.max(1, width - this.paddingX * 2);\n\n\t\t// Don't render anything if there's no actual text\n\t\tif (!this.text || this.text.trim() === \"\") {\n\t\t\tconst result: string[] = [];\n\t\t\t// Update cache\n\t\t\tthis.cachedText = this.text;\n\t\t\tthis.cachedWidth = width;\n\t\t\tthis.cachedLines = result;\n\t\t\treturn result;\n\t\t}\n\n\t\t// Replace tabs with 3 spaces for consistent rendering\n\t\tconst normalizedText = this.text.replace(/\\t/g, \" \");\n\n\t\t// Parse markdown to HTML-like tokens\n\t\tconst tokens = markdownParser.lexer(normalizedText);\n\n\t\t// Convert tokens to styled terminal output\n\t\tconst renderedLines: string[] = [];\n\n\t\tfor (let i = 0; i < tokens.length; i++) {\n\t\t\tconst token = tokens[i];\n\t\t\tconst nextToken = tokens[i + 1];\n\t\t\tconst tokenLines = this.renderToken(token, contentWidth, nextToken?.type);\n\t\t\trenderedLines.push(...tokenLines);\n\t\t}\n\n\t\t// Wrap lines (NO padding, NO background yet)\n\t\tconst wrappedLines: string[] = [];\n\t\tfor (const line of renderedLines) {\n\t\t\tif (isImageLine(line)) {\n\t\t\t\twrappedLines.push(line);\n\t\t\t} else {\n\t\t\t\twrappedLines.push(...wrapTextWithAnsi(line, contentWidth));\n\t\t\t}\n\t\t}\n\n\t\t// Add margins and background to each wrapped line\n\t\tconst leftMargin = \" \".repeat(this.paddingX);\n\t\tconst rightMargin = \" \".repeat(this.paddingX);\n\t\tconst bgFn = this.defaultTextStyle?.bgColor;\n\t\tconst contentLines: string[] = [];\n\n\t\tfor (const line of wrappedLines) {\n\t\t\tif (isImageLine(line)) {\n\t\t\t\tcontentLines.push(line);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst lineWithMargins = leftMargin + line + rightMargin;\n\n\t\t\tif (bgFn) {\n\t\t\t\tcontentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));\n\t\t\t} else {\n\t\t\t\t// No background - just pad to width\n\t\t\t\tconst visibleLen = visibleWidth(lineWithMargins);\n\t\t\t\tconst paddingNeeded = Math.max(0, width - visibleLen);\n\t\t\t\tcontentLines.push(lineWithMargins + \" \".repeat(paddingNeeded));\n\t\t\t}\n\t\t}\n\n\t\t// Add top/bottom padding (empty lines)\n\t\tconst emptyLine = \" \".repeat(width);\n\t\tconst emptyLines: string[] = [];\n\t\tfor (let i = 0; i < this.paddingY; i++) {\n\t\t\tconst line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;\n\t\t\temptyLines.push(line);\n\t\t}\n\n\t\t// Combine top padding, content, and bottom padding\n\t\tconst result = [...emptyLines, ...contentLines, ...emptyLines];\n\n\t\t// Update cache\n\t\tthis.cachedText = this.text;\n\t\tthis.cachedWidth = width;\n\t\tthis.cachedLines = result;\n\n\t\treturn result.length > 0 ? result : [\"\"];\n\t}\n\n\t/**\n\t * Apply default text style to a string.\n\t * This is the base styling applied to all text content.\n\t * NOTE: Background color is NOT applied here - it's applied at the padding stage\n\t * to ensure it extends to the full line width.\n\t */\n\tprivate applyDefaultStyle(text: string): string {\n\t\tif (!this.defaultTextStyle) {\n\t\t\treturn text;\n\t\t}\n\n\t\tlet styled = text;\n\n\t\t// Apply foreground color (NOT background - that's applied at padding stage)\n\t\tif (this.defaultTextStyle.color) {\n\t\t\tstyled = this.defaultTextStyle.color(styled);\n\t\t}\n\n\t\t// Apply text decorations using this.theme\n\t\tif (this.defaultTextStyle.bold) {\n\t\t\tstyled = this.theme.bold(styled);\n\t\t}\n\t\tif (this.defaultTextStyle.italic) {\n\t\t\tstyled = this.theme.italic(styled);\n\t\t}\n\t\tif (this.defaultTextStyle.strikethrough) {\n\t\t\tstyled = this.theme.strikethrough(styled);\n\t\t}\n\t\tif (this.defaultTextStyle.underline) {\n\t\t\tstyled = this.theme.underline(styled);\n\t\t}\n\n\t\treturn styled;\n\t}\n\n\tprivate getDefaultStylePrefix(): string {\n\t\tif (!this.defaultTextStyle) {\n\t\t\treturn \"\";\n\t\t}\n\n\t\tif (this.defaultStylePrefix !== undefined) {\n\t\t\treturn this.defaultStylePrefix;\n\t\t}\n\n\t\tconst sentinel = \"\\u0000\";\n\t\tlet styled = sentinel;\n\n\t\tif (this.defaultTextStyle.color) {\n\t\t\tstyled = this.defaultTextStyle.color(styled);\n\t\t}\n\n\t\tif (this.defaultTextStyle.bold) {\n\t\t\tstyled = this.theme.bold(styled);\n\t\t}\n\t\tif (this.defaultTextStyle.italic) {\n\t\t\tstyled = this.theme.italic(styled);\n\t\t}\n\t\tif (this.defaultTextStyle.strikethrough) {\n\t\t\tstyled = this.theme.strikethrough(styled);\n\t\t}\n\t\tif (this.defaultTextStyle.underline) {\n\t\t\tstyled = this.theme.underline(styled);\n\t\t}\n\n\t\tconst sentinelIndex = styled.indexOf(sentinel);\n\t\tthis.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : \"\";\n\t\treturn this.defaultStylePrefix;\n\t}\n\n\tprivate getStylePrefix(styleFn: (text: string) => string): string {\n\t\tconst sentinel = \"\\u0000\";\n\t\tconst styled = styleFn(sentinel);\n\t\tconst sentinelIndex = styled.indexOf(sentinel);\n\t\treturn sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : \"\";\n\t}\n\n\tprivate getDefaultInlineStyleContext(): InlineStyleContext {\n\t\treturn {\n\t\t\tapplyText: (text: string) => this.applyDefaultStyle(text),\n\t\t\tstylePrefix: this.getDefaultStylePrefix(),\n\t\t};\n\t}\n\n\tprivate renderToken(\n\t\ttoken: Token,\n\t\twidth: number,\n\t\tnextTokenType?: string,\n\t\tstyleContext?: InlineStyleContext,\n\t): string[] {\n\t\tconst lines: string[] = [];\n\n\t\tswitch (token.type) {\n\t\t\tcase \"heading\": {\n\t\t\t\tconst headingLevel = token.depth;\n\t\t\t\tconst headingPrefix = `${\"#\".repeat(headingLevel)} `;\n\n\t\t\t\t// Build a heading-specific style context so inline tokens (codespan, bold, etc.)\n\t\t\t\t// restore heading styling after their own ANSI resets instead of falling back to\n\t\t\t\t// the default text style.\n\t\t\t\tlet headingStyleFn: (text: string) => string;\n\t\t\t\tif (headingLevel === 1) {\n\t\t\t\t\theadingStyleFn = (text: string) => this.theme.heading(this.theme.bold(this.theme.underline(text)));\n\t\t\t\t} else {\n\t\t\t\t\theadingStyleFn = (text: string) => this.theme.heading(this.theme.bold(text));\n\t\t\t\t}\n\n\t\t\t\tconst headingStyleContext: InlineStyleContext = {\n\t\t\t\t\tapplyText: headingStyleFn,\n\t\t\t\t\tstylePrefix: this.getStylePrefix(headingStyleFn),\n\t\t\t\t};\n\n\t\t\t\tconst headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext);\n\t\t\t\tconst styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText;\n\t\t\t\tlines.push(styledHeading);\n\t\t\t\tif (nextTokenType && nextTokenType !== \"space\") {\n\t\t\t\t\tlines.push(\"\"); // Add spacing after headings (unless space token follows)\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"paragraph\": {\n\t\t\t\tconst paragraphText = this.renderInlineTokens(token.tokens || [], styleContext);\n\t\t\t\tlines.push(paragraphText);\n\t\t\t\t// Don't add spacing if next token is space or list\n\t\t\t\tif (nextTokenType && nextTokenType !== \"list\" && nextTokenType !== \"space\") {\n\t\t\t\t\tlines.push(\"\");\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"text\":\n\t\t\t\tlines.push(this.renderInlineTokens([token], styleContext));\n\t\t\t\tbreak;\n\n\t\t\tcase \"code\": {\n\t\t\t\tconst indent = this.theme.codeBlockIndent ?? \" \";\n\t\t\t\tlines.push(this.theme.codeBlockBorder(`\\`\\`\\`${token.lang || \"\"}`));\n\t\t\t\tif (this.theme.highlightCode) {\n\t\t\t\t\tconst highlightedLines = this.theme.highlightCode(token.text, token.lang);\n\t\t\t\t\tfor (const hlLine of highlightedLines) {\n\t\t\t\t\t\tlines.push(`${indent}${hlLine}`);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Split code by newlines and style each line\n\t\t\t\t\tconst codeLines = token.text.split(\"\\n\");\n\t\t\t\t\tfor (const codeLine of codeLines) {\n\t\t\t\t\t\tlines.push(`${indent}${this.theme.codeBlock(codeLine)}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlines.push(this.theme.codeBlockBorder(\"```\"));\n\t\t\t\tif (nextTokenType && nextTokenType !== \"space\") {\n\t\t\t\t\tlines.push(\"\"); // Add spacing after code blocks (unless space token follows)\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"list\": {\n\t\t\t\tconst listLines = this.renderList(token as Tokens.List, 0, width, styleContext);\n\t\t\t\tlines.push(...listLines);\n\t\t\t\t// Don't add spacing after lists if a space token follows\n\t\t\t\t// (the space token will handle it)\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"table\": {\n\t\t\t\tconst tableLines = this.renderTable(token as Tokens.Table, width, nextTokenType, styleContext);\n\t\t\t\tlines.push(...tableLines);\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"blockquote\": {\n\t\t\t\tconst quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text));\n\t\t\t\tconst quoteStylePrefix = this.getStylePrefix(quoteStyle);\n\t\t\t\tconst applyQuoteStyle = (line: string): string => {\n\t\t\t\t\tif (!quoteStylePrefix) {\n\t\t\t\t\t\treturn quoteStyle(line);\n\t\t\t\t\t}\n\t\t\t\t\tconst lineWithReappliedStyle = line.replace(/\\x1b\\[0m/g, `\\x1b[0m${quoteStylePrefix}`);\n\t\t\t\t\treturn quoteStyle(lineWithReappliedStyle);\n\t\t\t\t};\n\n\t\t\t\t// Calculate available width for quote content (subtract border \"│ \" = 2 chars)\n\t\t\t\tconst quoteContentWidth = Math.max(1, width - 2);\n\n\t\t\t\t// Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render\n\t\t\t\t// children with renderToken() instead of renderInlineTokens().\n\t\t\t\t// Default message style should not apply inside blockquotes.\n\t\t\t\tconst quoteInlineStyleContext: InlineStyleContext = {\n\t\t\t\t\tapplyText: (text: string) => text,\n\t\t\t\t\tstylePrefix: quoteStylePrefix,\n\t\t\t\t};\n\t\t\t\tconst quoteTokens = token.tokens || [];\n\t\t\t\tconst renderedQuoteLines: string[] = [];\n\t\t\t\tfor (let i = 0; i < quoteTokens.length; i++) {\n\t\t\t\t\tconst quoteToken = quoteTokens[i];\n\t\t\t\t\tconst nextQuoteToken = quoteTokens[i + 1];\n\t\t\t\t\trenderedQuoteLines.push(\n\t\t\t\t\t\t...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Avoid rendering an extra empty quote line before the outer blockquote spacing.\n\t\t\t\twhile (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === \"\") {\n\t\t\t\t\trenderedQuoteLines.pop();\n\t\t\t\t}\n\n\t\t\t\tfor (const quoteLine of renderedQuoteLines) {\n\t\t\t\t\tconst styledLine = applyQuoteStyle(quoteLine);\n\t\t\t\t\tconst wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);\n\t\t\t\t\tfor (const wrappedLine of wrappedLines) {\n\t\t\t\t\t\tlines.push(this.theme.quoteBorder(\"│ \") + wrappedLine);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (nextTokenType && nextTokenType !== \"space\") {\n\t\t\t\t\tlines.push(\"\"); // Add spacing after blockquotes (unless space token follows)\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"hr\":\n\t\t\t\tlines.push(this.theme.hr(\"─\".repeat(Math.min(width, 80))));\n\t\t\t\tif (nextTokenType && nextTokenType !== \"space\") {\n\t\t\t\t\tlines.push(\"\"); // Add spacing after horizontal rules (unless space token follows)\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"html\":\n\t\t\t\t// Render HTML as plain text (escaped for terminal)\n\t\t\t\tif (\"raw\" in token && typeof token.raw === \"string\") {\n\t\t\t\t\tlines.push(this.applyDefaultStyle(token.raw.trim()));\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"space\":\n\t\t\t\t// Space tokens represent blank lines in markdown\n\t\t\t\tlines.push(\"\");\n\t\t\t\tbreak;\n\n\t\t\tdefault:\n\t\t\t\t// Handle any other token types as plain text\n\t\t\t\tif (\"text\" in token && typeof token.text === \"string\") {\n\t\t\t\t\tlines.push(token.text);\n\t\t\t\t}\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\tprivate renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {\n\t\tlet result = \"\";\n\t\tconst resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext();\n\t\tconst { applyText, stylePrefix } = resolvedStyleContext;\n\t\tconst applyTextWithNewlines = (text: string): string => {\n\t\t\tconst segments: string[] = text.split(\"\\n\");\n\t\t\treturn segments.map((segment: string) => applyText(segment)).join(\"\\n\");\n\t\t};\n\n\t\tfor (const token of tokens) {\n\t\t\tswitch (token.type) {\n\t\t\t\tcase \"text\":\n\t\t\t\t\t// Text tokens in list items can have nested tokens for inline formatting\n\t\t\t\t\tif (token.tokens && token.tokens.length > 0) {\n\t\t\t\t\t\tresult += this.renderInlineTokens(token.tokens, resolvedStyleContext);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresult += applyTextWithNewlines(token.text);\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase \"paragraph\":\n\t\t\t\t\t// Paragraph tokens contain nested inline tokens\n\t\t\t\t\tresult += this.renderInlineTokens(token.tokens || [], resolvedStyleContext);\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase \"strong\": {\n\t\t\t\t\tconst boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);\n\t\t\t\t\tresult += this.theme.bold(boldContent) + stylePrefix;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tcase \"em\": {\n\t\t\t\t\tconst italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);\n\t\t\t\t\tresult += this.theme.italic(italicContent) + stylePrefix;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tcase \"codespan\":\n\t\t\t\t\tresult += this.theme.code(token.text) + stylePrefix;\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase \"link\": {\n\t\t\t\t\tconst linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);\n\t\t\t\t\tconst styledLink = this.theme.link(this.theme.underline(linkText));\n\t\t\t\t\tif (getCapabilities().hyperlinks) {\n\t\t\t\t\t\t// OSC 8: render as a clickable hyperlink. The URL is not printed inline,\n\t\t\t\t\t\t// so we always show only the link text regardless of whether it matches href.\n\t\t\t\t\t\tresult += hyperlink(styledLink, token.href) + stylePrefix;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Fallback: print URL in parentheses when text differs from href.\n\t\t\t\t\t\t// Compare raw token.text (not styled) against href for the equality check.\n\t\t\t\t\t\t// For mailto: links strip the prefix (autolinked emails use text=\"foo@bar.com\"\n\t\t\t\t\t\t// but href=\"mailto:foo@bar.com\").\n\t\t\t\t\t\tconst hrefForComparison = token.href.startsWith(\"mailto:\") ? token.href.slice(7) : token.href;\n\t\t\t\t\t\tif (token.text === token.href || token.text === hrefForComparison) {\n\t\t\t\t\t\t\tresult += styledLink + stylePrefix;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tresult += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tcase \"br\":\n\t\t\t\t\tresult += \"\\n\";\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase \"del\": {\n\t\t\t\t\tconst delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);\n\t\t\t\t\tresult += this.theme.strikethrough(delContent) + stylePrefix;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tcase \"html\":\n\t\t\t\t\t// Render inline HTML as plain text\n\t\t\t\t\tif (\"raw\" in token && typeof token.raw === \"string\") {\n\t\t\t\t\t\tresult += applyTextWithNewlines(token.raw);\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\n\t\t\t\tdefault:\n\t\t\t\t\t// Handle any other inline token types as plain text\n\t\t\t\t\tif (\"text\" in token && typeof token.text === \"string\") {\n\t\t\t\t\t\tresult += applyTextWithNewlines(token.text);\n\t\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\twhile (stylePrefix && result.endsWith(stylePrefix)) {\n\t\t\tresult = result.slice(0, -stylePrefix.length);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Render a list with proper nesting support\n\t */\n\tprivate renderList(token: Tokens.List, depth: number, width: number, styleContext?: InlineStyleContext): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst indent = \" \".repeat(depth);\n\t\t// Use the list's start property (defaults to 1 for ordered lists)\n\t\tconst startNumber = typeof token.start === \"number\" ? token.start : 1;\n\n\t\tfor (let i = 0; i < token.items.length; i++) {\n\t\t\tconst item = token.items[i];\n\t\t\tconst bullet = token.ordered ? `${startNumber + i}. ` : \"- \";\n\t\t\tconst firstPrefix = indent + this.theme.listBullet(bullet);\n\t\t\tconst continuationPrefix = indent + \" \".repeat(visibleWidth(bullet));\n\t\t\tconst itemWidth = Math.max(1, width - visibleWidth(firstPrefix));\n\t\t\tlet renderedAnyLine = false;\n\n\t\t\tfor (const itemToken of item.tokens) {\n\t\t\t\tif (itemToken.type === \"list\") {\n\t\t\t\t\tlines.push(...this.renderList(itemToken as Tokens.List, depth + 1, width, styleContext));\n\t\t\t\t\trenderedAnyLine = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext);\n\t\t\t\tfor (const line of itemLines) {\n\t\t\t\t\tfor (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) {\n\t\t\t\t\t\tconst linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix;\n\t\t\t\t\t\tlines.push(linePrefix + wrappedLine);\n\t\t\t\t\t\trenderedAnyLine = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!renderedAnyLine) {\n\t\t\t\tlines.push(firstPrefix);\n\t\t\t}\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\t/**\n\t * Get the visible width of the longest word in a string.\n\t */\n\tprivate getLongestWordWidth(text: string, maxWidth?: number): number {\n\t\tconst words = text.split(/\\s+/).filter((word) => word.length > 0);\n\t\tlet longest = 0;\n\t\tfor (const word of words) {\n\t\t\tlongest = Math.max(longest, visibleWidth(word));\n\t\t}\n\t\tif (maxWidth === undefined) {\n\t\t\treturn longest;\n\t\t}\n\t\treturn Math.min(longest, maxWidth);\n\t}\n\n\t/**\n\t * Wrap a table cell to fit into a column.\n\t *\n\t * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled\n\t * consistently with the rest of the renderer.\n\t */\n\tprivate wrapCellText(text: string, maxWidth: number): string[] {\n\t\treturn wrapTextWithAnsi(text, Math.max(1, maxWidth));\n\t}\n\n\t/**\n\t * Render a table with width-aware cell wrapping.\n\t * Cells that don't fit are wrapped to multiple lines.\n\t */\n\tprivate renderTable(\n\t\ttoken: Tokens.Table,\n\t\tavailableWidth: number,\n\t\tnextTokenType?: string,\n\t\tstyleContext?: InlineStyleContext,\n\t): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst numCols = token.header.length;\n\n\t\tif (numCols === 0) {\n\t\t\treturn lines;\n\t\t}\n\n\t\t// Calculate border overhead: \"│ \" + (n-1) * \" │ \" + \" │\"\n\t\t// = 2 + (n-1) * 3 + 2 = 3n + 1\n\t\tconst borderOverhead = 3 * numCols + 1;\n\t\tconst availableForCells = availableWidth - borderOverhead;\n\t\tif (availableForCells < numCols) {\n\t\t\t// Too narrow to render a stable table. Fall back to raw markdown.\n\t\t\tconst fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];\n\t\t\tif (nextTokenType && nextTokenType !== \"space\") {\n\t\t\t\tfallbackLines.push(\"\");\n\t\t\t}\n\t\t\treturn fallbackLines;\n\t\t}\n\n\t\tconst maxUnbrokenWordWidth = 30;\n\n\t\t// Calculate natural column widths (what each column needs without constraints)\n\t\tconst naturalWidths: number[] = [];\n\t\tconst minWordWidths: number[] = [];\n\t\tfor (let i = 0; i < numCols; i++) {\n\t\t\tconst headerText = this.renderInlineTokens(token.header[i].tokens || [], styleContext);\n\t\t\tnaturalWidths[i] = visibleWidth(headerText);\n\t\t\tminWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth));\n\t\t}\n\t\tfor (const row of token.rows) {\n\t\t\tfor (let i = 0; i < row.length; i++) {\n\t\t\t\tconst cellText = this.renderInlineTokens(row[i].tokens || [], styleContext);\n\t\t\t\tnaturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));\n\t\t\t\tminWordWidths[i] = Math.max(\n\t\t\t\t\tminWordWidths[i] || 1,\n\t\t\t\t\tthis.getLongestWordWidth(cellText, maxUnbrokenWordWidth),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tlet minColumnWidths = minWordWidths;\n\t\tlet minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);\n\n\t\tif (minCellsWidth > availableForCells) {\n\t\t\tminColumnWidths = new Array(numCols).fill(1);\n\t\t\tconst remaining = availableForCells - numCols;\n\n\t\t\tif (remaining > 0) {\n\t\t\t\tconst totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);\n\t\t\t\tconst growth = minWordWidths.map((width) => {\n\t\t\t\t\tconst weight = Math.max(0, width - 1);\n\t\t\t\t\treturn totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;\n\t\t\t\t});\n\n\t\t\t\tfor (let i = 0; i < numCols; i++) {\n\t\t\t\t\tminColumnWidths[i] += growth[i] ?? 0;\n\t\t\t\t}\n\n\t\t\t\tconst allocated = growth.reduce((total, width) => total + width, 0);\n\t\t\t\tlet leftover = remaining - allocated;\n\t\t\t\tfor (let i = 0; leftover > 0 && i < numCols; i++) {\n\t\t\t\t\tminColumnWidths[i]++;\n\t\t\t\t\tleftover--;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tminCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);\n\t\t}\n\n\t\t// Calculate column widths that fit within available width\n\t\tconst totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;\n\t\tlet columnWidths: number[];\n\n\t\tif (totalNaturalWidth <= availableWidth) {\n\t\t\t// Everything fits naturally\n\t\t\tcolumnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));\n\t\t} else {\n\t\t\t// Need to shrink columns to fit\n\t\t\tconst totalGrowPotential = naturalWidths.reduce((total, width, index) => {\n\t\t\t\treturn total + Math.max(0, width - minColumnWidths[index]);\n\t\t\t}, 0);\n\t\t\tconst extraWidth = Math.max(0, availableForCells - minCellsWidth);\n\t\t\tcolumnWidths = minColumnWidths.map((minWidth, index) => {\n\t\t\t\tconst naturalWidth = naturalWidths[index];\n\t\t\t\tconst minWidthDelta = Math.max(0, naturalWidth - minWidth);\n\t\t\t\tlet grow = 0;\n\t\t\t\tif (totalGrowPotential > 0) {\n\t\t\t\t\tgrow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);\n\t\t\t\t}\n\t\t\t\treturn minWidth + grow;\n\t\t\t});\n\n\t\t\t// Adjust for rounding errors - distribute remaining space\n\t\t\tconst allocated = columnWidths.reduce((a, b) => a + b, 0);\n\t\t\tlet remaining = availableForCells - allocated;\n\t\t\twhile (remaining > 0) {\n\t\t\t\tlet grew = false;\n\t\t\t\tfor (let i = 0; i < numCols && remaining > 0; i++) {\n\t\t\t\t\tif (columnWidths[i] < naturalWidths[i]) {\n\t\t\t\t\t\tcolumnWidths[i]++;\n\t\t\t\t\t\tremaining--;\n\t\t\t\t\t\tgrew = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (!grew) {\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Render top border\n\t\tconst topBorderCells = columnWidths.map((w) => \"─\".repeat(w));\n\t\tlines.push(`┌─${topBorderCells.join(\"─┬─\")}─┐`);\n\n\t\t// Render header with wrapping\n\t\tconst headerCellLines: string[][] = token.header.map((cell, i) => {\n\t\t\tconst text = this.renderInlineTokens(cell.tokens || [], styleContext);\n\t\t\treturn this.wrapCellText(text, columnWidths[i]);\n\t\t});\n\t\tconst headerLineCount = Math.max(...headerCellLines.map((c) => c.length));\n\n\t\tfor (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {\n\t\t\tconst rowParts = headerCellLines.map((cellLines, colIdx) => {\n\t\t\t\tconst text = cellLines[lineIdx] || \"\";\n\t\t\t\tconst padded = text + \" \".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));\n\t\t\t\treturn this.theme.bold(padded);\n\t\t\t});\n\t\t\tlines.push(`│ ${rowParts.join(\" │ \")} │`);\n\t\t}\n\n\t\t// Render separator\n\t\tconst separatorCells = columnWidths.map((w) => \"─\".repeat(w));\n\t\tconst separatorLine = `├─${separatorCells.join(\"─┼─\")}─┤`;\n\t\tlines.push(separatorLine);\n\n\t\t// Render rows with wrapping\n\t\tfor (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {\n\t\t\tconst row = token.rows[rowIndex];\n\t\t\tconst rowCellLines: string[][] = row.map((cell, i) => {\n\t\t\t\tconst text = this.renderInlineTokens(cell.tokens || [], styleContext);\n\t\t\t\treturn this.wrapCellText(text, columnWidths[i]);\n\t\t\t});\n\t\t\tconst rowLineCount = Math.max(...rowCellLines.map((c) => c.length));\n\n\t\t\tfor (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {\n\t\t\t\tconst rowParts = rowCellLines.map((cellLines, colIdx) => {\n\t\t\t\t\tconst text = cellLines[lineIdx] || \"\";\n\t\t\t\t\treturn text + \" \".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));\n\t\t\t\t});\n\t\t\t\tlines.push(`│ ${rowParts.join(\" │ \")} │`);\n\t\t\t}\n\n\t\t\tif (rowIndex < token.rows.length - 1) {\n\t\t\t\tlines.push(separatorLine);\n\t\t\t}\n\t\t}\n\n\t\t// Render bottom border\n\t\tconst bottomBorderCells = columnWidths.map((w) => \"─\".repeat(w));\n\t\tlines.push(`└─${bottomBorderCells.join(\"─┴─\")}─┘`);\n\n\t\tif (nextTokenType && nextTokenType !== \"space\") {\n\t\t\tlines.push(\"\"); // Add spacing after table\n\t\t}\n\t\treturn lines;\n\t}\n}\n"]}
@@ -1,7 +1,37 @@
1
- import { marked } from "marked";
2
- import { isImageLine } from "../terminal-image.js";
1
+ import { Marked, Tokenizer } from "marked";
2
+ import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
3
3
  import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
4
+ const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
5
+ class StrictStrikethroughTokenizer extends Tokenizer {
6
+ del(src) {
7
+ const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
8
+ if (!match) {
9
+ return undefined;
10
+ }
11
+ const text = match[2];
12
+ return {
13
+ type: "del",
14
+ raw: match[0],
15
+ text,
16
+ tokens: this.lexer.inlineTokens(text),
17
+ };
18
+ }
19
+ }
20
+ const markdownParser = new Marked();
21
+ markdownParser.setOptions({
22
+ tokenizer: new StrictStrikethroughTokenizer(),
23
+ });
4
24
  export class Markdown {
25
+ text;
26
+ paddingX; // Left/right padding
27
+ paddingY; // Top/bottom padding
28
+ defaultTextStyle;
29
+ theme;
30
+ defaultStylePrefix;
31
+ // Cache for rendered output
32
+ cachedText;
33
+ cachedWidth;
34
+ cachedLines;
5
35
  constructor(text, paddingX, paddingY, theme, defaultTextStyle) {
6
36
  this.text = text;
7
37
  this.paddingX = paddingX;
@@ -37,7 +67,7 @@ export class Markdown {
37
67
  // Replace tabs with 3 spaces for consistent rendering
38
68
  const normalizedText = this.text.replace(/\t/g, " ");
39
69
  // Parse markdown to HTML-like tokens
40
- const tokens = marked.lexer(normalizedText);
70
+ const tokens = markdownParser.lexer(normalizedText);
41
71
  // Convert tokens to styled terminal output
42
72
  const renderedLines = [];
43
73
  for (let i = 0; i < tokens.length; i++) {
@@ -168,19 +198,24 @@ export class Markdown {
168
198
  case "heading": {
169
199
  const headingLevel = token.depth;
170
200
  const headingPrefix = `${"#".repeat(headingLevel)} `;
171
- const headingText = this.renderInlineTokens(token.tokens || [], styleContext);
172
- let styledHeading;
201
+ // Build a heading-specific style context so inline tokens (codespan, bold, etc.)
202
+ // restore heading styling after their own ANSI resets instead of falling back to
203
+ // the default text style.
204
+ let headingStyleFn;
173
205
  if (headingLevel === 1) {
174
- styledHeading = this.theme.heading(this.theme.bold(this.theme.underline(headingText)));
175
- }
176
- else if (headingLevel === 2) {
177
- styledHeading = this.theme.heading(this.theme.bold(headingText));
206
+ headingStyleFn = (text) => this.theme.heading(this.theme.bold(this.theme.underline(text)));
178
207
  }
179
208
  else {
180
- styledHeading = this.theme.heading(this.theme.bold(headingPrefix + headingText));
209
+ headingStyleFn = (text) => this.theme.heading(this.theme.bold(text));
181
210
  }
211
+ const headingStyleContext = {
212
+ applyText: headingStyleFn,
213
+ stylePrefix: this.getStylePrefix(headingStyleFn),
214
+ };
215
+ const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext);
216
+ const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText;
182
217
  lines.push(styledHeading);
183
- if (nextTokenType !== "space") {
218
+ if (nextTokenType && nextTokenType !== "space") {
184
219
  lines.push(""); // Add spacing after headings (unless space token follows)
185
220
  }
186
221
  break;
@@ -194,6 +229,9 @@ export class Markdown {
194
229
  }
195
230
  break;
196
231
  }
232
+ case "text":
233
+ lines.push(this.renderInlineTokens([token], styleContext));
234
+ break;
197
235
  case "code": {
198
236
  const indent = this.theme.codeBlockIndent ?? " ";
199
237
  lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
@@ -211,20 +249,20 @@ export class Markdown {
211
249
  }
212
250
  }
213
251
  lines.push(this.theme.codeBlockBorder("```"));
214
- if (nextTokenType !== "space") {
252
+ if (nextTokenType && nextTokenType !== "space") {
215
253
  lines.push(""); // Add spacing after code blocks (unless space token follows)
216
254
  }
217
255
  break;
218
256
  }
219
257
  case "list": {
220
- const listLines = this.renderList(token, 0, styleContext);
258
+ const listLines = this.renderList(token, 0, width, styleContext);
221
259
  lines.push(...listLines);
222
260
  // Don't add spacing after lists if a space token follows
223
261
  // (the space token will handle it)
224
262
  break;
225
263
  }
226
264
  case "table": {
227
- const tableLines = this.renderTable(token, width, styleContext);
265
+ const tableLines = this.renderTable(token, width, nextTokenType, styleContext);
228
266
  lines.push(...tableLines);
229
267
  break;
230
268
  }
@@ -245,7 +283,7 @@ export class Markdown {
245
283
  // Default message style should not apply inside blockquotes.
246
284
  const quoteInlineStyleContext = {
247
285
  applyText: (text) => text,
248
- stylePrefix: "",
286
+ stylePrefix: quoteStylePrefix,
249
287
  };
250
288
  const quoteTokens = token.tokens || [];
251
289
  const renderedQuoteLines = [];
@@ -265,14 +303,14 @@ export class Markdown {
265
303
  lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
266
304
  }
267
305
  }
268
- if (nextTokenType !== "space") {
306
+ if (nextTokenType && nextTokenType !== "space") {
269
307
  lines.push(""); // Add spacing after blockquotes (unless space token follows)
270
308
  }
271
309
  break;
272
310
  }
273
311
  case "hr":
274
312
  lines.push(this.theme.hr("─".repeat(Math.min(width, 80))));
275
- if (nextTokenType !== "space") {
313
+ if (nextTokenType && nextTokenType !== "space") {
276
314
  lines.push(""); // Add spacing after horizontal rules (unless space token follows)
277
315
  }
278
316
  break;
@@ -332,19 +370,24 @@ export class Markdown {
332
370
  break;
333
371
  case "link": {
334
372
  const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
335
- // If link text matches href, only show the link once
336
- // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
337
- // For mailto: links, strip the prefix before comparing (autolinked emails have
338
- // text="foo@bar.com" but href="mailto:foo@bar.com")
339
- const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
340
- if (token.text === token.href || token.text === hrefForComparison) {
341
- result += this.theme.link(this.theme.underline(linkText)) + stylePrefix;
373
+ const styledLink = this.theme.link(this.theme.underline(linkText));
374
+ if (getCapabilities().hyperlinks) {
375
+ // OSC 8: render as a clickable hyperlink. The URL is not printed inline,
376
+ // so we always show only the link text regardless of whether it matches href.
377
+ result += hyperlink(styledLink, token.href) + stylePrefix;
342
378
  }
343
379
  else {
344
- result +=
345
- this.theme.link(this.theme.underline(linkText)) +
346
- this.theme.linkUrl(` (${token.href})`) +
347
- stylePrefix;
380
+ // Fallback: print URL in parentheses when text differs from href.
381
+ // Compare raw token.text (not styled) against href for the equality check.
382
+ // For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
383
+ // but href="mailto:foo@bar.com").
384
+ const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
385
+ if (token.text === token.href || token.text === hrefForComparison) {
386
+ result += styledLink + stylePrefix;
387
+ }
388
+ else {
389
+ result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;
390
+ }
348
391
  }
349
392
  break;
350
393
  }
@@ -369,103 +412,43 @@ export class Markdown {
369
412
  }
370
413
  }
371
414
  }
415
+ while (stylePrefix && result.endsWith(stylePrefix)) {
416
+ result = result.slice(0, -stylePrefix.length);
417
+ }
372
418
  return result;
373
419
  }
374
420
  /**
375
421
  * Render a list with proper nesting support
376
422
  */
377
- renderList(token, depth, styleContext) {
423
+ renderList(token, depth, width, styleContext) {
378
424
  const lines = [];
379
- const indent = " ".repeat(depth);
425
+ const indent = " ".repeat(depth);
380
426
  // Use the list's start property (defaults to 1 for ordered lists)
381
- const startNumber = token.start ?? 1;
427
+ const startNumber = typeof token.start === "number" ? token.start : 1;
382
428
  for (let i = 0; i < token.items.length; i++) {
383
429
  const item = token.items[i];
384
430
  const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
385
- // Process item tokens to handle nested lists
386
- const itemLines = this.renderListItem(item.tokens || [], depth, styleContext);
387
- if (itemLines.length > 0) {
388
- // First line - check if it's a nested list
389
- // A nested list will start with indent (spaces) followed by cyan bullet
390
- const firstLine = itemLines[0];
391
- const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char
392
- if (isNestedList) {
393
- // This is a nested list, just add it as-is (already has full indent)
394
- lines.push(firstLine);
395
- }
396
- else {
397
- // Regular text content - add indent and bullet
398
- lines.push(indent + this.theme.listBullet(bullet) + firstLine);
399
- }
400
- // Rest of the lines
401
- for (let j = 1; j < itemLines.length; j++) {
402
- const line = itemLines[j];
403
- const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
404
- if (isNestedListLine) {
405
- // Nested list line - already has full indent
406
- lines.push(line);
407
- }
408
- else {
409
- // Regular content - add parent indent + 2 spaces for continuation
410
- lines.push(`${indent} ${line}`);
411
- }
412
- }
413
- }
414
- else {
415
- lines.push(indent + this.theme.listBullet(bullet));
416
- }
417
- }
418
- return lines;
419
- }
420
- /**
421
- * Render list item tokens, handling nested lists
422
- * Returns lines WITHOUT the parent indent (renderList will add it)
423
- */
424
- renderListItem(tokens, parentDepth, styleContext) {
425
- const lines = [];
426
- for (const token of tokens) {
427
- if (token.type === "list") {
428
- // Nested list - render with one additional indent level
429
- // These lines will have their own indent, so we just add them as-is
430
- const nestedLines = this.renderList(token, parentDepth + 1, styleContext);
431
- lines.push(...nestedLines);
432
- }
433
- else if (token.type === "text") {
434
- // Text content (may have inline tokens)
435
- const text = token.tokens && token.tokens.length > 0
436
- ? this.renderInlineTokens(token.tokens, styleContext)
437
- : token.text || "";
438
- lines.push(text);
439
- }
440
- else if (token.type === "paragraph") {
441
- // Paragraph in list item
442
- const text = this.renderInlineTokens(token.tokens || [], styleContext);
443
- lines.push(text);
444
- }
445
- else if (token.type === "code") {
446
- // Code block in list item
447
- const indent = this.theme.codeBlockIndent ?? " ";
448
- lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
449
- if (this.theme.highlightCode) {
450
- const highlightedLines = this.theme.highlightCode(token.text, token.lang);
451
- for (const hlLine of highlightedLines) {
452
- lines.push(`${indent}${hlLine}`);
453
- }
431
+ const firstPrefix = indent + this.theme.listBullet(bullet);
432
+ const continuationPrefix = indent + " ".repeat(visibleWidth(bullet));
433
+ const itemWidth = Math.max(1, width - visibleWidth(firstPrefix));
434
+ let renderedAnyLine = false;
435
+ for (const itemToken of item.tokens) {
436
+ if (itemToken.type === "list") {
437
+ lines.push(...this.renderList(itemToken, depth + 1, width, styleContext));
438
+ renderedAnyLine = true;
439
+ continue;
454
440
  }
455
- else {
456
- const codeLines = token.text.split("\n");
457
- for (const codeLine of codeLines) {
458
- lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
441
+ const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext);
442
+ for (const line of itemLines) {
443
+ for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) {
444
+ const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix;
445
+ lines.push(linePrefix + wrappedLine);
446
+ renderedAnyLine = true;
459
447
  }
460
448
  }
461
- lines.push(this.theme.codeBlockBorder("```"));
462
449
  }
463
- else {
464
- // Other token types - try to render as inline
465
- const text = this.renderInlineTokens([token], styleContext);
466
- if (text) {
467
- lines.push(text);
468
- }
450
+ if (!renderedAnyLine) {
451
+ lines.push(firstPrefix);
469
452
  }
470
453
  }
471
454
  return lines;
@@ -497,7 +480,7 @@ export class Markdown {
497
480
  * Render a table with width-aware cell wrapping.
498
481
  * Cells that don't fit are wrapped to multiple lines.
499
482
  */
500
- renderTable(token, availableWidth, styleContext) {
483
+ renderTable(token, availableWidth, nextTokenType, styleContext) {
501
484
  const lines = [];
502
485
  const numCols = token.header.length;
503
486
  if (numCols === 0) {
@@ -510,7 +493,9 @@ export class Markdown {
510
493
  if (availableForCells < numCols) {
511
494
  // Too narrow to render a stable table. Fall back to raw markdown.
512
495
  const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
513
- fallbackLines.push("");
496
+ if (nextTokenType && nextTokenType !== "space") {
497
+ fallbackLines.push("");
498
+ }
514
499
  return fallbackLines;
515
500
  }
516
501
  const maxUnbrokenWordWidth = 30;
@@ -634,7 +619,9 @@ export class Markdown {
634
619
  // Render bottom border
635
620
  const bottomBorderCells = columnWidths.map((w) => "─".repeat(w));
636
621
  lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`);
637
- lines.push(""); // Add spacing after table
622
+ if (nextTokenType && nextTokenType !== "space") {
623
+ lines.push(""); // Add spacing after table
624
+ }
638
625
  return lines;
639
626
  }
640
627
  }