chenbhao Claude Big Pickle commited on
Commit
46f810c
·
1 Parent(s): a699180

refactor: replace custom image rendering with ink-picture across all components

Browse files

- ImageShowTool: remove jimp/timg rendering, use ink-picture Image component
with auto-detected protocol (Kitty, Sixel, iTerm2, HalfBlock, Braille, ASCII)
- InlineImage: replace manual kitty escape sequences + writeRaw with ink-picture
- Ink runtime: remove dead rawWritesAtRow APC/DCS handling (no longer needed)
- Delete unused utils/terminalImage.ts and utils/kittyProtocol.ts
- tests: expand ImageShowTool test coverage to 29 tests

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

src/components/InlineImage.tsx CHANGED
@@ -1,26 +1,14 @@
1
- import React, { useContext, useEffect, useRef, useState } from 'react'
 
2
  import Link from '../ink/components/Link.js'
3
  import { supportsHyperlinks } from '../ink/supports-hyperlinks.js'
4
  import { Box, Text } from '../ink.js'
5
- import { TerminalWriteContext } from '../ink/useTerminalNotification.js'
6
- import { downloadImage, type CachedImage } from '../utils/imageUrlCache.js'
7
- import {
8
- detectImageProtocol,
9
- encodeImageForTerminal,
10
- isInsideTmux,
11
- type ImageProtocol,
12
- } from '../utils/terminalImage.js'
13
 
14
  type Props = {
15
  url: string
16
  alt?: string
17
  }
18
 
