chenbhao Claude Big Pickle commited on
Commit
ac3f753
·
1 Parent(s): 6fc63f8

fix: simplify ImageShowTool to always render timg blocks within Ink's virtual DOM

Browse files

Remove Kitty protocol/writeRaw bypass that caused TUI cursor corruption and
image display failures. Always render via timg Unicode blocks through RawAnsi
so Ink knows the image dimensions and the cursor stays in sync.

Clean up terminalImage.ts by removing TimgMode, mode parameter, getImageRowsCount,
and tmux TERM_PROGRAM detection that were added in subsequent broken commits.

Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>

src/tools/ImageShowTool/ImageShowTool.tsx CHANGED
@@ -1,14 +1,11 @@
1
  import { readFile } from 'fs/promises'
2
  import { homedir } from 'os'
3
- import React, { useContext, useEffect } from 'react'
4
  import { z } from 'zod/v4'
5
  import { RawAnsi, Text } from '../../ink.js'
6
- import { wrapForMultiplexer } from '../../ink/termio/osc.js'
7
- import { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
8
  import { buildTool, type ToolDef } from '../../Tool.js'
9
  import { logForDebugging } from '../../utils/debug.js'
10
  import {
11
- detectImageProtocol,
12
  renderImageWithTimgSync,
13
  } from '../../utils/terminalImage.js'
14
 
@@ -40,46 +37,111 @@ function detectFormat(url: string): string {
40
  return 'png'
41
  }
42
 
43
- function getToolUseSummary(input: Partial<Input>): string | null {
44
- return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
 
 
 
 
 
 
 
45
  }
46
 
47
- /**
48
- * Renders a Kitty-protocol image by writing the escape sequence directly
49
- * to the terminal via writeRaw, bypassing Ink's virtual DOM. Reserves
50
- * vertical space with empty RawAnsi lines so Ink doesn't overwrite the
51
- * image area.
52
- */
53
- function KittyImage({
54
- kittyOutput,
55
- imageRows,
56
- offset = 4,
57
- }: {
58
- kittyOutput: string
59
- imageRows: number
60
- offset?: number
61
- }) {
62
- const writeRaw = useContext(TerminalWriteContext)
63
-
64
- useEffect(() => {
65
- if (writeRaw && kittyOutput) {
66
- writeRaw(`\x1b[${imageRows + offset}A${kittyOutput}\x1b[${imageRows + offset}B`)
67
  }
68
- }, [kittyOutput, imageRows, offset, writeRaw])
69
 
70
- const width = process.stdout.columns ?? 80
71
- const lines = new Array<string>(imageRows).fill('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  return <RawAnsi lines={lines} width={width} />
73
  }
74
 
 
 
 
 
75
  export const ImageShowTool = buildTool({
76
  name: IMAGE_TOOL_NAME,
77
  description:
78
  'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
79
- 'Uses the Kitty graphics protocol via timg for native quality when the ' +
80
- 'terminal supports it, with a Unicode-block fallback for universal compatibility. ' +
81
- 'The block-mode output is rendered within Ink\'s virtual DOM so the cursor ' +
82
- 'stays in sync and the image scrolls with conversation content.',
83
 
84
  getToolUseSummary,
85
  getActivityDescription(input) {
@@ -102,7 +164,7 @@ export const ImageShowTool = buildTool({
102
  },
103
 
104
  async prompt(_options): Promise<string> {
105
- return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal. Uses the Kitty graphics protocol via timg when the terminal supports it (Kitty, Ghostty, WezTerm, foot), or falls back to timg Unicode-block rendering for universal terminal compatibility. Supports local file paths (e.g. /tmp/image.png) and HTTPS URLs (e.g. https://example.com/image.png). The image is shown inline above the tool result.`
106
  },
107
 
108
  async checkPermissions(): Promise<{ behavior: 'allow' }> {
@@ -157,9 +219,7 @@ export const ImageShowTool = buildTool({
157
  content: {
158
  success: boolean
159
  message: string
160
- kittyOutput?: string
161
  timgOutput?: string
162
- imageRows?: number
163
  },
164
  _progressMessages,
165
  _options,
@@ -168,26 +228,8 @@ export const ImageShowTool = buildTool({
168
  return null
169
  }
170
 
171
- // Kitty protocol: bypass Ink's virtual DOM via writeRaw and reserve
172
- // space with empty RawAnsi lines.
173
- if (content.kittyOutput && content.imageRows && content.imageRows > 0) {
174
- return (
175
- <KittyImage
176
- kittyOutput={content.kittyOutput}
177
- imageRows={content.imageRows}
178
- />
179
- )
180
- }
181
-
182
- // Block-mode fallback when Kitty protocol is not supported.
183
  if (content.timgOutput) {
184
- const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
185
- const lines = cleaned.split('\n').filter(l => l.length > 0)
186
- if (lines.length > 0) {
187
- const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
188
- const width = Math.max(...lines.map(l => ansiStrip(l).length))
189
- return <RawAnsi lines={lines} width={width} />
190
- }
191
  }
192
 
193
  return <Text dimColor>{content.message}</Text>
@@ -199,8 +241,6 @@ export const ImageShowTool = buildTool({
199
  message: string
200
  imageData?: { base64: string; mediaType: string }
201
  base64?: string
202
- format?: string
203
- kittyOutput?: string
204
  timgOutput?: string
205
  }
206
  }> {
@@ -209,60 +249,9 @@ export const ImageShowTool = buildTool({
209
 
210
  logForDebugging(`ImageShow: loading ${url}`)
211
 
212
- // Expand ~ to home directory for local files
213
- const normalizedUrl = url.startsWith('~')
214
- ? url.replace(/^~(?=$|\/)/, homedir())
215
- : url
216
-
217
- let imgResult: { buffer: Buffer; format: string } | null = null
218
-
219
- if (normalizedUrl.startsWith('file://') || normalizedUrl.startsWith('/') || normalizedUrl.startsWith('.')) {
220
- const path = normalizedUrl.startsWith('file://') ? normalizedUrl.slice(7) : normalizedUrl
221
- try {
222
- const format = detectFormat(path)
223
- const buffer = await readFile(path)
224
- imgResult = { buffer, format }
225
- } catch {
226
- // fall through
227
- }
228
- } else {
229
- try {
230
- const response = await fetch(normalizedUrl, {
231
- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Codev/1.0)' },
232
- redirect: 'follow',
233
- })
234
- if (response.ok) {
235
- const contentType = response.headers.get('content-type') ?? ''
236
- const format = detectFormat(normalizedUrl !== contentType ? normalizedUrl : contentType)
237
- const reader = response.body?.getReader()
238
- if (reader) {
239
- const chunks: Uint8Array[] = []
240
- let totalSize = 0
241
- while (true) {
242
- const { done, value } = await reader.read()
243
- if (done) break
244
- totalSize += value.byteLength
245
- if (totalSize > 10_000_000) {
246
- reader.cancel()
247
- break
248
- }
249
- chunks.push(value)
250
- }
251
- const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.byteLength, 0))
252
- let offset = 0
253
- for (const chunk of chunks) {
254
- combined.set(chunk, offset)
255
- offset += chunk.byteLength
256
- }
257
- imgResult = { buffer: Buffer.from(combined.buffer), format }
258
- }
259
- }
260
- } catch (err) {
261
- logForDebugging(`ImageShow: fetch error ${err} for ${normalizedUrl}`)
262
- }
263
- }
264
 
265
- if (!imgResult) {
266
  return {
267
  data: {
268
  success: false,
@@ -271,45 +260,25 @@ export const ImageShowTool = buildTool({
271
  }
272
  }
273
 
274
- const { buffer, format } = imgResult
 
275
  const mediaType =
276
- format === 'jpeg' ? 'image/jpeg'
277
- : format === 'gif' ? 'image/gif'
278
- : format === 'webp' ? 'image/webp'
 
 
 
279
  : 'image/png'
280
 
281
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
282
 
283
- // Rendering approach:
284
- // 1. Kitty protocol via timg for native-quality display (when supported)
285
- // 2. Block-mode via timg as fallback for terminals without Kitty support
286
- const protocol = detectImageProtocol()
287
- let kittyOutput: string | undefined
288
- let timgOutput: string | undefined
289
- let imageRows = 0
290
-
291
- if (protocol === 'kitty') {
292
- const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
293
- if (rawKitty) {
294
- // Pass the raw timg output directly through writeRaw — no need to
295
- // strip cursor sequences since writeRaw bypasses Ink's screen buffer.
296
- kittyOutput = wrapForMultiplexer(rawKitty.trimEnd())
297
- const termRows = process.stdout.rows ?? 40
298
- imageRows = Math.max(10, Math.floor(termRows * 0.5))
299
- logForDebugging(
300
- `ImageShow: generated Kitty protocol output, image rows = ${imageRows}`,
301
- )
302
- }
303
- if (imageRows === 0) {
304
- kittyOutput = undefined
305
- }
306
- }
307
-
308
- if (!kittyOutput) {
309
- timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
310
- if (timgOutput) {
311
- logForDebugging('ImageShow: generated block-mode output via timg')
312
- }
313
  }
314
 
315
  return {
@@ -321,10 +290,7 @@ export const ImageShowTool = buildTool({
321
  mediaType,
322
  },
323
  base64: buffer.toString('base64'),
324
- format,
325
- kittyOutput,
326
  timgOutput,
327
- imageRows,
328
  },
329
  }
330
  },
 
1
  import { readFile } from 'fs/promises'
2
  import { homedir } from 'os'
3
+ import React from 'react'
4
  import { z } from 'zod/v4'
5
  import { RawAnsi, Text } from '../../ink.js'
 
 
6
  import { buildTool, type ToolDef } from '../../Tool.js'
7
  import { logForDebugging } from '../../utils/debug.js'
8
  import {
 
9
  renderImageWithTimgSync,
10
  } from '../../utils/terminalImage.js'
11
 
 
37
  return 'png'
38
  }
39
 
40
+ /** Read a local file and return its buffer + detected format */
41
+ async function readLocalImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
42
+ try {
43
+ const format = detectFormat(url)
44
+ const buffer = await readFile(url)
45
+ return { buffer, format }
46
+ } catch {
47
+ return null
48
+ }
49
  }
50
 
51
+ /** Download an image from URL and return its buffer + detected format */
52
+ async function fetchImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
53
+ try {
54
+ const response = await fetch(url, {
55
+ headers: {
56
+ 'User-Agent': 'Mozilla/5.0 (compatible; Codev/1.0)',
57
+ },
58
+ redirect: 'follow',
59
+ })
60
+
61
+ if (!response.ok) {
62
+ logForDebugging(`ImageShow: HTTP ${response.status} for ${url}`)
63
+ return null
 
 
 
 
 
 
 
64
  }
 
65
 
66
+ const contentType = response.headers.get('content-type') ?? ''
67
+ const format = detectFormat(url !== contentType ? url : contentType)
68
+
69
+ const reader = response.body?.getReader()
70
+ if (!reader) return null
71
+
72
+ const chunks: Uint8Array[] = []
73
+ let totalSize = 0
74
+ const MAX_SIZE = 10_000_000
75
+
76
+ while (true) {
77
+ const { done, value } = await reader.read()
78
+ if (done) break
79
+ totalSize += value.byteLength
80
+ if (totalSize > MAX_SIZE) {
81
+ logForDebugging(`ImageShow: image too large (${totalSize} bytes) for ${url}`)
82
+ reader.cancel()
83
+ return null
84
+ }
85
+ chunks.push(value)
86
+ }
87
+
88
+ const combinedLength = chunks.reduce((acc, c) => acc + c.byteLength, 0)
89
+ const combined = new Uint8Array(combinedLength)
90
+ let offset = 0
91
+ for (const chunk of chunks) {
92
+ combined.set(chunk, offset)
93
+ offset += chunk.byteLength
94
+ }
95
+
96
+ return { buffer: Buffer.from(combined.buffer), format }
97
+ } catch (err) {
98
+ logForDebugging(`ImageShow: fetch error ${err} for ${url}`)
99
+ return null
100
+ }
101
+ }
102
+
103
+ /** Load image: local file or remote URL */
104
+ async function loadImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
105
+ // Expand ~ to home directory
106
+ const normalizedUrl = url.startsWith('~')
107
+ ? url.replace(/^~(?=$|\/)/, homedir())
108
+ : url
109
+ if (normalizedUrl.startsWith('file://') || normalizedUrl.startsWith('/') || normalizedUrl.startsWith('.')) {
110
+ const path = normalizedUrl.startsWith('file://') ? normalizedUrl.slice(7) : normalizedUrl
111
+ return readLocalImage(path)
112
+ }
113
+ return fetchImage(normalizedUrl)
114
+ }
115
+
116
+ /**
117
+ * React component that renders timg Unicode-block output within Ink's
118
+ * virtual DOM so Ink knows the image dimensions and the cursor stays
119
+ * in sync with the terminal.
120
+ */
121
+ function TimgDisplay({ output }: { output: string }): React.ReactNode {
122
+ // Strip cursor hide/show sequences that timg adds
123
+ const cleaned = output.replace(/\x1b\[\?25[hl]/g, '')
124
+ const lines = cleaned.split('\n').filter(l => l.length > 0)
125
+ if (lines.length === 0) {
126
+ return null
127
+ }
128
+ // Measure visible width (strip ANSI escape codes)
129
+ const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
130
+ const width = Math.max(...lines.map(l => ansiStrip(l).length))
131
  return <RawAnsi lines={lines} width={width} />
132
  }
133
 
134
+ function getToolUseSummary(input: Partial<Input>): string | null {
135
+ return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
136
+ }
137
+
138
  export const ImageShowTool = buildTool({
139
  name: IMAGE_TOOL_NAME,
140
  description:
141
  'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
142
+ 'Renders with timg Unicode-block characters within Ink\'s virtual DOM ' +
143
+ 'so the cursor stays in sync and the image scrolls with conversation content. ' +
144
+ 'Supports both URLs (https://) and local file paths.',
 
145
 
146
  getToolUseSummary,
147
  getActivityDescription(input) {
 
164
  },
165
 
166
  async prompt(_options): Promise<string> {
167
+ return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal using timg Unicode-block rendering. Supports local file paths (e.g. /tmp/image.png) and HTTPS URLs (e.g. https://example.com/image.png). The image is rendered within Ink's virtual DOM so the cursor stays in sync.`
168
  },
169
 
170
  async checkPermissions(): Promise<{ behavior: 'allow' }> {
 
219
  content: {
220
  success: boolean
221
  message: string
 
222
  timgOutput?: string
 
223
  },
224
  _progressMessages,
225
  _options,
 
228
  return null
229
  }
230
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  if (content.timgOutput) {
232
+ return <TimgDisplay output={content.timgOutput} />
 
 
 
 
 
 
233
  }
234
 
235
  return <Text dimColor>{content.message}</Text>
 
241
  message: string
242
  imageData?: { base64: string; mediaType: string }
243
  base64?: string
 
 
244
  timgOutput?: string
245
  }
246
  }> {
 
249
 
250
  logForDebugging(`ImageShow: loading ${url}`)
251
 
252
+ const result = await loadImage(url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
+ if (!result) {
255
  return {
256
  data: {
257
  success: false,
 
260
  }
261
  }
262
 
263
+ const { buffer, format } = result
264
+
265
  const mediaType =
266
+ format === 'jpeg'
267
+ ? 'image/jpeg'
268
+ : format === 'gif'
269
+ ? 'image/gif'
270
+ : format === 'webp'
271
+ ? 'image/webp'
272
  : 'image/png'
273
 
274
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
275
 
276
+ // Always render via timg Unicode blocks within Ink's virtual DOM.
277
+ // RawAnsi in renderToolResultMessage renders the output so Ink knows
278
+ // the image dimensions and the cursor stays in sync.
279
+ const timgOutput = renderImageWithTimgSync(buffer, format) ?? undefined
280
+ if (timgOutput) {
281
+ logForDebugging('ImageShow: generated timg Unicode-block output')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
283
 
284
  return {
 
290
  mediaType,
291
  },
292
  base64: buffer.toString('base64'),
 
 
293
  timgOutput,
 
294
  },
295
  }
296
  },
src/utils/terminalImage.ts CHANGED
@@ -58,14 +58,6 @@ export function detectImageProtocol(): ImageProtocol {
58
  return 'kitty'
59
  }
60
 
61
- // Inside tmux, TERM_PROGRAM may reveal the host terminal if forwarded
62
- if (process.env.TMUX) {
63
- const hostTerm = process.env.TERM_PROGRAM ?? ''
64
- if (['kitty', 'WezTerm', 'ghostty', 'foot'].includes(hostTerm)) {
65
- return 'kitty'
66
- }
67
- }
68
-
69
  return null
70
  }
71
 
@@ -141,32 +133,25 @@ export function getImageProtocolSummary(): string {
141
  }
142
 
143
  /**
144
- * Supported rendering modes for timg-based image display.
145
- */
146
- export type TimgMode = 'kitty' | 'blocks'
147
-
148
- /**
149
- * Render an image using the `timg` utility.
150
- *
151
- * In `kitty` mode, uses the Kitty graphics protocol for native-quality
152
- * rendering in terminals that support it.
153
  *
154
- * In `blocks` mode, renders with Unicode quarter-block characters for
155
- * universal compatibility, falling back to half-blocks if needed.
 
156
  *
157
  * @param buffer - The raw image buffer (decoded)
158
  * @param format - Image format (png, jpeg, gif, webp)
159
- * @param columns - Optional terminal width in character columns
160
- * @param rows - Optional terminal height in character rows
161
- * @param mode - Rendering mode: `'kitty'` or `'blocks'` (default: `'blocks'`)
162
- * @returns Escape sequence string for rendering, or null on failure
163
  */
164
  export async function renderImageWithTimg(
165
  buffer: Buffer,
166
  format: string,
167
  columns?: number,
168
  rows?: number,
169
- mode: TimgMode = 'blocks',
170
  ): Promise<string | null> {
171
  try {
172
  const timgPath = await which('timg')
@@ -185,37 +170,25 @@ export async function renderImageWithTimg(
185
  try {
186
  writeFileSync(tmpFile, buffer)
187
 
188
- if (mode === 'kitty') {
189
- // Native Kitty protocol rendering
190
- const kitty = await execFileNoThrow(
191
- timgPath,
192
- ['-p', 'kitty', '-g', `${cols}x${maxRows}`, tmpFile],
193
- { timeout: 30000, preserveOutputOnError: true },
194
- )
195
- if (kitty.code === 0 && kitty.stdout) {
196
- result = kitty.stdout
197
- }
198
- } else {
199
- // Try quarter blocks first (4 pixels per cell, better quality)
200
- const quarter = await execFileNoThrow(
201
  timgPath,
202
- ['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
203
  { timeout: 15000, preserveOutputOnError: true },
204
  )
205
- if (quarter.code === 0 && quarter.stdout) {
206
- result = quarter.stdout
207
- }
208
-
209
- // Fallback to half blocks (2 pixels per cell, max compatibility)
210
- if (!result) {
211
- const half = await execFileNoThrow(
212
- timgPath,
213
- ['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
214
- { timeout: 15000, preserveOutputOnError: true },
215
- )
216
- if (half.code === 0 && half.stdout) {
217
- result = half.stdout
218
- }
219
  }
220
  }
221
  } finally {
@@ -232,63 +205,6 @@ export async function renderImageWithTimg(
232
  }
233
  }
234
 
235
- /**
236
- * Determine the number of terminal rows an image occupies when rendered.
237
- * Runs `timg -p q` (block-mode) and counts the output lines.
238
- * Used to reserve layout space in Ink's virtual DOM when the Kitty
239
- * protocol is used for native-quality rendering.
240
- *
241
- * @param buffer - The raw image buffer
242
- * @param format - Image format (png, jpeg, gif, webp)
243
- * @param columns - Optional terminal width in character columns (default: stdout.columns)
244
- * @returns Number of terminal rows, or 0 on failure
245
- */
246
- export function getImageRowsCount(
247
- buffer: Buffer,
248
- format: string,
249
- columns?: number,
250
- ): number {
251
- try {
252
- const timgPath = whichSync('timg')
253
- if (!timgPath) return 0
254
-
255
- const cols = columns ?? process.stdout.columns ?? 80
256
- const termRows = process.stdout.rows ?? 40
257
- const maxRows = Math.max(10, Math.floor(termRows * 0.5))
258
- const tmpDir = mkdtempSync(join(tmpdir(), 'codev-timg-rows-'))
259
- const ext = format === 'jpeg' ? 'jpg' : format
260
- const tmpFile = join(tmpDir, `image.${ext}`)
261
-
262
- try {
263
- writeFileSync(tmpFile, buffer)
264
-
265
- const stdout = execFileSync(
266
- timgPath,
267
- ['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
268
- { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
269
- )
270
-
271
- // Count non-empty lines; strip ANSI escape sequences first so we
272
- // only count lines that actually contain block-mode content.
273
- const lines = stdout
274
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
275
- .replace(/\x1b\[?25[hl]/g, '')
276
- .split('\n')
277
- .filter(l => l.trim().length > 0)
278
-
279
- return lines.length
280
- } finally {
281
- try {
282
- rmSync(tmpDir, { recursive: true, force: true })
283
- } catch {
284
- // ignore cleanup errors
285
- }
286
- }
287
- } catch {
288
- return 0
289
- }
290
- }
291
-
292
  /**
293
  * Synchronous version of renderImageWithTimg. Blocks the event loop while
294
  * spawning timg, which prevents Ink from rendering between the tool call
@@ -301,7 +217,6 @@ export function renderImageWithTimgSync(
301
  format: string,
302
  columns?: number,
303
  rows?: number,
304
- mode: TimgMode = 'blocks',
305
  ): string | null {
306
  try {
307
  const timgPath = whichSync('timg')
@@ -318,43 +233,29 @@ export function renderImageWithTimgSync(
318
  try {
319
  writeFileSync(tmpFile, buffer)
320
 
321
- if (mode === 'kitty') {
322
- // Native Kitty protocol rendering
323
- try {
324
- const stdout = execFileSync(
325
- timgPath,
326
- ['-p', 'kitty', '-g', `${cols}x${maxRows}`, tmpFile],
327
- { encoding: 'utf8', timeout: 30000, maxBuffer: 50 * 1024 * 1024 },
328
- )
329
- if (stdout) result = stdout
330
- } catch {
331
- // failed
332
- }
333
- } else {
334
- // Try quarter blocks first (4 pixels per cell, better quality)
335
  try {
336
  const stdout = execFileSync(
337
  timgPath,
338
- ['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
339
  { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
340
  )
341
  if (stdout) result = stdout
342
  } catch {
343
- // fall through to half blocks
344
- }
345
-
346
- // Fallback to half blocks (2 pixels per cell, max compatibility)
347
- if (!result) {
348
- try {
349
- const stdout = execFileSync(
350
- timgPath,
351
- ['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
352
- { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
353
- )
354
- if (stdout) result = stdout
355
- } catch {
356
- // ignored
357
- }
358
  }
359
  }
360
  } finally {
 
58
  return 'kitty'
59
  }
60
 
 
 
 
 
 
 
 
 
61
  return null
62
  }
63
 
 
133
  }
134
 
135
  /**
136
+ * Render an image using the `timg` utility with Unicode block characters.
137
+ * Works in any terminal that supports Unicode and 24-bit color (virtually all
138
+ * modern terminals). Falls back gracefully if timg is not installed.
 
 
 
 
 
 
139
  *
140
+ * The image is rendered using quarter-block characters ("pixelation q") for
141
+ * the best quality-to-compatibility ratio. If timg fails, half-blocks are
142
+ * tried as a fallback.
143
  *
144
  * @param buffer - The raw image buffer (decoded)
145
  * @param format - Image format (png, jpeg, gif, webp)
146
+ * @param columns - Optional terminal width in character columns (auto-detected)
147
+ * @param rows - Optional terminal height in character rows (auto-detected)
148
+ * @returns ANSI escape sequence string for rendering, or null on failure
 
149
  */
150
  export async function renderImageWithTimg(
151
  buffer: Buffer,
152
  format: string,
153
  columns?: number,
154
  rows?: number,
 
155
  ): Promise<string | null> {
156
  try {
157
  const timgPath = await which('timg')
 
170
  try {
171
  writeFileSync(tmpFile, buffer)
172
 
173
+ // Try quarter blocks first (4 pixels per cell, better quality)
174
+ const quarter = await execFileNoThrow(
175
+ timgPath,
176
+ ['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
177
+ { timeout: 15000, preserveOutputOnError: true },
178
+ )
179
+ if (quarter.code === 0 && quarter.stdout) {
180
+ result = quarter.stdout
181
+ }
182
+
183
+ // Fallback to half blocks (2 pixels per cell, max compatibility)
184
+ if (!result) {
185
+ const half = await execFileNoThrow(
186
  timgPath,
187
+ ['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
188
  { timeout: 15000, preserveOutputOnError: true },
189
  )
190
+ if (half.code === 0 && half.stdout) {
191
+ result = half.stdout
 
 
 
 
 
 
 
 
 
 
 
 
192
  }
193
  }
194
  } finally {
 
205
  }
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  /**
209
  * Synchronous version of renderImageWithTimg. Blocks the event loop while
210
  * spawning timg, which prevents Ink from rendering between the tool call
 
217
  format: string,
218
  columns?: number,
219
  rows?: number,
 
220
  ): string | null {
221
  try {
222
  const timgPath = whichSync('timg')
 
233
  try {
234
  writeFileSync(tmpFile, buffer)
235
 
236
+ // Try quarter blocks first (4 pixels per cell, better quality)
237
+ try {
238
+ const stdout = execFileSync(
239
+ timgPath,
240
+ ['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
241
+ { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
242
+ )
243
+ if (stdout) result = stdout
244
+ } catch {
245
+ // fall through to half blocks
246
+ }
247
+
248
+ // Fallback to half blocks (2 pixels per cell, max compatibility)
249
+ if (!result) {
250
  try {
251
  const stdout = execFileSync(
252
  timgPath,
253
+ ['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
254
  { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
255
  )
256
  if (stdout) result = stdout
257
  } catch {
258
+ // ignored
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  }
260
  }
261
  } finally {