lonny-agent 0.1.2 → 0.1.9

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 (216) hide show
  1. package/.kilo/plans/1780064105789-mighty-pixel.md +171 -0
  2. package/.lonny/fix-new-command-session-cleanup.md +65 -0
  3. package/.lonny/tui-autocomplete-above.md +62 -0
  4. package/dist/agent/__tests__/compaction.test.js +6 -4
  5. package/dist/agent/__tests__/compaction.test.js.map +1 -1
  6. package/dist/agent/llm.d.ts +1 -1
  7. package/dist/agent/llm.d.ts.map +1 -1
  8. package/dist/agent/prompt-builder.d.ts.map +1 -1
  9. package/dist/agent/prompt-builder.js +26 -8
  10. package/dist/agent/prompt-builder.js.map +1 -1
  11. package/dist/agent/providers/anthropic.d.ts +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +2 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.d.ts +1 -1
  16. package/dist/agent/providers/google.d.ts.map +1 -1
  17. package/dist/agent/providers/google.js +2 -1
  18. package/dist/agent/providers/google.js.map +1 -1
  19. package/dist/agent/providers/ollama.d.ts +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts +1 -1
  24. package/dist/agent/providers/openai.d.ts.map +1 -1
  25. package/dist/agent/providers/openai.js +2 -2
  26. package/dist/agent/providers/openai.js.map +1 -1
  27. package/dist/agent/session.d.ts +1 -0
  28. package/dist/agent/session.d.ts.map +1 -1
  29. package/dist/agent/session.js +8 -2
  30. package/dist/agent/session.js.map +1 -1
  31. package/dist/pi-tui/autocomplete.d.ts +54 -0
  32. package/dist/pi-tui/autocomplete.d.ts.map +1 -0
  33. package/dist/pi-tui/autocomplete.js +636 -0
  34. package/dist/pi-tui/autocomplete.js.map +1 -0
  35. package/dist/pi-tui/components/box.d.ts +22 -0
  36. package/dist/pi-tui/components/box.d.ts.map +1 -0
  37. package/dist/pi-tui/components/box.js +104 -0
  38. package/dist/pi-tui/components/box.js.map +1 -0
  39. package/dist/pi-tui/components/cancellable-loader.d.ts +22 -0
  40. package/dist/pi-tui/components/cancellable-loader.d.ts.map +1 -0
  41. package/dist/pi-tui/components/cancellable-loader.js +35 -0
  42. package/dist/pi-tui/components/cancellable-loader.js.map +1 -0
  43. package/dist/pi-tui/components/editor.d.ts +249 -0
  44. package/dist/pi-tui/components/editor.d.ts.map +1 -0
  45. package/dist/pi-tui/components/editor.js +1883 -0
  46. package/dist/pi-tui/components/editor.js.map +1 -0
  47. package/dist/pi-tui/components/image.d.ts +28 -0
  48. package/dist/pi-tui/components/image.d.ts.map +1 -0
  49. package/dist/pi-tui/components/image.js +90 -0
  50. package/dist/pi-tui/components/image.js.map +1 -0
  51. package/dist/pi-tui/components/input.d.ts +37 -0
  52. package/dist/pi-tui/components/input.d.ts.map +1 -0
  53. package/dist/pi-tui/components/input.js +382 -0
  54. package/dist/pi-tui/components/input.js.map +1 -0
  55. package/dist/pi-tui/components/loader.d.ts +31 -0
  56. package/dist/pi-tui/components/loader.d.ts.map +1 -0
  57. package/dist/pi-tui/components/loader.js +70 -0
  58. package/dist/pi-tui/components/loader.js.map +1 -0
  59. package/dist/pi-tui/components/markdown.d.ts +96 -0
  60. package/dist/pi-tui/components/markdown.d.ts.map +1 -0
  61. package/dist/pi-tui/components/markdown.js +647 -0
  62. package/dist/pi-tui/components/markdown.js.map +1 -0
  63. package/dist/pi-tui/components/select-list.d.ts +50 -0
  64. package/dist/pi-tui/components/select-list.d.ts.map +1 -0
  65. package/dist/pi-tui/components/select-list.js +167 -0
  66. package/dist/pi-tui/components/select-list.js.map +1 -0
  67. package/dist/pi-tui/components/settings-list.d.ts +50 -0
  68. package/dist/pi-tui/components/settings-list.d.ts.map +1 -0
  69. package/dist/pi-tui/components/settings-list.js +189 -0
  70. package/dist/pi-tui/components/settings-list.js.map +1 -0
  71. package/dist/pi-tui/components/spacer.d.ts +12 -0
  72. package/dist/pi-tui/components/spacer.d.ts.map +1 -0
  73. package/dist/pi-tui/components/spacer.js +23 -0
  74. package/dist/pi-tui/components/spacer.js.map +1 -0
  75. package/dist/pi-tui/components/text.d.ts +19 -0
  76. package/dist/pi-tui/components/text.d.ts.map +1 -0
  77. package/dist/pi-tui/components/text.js +91 -0
  78. package/dist/pi-tui/components/text.js.map +1 -0
  79. package/dist/pi-tui/components/truncated-text.d.ts +13 -0
  80. package/dist/pi-tui/components/truncated-text.d.ts.map +1 -0
  81. package/dist/pi-tui/components/truncated-text.js +51 -0
  82. package/dist/pi-tui/components/truncated-text.js.map +1 -0
  83. package/dist/pi-tui/editor-component.d.ts +39 -0
  84. package/dist/pi-tui/editor-component.d.ts.map +1 -0
  85. package/dist/pi-tui/editor-component.js +2 -0
  86. package/dist/pi-tui/editor-component.js.map +1 -0
  87. package/dist/pi-tui/fuzzy.d.ts +16 -0
  88. package/dist/pi-tui/fuzzy.d.ts.map +1 -0
  89. package/dist/pi-tui/fuzzy.js +110 -0
  90. package/dist/pi-tui/fuzzy.js.map +1 -0
  91. package/dist/pi-tui/index.d.ts +23 -0
  92. package/dist/pi-tui/index.d.ts.map +1 -0
  93. package/dist/pi-tui/index.js +32 -0
  94. package/dist/pi-tui/index.js.map +1 -0
  95. package/dist/pi-tui/keybindings.d.ts +193 -0
  96. package/dist/pi-tui/keybindings.d.ts.map +1 -0
  97. package/dist/pi-tui/keybindings.js +174 -0
  98. package/dist/pi-tui/keybindings.js.map +1 -0
  99. package/dist/pi-tui/keys.d.ts +184 -0
  100. package/dist/pi-tui/keys.d.ts.map +1 -0
  101. package/dist/pi-tui/keys.js +1182 -0
  102. package/dist/pi-tui/keys.js.map +1 -0
  103. package/dist/pi-tui/kill-ring.d.ts +28 -0
  104. package/dist/pi-tui/kill-ring.d.ts.map +1 -0
  105. package/dist/pi-tui/kill-ring.js +44 -0
  106. package/dist/pi-tui/kill-ring.js.map +1 -0
  107. package/dist/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  108. package/dist/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  109. package/dist/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  110. package/dist/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  111. package/dist/pi-tui/native-modifiers.d.ts +3 -0
  112. package/dist/pi-tui/native-modifiers.d.ts.map +1 -0
  113. package/dist/pi-tui/native-modifiers.js +53 -0
  114. package/dist/pi-tui/native-modifiers.js.map +1 -0
  115. package/dist/pi-tui/stdin-buffer.d.ts +50 -0
  116. package/dist/pi-tui/stdin-buffer.d.ts.map +1 -0
  117. package/dist/pi-tui/stdin-buffer.js +361 -0
  118. package/dist/pi-tui/stdin-buffer.js.map +1 -0
  119. package/dist/pi-tui/terminal-image.d.ts +90 -0
  120. package/dist/pi-tui/terminal-image.d.ts.map +1 -0
  121. package/dist/pi-tui/terminal-image.js +343 -0
  122. package/dist/pi-tui/terminal-image.js.map +1 -0
  123. package/dist/pi-tui/terminal.d.ts +113 -0
  124. package/dist/pi-tui/terminal.d.ts.map +1 -0
  125. package/dist/pi-tui/terminal.js +478 -0
  126. package/dist/pi-tui/terminal.js.map +1 -0
  127. package/dist/pi-tui/tui.d.ts +227 -0
  128. package/dist/pi-tui/tui.d.ts.map +1 -0
  129. package/dist/pi-tui/tui.js +1091 -0
  130. package/dist/pi-tui/tui.js.map +1 -0
  131. package/dist/pi-tui/undo-stack.d.ts +17 -0
  132. package/dist/pi-tui/undo-stack.d.ts.map +1 -0
  133. package/dist/pi-tui/undo-stack.js +25 -0
  134. package/dist/pi-tui/undo-stack.js.map +1 -0
  135. package/dist/pi-tui/utils.d.ts +84 -0
  136. package/dist/pi-tui/utils.d.ts.map +1 -0
  137. package/dist/pi-tui/utils.js +1024 -0
  138. package/dist/pi-tui/utils.js.map +1 -0
  139. package/dist/pi-tui/word-navigation.d.ts +25 -0
  140. package/dist/pi-tui/word-navigation.d.ts.map +1 -0
  141. package/dist/pi-tui/word-navigation.js +98 -0
  142. package/dist/pi-tui/word-navigation.js.map +1 -0
  143. package/dist/tools/__tests__/edit.test.js +85 -0
  144. package/dist/tools/__tests__/edit.test.js.map +1 -1
  145. package/dist/tools/edit.d.ts.map +1 -1
  146. package/dist/tools/edit.js +23 -1
  147. package/dist/tools/edit.js.map +1 -1
  148. package/dist/tui/components.d.ts +1 -1
  149. package/dist/tui/components.d.ts.map +1 -1
  150. package/dist/tui/components.js +1 -1
  151. package/dist/tui/components.js.map +1 -1
  152. package/dist/tui/editor-patch.d.ts +13 -0
  153. package/dist/tui/editor-patch.d.ts.map +1 -0
  154. package/dist/tui/editor-patch.js +41 -0
  155. package/dist/tui/editor-patch.js.map +1 -0
  156. package/dist/tui/index.d.ts.map +1 -1
  157. package/dist/tui/index.js +15 -3
  158. package/dist/tui/index.js.map +1 -1
  159. package/dist/web/index.d.ts.map +1 -1
  160. package/dist/web/index.js +9 -2
  161. package/dist/web/index.js.map +1 -1
  162. package/dist/web/public/app.js +65 -4
  163. package/dist/web/public/style.css +49 -0
  164. package/dist/web/session-bridge.d.ts +0 -4
  165. package/dist/web/session-bridge.d.ts.map +1 -1
  166. package/dist/web/session-bridge.js +16 -2
  167. package/dist/web/session-bridge.js.map +1 -1
  168. package/package.json +5 -3
  169. package/src/agent/__tests__/compaction.test.ts +6 -4
  170. package/src/agent/llm.ts +5 -1
  171. package/src/agent/prompt-builder.ts +27 -8
  172. package/src/agent/providers/anthropic.ts +15 -8
  173. package/src/agent/providers/google.ts +6 -1
  174. package/src/agent/providers/ollama.ts +6 -1
  175. package/src/agent/providers/openai.ts +21 -12
  176. package/src/agent/session.ts +12 -2
  177. package/src/pi-tui/autocomplete.ts +808 -0
  178. package/src/pi-tui/components/box.ts +137 -0
  179. package/src/pi-tui/components/cancellable-loader.ts +40 -0
  180. package/src/pi-tui/components/editor.ts +2321 -0
  181. package/src/pi-tui/components/image.ts +130 -0
  182. package/src/pi-tui/components/input.ts +456 -0
  183. package/src/pi-tui/components/loader.ts +93 -0
  184. package/src/pi-tui/components/markdown.ts +839 -0
  185. package/src/pi-tui/components/select-list.ts +263 -0
  186. package/src/pi-tui/components/settings-list.ts +264 -0
  187. package/src/pi-tui/components/spacer.ts +28 -0
  188. package/src/pi-tui/components/text.ts +113 -0
  189. package/src/pi-tui/components/truncated-text.ts +65 -0
  190. package/src/pi-tui/editor-component.ts +74 -0
  191. package/src/pi-tui/fuzzy.ts +137 -0
  192. package/src/pi-tui/index.ts +115 -0
  193. package/src/pi-tui/keybindings.ts +245 -0
  194. package/src/pi-tui/keys.ts +1440 -0
  195. package/src/pi-tui/kill-ring.ts +46 -0
  196. package/src/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  197. package/src/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  198. package/src/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  199. package/src/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  200. package/src/pi-tui/native-modifiers.ts +65 -0
  201. package/src/pi-tui/stdin-buffer.ts +434 -0
  202. package/src/pi-tui/terminal-image.ts +469 -0
  203. package/src/pi-tui/terminal.ts +594 -0
  204. package/src/pi-tui/tui.ts +1376 -0
  205. package/src/pi-tui/undo-stack.ts +28 -0
  206. package/src/pi-tui/utils.ts +1176 -0
  207. package/src/pi-tui/word-navigation.ts +127 -0
  208. package/src/tools/__tests__/edit.test.ts +91 -0
  209. package/src/tools/edit.ts +27 -1
  210. package/src/tui/components.ts +2 -2
  211. package/src/tui/index.ts +24 -10
  212. package/src/web/index.ts +9 -2
  213. package/src/web/public/app.js +65 -4
  214. package/src/web/public/style.css +49 -0
  215. package/src/web/session-bridge.ts +13 -2
  216. package/tsconfig.json +2 -2