19
- type Status = 'loading' | 'ready' | 'error'
20
-
21
- /**
22
- * Extract a display filename from a URL (for fallback text).
23
- */
24
  function filenameFromUrl(url: string): string {
25
  try {
26
  const pathname = new URL(url).pathname
@@ -33,110 +21,21 @@ function filenameFromUrl(url: string): string {
33
  }
34
 
35
  /**
36
- * Renders an inline image in the terminal using the Kitty graphics protocol
37
- * when available. Falls back to a clickable hyperlink for terminals without
38
- * image protocol support.
39
  *
40
- * Behavior:
41
- * - loading: dimmed "[Loading image...]" text + tmux passthrough notice
42
- * - ready + kitty protocol: writes escape sequence via Ink's writeRaw,
43
- * shows clickable [Image] link
44
- * - ready + no protocol: shows clickable link with the URL
45
- * - error: shows clickable link with plain text fallback
46
  */
47
  export function InlineImage({ url, alt }: Props) {
48
- const writeRaw = useContext(TerminalWriteContext)
49
- const [status, setStatus] = useState<Status>('loading')
50
- const [imageData, setImageData] = useState<CachedImage | null>(null)
51
- const [protocol, setProtocol] = useState<ImageProtocol>(() =>
52
- detectImageProtocol(),
53
- )
54
- const protocolRef = useRef(protocol)
55
- protocolRef.current = protocol
56
- const imageWrittenRef = useRef(false)
57
-
58
- const insideTmux = isInsideTmux()
59
-
60
- // Download image on mount
61
- useEffect(() => {
62
- let cancelled = false
63
-
64
- setStatus('loading')
65
- setImageData(null)
66
- setProtocol(detectImageProtocol())
67
- imageWrittenRef.current = false
68
- console.error(`[InlineImage] Starting download: ${url}`)
69
-
70
- downloadImage(url)
71
- .then(result => {
72
- if (cancelled) return
73
- console.error(`[InlineImage] Download result for ${url}:`, result ? `OK (${result.buffer.length} bytes, ${result.format})` : 'FAILED')
74
- if (result) {
75
- setImageData(result)
76
- setStatus('ready')
77
- } else {
78
- setStatus('error')
79
- }
80
- })
81
- .catch(err => {
82
- if (!cancelled) {
83
- console.error(`[InlineImage] Download error for ${url}:`, err)
84
- setStatus('error')
85
- }
86
- })
87
-
88
- return () => {
89
- cancelled = true
90
- }
91
- }, [url])
92
-
93
- // Write terminal image escape sequence via Ink's writeRaw (not raw stdout)
94
- // so it stays synchronous with Ink's output and avoids cursor-position races.
95
- useEffect(() => {
96
- if (status !== 'ready' || !imageData || !protocol || imageWrittenRef.current) return
97
-
98
- const sequence = encodeImageForTerminal(imageData.buffer, imageData.format)
99
- if (sequence && writeRaw) {
100
- console.error(`[InlineImage] Writing ${imageData.buffer.length} byte ${imageData.format} image via writeRaw`)
101
- writeRaw(sequence)
102
- imageWrittenRef.current = true
103
- } else if (!writeRaw) {
104
- console.error(`[InlineImage] writeRaw not available!`)
105
- } else {
106
- console.error(`[InlineImage] No sequence generated`)
107
- }
108
- }, [status, imageData, protocol, writeRaw])
109
-
110
  const displayName = alt || filenameFromUrl(url)
111
 
112
- // -- Render states --
113
-
114
- // Loading: dimmed placeholder text
115
- if (status === 'loading') {
116
- if (insideTmux) {
117
- return (
118
- <Text dimColor>
119
- {`[Loading ${displayName}...] (tmux may need allow-passthrough on)`}
120
- </Text>
121
- )
122
- }
123
- return <Text dimColor>{`[Loading ${displayName}...]`}</Text>
124
- }
125
-
126
- // Error: clickable link or plain text
127
- if (status === 'error') {
128
- if (supportsHyperlinks()) {
129
- return (
130
- <Link url={url}>
131
- <Text dimColor>{`[${displayName}]`}</Text>
132
- </Link>
133
- )
134
- }
135
- return <Text dimColor>{`[${displayName}: ${url}]`}</Text>
136
- }
137
 
138
- // Ready: if we have a protocol and wrote the sequence, render a small label.
139
- // The image was already written to the terminal via writeRaw above.
140
  const link = supportsHyperlinks() ? (
141
  <Link url={url}>
142
  <Text dimColor>{`[${displayName}]`}</Text>
@@ -147,10 +46,15 @@ export function InlineImage({ url, alt }: Props) {
147
 
148
  return (
149
  <Box flexDirection="column">
 
 
 
 
 
 
 
 
150
  {link}
151
- {insideTmux && protocol && (
152
- <Text dimColor>{` ⚠ tmux: add \`set -g allow-passthrough on\` to ~/.tmux.conf`}</Text>
153
- )}
154
  </Box>
155
  )
156
  }
 
1
+ import React from 'react'
2
+ import Image, { InkPictureProvider } from 'ink-picture'
3
  import Link from '../ink/components/Link.js'
4
  import { supportsHyperlinks } from '../ink/supports-hyperlinks.js'
5
  import { Box, Text } from '../ink.js'
 
 
 
 
 
 
 
 
6
 
7
  type Props = {
8
  url: string
9
  alt?: string
10
  }
11
 
 
 
 
 
 
12
  function filenameFromUrl(url: string): string {
13
  try {
14
  const pathname = new URL(url).pathname
 
21
  }
22
 
23
  /**
24
+ * Renders an inline image in the terminal using ink-picture, which
25
+ * auto-detects the best available protocol (Kitty, Sixel, iTerm2,
26
+ * HalfBlock, Braille, ASCII) and handles loading/error states.
27
  *
28
+ * A clickable [image_name] link is shown below the image as a stable
29
+ * fallback for screen readers and quick URL access.
 
 
 
 
30
  */
31
  export function InlineImage({ url, alt }: Props) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  const displayName = alt || filenameFromUrl(url)
33
 
34
+ const cols = process.stdout.columns ?? 80
35
+ const rows = process.stdout.rows ?? 40
36
+ const imgWidth = Math.floor(cols * 0.6)
37
+ const imgHeight = Math.floor(rows * 0.4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
 
 
39
  const link = supportsHyperlinks() ? (
40
  <Link url={url}>
41
  <Text dimColor>{`[${displayName}]`}</Text>
 
46
 
47
  return (
48
  <Box flexDirection="column">
49
+ <InkPictureProvider>
50
+ <Image
51
+ src={url}
52
+ width={imgWidth}
53
+ height={imgHeight}
54
+ alt={alt}
55
+ />
56
+ </InkPictureProvider>
57
  {link}
 
 
 
58
  </Box>
59
  )
60
  }
src/ink/log-update.ts CHANGED
@@ -302,9 +302,6 @@ export class LogUpdate {
302
  let currentStyleId = stylePool.none
303
  let currentHyperlink: Hyperlink = undefined
304
 
305
- // Track which rows had raw writes emitted to avoid duplicates
306
- const emittedRawRows = new Set<number>()
307
-
308
  // First pass: render changes to existing rows (rows < prev.screen.height)
309
  let needsFullReset = false
310
  let resetTriggerY = -1
@@ -381,24 +378,6 @@ export class LogUpdate {
381
  return [patches, { dx: 1, dy: 0 }]
382
  })
383
  }
384
-
385
- // Emit cursor-hide + bare APC/DCS + cursor-show after cell content
386
- // so the native image overlays cell characters with no cursor flicker.
387
- // No CUP prefix — the cursor is already at the correct row/column
388
- // from moveCursorTo above.
389
- if (!emittedRawRows.has(y)) {
390
- emittedRawRows.add(y)
391
- const rw = next.screen.rawWritesAtRow.get(y)
392
- if (rw) {
393
- screen.diff.push({
394
- type: 'stdout',
395
- content: '\x1b[?25l' + rw + '\x1b[?25h',
396
- })
397
- screen.txn(prev => {
398
- return [[], { dx: x - prev.x, dy: y - prev.y }]
399
- })
400
- }
401
- }
402
  })
403
  if (needsFullReset) {
404
  return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
@@ -429,7 +408,6 @@ export class LogUpdate {
429
  prev.screen.height,
430
  next.screen.height,
431
  stylePool,
432
- viewportY,
433
  )
434
  }
435
 
@@ -545,8 +523,6 @@ function renderFrame(
545
  /**
546
  * Render a slice of rows from the frame's screen.
547
  * Each row is rendered followed by a newline. Cursor ends at (0, endY).
548
- * @param viewportY Number of rows above the viewport (scrollback), for
549
- * viewport-adjusted raw write (APC/DCS) CUP positioning.
550
  */
551
  function renderFrameSlice(
552
  screen: VirtualScreen,
@@ -554,7 +530,6 @@ function renderFrameSlice(
554
  startY: number,
555
  endY: number,
556
  stylePool: StylePool,
557
- viewportY = 0,
558
  ): VirtualScreen {
559
  let currentStyleId = stylePool.none
560
  let currentHyperlink: Hyperlink = undefined
@@ -635,19 +610,6 @@ function renderFrameSlice(
635
  currentHyperlink,
636
  undefined,
637
  )
638
- // Emit cursor-hide + bare APC/DCS + cursor-show after cell content.
639
- // The cursor is already at the CR+LF row position — no CUP needed.
640
- {
641
- const rw = frame.screen.rawWritesAtRow.get(y)
642
- if (rw) {
643
- screen.diff.push({
644
- type: 'stdout',
645
- content: '\x1b[?25l' + rw + '\x1b[?25h',
646
- })
647
- screen.txn(prev => [[], { dx: -prev.x, dy: y - prev.y }])
648
- }
649
- }
650
-
651
  // CR+LF at end of row — \r resets to column 0, \n moves to next line.
652
  // Without \r, the terminal cursor stays at whatever column content ended
653
  // (since we skip trailing spaces, this can be mid-row).
 
302
  let currentStyleId = stylePool.none
303
  let currentHyperlink: Hyperlink = undefined
304
 
 
 
 
305
  // First pass: render changes to existing rows (rows < prev.screen.height)
306
  let needsFullReset = false
307
  let resetTriggerY = -1
 
378
  return [patches, { dx: 1, dy: 0 }]
379
  })
380
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  })
382
  if (needsFullReset) {
383
  return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
 
408
  prev.screen.height,
409
  next.screen.height,
410
  stylePool,
 
411
  )
412
  }
413
 
 
523
  /**
524
  * Render a slice of rows from the frame's screen.
525
  * Each row is rendered followed by a newline. Cursor ends at (0, endY).
 
 
526
  */
527
  function renderFrameSlice(
528
  screen: VirtualScreen,
 
530
  startY: number,
531
  endY: number,
532
  stylePool: StylePool,
 
533
  ): VirtualScreen {
534
  let currentStyleId = stylePool.none
535
  let currentHyperlink: Hyperlink = undefined
 
610
  currentHyperlink,
611
  undefined,
612
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  // CR+LF at end of row — \r resets to column 0, \n moves to next line.
614
  // Without \r, the terminal cursor stays at whatever column content ended
615
  // (since we skip trailing spaces, this can be mid-row).
src/ink/output.ts CHANGED
@@ -704,56 +704,29 @@ function writeLineToScreen(
704
  break
705
  }
706
  }
707
- } else if (nextChar === '_') {
708
- // APC (Application Program Command): store in screen.rawWritesAtRow
709
- // for emission by log-update.ts before the row's cell content.
710
- // This avoids the screen buffer width limit — APC payloads (e.g.
711
- // Kitty base64 images) can be hundreds of KB, but screen rows are
712
- // only ~80 columns wide.
713
- const apcStartIdx = charIdx
714
- charIdx++ // skip past ESC
715
- while (charIdx < characters.length - 1) {
716
- charIdx++
717
- const c = characters[charIdx]?.value
718
- if (c === '\x07') break
719
- if (c === '\x1b') {
720
- const nextC = characters[charIdx + 1]?.value
721
- if (nextC === '\\') {
722
- charIdx++ // skip backslash
723
- break
724
- }
725
- }
726
- }
727
- // Reconstruct the full APC sequence from char tokens
728
- let apc = ''
729
- for (let i = apcStartIdx; i <= charIdx; i++) {
730
- const ci = characters[i]
731
- if (ci) apc += ci.value
732
- }
733
- // Append or create raw write entry. Store the bare APC — the
734
- // cursor is already at the correct screen-buffer position when
735
- // this entry is emitted (via CR+LF in renderFrameSlice or
736
- // moveCursorTo in diffEach).
737
- const existing = screen.rawWritesAtRow.get(y)
738
- if (existing) {
739
- screen.rawWritesAtRow.set(y, existing + apc)
740
- } else {
741
- screen.rawWritesAtRow.set(y, apc)
742
- }
743
  } else if (
744
  nextChar === ']' ||
745
  nextChar === 'P' ||
 
746
  nextChar === '^' ||
747
  nextChar === 'X'
748
  ) {
749
- // String-based sequences (OSC, DCS, PM, SOS) terminated by
750
- // BEL (0x07) or ST (ESC \): skip as before.
751
- const seqStartIdx = charIdx
 
 
 
752
  charIdx++ // skip the introducer char
753
  while (charIdx < characters.length - 1) {
754
  charIdx++
755
  const c = characters[charIdx]?.value
756
- if (c === '\x07') break
 
 
 
 
 
757
  if (c === '\x1b') {
758
  const nextC = characters[charIdx + 1]?.value
759
  if (nextC === '\\') {
@@ -762,31 +735,6 @@ function writeLineToScreen(
762
  }
763
  }
764
  }
765
-
766
- // Tmux DCS passthrough (\x1bPtmux;...\x1b\\): store the entire
767
- // DCS sequence in rawWritesAtRow so tmux forwards the inner APC
768
- // to the terminal for native Kitty image rendering.
769
- if (
770
- nextChar === 'P' &&
771
- charIdx + 2 < characters.length &&
772
- characters[seqStartIdx + 2]?.value === 't' &&
773
- characters[seqStartIdx + 3]?.value === 'm' &&
774
- characters[seqStartIdx + 4]?.value === 'u' &&
775
- characters[seqStartIdx + 5]?.value === 'x' &&
776
- characters[seqStartIdx + 6]?.value === ';' &&
777
- characters[charIdx + 1]?.value === '\x1b' &&
778
- characters[charIdx + 2]?.value === '\\'
779
- ) {
780
- // Extend to include outer DCS terminator (\x1b\\)
781
- const dcsEnd = charIdx + 2
782
- let dcs = ''
783
- for (let i = seqStartIdx; i <= dcsEnd; i++) {
784
- const ci = characters[i]
785
- if (ci) dcs += ci.value
786
- }
787
- screen.rawWritesAtRow.set(y, dcs)
788
- charIdx = dcsEnd
789
- }
790
  } else if (
791
  nextCode !== undefined &&
792
  nextCode >= 0x30 &&
 
704
  break
705
  }
706
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
707
  } else if (
708
  nextChar === ']' ||
709
  nextChar === 'P' ||
710
+ nextChar === '_' ||
711
  nextChar === '^' ||
712
  nextChar === 'X'
713
  ) {
714
+ // String-based sequences terminated by BEL (0x07) or ST (ESC \):
715
+ // - OSC: ESC ] ... (Operating System Command)
716
+ // - DCS: ESC P ... (Device Control String)
717
+ // - APC: ESC _ ... (Application Program Command)
718
+ // - PM: ESC ^ ... (Privacy Message)
719
+ // - SOS: ESC X ... (Start of String)
720
  charIdx++ // skip the introducer char
721
  while (charIdx < characters.length - 1) {
722
  charIdx++
723
  const c = characters[charIdx]?.value
724
+ // BEL (0x07) terminates the sequence
725
+ if (c === '\x07') {
726
+ break
727
+ }
728
+ // ST (String Terminator) is ESC \
729
+ // When we see ESC, check if next char is backslash
730
  if (c === '\x1b') {
731
  const nextC = characters[charIdx + 1]?.value
732
  if (nextC === '\\') {
 
735
  }
736
  }
737
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
738
  } else if (
739
  nextCode !== undefined &&
740
  nextCode >= 0x30 &&
src/ink/screen.ts CHANGED
@@ -391,15 +391,6 @@ export type Screen = Size & {
391
  */
392
  noSelect: Uint8Array
393
 
394
- /**
395
- * Per-row raw escape sequences (e.g. Kitty protocol APC) that must be
396
- * emitted before the row's cell content. Populated by writeLineToScreen
397
- * in output.ts when a RawAnsi line starts with \x1b_ (APC). Each entry
398
- * already includes CUP positioning: \x1b[row;colH + raw-sequence.
399
- * log-update.ts emits these before diffing cells in a row.
400
- */
401
- rawWritesAtRow: Map<number, string>
402
-
403
  /**
404
  * Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r
405
  * is a word-wrap continuation of row r-1 (the `\n` before it was
@@ -496,7 +487,6 @@ export function createScreen(
496
  emptyStyleId: styles.none,
497
  damage: undefined,
498
  noSelect: new Uint8Array(size),
499
- rawWritesAtRow: new Map(),
500
  softWrap: new Int32Array(height),
501
  }
502
  }
@@ -549,9 +539,8 @@ export function resetScreen(
549
 
550
  // Shared pools accumulate — no clearing needed. Unique char/hyperlink sets are bounded.
551
 
552
- // Clear damage tracking and per-row raw writes
553
  screen.damage = undefined
554
- screen.rawWritesAtRow.clear()
555
  }
556
 
557
  /**
 
391
  */
392
  noSelect: Uint8Array
393
 
 
 
 
 
 
 
 
 
 
394
  /**
395
  * Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r
396
  * is a word-wrap continuation of row r-1 (the `\n` before it was
 
487
  emptyStyleId: styles.none,
488
  damage: undefined,
489
  noSelect: new Uint8Array(size),
 
490
  softWrap: new Int32Array(height),
491
  }
492
  }
 
539
 
540
  // Shared pools accumulate — no clearing needed. Unique char/hyperlink sets are bounded.
541
 
542
+ // Clear damage tracking
543
  screen.damage = undefined
 
544
  }
545
 
546
  /**
src/tools/ImageShowTool/ImageShowTool.tsx CHANGED
@@ -2,16 +2,10 @@ import { readFile } from 'fs/promises'
2
  import { homedir } from 'os'
3
  import React from 'react'
4
  import { z } from 'zod/v4'
5
- import Jimp from 'jimp'
6
- import { RawAnsi, Text } from '../../ink.js'
7
  import { buildTool, type ToolDef } from '../../Tool.js'
8
  import { logForDebugging } from '../../utils/debug.js'
9
- import {
10
- getBestTextProtocol,
11
- renderImage,
12
- type PixelData,
13
- type TextImageProtocol,
14
- } from '../../utils/imageRenderers.js'
15
 
16
  const IMAGE_TOOL_NAME = 'ImageShow'
17
 
@@ -117,83 +111,6 @@ async function loadImage(url: string): Promise<{ buffer: Buffer; format: string
117
  return fetchImage(normalizedUrl)
118
  }
119
 
120
- /**
121
- * Render an image buffer to an ANSI-escaped string via jimp + text-based
122
- * protocol (halfBlock / braille / ascii).
123
- *
124
- * Pure-JS equivalent of the old renderImageWithTimgSync that used the
125
- * external `timg` binary.
126
- *
127
- * @returns ANSI-escaped string, or null on failure.
128
- */
129
- async function renderImageWithJimp(
130
- buffer: Buffer,
131
- _format: string,
132
- ): Promise<string | null> {
133
- try {
134
- const cols = process.stdout.columns ?? 80
135
- const termRows = process.stdout.rows ?? 40
136
- // Use at most half the terminal height so the image doesn't dominate
137
- const maxRows = Math.max(10, Math.floor(termRows * 0.5))
138
- // Aim for roughly 2:1 cell ratio (cells are ~2x tall)
139
- const maxCols = Math.min(cols, Math.round(maxRows * 2))
140
-
141
- // Decode image via jimp
142
- const image = await Jimp.read(buffer)
143
- const origWidth = image.bitmap.width
144
- const origHeight = image.bitmap.height
145
-
146
- // Scale to fit terminal
147
- const scale = Math.min(maxCols / origWidth, maxRows / origHeight, 1)
148
- const targetWidth = Math.round(origWidth * scale)
149
- const targetHeight = Math.round(origHeight * scale)
150
-
151
- image.resize(targetWidth, targetHeight)
152
-
153
- // Extract pixel data
154
- const pixelData: PixelData = {
155
- data: image.bitmap.data,
156
- info: {
157
- width: targetWidth,
158
- height: targetHeight,
159
- channels: 4,
160
- },
161
- }
162
-
163
- // Detect terminal capabilities and pick the best text protocol
164
- const supportsColor = process.env.NO_COLOR === undefined
165
- const supportsUnicode = true // modern terminals all support Unicode
166
-
167
- const protocol: TextImageProtocol = getBestTextProtocol({
168
- supportsUnicode,
169
- supportsColor,
170
- })
171
-
172
- logForDebugging(`ImageShow: rendering ${targetWidth}x${targetHeight} via ${protocol}`)
173
-
174
- return renderImage(pixelData, protocol, supportsColor)
175
- } catch (err) {
176
- logForDebugging(`ImageShow: jimp render error ${err}`)
177
- return null
178
- }
179
- }
180
-
181
- /**
182
- * React component that renders ink-picture output within Ink's
183
- * virtual DOM so Ink knows the image dimensions and the cursor stays
184
- * in sync with the terminal.
185
- */
186
- function ImageDisplay({ output }: { output: string }): React.ReactNode {
187
- const lines = output.split('\n').filter(l => l.length > 0)
188
- if (lines.length === 0) {
189
- return null
190
- }
191
- // Measure visible width (strip ANSI escape codes)
192
- const ansiStrip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
193
- const width = Math.max(...lines.map(l => ansiStrip(l).length))
194
- return <RawAnsi lines={lines} width={width} />
195
- }
196
-
197
  function getToolUseSummary(input: Partial<Input>): string | null {
198
  return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
199
  }
@@ -202,9 +119,8 @@ export const ImageShowTool = buildTool({
202
  name: IMAGE_TOOL_NAME,
203
  description:
204
  'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
205
- 'Renders with Unicode-block characters within Ink\'s virtual DOM ' +
206
- 'so the cursor stays in sync and the image scrolls with conversation content. ' +
207
- 'Supports both URLs (https://) and local file paths.',
208
 
209
  getToolUseSummary,
210
  getActivityDescription(input) {
@@ -282,7 +198,8 @@ export const ImageShowTool = buildTool({
282
  content: {
283
  success: boolean
284
  message: string
285
- imageOutput?: string
 
286
  },
287
  _progressMessages,
288
  _options,
@@ -291,8 +208,23 @@ export const ImageShowTool = buildTool({
291
  return null
292
  }
293
 
294
- if (content.imageOutput) {
295
- return <ImageDisplay output={content.imageOutput} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  }
297
 
298
  return <Text dimColor>{content.message}</Text>
@@ -304,12 +236,22 @@ export const ImageShowTool = buildTool({
304
  message: string
305
  imageData?: { base64: string; mediaType: string }
306
  base64?: string
307
- imageOutput?: string
 
308
  }
309
  }> {
310
  const url = input.url
311
  const alt = input.alt ?? url.split('/').pop() ?? 'image'
312
 
 
 
 
 
 
 
 
 
 
313
  logForDebugging(`ImageShow: loading ${url}`)
314
 
315
  const result = await loadImage(url)
@@ -336,14 +278,6 @@ export const ImageShowTool = buildTool({
336
 
337
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
338
 
339
- // Render via jimp + text protocol within Ink's virtual DOM.
340
- // ImageDisplay in renderToolResultMessage renders the output so Ink
341
- // knows the image dimensions and the cursor stays in sync.
342
- const imageOutput = await renderImageWithJimp(buffer, format)
343
- if (imageOutput) {
344
- logForDebugging('ImageShow: generated text-protocol output')
345
- }
346
-
347
  return {
348
  data: {
349
  success: true,
@@ -353,7 +287,8 @@ export const ImageShowTool = buildTool({
353
  mediaType,
354
  },
355
  base64: buffer.toString('base64'),
356
- imageOutput,
 
357
  },
358
  }
359
  },
 
2
  import { homedir } from 'os'
3
  import React from 'react'
4
  import { z } from 'zod/v4'
5
+ import { Text } from '../../ink.js'
 
6
  import { buildTool, type ToolDef } from '../../Tool.js'
7
  import { logForDebugging } from '../../utils/debug.js'
8
+ import Image, { InkPictureProvider } from 'ink-picture'
 
 
 
 
 
9
 
10
  const IMAGE_TOOL_NAME = 'ImageShow'
11
 
 
111
  return fetchImage(normalizedUrl)
112
  }
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  function getToolUseSummary(input: Partial<Input>): string | null {
115
  return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
116
  }
 
119
  name: IMAGE_TOOL_NAME,
120
  description:
121
  'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
122
+ 'Renders with full-resolution via Kitty Graphics Protocol when supported, ' +
123
+ 'with automatic fallback to text-based rendering (half-block, braille, ascii).',
 
124
 
125
  getToolUseSummary,
126
  getActivityDescription(input) {
 
198
  content: {
199
  success: boolean
200
  message: string
201
+ src?: string
202
+ alt?: string
203
  },
204
  _progressMessages,
205
  _options,
 
208
  return null
209
  }
210
 
211
+ if (content.src) {
212
+ const cols = process.stdout.columns ?? 80
213
+ const rows = process.stdout.rows ?? 40
214
+ // Reasonable image size: 60% of terminal width, 40% of terminal height
215
+ const imgWidth = Math.floor(cols * 0.6)
216
+ const imgHeight = Math.floor(rows * 0.4)
217
+
218
+ return (
219
+ <InkPictureProvider>
220
+ <Image
221
+ src={content.src}
222
+ width={imgWidth}
223
+ height={imgHeight}
224
+ alt={content.alt}
225
+ />
226
+ </InkPictureProvider>
227
+ )
228
  }
229
 
230
  return <Text dimColor>{content.message}</Text>
 
236
  message: string
237
  imageData?: { base64: string; mediaType: string }
238
  base64?: string
239
+ src?: string
240
+ alt?: string
241
  }
242
  }> {
243
  const url = input.url
244
  const alt = input.alt ?? url.split('/').pop() ?? 'image'
245
 
246
+ // Normalize src for ink-picture rendering
247
+ let src = url
248
+ if (src.startsWith('~')) {
249
+ src = src.replace(/^~(?=$|\/)/, homedir())
250
+ }
251
+ if (src.startsWith('file://')) {
252
+ src = src.slice(7)
253
+ }
254
+
255
  logForDebugging(`ImageShow: loading ${url}`)
256
 
257
  const result = await loadImage(url)
 
278
 
279
  logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
280
 
 
 
 
 
 
 
 
 
281
  return {
282
  data: {
283
  success: true,
 
287
  mediaType,
288
  },
289
  base64: buffer.toString('base64'),
290
+ src,
291
+ alt,
292
  },
293
  }
294
  },
src/tools/ImageShowTool/__tests__/ImageShowTool.test.ts ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeAll, vi } from 'vitest'
2
+ import { ImageShowTool } from '../ImageShowTool'
3
+ import { readFile } from 'fs/promises'
4
+ import { homedir } from 'os'
5
+ import path from 'path'
6
+ import React from 'react'
7
+ import type { Text } from 'ink'
8
+
9
+ // ── Helpers ──────────────────────────────────────────────────────────────────
10
+
11
+ function getExampleImagePath(): string {
12
+ const rel = path.join('src', 'ink-picture', 'examples', 'images', 'house.png')
13
+ return path.resolve(__dirname, '..', '..', '..', '..', rel)
14
+ }
15
+
16
+ function writeTempPng(): string {
17
+ // Minimal valid 1x1 red PNG (baked base64)
18
+ const pngBase64 =
19
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
20
+ const tmpFile = path.join(
21
+ process.env.TMPDIR || process.env.TEMP || '/tmp',
22
+ `imgshow-test-${Date.now()}.png`,
23
+ )
24
+ require('fs').writeFileSync(tmpFile, Buffer.from(pngBase64, 'base64'))
25
+ return tmpFile
26
+ }
27
+
28
+ // ── Suite ────────────────────────────────────────────────────────────────────
29
+
30
+ describe('ImageShowTool – comprehensive', () => {
31
+ let imagePath: string
32
+
33
+ beforeAll(async () => {
34
+ imagePath = getExampleImagePath()
35
+ const exists = await readFile(imagePath).catch(() => null)
36
+ expect(exists).not.toBeNull()
37
+ })
38
+
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+ // Metadata
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+ describe('metadata', () => {
43
+ it('has correct name', () => {
44
+ expect(ImageShowTool.name).toBe('ImageShow')
45
+ })
46
+
47
+ it('is enabled', () => {
48
+ expect(ImageShowTool.isEnabled()).toBe(true)
49
+ })
50
+
51
+ it('is read only', () => {
52
+ expect(ImageShowTool.isReadOnly()).toBe(true)
53
+ })
54
+
55
+ it('is concurrency safe', () => {
56
+ expect(ImageShowTool.isConcurrencySafe()).toBe(true)
57
+ })
58
+
59
+ it('returns a prompt string', async () => {
60
+ const prompt = await ImageShowTool.prompt({} as any)
61
+ expect(typeof prompt).toBe('string')
62
+ expect(prompt.length).toBeGreaterThan(0)
63
+ expect(prompt.toLowerCase()).toContain('image')
64
+ })
65
+ })
66
+
67
+ // ═══════════════════════════════════════════════════════════════════════════
68
+ // Input validation
69
+ // ═══════════════════════════════════════════════════════════════════════════
70
+ describe('validateInput', () => {
71
+ it('accepts a valid local path', async () => {
72
+ const r = await ImageShowTool.validateInput({ url: imagePath })
73
+ expect(r.result).toBe(true)
74
+ })
75
+
76
+ it('accepts a remote URL', async () => {
77
+ const r = await ImageShowTool.validateInput({
78
+ url: 'https://example.com/img.png',
79
+ })
80
+ expect(r.result).toBe(true)
81
+ })
82
+
83
+ it('rejects missing url key', async () => {
84
+ const r = await ImageShowTool.validateInput({} as any)
85
+ expect(r.result).toBe(false)
86
+ expect(r.message).toMatch(/missing/i)
87
+ expect(r.errorCode).toBe(1)
88
+ })
89
+
90
+ it('rejects undefined input', async () => {
91
+ const r = await ImageShowTool.validateInput(undefined as any)
92
+ expect(r.result).toBe(false)
93
+ expect(r.message).toMatch(/missing/i)
94
+ })
95
+
96
+ it('rejects empty string url', async () => {
97
+ const r = await ImageShowTool.validateInput({ url: '' })
98
+ expect(r.result).toBe(false)
99
+ })
100
+ })
101
+
102
+ // ═══════════════════════════════════════════════════════════════════════════
103
+ // call — local files
104
+ // ═══════════════════════════════════════════════════════════════════════════
105
+ describe('call – local files', () => {
106
+ it('loads a PNG and returns base64 + metadata', async () => {
107
+ const result = await ImageShowTool.call({ url: imagePath })
108
+
109
+ expect(result.data.success).toBe(true)
110
+ expect(result.data.message).toContain('house.png')
111
+
112
+ // imageData
113
+ expect(result.data.imageData).toBeDefined()
114
+ expect(result.data.imageData!.mediaType).toBe('image/png')
115
+ expect(typeof result.data.imageData!.base64).toBe('string')
116
+ expect(result.data.imageData!.base64.length).toBeGreaterThan(0)
117
+
118
+ // base64 top-level alias
119
+ expect(result.data.base64).toBe(result.data.imageData!.base64)
120
+
121
+ // src – should be the raw file-system path
122
+ expect(result.data.src).toBe(imagePath)
123
+
124
+ // alt – defaults to filename
125
+ expect(result.data.alt).toBe('house.png')
126
+ })
127
+
128
+ it('handles file:// protocol', async () => {
129
+ const result = await ImageShowTool.call({ url: `file://${imagePath}` })
130
+ expect(result.data.success).toBe(true)
131
+ expect(result.data.imageData!.mediaType).toBe('image/png')
132
+ // src is normalized (file:// stripped)
133
+ expect(result.data.src).toBe(imagePath)
134
+ })
135
+
136
+ it('expands ~ to homedir', async () => {
137
+ // Write a temp PNG in the home dir so the ~ path resolves
138
+ const home = homedir()
139
+ const tmpFile = writeTempPng()
140
+ const homeFile = path.join(home, path.basename(tmpFile))
141
+ try {
142
+ require('fs').renameSync(tmpFile, homeFile)
143
+ } catch {
144
+ // home dir not writable — skip
145
+ require('fs').unlinkSync(tmpFile)
146
+ return
147
+ }
148
+
149
+ const result = await ImageShowTool.call({
150
+ url: `~/${path.basename(homeFile)}`,
151
+ })
152
+ expect(result.data.success).toBe(true)
153
+
154
+ // Cleanup
155
+ try { require('fs').unlinkSync(homeFile) } catch {}
156
+ })
157
+
158
+ it('uses custom alt text', async () => {
159
+ const result = await ImageShowTool.call({
160
+ url: imagePath,
161
+ alt: 'My House',
162
+ })
163
+ expect(result.data.alt).toBe('My House')
164
+ })
165
+
166
+ it('fails gracefully for non-existent file', async () => {
167
+ const result = await ImageShowTool.call({
168
+ url: '/tmp/__nonexistent_img_test_file__xyz.png',
169
+ })
170
+ expect(result.data.success).toBe(false)
171
+ expect(result.data.message).toMatch(/fail/i)
172
+ })
173
+ })
174
+
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+ // call — remote URLs
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+ describe('call – remote URLs', () => {
179
+ it('fails gracefully for unreachable URL', async () => {
180
+ // Temporarily mock fetch to reject quickly
181
+ const origFetch = globalThis.fetch
182
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error('fetch failed'))
183
+ try {
184
+ const result = await ImageShowTool.call({
185
+ url: 'https://example.com/img.png',
186
+ })
187
+ expect(result.data.success).toBe(false)
188
+ expect(result.data.message).toMatch(/fail/i)
189
+ } finally {
190
+ globalThis.fetch = origFetch
191
+ }
192
+ })
193
+ })
194
+
195
+ // ═══════════════════════════════════════════════════════════════════════════
196
+ // getToolUseSummary
197
+ // ═══════════════════════════════════════════════════════════════════════════
198
+ describe('getToolUseSummary', () => {
199
+ it('returns null for empty input', () => {
200
+ expect(ImageShowTool.getToolUseSummary({} as any)).toBeNull()
201
+ })
202
+
203
+ it('includes filename from path', () => {
204
+ const s = ImageShowTool.getToolUseSummary({ url: imagePath })
205
+ expect(s).toBe(`Show: house.png`)
206
+ })
207
+
208
+ it('includes filename from remote URL', () => {
209
+ const s = ImageShowTool.getToolUseSummary({
210
+ url: 'https://example.com/photo.jpg',
211
+ })
212
+ expect(s).toBe('Show: photo.jpg')
213
+ })
214
+ })
215
+
216
+ // ═══════════════════════════════════════════════════════════════════════════
217
+ // getActivityDescription
218
+ // ═══════════════════════════════════════════════════════════════════════════
219
+ describe('getActivityDescription', () => {
220
+ it('returns generic string without input', () => {
221
+ expect(ImageShowTool.getActivityDescription({} as any)).toBe(
222
+ 'Showing image',
223
+ )
224
+ })
225
+
226
+ it('includes URL in description', () => {
227
+ const d = ImageShowTool.getActivityDescription({ url: imagePath })
228
+ expect(d).toContain(imagePath)
229
+ })
230
+ })
231
+
232
+ // ═══════════════════════════════════════════════════════════════════════════
233
+ // mapToolResultToToolResultBlockParam
234
+ // ═══════════════════════════════════════════════════════════════════════════
235
+ describe('mapToolResultToToolResultBlockParam', () => {
236
+ it('returns image block on success with imageData', () => {
237
+ const content = {
238
+ success: true,
239
+ message: 'ok',
240
+ imageData: { base64: 'abc123', mediaType: 'image/png' },
241
+ }
242
+ const mapped = ImageShowTool.mapToolResultToToolResultBlockParam(
243
+ content as any,
244
+ 'tool-use-42',
245
+ )
246
+ expect(mapped.tool_use_id).toBe('tool-use-42')
247
+ expect(mapped.content).toHaveLength(1)
248
+ const block = mapped.content[0]!
249
+ expect(block.type).toBe('image')
250
+ if (block.type === 'image') {
251
+ expect(block.source.type).toBe('base64')
252
+ expect(block.source.data).toBe('abc123')
253
+ expect(block.source.media_type).toBe('image/png')
254
+ }
255
+ })
256
+
257
+ it('returns image block with JPEG type', () => {
258
+ const content = {
259
+ success: true,
260
+ message: 'ok',
261
+ imageData: { base64: 'xyz', mediaType: 'image/jpeg' },
262
+ }
263
+ const mapped = ImageShowTool.mapToolResultToToolResultBlockParam(
264
+ content as any,
265
+ 'tid',
266
+ )
267
+ const block = mapped.content[0]!
268
+ expect(block.type).toBe('image')
269
+ if (block.type === 'image') {
270
+ expect(block.source.media_type).toBe('image/jpeg')
271
+ }
272
+ })
273
+
274
+ it('returns text block on failure (no imageData)', () => {
275
+ const content = { success: false, message: 'Failed to load image' }
276
+ const mapped = ImageShowTool.mapToolResultToToolResultBlockParam(
277
+ content as any,
278
+ 'tid',
279
+ )
280
+ expect(mapped.content).toHaveLength(1)
281
+ const block = mapped.content[0]!
282
+ expect(block.type).toBe('text')
283
+ if (block.type === 'text') {
284
+ expect(block.text).toBe('Failed to load image')
285
+ }
286
+ })
287
+ })
288
+
289
+ // ═══════════════════════════════════════════════════════════════════════════
290
+ // renderToolResultMessage
291
+ // ═══════════════════════════════════════════════════════════════════════════
292
+ describe('renderToolResultMessage', () => {
293
+ it('returns null when success is false', () => {
294
+ const node = ImageShowTool.renderToolResultMessage(
295
+ { success: false, message: 'error' },
296
+ [],
297
+ {} as any,
298
+ )
299
+ expect(node).toBeNull()
300
+ })
301
+
302
+ it('returns InkPictureProvider/Image element when src is provided', () => {
303
+ const node = ImageShowTool.renderToolResultMessage(
304
+ { success: true, message: 'ok', src: imagePath, alt: 'house' },
305
+ [],
306
+ {} as any,
307
+ )
308
+ expect(node).not.toBeNull()
309
+ expect(React.isValidElement(node)).toBe(true)
310
+ })
311
+
312
+ it('returns Text element when success but no src', () => {
313
+ const node = ImageShowTool.renderToolResultMessage(
314
+ { success: true, message: 'Displayed: house.png' },
315
+ [],
316
+ {} as any,
317
+ )
318
+ expect(node).not.toBeNull()
319
+ expect(React.isValidElement(node)).toBe(true)
320
+ })
321
+ })
322
+
323
+ // ═══════════════════════════════════════════════════════════════════════════
324
+ // prompt helper
325
+ // ═══════════════════════════════════════════════════════════════════════════
326
+ describe('prompt', () => {
327
+ it('describes supported formats and protocols', async () => {
328
+ const p = await ImageShowTool.prompt({} as any)
329
+ expect(p).toMatch(/png|jpeg|gif|webp/i)
330
+ expect(p).toMatch(/unicode|kitty|terminal/i)
331
+ })
332
+ })
333
+
334
+ // ═══════════════════════════════════════════════════════════════════════════
335
+ // checkPermissions
336
+ // ═══════════════════════════════════════════════════════════════════════════
337
+ describe('checkPermissions', () => {
338
+ it('always allows', async () => {
339
+ const r = await ImageShowTool.checkPermissions()
340
+ expect(r.behavior).toBe('allow')
341
+ })
342
+ })
343
+ })
src/utils/terminalImage.ts DELETED
@@ -1,273 +0,0 @@
1
- import { execFileSync } from 'child_process'
2
- import { mkdtempSync, rmSync, writeFileSync } from 'fs'
3
- import { tmpdir } from 'os'
4
- import { join } from 'path'
5
- import { Buffer } from 'buffer'
6
- import { env } from './env.js'
7
- import { execFileNoThrow } from './execFileNoThrow.js'
8
- import { wrapForMultiplexer } from '../ink/termio/osc.js'
9
- import { which, whichSync } from './which.js'
10
-
11
- export type ImageProtocol = 'kitty' | null
12
-
13
- /** Is the terminal inside tmux? */
14
- export function isInsideTmux(): boolean {
15
- return !!process.env.TMUX
16
- }
17
-
18
- const KITTY_IMAGE_CHUNK_SIZE = 4096
19
-
20
- // Image format codes for Kitty protocol
21
- const KITTY_FORMAT_CODES: Record<string, number> = {
22
- png: 100,
23
- jpeg: 101,
24
- jpg: 101,
25
- gif: 102,
26
- webp: 103,
27
- }
28
-
29
- /**
30
- * Detect whether the current terminal supports inline image display via
31
- * the Kitty graphics protocol. Kitty, WezTerm, Konsole, foot, and
32
- * Ghostty all support it.
33
- */
34
- export function supportsImageProtocol(): boolean {
35
- return detectImageProtocol() !== null
36
- }
37
-
38
- /**
39
- * Detect the best available terminal image protocol.
40
- * Currently only supports the Kitty protocol.
41
- *
42
- * Returns `'kitty'` or `null` if no protocol is available.
43
- */
44
- export function detectImageProtocol(): ImageProtocol {
45
- const term = env.terminal ?? ''
46
-
47
- // Explicit TERM check for Kitty (TERM=xterm-kitty or TERM contains kitty)
48
- if (process.env.TERM?.includes('kitty')) return 'kitty'
49
- if (process.env.KITTY_WINDOW_ID) return 'kitty'
50
-
51
- // TERM_PROGRAM-based detection
52
- switch (term) {
53
- case 'kitty':
54
- case 'WezTerm':
55
- case 'konsole':
56
- case 'ghostty':
57
- case 'foot':
58
- return 'kitty'
59
- }
60
-
61
- return null
62
- }
63
-
64
- /**
65
- * Get the Kitty protocol format code for a given image format string.
66
- * Defaults to PNG (100) for unknown formats.
67
- */
68
- function getKittyFormatCode(format: string): number {
69
- return KITTY_FORMAT_CODES[format.toLowerCase()] ?? 100
70
- }
71
-
72
- /**
73
- * Encode an image buffer into Kitty graphics protocol escape sequences.
74
- *
75
- * Large images are split into chunks of KITTY_IMAGE_CHUNK_SIZE bytes
76
- * to avoid overflowing terminal input buffers.
77
- *
78
- * @param buffer - Raw image data
79
- * @param format - Image format (png, jpeg, gif, webp)
80
- * @returns Kitty protocol escape sequence
81
- */
82
- export function encodeKittyImage(buffer: Buffer, format: string): string {
83
- const f = getKittyFormatCode(format)
84
- const b64 = buffer.toString('base64')
85
- const parts: string[] = []
86
-
87
- if (b64.length <= KITTY_IMAGE_CHUNK_SIZE) {
88
- // Single chunk — no splitting needed
89
- parts.push(`\x1b_Ga=d,f=${f},m=0;${b64}\x1b\\`)
90
- } else {
91
- // Split into multiple chunks
92
- let offset = 0
93
- while (offset < b64.length) {
94
- const chunk = b64.slice(offset, offset + KITTY_IMAGE_CHUNK_SIZE)
95
- const isLast = offset + KITTY_IMAGE_CHUNK_SIZE >= b64.length
96
- parts.push(`\x1b_Ga=d,f=${f},m=${isLast ? 0 : 1};${chunk}\x1b\\`)
97
- offset += KITTY_IMAGE_CHUNK_SIZE
98
- }
99
- }
100
-
101
- const sequence = parts.join('')
102
-
103
- // Wrap for tmux/screen multiplexer passthrough if needed
104
- return wrapForMultiplexer(sequence)
105
- }
106
-
107
- /**
108
- * Generate a terminal image display sequence for an image buffer.
109
- * Picks the best protocol for the current terminal.
110
- *
111
- * @param buffer - Raw image data
112
- * @param format - Image format (png, jpeg, gif, webp)
113
- * @returns The escape sequence to write to stdout, or null if no protocol available
114
- */
115
- export function encodeImageForTerminal(
116
- buffer: Buffer,
117
- format: string,
118
- ): string | null {
119
- const protocol = detectImageProtocol()
120
- if (protocol === 'kitty') {
121
- return encodeKittyImage(buffer, format)
122
- }
123
- return null
124
- }
125
-
126
- /**
127
- * Image protocol capabilities summary (for debugging/logging).
128
- */
129
- export function getImageProtocolSummary(): string {
130
- const protocol = detectImageProtocol()
131
- if (protocol === 'kitty') return 'kitty'
132
- return 'none'
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')
158
- if (!timgPath) return null
159
-
160
- const cols = columns ?? process.stdout.columns ?? 80
161
- const termRows = rows ?? process.stdout.rows ?? 40
162
- const tmpDir = mkdtempSync(join(tmpdir(), 'codev-timg-'))
163
- const ext = format === 'jpeg' ? 'jpg' : format
164
- const tmpFile = join(tmpDir, `image.${ext}`)
165
- let result: string | null = null
166
-
167
- // Use at most half the terminal height so the image doesn't dominate
168
- const maxRows = Math.max(10, Math.floor(termRows * 0.5))
169
-
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 {
195
- try {
196
- rmSync(tmpDir, { recursive: true, force: true })
197
- } catch {
198
- // ignore cleanup errors
199
- }
200
- }
201
-
202
- return result
203
- } catch {
204
- return null
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
211
- * and the image output — eliminating cursor-position races.
212
- *
213
- * @see renderImageWithTimg
214
- */
215
- export function renderImageWithTimgSync(
216
- buffer: Buffer,
217
- format: string,
218
- columns?: number,
219
- rows?: number,
220
- ): string | null {
221
- try {
222
- const timgPath = whichSync('timg')
223
- if (!timgPath) return null
224
-
225
- const cols = columns ?? process.stdout.columns ?? 80
226
- const termRows = rows ?? process.stdout.rows ?? 40
227
- const maxRows = Math.max(10, Math.floor(termRows * 0.5))
228
- const tmpDir = mkdtempSync(join(tmpdir(), 'codev-timg-'))
229
- const ext = format === 'jpeg' ? 'jpg' : format
230
- const tmpFile = join(tmpDir, `image.${ext}`)
231
- let result: string | null = null
232
-
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 {
262
- try {
263
- rmSync(tmpDir, { recursive: true, force: true })
264
- } catch {
265
- // ignore cleanup errors
266
- }
267
- }
268
-
269
- return result
270
- } catch {
271
- return null
272
- }
273
- }