chenbhao commited on
Commit
af27eec
·
1 Parent(s): 80a03ed

feat: timg -p kitty

Browse files
src/tools/ImageShowTool/ImageShowTool.tsx CHANGED
@@ -4,12 +4,11 @@ import React, { useContext, useEffect, useRef } from 'react'
4
  import { z } from 'zod/v4'
5
  import { RawAnsi, Text } from '../../ink.js'
6
  import { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
 
7
  import { buildTool, type ToolDef } from '../../Tool.js'
8
  import { logForDebugging } from '../../utils/debug.js'
9
  import {
10
  detectImageProtocol,
11
- encodeKittyImage,
12
- isInsideTmux,
13
  renderImageWithTimgSync,
14
  } from '../../utils/terminalImage.js'
15
 
@@ -122,19 +121,15 @@ function getToolUseSummary(input: Partial<Input>): string | null {
122
  }
123
 
124
  /**
125
- * React component that renders the image via the Kitty graphics protocol
126
- * when the terminal naturally supports it (Kitty/Ghostty/WezTerm outside
127
- * tmux). timg fallback is rendered in-band via RawAnsi (in
128
- * renderToolResultMessage), so Ink knows the image dimensions and its
129
- * virtual cursor stays in sync with the terminal.
130
  */
131
  function TerminalImageDisplay({
132
- base64,
133
- format,
134
  message,
135
  }: {
136
- base64?: string
137
- format?: string
138
  message: string
139
  }): React.ReactNode {
140
  const writeRaw = useContext(TerminalWriteContext)
@@ -143,21 +138,9 @@ function TerminalImageDisplay({
143
  useEffect(() => {
144
  if (renderedRef.current || !writeRaw) return
145
  renderedRef.current = true
146
-
147
- // Kitty protocol: timg fallback is rendered in renderToolResultMessage
148
- // via RawAnsi (in-band with Ink's virtual DOM), so Ink knows the image
149
- // dimensions and cursor position stays correct.
150
- // Only use Kitty when it can reach the terminal natively.
151
- const protocol = detectImageProtocol()
152
- if (protocol === 'kitty' && !isInsideTmux() && base64 && format) {
153
- const buf = Buffer.from(base64, 'base64')
154
- const sequence = encodeKittyImage(buf, format)
155
- if (sequence) {
156
- writeRaw(sequence + '\n')
157
- logForDebugging('ImageShow: displayed via Kitty protocol')
158
- }
159
- }
160
- }, [base64, format, writeRaw])
161
 
162
  return <Text dimColor>{message}</Text>
163
  }
@@ -166,7 +149,7 @@ export const ImageShowTool = buildTool({
166
  name: IMAGE_TOOL_NAME,
167
  description:
168
  'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
169
- 'Uses the Kitty graphics protocol when available outside tmux, ' +
170
  'or falls back to timg Unicode-block rendering for universal compatibility. ' +
171
  'Supports both URLs (https://) and local file paths. Images are shown inline above the tool result.',
172
 
@@ -191,7 +174,7 @@ export const ImageShowTool = buildTool({
191
  },
192
 
193
  async prompt(_options): Promise<string> {
194
- return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal. Uses the Kitty graphics protocol when available (outside tmux), 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.`
195
  },
196
 
197
  async checkPermissions(): Promise<{ behavior: 'allow' }> {
@@ -248,6 +231,7 @@ export const ImageShowTool = buildTool({
248
  message: string
249
  base64?: string
250
  format?: string
 
251
  timgOutput?: string
252
  },
253
  _progressMessages,
@@ -257,8 +241,20 @@ export const ImageShowTool = buildTool({
257
  return null
258
  }
259
 
260
- // timg output rendered in-band via RawAnsi so Ink knows the image
261
- // dimensions and its virtual cursor stays in sync with the terminal.
 
 
 
 
 
 
 
 
 
 
 
 
262
  if (content.timgOutput) {
263
  // Strip cursor hide/show sequences that timg adds
264
  const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
@@ -272,13 +268,7 @@ export const ImageShowTool = buildTool({
272
  return <RawAnsi lines={lines} width={width} />
273
  }
274
 
275
- return (
276
- <TerminalImageDisplay
277
- base64={content.base64}
278
- format={content.format}
279
- message={content.message}
280
- />
281
- )
282
  },
283
 
284
  async call(input: Input): Promise<{
@@ -288,6 +278,7 @@ export const ImageShowTool = buildTool({
288
  imageData?: { base64: string; mediaType: string }
289
  base64?: string
290
  format?: string
 
291
  timgOutput?: string
292
  }
293
  }> {
@@ -320,20 +311,27 @@ export const ImageShowTool = buildTool({
320
 
321
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
322
 
323
- // Generate timg Unicode-block rendering. The output is passed through
324
- // to the Ink virtual DOM via RawAnsi in renderToolResultMessage, so
325
- // Ink knows the image dimensions and its cursor stays in sync.
326
- // Kitty protocol is separately handled in TerminalImageDisplay via
327
- // writeRaw (only when the terminal natively supports it outside tmux).
328
  const protocol = detectImageProtocol()
 
329
  let timgOutput: string | undefined
330
- if (isInsideTmux() || protocol !== 'kitty') {
331
- timgOutput = renderImageWithTimgSync(buffer, format) ?? undefined
332
- if (timgOutput) {
333
- logForDebugging('ImageShow: displayed via timg Unicode blocks (in-band with Ink)')
 
 
334
  }
335
  }
336
 
 
 
 
 
337
  return {
338
  data: {
339
  success: true,
@@ -344,6 +342,7 @@ export const ImageShowTool = buildTool({
344
  },
345
  base64: buffer.toString('base64'),
346
  format,
 
347
  timgOutput,
348
  },
349
  }
 
4
  import { z } from 'zod/v4'
5
  import { RawAnsi, Text } from '../../ink.js'
6
  import { TerminalWriteContext } from '../../ink/useTerminalNotification.js'
7
+ import { wrapForMultiplexer } from '../../ink/termio/osc.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
 
 
121
  }
122
 
123
  /**
124
+ * Renders a pre-generated Kitty protocol image sequence by writing it
125
+ * directly to stdout via writeRaw (bypassing Ink's virtual DOM so the
126
+ * native image protocol reaches the terminal cleanly).
 
 
127
  */
128
  function TerminalImageDisplay({
129
+ sequence,
 
130
  message,
131
  }: {
132
+ sequence: string
 
133
  message: string
134
  }): React.ReactNode {
135
  const writeRaw = useContext(TerminalWriteContext)
 
138
  useEffect(() => {
139
  if (renderedRef.current || !writeRaw) return
140
  renderedRef.current = true
141
+ writeRaw(sequence + '\n')
142
+ logForDebugging('ImageShow: displayed via Kitty protocol (timg)')
143
+ }, [sequence, writeRaw])
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  return <Text dimColor>{message}</Text>
146
  }
 
149
  name: IMAGE_TOOL_NAME,
150
  description:
151
  'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
152
+ 'Uses the Kitty graphics protocol via timg when the terminal supports it, ' +
153
  'or falls back to timg Unicode-block rendering for universal compatibility. ' +
154
  'Supports both URLs (https://) and local file paths. Images are shown inline above the tool result.',
155
 
 
174
  },
175
 
176
  async prompt(_options): Promise<string> {
177
+ 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.`
178
  },
179
 
180
  async checkPermissions(): Promise<{ behavior: 'allow' }> {
 
231
  message: string
232
  base64?: string
233
  format?: string
234
+ kittyOutput?: string
235
  timgOutput?: string
236
  },
237
  _progressMessages,
 
241
  return null
242
  }
243
 
244
+ // Kitty protocol output rendered via writeRaw (out-of-band) for native
245
+ // terminal image rendering. The text message tells Ink the image exists
246
+ // so it doesn't leave a blank gap.
247
+ if (content.kittyOutput) {
248
+ return (
249
+ <TerminalImageDisplay
250
+ sequence={content.kittyOutput}
251
+ message={content.message}
252
+ />
253
+ )
254
+ }
255
+
256
+ // timg block-mode output rendered in-band via RawAnsi so Ink knows the
257
+ // image dimensions and its virtual cursor stays in sync with the terminal.
258
  if (content.timgOutput) {
259
  // Strip cursor hide/show sequences that timg adds
260
  const cleaned = content.timgOutput.replace(/\x1b\[\?25[hl]/g, '')
 
268
  return <RawAnsi lines={lines} width={width} />
269
  }
270
 
271
+ return <Text dimColor>{content.message}</Text>
 
 
 
 
 
 
272
  },
273
 
274
  async call(input: Input): Promise<{
 
278
  imageData?: { base64: string; mediaType: string }
279
  base64?: string
280
  format?: string
281
+ kittyOutput?: string
282
  timgOutput?: string
283
  }
284
  }> {
 
311
 
312
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
313
 
314
+ // Try native Kitty protocol rendering via timg when the terminal supports it.
315
+ // timg's -p kitty produces Kitty protocol escape sequences natively,
316
+ // which are then wrapped for tmux passthrough if needed and written
317
+ // directly to stdout via writeRaw in TerminalImageDisplay.
318
+ // Falls back to Unicode block rendering for universal compatibility.
319
  const protocol = detectImageProtocol()
320
+ let kittyOutput: string | undefined
321
  let timgOutput: string | undefined
322
+
323
+ if (protocol === 'kitty') {
324
+ const rawKitty = renderImageWithTimgSync(buffer, format, undefined, undefined, 'kitty')
325
+ if (rawKitty) {
326
+ kittyOutput = wrapForMultiplexer(rawKitty)
327
+ logForDebugging('ImageShow: generated via timg Kitty protocol')
328
  }
329
  }
330
 
331
+ if (!kittyOutput) {
332
+ timgOutput = renderImageWithTimgSync(buffer, format, undefined, undefined, 'blocks') ?? undefined
333
+ }
334
+
335
  return {
336
  data: {
337
  success: true,
 
342
  },
343
  base64: buffer.toString('base64'),
344
  format,
345
+ kittyOutput,
346
  timgOutput,
347
  },
348
  }
src/utils/terminalImage.ts CHANGED
@@ -58,6 +58,14 @@ export function detectImageProtocol(): ImageProtocol {
58
  return 'kitty'
59
  }
60
 
 
 
 
 
 
 
 
 
61
  return null
62
  }
63
 
@@ -133,25 +141,32 @@ export function getImageProtocolSummary(): string {
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,25 +185,37 @@ export async function renderImageWithTimg(
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 {
@@ -217,6 +244,7 @@ export function renderImageWithTimgSync(
217
  format: string,
218
  columns?: number,
219
  rows?: number,
 
220
  ): string | null {
221
  try {
222
  const timgPath = whichSync('timg')
@@ -233,29 +261,43 @@ export function renderImageWithTimgSync(
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 {
 
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
  }
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
  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 {
 
244
  format: string,
245
  columns?: number,
246
  rows?: number,
247
+ mode: TimgMode = 'blocks',
248
  ): string | null {
249
  try {
250
  const timgPath = whichSync('timg')
 
261
  try {
262
  writeFileSync(tmpFile, buffer)
263
 
264
+ if (mode === 'kitty') {
265
+ // Native Kitty protocol rendering
 
 
 
 
 
 
 
 
 
 
 
 
266
  try {
267
  const stdout = execFileSync(
268
  timgPath,
269
+ ['-p', 'kitty', '-g', `${cols}x${maxRows}`, tmpFile],
270
+ { encoding: 'utf8', timeout: 30000, maxBuffer: 50 * 1024 * 1024 },
271
+ )
272
+ if (stdout) result = stdout
273
+ } catch {
274
+ // failed
275
+ }
276
+ } else {
277
+ // Try quarter blocks first (4 pixels per cell, better quality)
278
+ try {
279
+ const stdout = execFileSync(
280
+ timgPath,
281
+ ['-p', 'q', '-g', `${cols}x${maxRows}`, tmpFile],
282
  { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
283
  )
284
  if (stdout) result = stdout
285
  } catch {
286
+ // fall through to half blocks
287
+ }
288
+
289
+ // Fallback to half blocks (2 pixels per cell, max compatibility)
290
+ if (!result) {
291
+ try {
292
+ const stdout = execFileSync(
293
+ timgPath,
294
+ ['-p', 'h', '-g', `${cols}x${maxRows}`, tmpFile],
295
+ { encoding: 'utf8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 },
296
+ )
297
+ if (stdout) result = stdout
298
+ } catch {
299
+ // ignored
300
+ }
301
  }
302
  }
303
  } finally {