@@ -0,0 +1,839 @@
1
+ import { Marked, type Token, Tokenizer, type Tokens } from 'marked'
2
+ import { getCapabilities, hyperlink, isImageLine } from '../terminal-image.js'
3
+ import type { Component } from '../tui.js'
4
+ import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from '../utils.js'
5
+
6
+ const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/
7
+
8
+ class StrictStrikethroughTokenizer extends Tokenizer {
9
+ override del(src: string): Tokens.Del | undefined {
10
+ const match = STRICT_STRIKETHROUGH_REGEX.exec(src)
11
+ if (!match) {
12
+ return undefined
13
+ }
14
+
15
+ const text = match[2]
16
+ return {
17
+ type: 'del',
18
+ raw: match[0],
19
+ text,
20
+ tokens: this.lexer.inlineTokens(text),
21
+ }
22
+ }
23
+ }
24
+
25
+ const markdownParser = new Marked()
26
+ markdownParser.setOptions({
27
+ tokenizer: new StrictStrikethroughTokenizer(),
28
+ })
29
+
30
+ /**
31
+ * Default text styling for markdown content.
32
+ * Applied to all text unless overridden by markdown formatting.
33
+ */
34
+ export interface DefaultTextStyle {
35
+ /** Foreground color function */
36
+ color?: (text: string) => string
37
+ /** Background color function */
38
+ bgColor?: (text: string) => string
39
+ /** Bold text */
40
+ bold?: boolean
41
+ /** Italic text */
42
+ italic?: boolean
43
+ /** Strikethrough text */
44
+ strikethrough?: boolean
45
+ /** Underline text */
46
+ underline?: boolean
47
+ }
48
+
49
+ /**
50
+ * Theme functions for markdown elements.
51
+ * Each function takes text and returns styled text with ANSI codes.
52
+ */
53
+ export interface MarkdownTheme {
54
+ heading: (text: string) => string
55
+ link: (text: string) => string
56
+ linkUrl: (text: string) => string
57
+ code: (text: string) => string
58
+ codeBlock: (text: string) => string
59
+ codeBlockBorder: (text: string) => string
60
+ quote: (text: string) => string
61
+ quoteBorder: (text: string) => string
62
+ hr: (text: string) => string
63
+ listBullet: (text: string) => string
64
+ bold: (text: string) => string
65
+ italic: (text: string) => string
66
+ strikethrough: (text: string) => string
67
+ underline: (text: string) => string
68
+ highlightCode?: (code: string, lang?: string) => string[]
69
+ /** Prefix applied to each rendered code block line (default: " ") */
70
+ codeBlockIndent?: string
71
+ }
72
+
73
+ export interface MarkdownOptions {
74
+ /** Preserve source ordered-list markers instead of normalizing them from the list start. */
75
+ preserveOrderedListMarkers?: boolean
76
+ }
77
+
78
+ interface InlineStyleContext {
79
+ applyText: (text: string) => string
80
+ stylePrefix: string
81
+ }
82
+
83
+ export class Markdown implements Component {
84
+ private text: string
85
+ private paddingX: number // Left/right padding
86
+ private paddingY: number // Top/bottom padding
87
+ private defaultTextStyle?: DefaultTextStyle
88
+ private theme: MarkdownTheme
89
+ private options: MarkdownOptions
90
+ private defaultStylePrefix?: string
91
+
92
+ // Cache for rendered output
93
+ private cachedText?: string
94
+ private cachedWidth?: number
95
+ private cachedLines?: string[]
96
+
97
+ constructor(
98
+ text: string,
99
+ paddingX: number,
100
+ paddingY: number,
101
+ theme: MarkdownTheme,
102
+ defaultTextStyle?: DefaultTextStyle,
103
+ options?: MarkdownOptions,
104
+ ) {
105
+ this.text = text
106
+ this.paddingX = paddingX
107
+ this.paddingY = paddingY
108
+ this.theme = theme
109
+ this.defaultTextStyle = defaultTextStyle
110
+ this.options = options ? { ...options } : {}
111
+ }
112
+
113
+ setText(text: string): void {
114
+ this.text = text
115
+ this.invalidate()
116
+ }
117
+
118
+ invalidate(): void {
119
+ this.cachedText = undefined
120
+ this.cachedWidth = undefined
121
+ this.cachedLines = undefined
122
+ }
123
+
124
+ render(width: number): string[] {
125
+ // Check cache
126
+ if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
127
+ return this.cachedLines
128
+ }
129
+
130
+ // Calculate available width for content (subtract horizontal padding)
131
+ const contentWidth = Math.max(1, width - this.paddingX * 2)
132
+
133
+ // Don't render anything if there's no actual text
134
+ if (!this.text || this.text.trim() === '') {
135
+ const result: string[] = []
136
+ // Update cache
137
+ this.cachedText = this.text
138
+ this.cachedWidth = width
139
+ this.cachedLines = result
140
+ return result
141
+ }
142
+
143
+ // Replace tabs with 3 spaces for consistent rendering
144
+ const normalizedText = this.text.replace(/\t/g, ' ')
145
+
146
+ // Parse markdown to HTML-like tokens
147
+ const tokens = markdownParser.lexer(normalizedText)
148
+
149
+ // Convert tokens to styled terminal output
150
+ const renderedLines: string[] = []
151
+
152
+ for (let i = 0; i < tokens.length; i++) {
153
+ const token = tokens[i]
154
+ const nextToken = tokens[i + 1]
155
+ const tokenLines = this.renderToken(token, contentWidth, nextToken?.type)
156
+ for (const tokenLine of tokenLines) {
157
+ renderedLines.push(tokenLine)
158
+ }
159
+ }
160
+
161
+ // Wrap lines (NO padding, NO background yet)
162
+ const wrappedLines: string[] = []
163
+ for (const line of renderedLines) {
164
+ if (isImageLine(line)) {
165
+ wrappedLines.push(line)
166
+ } else {
167
+ for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) {
168
+ wrappedLines.push(wrappedLine)
169
+ }
170
+ }
171
+ }
172
+
173
+ // Add margins and background to each wrapped line
174
+ const leftMargin = ' '.repeat(this.paddingX)
175
+ const rightMargin = ' '.repeat(this.paddingX)
176
+ const bgFn = this.defaultTextStyle?.bgColor
177
+ const contentLines: string[] = []
178
+
179
+ for (const line of wrappedLines) {
180
+ if (isImageLine(line)) {
181
+ contentLines.push(line)
182
+ continue
183
+ }
184
+
185
+ const lineWithMargins = leftMargin + line + rightMargin
186
+
187
+ if (bgFn) {
188
+ contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn))
189
+ } else {
190
+ // No background - just pad to width
191
+ const visibleLen = visibleWidth(lineWithMargins)
192
+ const paddingNeeded = Math.max(0, width - visibleLen)
193
+ contentLines.push(lineWithMargins + ' '.repeat(paddingNeeded))
194
+ }
195
+ }
196
+
197
+ // Add top/bottom padding (empty lines)
198
+ const emptyLine = ' '.repeat(width)
199
+ const emptyLines: string[] = []
200
+ for (let i = 0; i < this.paddingY; i++) {
201
+ const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine
202
+ emptyLines.push(line)
203
+ }
204
+
205
+ // Combine top padding, content, and bottom padding
206
+ const result = emptyLines.concat(contentLines, emptyLines)
207
+
208
+ // Update cache
209
+ this.cachedText = this.text
210
+ this.cachedWidth = width
211
+ this.cachedLines = result
212
+
213
+ return result.length > 0 ? result : ['']
214
+ }
215
+
216
+ /**
217
+ * Apply default text style to a string.
218
+ * This is the base styling applied to all text content.
219
+ * NOTE: Background color is NOT applied here - it's applied at the padding stage
220
+ * to ensure it extends to the full line width.
221
+ */
222
+ private applyDefaultStyle(text: string): string {
223
+ if (!this.defaultTextStyle) {
224
+ return text
225
+ }
226
+
227
+ let styled = text
228
+
229
+ // Apply foreground color (NOT background - that's applied at padding stage)
230
+ if (this.defaultTextStyle.color) {
231
+ styled = this.defaultTextStyle.color(styled)
232
+ }
233
+
234
+ // Apply text decorations using this.theme
235
+ if (this.defaultTextStyle.bold) {
236
+ styled = this.theme.bold(styled)
237
+ }
238
+ if (this.defaultTextStyle.italic) {
239
+ styled = this.theme.italic(styled)
240
+ }
241
+ if (this.defaultTextStyle.strikethrough) {
242
+ styled = this.theme.strikethrough(styled)
243
+ }
244
+ if (this.defaultTextStyle.underline) {
245
+ styled = this.theme.underline(styled)
246
+ }
247
+
248
+ return styled
249
+ }
250
+
251
+ private getDefaultStylePrefix(): string {
252
+ if (!this.defaultTextStyle) {
253
+ return ''
254
+ }
255
+
256
+ if (this.defaultStylePrefix !== undefined) {
257
+ return this.defaultStylePrefix
258
+ }
259
+
260
+ const sentinel = '\u0000'
261
+ let styled = sentinel
262
+
263
+ if (this.defaultTextStyle.color) {
264
+ styled = this.defaultTextStyle.color(styled)
265
+ }
266
+
267
+ if (this.defaultTextStyle.bold) {
268
+ styled = this.theme.bold(styled)
269
+ }
270
+ if (this.defaultTextStyle.italic) {
271
+ styled = this.theme.italic(styled)
272
+ }
273
+ if (this.defaultTextStyle.strikethrough) {
274
+ styled = this.theme.strikethrough(styled)
275
+ }
276
+ if (this.defaultTextStyle.underline) {
277
+ styled = this.theme.underline(styled)
278
+ }
279
+
280
+ const sentinelIndex = styled.indexOf(sentinel)
281
+ this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ''
282
+ return this.defaultStylePrefix
283
+ }
284
+
285
+ private getStylePrefix(styleFn: (text: string) => string): string {
286
+ const sentinel = '\u0000'
287
+ const styled = styleFn(sentinel)
288
+ const sentinelIndex = styled.indexOf(sentinel)
289
+ return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ''
290
+ }
291
+
292
+ private getDefaultInlineStyleContext(): InlineStyleContext {
293
+ return {
294
+ applyText: (text: string) => this.applyDefaultStyle(text),
295
+ stylePrefix: this.getDefaultStylePrefix(),
296
+ }
297
+ }
298
+
299
+ private renderToken(
300
+ token: Token,
301
+ width: number,
302
+ nextTokenType?: string,
303
+ styleContext?: InlineStyleContext,
304
+ ): string[] {
305
+ const lines: string[] = []
306
+
307
+ switch (token.type) {
308
+ case 'heading': {
309
+ const headingLevel = token.depth
310
+ const headingPrefix = `${'#'.repeat(headingLevel)} `
311
+
312
+ // Build a heading-specific style context so inline tokens (codespan, bold, etc.)
313
+ // restore heading styling after their own ANSI resets instead of falling back to
314
+ // the default text style.
315
+ let headingStyleFn: (text: string) => string
316
+ if (headingLevel === 1) {
317
+ headingStyleFn = (text: string) =>
318
+ this.theme.heading(this.theme.bold(this.theme.underline(text)))
319
+ } else {
320
+ headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(text))
321
+ }
322
+
323
+ const headingStyleContext: InlineStyleContext = {
324
+ applyText: headingStyleFn,
325
+ stylePrefix: this.getStylePrefix(headingStyleFn),
326
+ }
327
+
328
+ const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext)
329
+ const styledHeading =
330
+ headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText
331
+ lines.push(styledHeading)
332
+ if (nextTokenType && nextTokenType !== 'space') {
333
+ lines.push('') // Add spacing after headings (unless space token follows)
334
+ }
335
+ break
336
+ }
337
+
338
+ case 'paragraph': {
339
+ const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext)
340
+ lines.push(paragraphText)
341
+ // Don't add spacing if next token is space or list
342
+ if (nextTokenType && nextTokenType !== 'list' && nextTokenType !== 'space') {
343
+ lines.push('')
344
+ }
345
+ break
346
+ }
347
+
348
+ case 'text':
349
+ lines.push(this.renderInlineTokens([token], styleContext))
350
+ break
351
+
352
+ case 'code': {
353
+ const indent = this.theme.codeBlockIndent ?? ' '
354
+ lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ''}`))
355
+ if (this.theme.highlightCode) {
356
+ const highlightedLines = this.theme.highlightCode(token.text, token.lang)
357
+ for (const hlLine of highlightedLines) {
358
+ lines.push(`${indent}${hlLine}`)
359
+ }
360
+ } else {
361
+ // Split code by newlines and style each line
362
+ const codeLines = token.text.split('\n')
363
+ for (const codeLine of codeLines) {
364
+ lines.push(`${indent}${this.theme.codeBlock(codeLine)}`)
365
+ }
366
+ }
367
+ lines.push(this.theme.codeBlockBorder('```'))
368
+ if (nextTokenType && nextTokenType !== 'space') {
369
+ lines.push('') // Add spacing after code blocks (unless space token follows)
370
+ }
371
+ break
372
+ }
373
+
374
+ case 'list': {
375
+ const listLines = this.renderList(token as Tokens.List, 0, width, styleContext)
376
+ lines.push(...listLines)
377
+ // Don't add spacing after lists if a space token follows
378
+ // (the space token will handle it)
379
+ break
380
+ }
381
+
382
+ case 'table': {
383
+ const tableLines = this.renderTable(
384
+ token as Tokens.Table,
385
+ width,
386
+ nextTokenType,
387
+ styleContext,
388
+ )
389
+ lines.push(...tableLines)
390
+ break
391
+ }
392
+
393
+ case 'blockquote': {
394
+ const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text))
395
+ const quoteStylePrefix = this.getStylePrefix(quoteStyle)
396
+ const applyQuoteStyle = (line: string): string => {
397
+ if (!quoteStylePrefix) {
398
+ return quoteStyle(line)
399
+ }
400
+ const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`)
401
+ return quoteStyle(lineWithReappliedStyle)
402
+ }
403
+
404
+ // Calculate available width for quote content (subtract border "│ " = 2 chars)
405
+ const quoteContentWidth = Math.max(1, width - 2)
406
+
407
+ // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
408
+ // children with renderToken() instead of renderInlineTokens().
409
+ // Default message style should not apply inside blockquotes.
410
+ const quoteInlineStyleContext: InlineStyleContext = {
411
+ applyText: (text: string) => text,
412
+ stylePrefix: quoteStylePrefix,
413
+ }
414
+ const quoteTokens = token.tokens || []
415
+ const renderedQuoteLines: string[] = []
416
+ for (let i = 0; i < quoteTokens.length; i++) {
417
+ const quoteToken = quoteTokens[i]
418
+ const nextQuoteToken = quoteTokens[i + 1]
419
+ renderedQuoteLines.push(
420
+ ...this.renderToken(
421
+ quoteToken,
422
+ quoteContentWidth,
423
+ nextQuoteToken?.type,
424
+ quoteInlineStyleContext,
425
+ ),
426
+ )
427
+ }
428
+
429
+ // Avoid rendering an extra empty quote line before the outer blockquote spacing.
430
+ while (
431
+ renderedQuoteLines.length > 0 &&
432
+ renderedQuoteLines[renderedQuoteLines.length - 1] === ''
433
+ ) {
434
+ renderedQuoteLines.pop()
435
+ }
436
+
437
+ for (const quoteLine of renderedQuoteLines) {
438
+ const styledLine = applyQuoteStyle(quoteLine)
439
+ const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth)
440
+ for (const wrappedLine of wrappedLines) {
441
+ lines.push(this.theme.quoteBorder('│ ') + wrappedLine)
442
+ }
443
+ }
444
+ if (nextTokenType && nextTokenType !== 'space') {
445
+ lines.push('') // Add spacing after blockquotes (unless space token follows)
446
+ }
447
+ break
448
+ }
449
+
450
+ case 'hr':
451
+ lines.push(this.theme.hr('─'.repeat(Math.min(width, 80))))
452
+ if (nextTokenType && nextTokenType !== 'space') {
453
+ lines.push('') // Add spacing after horizontal rules (unless space token follows)
454
+ }
455
+ break
456
+
457
+ case 'html':
458
+ // Render HTML as plain text (escaped for terminal)
459
+ if ('raw' in token && typeof token.raw === 'string') {
460
+ lines.push(this.applyDefaultStyle(token.raw.trim()))
461
+ }
462
+ break
463
+
464
+ case 'space':
465
+ // Space tokens represent blank lines in markdown
466
+ lines.push('')
467
+ break
468
+
469
+ default:
470
+ // Handle any other token types as plain text
471
+ if ('text' in token && typeof token.text === 'string') {
472
+ lines.push(token.text)
473
+ }
474
+ }
475
+
476
+ return lines
477
+ }
478
+
479
+ private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
480
+ let result = ''
481
+ const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext()
482
+ const { applyText, stylePrefix } = resolvedStyleContext
483
+ const applyTextWithNewlines = (text: string): string => {
484
+ const segments: string[] = text.split('\n')
485
+ return segments.map((segment: string) => applyText(segment)).join('\n')
486
+ }
487
+
488
+ for (const token of tokens) {
489
+ switch (token.type) {
490
+ case 'text':
491
+ // Text tokens in list items can have nested tokens for inline formatting
492
+ if (token.tokens && token.tokens.length > 0) {
493
+ result += this.renderInlineTokens(token.tokens, resolvedStyleContext)
494
+ } else {
495
+ result += applyTextWithNewlines(token.text)
496
+ }
497
+ break
498
+
499
+ case 'paragraph':
500
+ // Paragraph tokens contain nested inline tokens
501
+ result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext)
502
+ break
503
+
504
+ case 'strong': {
505
+ const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext)
506
+ result += this.theme.bold(boldContent) + stylePrefix
507
+ break
508
+ }
509
+
510
+ case 'em': {
511
+ const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext)
512
+ result += this.theme.italic(italicContent) + stylePrefix
513
+ break
514
+ }
515
+
516
+ case 'codespan':
517
+ result += this.theme.code(token.text) + stylePrefix
518
+ break
519
+
520
+ case 'link': {
521
+ const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext)
522
+ const styledLink = this.theme.link(this.theme.underline(linkText))
523
+ if (getCapabilities().hyperlinks) {
524
+ // OSC 8: render as a clickable hyperlink. The URL is not printed inline,
525
+ // so we always show only the link text regardless of whether it matches href.
526
+ result += hyperlink(styledLink, token.href) + stylePrefix
527
+ } else {
528
+ // Fallback: print URL in parentheses when text differs from href.
529
+ // Compare raw token.text (not styled) against href for the equality check.
530
+ // For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
531
+ // but href="mailto:foo@bar.com").
532
+ const hrefForComparison = token.href.startsWith('mailto:')
533
+ ? token.href.slice(7)
534
+ : token.href
535
+ if (token.text === token.href || token.text === hrefForComparison) {
536
+ result += styledLink + stylePrefix
537
+ } else {
538
+ result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix
539
+ }
540
+ }
541
+ break
542
+ }
543
+
544
+ case 'br':
545
+ result += '\n'
546
+ break
547
+
548
+ case 'del': {
549
+ const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext)
550
+ result += this.theme.strikethrough(delContent) + stylePrefix
551
+ break
552
+ }
553
+
554
+ case 'html':
555
+ // Render inline HTML as plain text
556
+ if ('raw' in token && typeof token.raw === 'string') {
557
+ result += applyTextWithNewlines(token.raw)
558
+ }
559
+ break
560
+
561
+ default:
562
+ // Handle any other inline token types as plain text
563
+ if ('text' in token && typeof token.text === 'string') {
564
+ result += applyTextWithNewlines(token.text)
565
+ }
566
+ }
567
+ }
568
+
569
+ while (stylePrefix && result.endsWith(stylePrefix)) {
570
+ result = result.slice(0, -stylePrefix.length)
571
+ }
572
+
573
+ return result
574
+ }
575
+
576
+ private getOrderedListMarker(item: Tokens.ListItem): string | undefined {
577
+ const match = /^(?: {0,3})(\d{1,9}[.)])[ \t]+/.exec(item.raw)
578
+ return match ? `${match[1]} ` : undefined
579
+ }
580
+
581
+ /**
582
+ * Render a list with proper nesting support
583
+ */
584
+ private renderList(
585
+ token: Tokens.List,
586
+ depth: number,
587
+ width: number,
588
+ styleContext?: InlineStyleContext,
589
+ ): string[] {
590
+ const lines: string[] = []
591
+ const indent = ' '.repeat(depth)
592
+ // Use the list's start property (defaults to 1 for ordered lists)
593
+ const startNumber = typeof token.start === 'number' ? token.start : 1
594
+
595
+ for (let i = 0; i < token.items.length; i++) {
596
+ const item = token.items[i]
597
+ const bullet = token.ordered
598
+ ? this.options.preserveOrderedListMarkers
599
+ ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `)
600
+ : `${startNumber + i}. `
601
+ : '- '
602
+ const taskMarker = item.task ? `[${item.checked ? 'x' : ' '}] ` : ''
603
+ const marker = bullet + taskMarker
604
+ const firstPrefix = indent + this.theme.listBullet(marker)
605
+ const continuationPrefix = indent + ' '.repeat(visibleWidth(marker))
606
+ const itemWidth = Math.max(1, width - visibleWidth(firstPrefix))
607
+ let renderedAnyLine = false
608
+
609
+ for (const itemToken of item.tokens) {
610
+ if (itemToken.type === 'list') {
611
+ lines.push(...this.renderList(itemToken as Tokens.List, depth + 1, width, styleContext))
612
+ renderedAnyLine = true
613
+ continue
614
+ }
615
+
616
+ const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext)
617
+ for (const line of itemLines) {
618
+ for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) {
619
+ const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix
620
+ lines.push(linePrefix + wrappedLine)
621
+ renderedAnyLine = true
622
+ }
623
+ }
624
+ }
625
+
626
+ if (!renderedAnyLine) {
627
+ lines.push(firstPrefix)
628
+ }
629
+ }
630
+
631
+ return lines
632
+ }
633
+
634
+ /**
635
+ * Get the visible width of the longest word in a string.
636
+ */
637
+ private getLongestWordWidth(text: string, maxWidth?: number): number {
638
+ const words = text.split(/\s+/).filter(word => word.length > 0)
639
+ let longest = 0
640
+ for (const word of words) {
641
+ longest = Math.max(longest, visibleWidth(word))
642
+ }
643
+ if (maxWidth === undefined) {
644
+ return longest
645
+ }
646
+ return Math.min(longest, maxWidth)
647
+ }
648
+
649
+ /**
650
+ * Wrap a table cell to fit into a column.
651
+ *
652
+ * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
653
+ * consistently with the rest of the renderer.
654
+ */
655
+ private wrapCellText(text: string, maxWidth: number): string[] {
656
+ return wrapTextWithAnsi(text, Math.max(1, maxWidth))
657
+ }
658
+
659
+ /**
660
+ * Render a table with width-aware cell wrapping.
661
+ * Cells that don't fit are wrapped to multiple lines.
662
+ */
663
+ private renderTable(
664
+ token: Tokens.Table,
665
+ availableWidth: number,
666
+ nextTokenType?: string,
667
+ styleContext?: InlineStyleContext,
668
+ ): string[] {
669
+ const lines: string[] = []
670
+ const numCols = token.header.length
671
+
672
+ if (numCols === 0) {
673
+ return lines
674
+ }
675
+
676
+ // Calculate border overhead: "│ " + (n-1) * " │ " + " │"
677
+ // = 2 + (n-1) * 3 + 2 = 3n + 1
678
+ const borderOverhead = 3 * numCols + 1
679
+ const availableForCells = availableWidth - borderOverhead
680
+ if (availableForCells < numCols) {
681
+ // Too narrow to render a stable table. Fall back to raw markdown.
682
+ const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : []
683
+ if (nextTokenType && nextTokenType !== 'space') {
684
+ fallbackLines.push('')
685
+ }
686
+ return fallbackLines
687
+ }
688
+
689
+ const maxUnbrokenWordWidth = 30
690
+
691
+ // Calculate natural column widths (what each column needs without constraints)
692
+ const naturalWidths: number[] = []
693
+ const minWordWidths: number[] = []
694
+ for (let i = 0; i < numCols; i++) {
695
+ const headerText = this.renderInlineTokens(token.header[i].tokens || [], styleContext)
696
+ naturalWidths[i] = visibleWidth(headerText)
697
+ minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth))
698
+ }
699
+ for (const row of token.rows) {
700
+ for (let i = 0; i < row.length; i++) {
701
+ const cellText = this.renderInlineTokens(row[i].tokens || [], styleContext)
702
+ naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText))
703
+ minWordWidths[i] = Math.max(
704
+ minWordWidths[i] || 1,
705
+ this.getLongestWordWidth(cellText, maxUnbrokenWordWidth),
706
+ )
707
+ }
708
+ }
709
+
710
+ let minColumnWidths = minWordWidths
711
+ let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0)
712
+
713
+ if (minCellsWidth > availableForCells) {
714
+ minColumnWidths = new Array(numCols).fill(1)
715
+ const remaining = availableForCells - numCols
716
+
717
+ if (remaining > 0) {
718
+ const totalWeight = minWordWidths.reduce(
719
+ (total, width) => total + Math.max(0, width - 1),
720
+ 0,
721
+ )
722
+ const growth = minWordWidths.map(width => {
723
+ const weight = Math.max(0, width - 1)
724
+ return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0
725
+ })
726
+
727
+ for (let i = 0; i < numCols; i++) {
728
+ minColumnWidths[i] += growth[i] ?? 0
729
+ }
730
+
731
+ const allocated = growth.reduce((total, width) => total + width, 0)
732
+ let leftover = remaining - allocated
733
+ for (let i = 0; leftover > 0 && i < numCols; i++) {
734
+ minColumnWidths[i]++
735
+ leftover--
736
+ }
737
+ }
738
+
739
+ minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0)
740
+ }
741
+
742
+ // Calculate column widths that fit within available width
743
+ const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead
744
+ let columnWidths: number[]
745
+
746
+ if (totalNaturalWidth <= availableWidth) {
747
+ // Everything fits naturally
748
+ columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]))
749
+ } else {
750
+ // Need to shrink columns to fit
751
+ const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
752
+ return total + Math.max(0, width - minColumnWidths[index])
753
+ }, 0)
754
+ const extraWidth = Math.max(0, availableForCells - minCellsWidth)
755
+ columnWidths = minColumnWidths.map((minWidth, index) => {
756
+ const naturalWidth = naturalWidths[index]
757
+ const minWidthDelta = Math.max(0, naturalWidth - minWidth)
758
+ let grow = 0
759
+ if (totalGrowPotential > 0) {
760
+ grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth)
761
+ }
762
+ return minWidth + grow
763
+ })
764
+
765
+ // Adjust for rounding errors - distribute remaining space
766
+ const allocated = columnWidths.reduce((a, b) => a + b, 0)
767
+ let remaining = availableForCells - allocated
768
+ while (remaining > 0) {
769
+ let grew = false
770
+ for (let i = 0; i < numCols && remaining > 0; i++) {
771
+ if (columnWidths[i] < naturalWidths[i]) {
772
+ columnWidths[i]++
773
+ remaining--
774
+ grew = true
775
+ }
776
+ }
777
+ if (!grew) {
778
+ break
779
+ }
780
+ }
781
+ }
782
+
783
+ // Render top border
784
+ const topBorderCells = columnWidths.map(w => '─'.repeat(w))
785
+ lines.push(`┌─${topBorderCells.join('─┬─')}─┐`)
786
+
787
+ // Render header with wrapping
788
+ const headerCellLines: string[][] = token.header.map((cell, i) => {
789
+ const text = this.renderInlineTokens(cell.tokens || [], styleContext)
790
+ return this.wrapCellText(text, columnWidths[i])
791
+ })
792
+ const headerLineCount = Math.max(...headerCellLines.map(c => c.length))
793
+
794
+ for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
795
+ const rowParts = headerCellLines.map((cellLines, colIdx) => {
796
+ const text = cellLines[lineIdx] || ''
797
+ const padded = text + ' '.repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)))
798
+ return this.theme.bold(padded)
799
+ })
800
+ lines.push(`│ ${rowParts.join(' │ ')} │`)
801
+ }
802
+
803
+ // Render separator
804
+ const separatorCells = columnWidths.map(w => '─'.repeat(w))
805
+ const separatorLine = `├─${separatorCells.join('─┼─')}─┤`
806
+ lines.push(separatorLine)
807
+
808
+ // Render rows with wrapping
809
+ for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
810
+ const row = token.rows[rowIndex]
811
+ const rowCellLines: string[][] = row.map((cell, i) => {
812
+ const text = this.renderInlineTokens(cell.tokens || [], styleContext)
813
+ return this.wrapCellText(text, columnWidths[i])
814
+ })
815
+ const rowLineCount = Math.max(...rowCellLines.map(c => c.length))
816
+
817
+ for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
818
+ const rowParts = rowCellLines.map((cellLines, colIdx) => {
819
+ const text = cellLines[lineIdx] || ''
820
+ return text + ' '.repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)))
821
+ })
822
+ lines.push(`│ ${rowParts.join(' │ ')} │`)
823
+ }
824
+
825
+ if (rowIndex < token.rows.length - 1) {
826
+ lines.push(separatorLine)
827
+ }
828
+ }
829
+
830
+ // Render bottom border
831
+ const bottomBorderCells = columnWidths.map(w => '─'.repeat(w))
832
+ lines.push(`└─${bottomBorderCells.join('─┴─')}─┘`)
833
+
834
+ if (nextTokenType && nextTokenType !== 'space') {
835
+ lines.push('') // Add spacing after table
836
+ }
837
+ return lines
838
+ }
839
+ }