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,469 @@
1
+ export type ImageProtocol = 'kitty' | 'iterm2' | null
2
+
3
+ export interface TerminalCapabilities {
4
+ images: ImageProtocol
5
+ trueColor: boolean
6
+ hyperlinks: boolean
7
+ }
8
+
9
+ export interface CellDimensions {
10
+ widthPx: number
11
+ heightPx: number
12
+ }
13
+
14
+ export interface ImageDimensions {
15
+ widthPx: number
16
+ heightPx: number
17
+ }
18
+
19
+ export interface ImageRenderOptions {
20
+ maxWidthCells?: number
21
+ maxHeightCells?: number
22
+ preserveAspectRatio?: boolean
23
+ /** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
24
+ imageId?: number
25
+ /** Whether Kitty should apply its default cursor movement after placement. */
26
+ moveCursor?: boolean
27
+ }
28
+
29
+ let cachedCapabilities: TerminalCapabilities | null = null
30
+
31
+ // Default cell dimensions - updated by TUI when terminal responds to query
32
+ let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }
33
+
34
+ export function getCellDimensions(): CellDimensions {
35
+ return cellDimensions
36
+ }
37
+
38
+ export function setCellDimensions(dims: CellDimensions): void {
39
+ cellDimensions = dims
40
+ }
41
+
42
+ export function detectCapabilities(): TerminalCapabilities {
43
+ const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ''
44
+ const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ''
45
+ const term = process.env.TERM?.toLowerCase() || ''
46
+ const colorTerm = process.env.COLORTERM?.toLowerCase() || ''
47
+ const hasTrueColorHint = colorTerm === 'truecolor' || colorTerm === '24bit'
48
+
49
+ // tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps
50
+ // sequences differently). Force hyperlinks off whenever we detect them, even
51
+ // when the outer terminal would otherwise support OSC 8. Image protocols are
52
+ // also unreliable under tmux/screen, so leave `images: null` for safety.
53
+ const inTmuxOrScreen = !!process.env.TMUX || term.startsWith('tmux') || term.startsWith('screen')
54
+ if (inTmuxOrScreen) {
55
+ return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }
56
+ }
57
+
58
+ if (process.env.KITTY_WINDOW_ID || termProgram === 'kitty') {
59
+ return { images: 'kitty', trueColor: true, hyperlinks: true }
60
+ }
61
+
62
+ if (termProgram === 'ghostty' || term.includes('ghostty') || process.env.GHOSTTY_RESOURCES_DIR) {
63
+ return { images: 'kitty', trueColor: true, hyperlinks: true }
64
+ }
65
+
66
+ if (process.env.WEZTERM_PANE || termProgram === 'wezterm') {
67
+ return { images: 'kitty', trueColor: true, hyperlinks: true }
68
+ }
69
+
70
+ if (process.env.ITERM_SESSION_ID || termProgram === 'iterm.app') {
71
+ return { images: 'iterm2', trueColor: true, hyperlinks: true }
72
+ }
73
+
74
+ if (process.env.WT_SESSION) {
75
+ return { images: null, trueColor: true, hyperlinks: true }
76
+ }
77
+
78
+ if (termProgram === 'vscode') {
79
+ return { images: null, trueColor: true, hyperlinks: true }
80
+ }
81
+
82
+ if (termProgram === 'alacritty') {
83
+ return { images: null, trueColor: true, hyperlinks: true }
84
+ }
85
+
86
+ if (terminalEmulator === 'jetbrains-jediterm') {
87
+ return { images: null, trueColor: true, hyperlinks: false }
88
+ }
89
+
90
+ // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just
91
+ // text" on terminals that swallow it, which means the URL disappears from
92
+ // the rendered output. Default to the legacy `text (url)` behavior unless we
93
+ // have positively identified a hyperlink-capable terminal above.
94
+ return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }
95
+ }
96
+
97
+ export function getCapabilities(): TerminalCapabilities {
98
+ if (!cachedCapabilities) {
99
+ cachedCapabilities = detectCapabilities()
100
+ }
101
+ return cachedCapabilities
102
+ }
103
+
104
+ export function resetCapabilitiesCache(): void {
105
+ cachedCapabilities = null
106
+ }
107
+
108
+ /** Override the cached capabilities. Useful in tests to exercise both code paths. */
109
+ export function setCapabilities(caps: TerminalCapabilities): void {
110
+ cachedCapabilities = caps
111
+ }
112
+
113
+ const KITTY_PREFIX = '\x1b_G'
114
+ const ITERM2_PREFIX = '\x1b]1337;File='
115
+
116
+ export function isImageLine(line: string): boolean {
117
+ // Fast path: sequence at line start (single-row images)
118
+ if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) {
119
+ return true
120
+ }
121
+ // Slow path: sequence elsewhere (multi-row images have cursor-up prefix)
122
+ return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX)
123
+ }
124
+
125
+ /**
126
+ * Generate a random image ID for Kitty graphics protocol.
127
+ * Uses random IDs to avoid collisions between different module instances
128
+ * (e.g., main app vs extensions).
129
+ */
130
+ export function allocateImageId(): number {
131
+ // Use random ID in range [1, 0xffffffff] to avoid collisions
132
+ return Math.floor(Math.random() * 0xfffffffe) + 1
133
+ }
134
+
135
+ export function encodeKitty(
136
+ base64Data: string,
137
+ options: {
138
+ columns?: number
139
+ rows?: number
140
+ imageId?: number
141
+ /** Whether Kitty should apply its default cursor movement after placement. Default: true. */
142
+ moveCursor?: boolean
143
+ } = {},
144
+ ): string {
145
+ const CHUNK_SIZE = 4096
146
+
147
+ const params: string[] = ['a=T', 'f=100', 'q=2']
148
+
149
+ if (options.moveCursor === false) params.push('C=1')
150
+ if (options.columns) params.push(`c=${options.columns}`)
151
+ if (options.rows) params.push(`r=${options.rows}`)
152
+ if (options.imageId) params.push(`i=${options.imageId}`)
153
+
154
+ if (base64Data.length <= CHUNK_SIZE) {
155
+ return `\x1b_G${params.join(',')};${base64Data}\x1b\\`
156
+ }
157
+
158
+ const chunks: string[] = []
159
+ let offset = 0
160
+ let isFirst = true
161
+
162
+ while (offset < base64Data.length) {
163
+ const chunk = base64Data.slice(offset, offset + CHUNK_SIZE)
164
+ const isLast = offset + CHUNK_SIZE >= base64Data.length
165
+
166
+ if (isFirst) {
167
+ chunks.push(`\x1b_G${params.join(',')},m=1;${chunk}\x1b\\`)
168
+ isFirst = false
169
+ } else if (isLast) {
170
+ chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`)
171
+ } else {
172
+ chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`)
173
+ }
174
+
175
+ offset += CHUNK_SIZE
176
+ }
177
+
178
+ return chunks.join('')
179
+ }
180
+
181
+ /**
182
+ * Delete a Kitty graphics image by ID.
183
+ * Uses uppercase 'I' to also free the image data.
184
+ */
185
+ export function deleteKittyImage(imageId: number): string {
186
+ return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`
187
+ }
188
+
189
+ /**
190
+ * Delete all visible Kitty graphics images.
191
+ * Uses uppercase 'A' to also free the image data.
192
+ */
193
+ export function deleteAllKittyImages(): string {
194
+ return '\x1b_Ga=d,d=A,q=2\x1b\\'
195
+ }
196
+
197
+ export function encodeITerm2(
198
+ base64Data: string,
199
+ options: {
200
+ width?: number | string
201
+ height?: number | string
202
+ name?: string
203
+ preserveAspectRatio?: boolean
204
+ inline?: boolean
205
+ } = {},
206
+ ): string {
207
+ const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`]
208
+
209
+ if (options.width !== undefined) params.push(`width=${options.width}`)
210
+ if (options.height !== undefined) params.push(`height=${options.height}`)
211
+ if (options.name) {
212
+ const nameBase64 = Buffer.from(options.name).toString('base64')
213
+ params.push(`name=${nameBase64}`)
214
+ }
215
+ if (options.preserveAspectRatio === false) {
216
+ params.push('preserveAspectRatio=0')
217
+ }
218
+
219
+ return `\x1b]1337;File=${params.join(';')}:${base64Data}\x07`
220
+ }
221
+
222
+ export interface ImageCellSize {
223
+ columns: number
224
+ rows: number
225
+ }
226
+
227
+ export function calculateImageCellSize(
228
+ imageDimensions: ImageDimensions,
229
+ maxWidthCells: number,
230
+ maxHeightCells?: number,
231
+ cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
232
+ ): ImageCellSize {
233
+ const maxWidth = Math.max(1, Math.floor(maxWidthCells))
234
+ const maxHeight =
235
+ maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells))
236
+ const imageWidth = Math.max(1, imageDimensions.widthPx)
237
+ const imageHeight = Math.max(1, imageDimensions.heightPx)
238
+
239
+ const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth
240
+ const heightScale =
241
+ maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight
242
+ const scale = Math.min(widthScale, heightScale)
243
+
244
+ const scaledWidthPx = imageWidth * scale
245
+ const scaledHeightPx = imageHeight * scale
246
+ const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx)
247
+ const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx)
248
+
249
+ return {
250
+ columns: Math.max(1, Math.min(maxWidth, columns)),
251
+ rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)),
252
+ }
253
+ }
254
+
255
+ export function calculateImageRows(
256
+ imageDimensions: ImageDimensions,
257
+ targetWidthCells: number,
258
+ cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
259
+ ): number {
260
+ return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows
261
+ }
262
+
263
+ export function getPngDimensions(base64Data: string): ImageDimensions | null {
264
+ try {
265
+ const buffer = Buffer.from(base64Data, 'base64')
266
+
267
+ if (buffer.length < 24) {
268
+ return null
269
+ }
270
+
271
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
272
+ return null
273
+ }
274
+
275
+ const width = buffer.readUInt32BE(16)
276
+ const height = buffer.readUInt32BE(20)
277
+
278
+ return { widthPx: width, heightPx: height }
279
+ } catch {
280
+ return null
281
+ }
282
+ }
283
+
284
+ export function getJpegDimensions(base64Data: string): ImageDimensions | null {
285
+ try {
286
+ const buffer = Buffer.from(base64Data, 'base64')
287
+
288
+ if (buffer.length < 2) {
289
+ return null
290
+ }
291
+
292
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
293
+ return null
294
+ }
295
+
296
+ let offset = 2
297
+ while (offset < buffer.length - 9) {
298
+ if (buffer[offset] !== 0xff) {
299
+ offset++
300
+ continue
301
+ }
302
+
303
+ const marker = buffer[offset + 1]
304
+
305
+ if (marker >= 0xc0 && marker <= 0xc2) {
306
+ const height = buffer.readUInt16BE(offset + 5)
307
+ const width = buffer.readUInt16BE(offset + 7)
308
+ return { widthPx: width, heightPx: height }
309
+ }
310
+
311
+ if (offset + 3 >= buffer.length) {
312
+ return null
313
+ }
314
+ const length = buffer.readUInt16BE(offset + 2)
315
+ if (length < 2) {
316
+ return null
317
+ }
318
+ offset += 2 + length
319
+ }
320
+
321
+ return null
322
+ } catch {
323
+ return null
324
+ }
325
+ }
326
+
327
+ export function getGifDimensions(base64Data: string): ImageDimensions | null {
328
+ try {
329
+ const buffer = Buffer.from(base64Data, 'base64')
330
+
331
+ if (buffer.length < 10) {
332
+ return null
333
+ }
334
+
335
+ const sig = buffer.slice(0, 6).toString('ascii')
336
+ if (sig !== 'GIF87a' && sig !== 'GIF89a') {
337
+ return null
338
+ }
339
+
340
+ const width = buffer.readUInt16LE(6)
341
+ const height = buffer.readUInt16LE(8)
342
+
343
+ return { widthPx: width, heightPx: height }
344
+ } catch {
345
+ return null
346
+ }
347
+ }
348
+
349
+ export function getWebpDimensions(base64Data: string): ImageDimensions | null {
350
+ try {
351
+ const buffer = Buffer.from(base64Data, 'base64')
352
+
353
+ if (buffer.length < 30) {
354
+ return null
355
+ }
356
+
357
+ const riff = buffer.slice(0, 4).toString('ascii')
358
+ const webp = buffer.slice(8, 12).toString('ascii')
359
+ if (riff !== 'RIFF' || webp !== 'WEBP') {
360
+ return null
361
+ }
362
+
363
+ const chunk = buffer.slice(12, 16).toString('ascii')
364
+ if (chunk === 'VP8 ') {
365
+ if (buffer.length < 30) return null
366
+ const width = buffer.readUInt16LE(26) & 0x3fff
367
+ const height = buffer.readUInt16LE(28) & 0x3fff
368
+ return { widthPx: width, heightPx: height }
369
+ } else if (chunk === 'VP8L') {
370
+ if (buffer.length < 25) return null
371
+ const bits = buffer.readUInt32LE(21)
372
+ const width = (bits & 0x3fff) + 1
373
+ const height = ((bits >> 14) & 0x3fff) + 1
374
+ return { widthPx: width, heightPx: height }
375
+ } else if (chunk === 'VP8X') {
376
+ if (buffer.length < 30) return null
377
+ const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1
378
+ const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1
379
+ return { widthPx: width, heightPx: height }
380
+ }
381
+
382
+ return null
383
+ } catch {
384
+ return null
385
+ }
386
+ }
387
+
388
+ export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
389
+ if (mimeType === 'image/png') {
390
+ return getPngDimensions(base64Data)
391
+ }
392
+ if (mimeType === 'image/jpeg') {
393
+ return getJpegDimensions(base64Data)
394
+ }
395
+ if (mimeType === 'image/gif') {
396
+ return getGifDimensions(base64Data)
397
+ }
398
+ if (mimeType === 'image/webp') {
399
+ return getWebpDimensions(base64Data)
400
+ }
401
+ return null
402
+ }
403
+
404
+ export function renderImage(
405
+ base64Data: string,
406
+ imageDimensions: ImageDimensions,
407
+ options: ImageRenderOptions = {},
408
+ ): { sequence: string; rows: number; imageId?: number } | null {
409
+ const caps = getCapabilities()
410
+
411
+ if (!caps.images) {
412
+ return null
413
+ }
414
+
415
+ const maxWidth = options.maxWidthCells ?? 80
416
+ const size = calculateImageCellSize(
417
+ imageDimensions,
418
+ maxWidth,
419
+ options.maxHeightCells,
420
+ getCellDimensions(),
421
+ )
422
+
423
+ if (caps.images === 'kitty') {
424
+ const sequence = encodeKitty(base64Data, {
425
+ columns: size.columns,
426
+ rows: size.rows,
427
+ imageId: options.imageId,
428
+ moveCursor: options.moveCursor,
429
+ })
430
+ return { sequence, rows: size.rows, imageId: options.imageId }
431
+ }
432
+
433
+ if (caps.images === 'iterm2') {
434
+ const sequence = encodeITerm2(base64Data, {
435
+ width: size.columns,
436
+ height: 'auto',
437
+ preserveAspectRatio: options.preserveAspectRatio ?? true,
438
+ })
439
+ return { sequence, rows: size.rows }
440
+ }
441
+
442
+ return null
443
+ }
444
+
445
+ /**
446
+ * Wrap text in an OSC 8 hyperlink sequence.
447
+ * The text is rendered as a clickable hyperlink in terminals that support OSC 8
448
+ * (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others).
449
+ * In terminals that do not support OSC 8, the escape sequences are ignored
450
+ * and only the plain text is displayed.
451
+ *
452
+ * @param text - The visible text to display
453
+ * @param url - The URL to link to
454
+ */
455
+ export function hyperlink(text: string, url: string): string {
456
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`
457
+ }
458
+
459
+ export function imageFallback(
460
+ mimeType: string,
461
+ dimensions?: ImageDimensions,
462
+ filename?: string,
463
+ ): string {
464
+ const parts: string[] = []
465
+ if (filename) parts.push(filename)
466
+ parts.push(`[${mimeType}]`)
467
+ if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`)
468
+ return `[Image: ${parts.join(' ')}]`
469
+ }