chenbhao Claude Opus 4.6 commited on
Commit
a699180
·
1 Parent(s): ac3f753

refactor: replace timg with jimp + ink-picture renderers for image display

Browse files

Remove external `timg` binary dependency in ImageShowTool by using `jimp`
for image decoding and adapted ink-picture renderers (halfBlock/braille/ascii)
for ANSI text-protocol output. The renderers are pure functions that work
with codev's Ink fork via RawAnsi, avoiding context-incompatibility with
the ink-picture React components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

src/tools/ImageShowTool/ImageShowTool.tsx CHANGED
@@ -2,12 +2,16 @@ 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
 
12
  const IMAGE_TOOL_NAME = 'ImageShow'
13
 
@@ -114,14 +118,73 @@ async function loadImage(url: string): Promise<{ buffer: Buffer; format: string
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
  }
@@ -139,7 +202,7 @@ 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
 
@@ -164,7 +227,7 @@ export const ImageShowTool = buildTool({
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,7 +282,7 @@ export const ImageShowTool = buildTool({
219
  content: {
220
  success: boolean
221
  message: string
222
- timgOutput?: string
223
  },
224
  _progressMessages,
225
  _options,
@@ -228,8 +291,8 @@ export const ImageShowTool = buildTool({
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,7 +304,7 @@ export const ImageShowTool = buildTool({
241
  message: string
242
  imageData?: { base64: string; mediaType: string }
243
  base64?: string
244
- timgOutput?: string
245
  }
246
  }> {
247
  const url = input.url
@@ -273,12 +336,12 @@ export const ImageShowTool = buildTool({
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,7 +353,7 @@ export const ImageShowTool = buildTool({
290
  mediaType,
291
  },
292
  base64: buffer.toString('base64'),
293
- timgOutput,
294
  },
295
  }
296
  },
 
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
 
 
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
  }
 
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
 
 
227
  },
228
 
229
  async prompt(_options): Promise<string> {
230
+ return `ImageShow displays a PNG/JPEG/GIF/WebP image directly in the terminal using 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.`
231
  },
232
 
233
  async checkPermissions(): Promise<{ behavior: 'allow' }> {
 
282
  content: {
283
  success: boolean
284
  message: string
285
+ imageOutput?: string
286
  },
287
  _progressMessages,
288
  _options,
 
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
  message: string
305
  imageData?: { base64: string; mediaType: string }
306
  base64?: string
307
+ imageOutput?: string
308
  }
309
  }> {
310
  const url = input.url
 
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 {
 
353
  mediaType,
354
  },
355
  base64: buffer.toString('base64'),
356
+ imageOutput,
357
  },
358
  }
359
  },
src/utils/imageRenderers.ts ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Terminal image renderers adapted from ink-picture.
3
+ *
4
+ * Pure functions that convert pixel data into ANSI-escaped strings
5
+ * for display in Ink's RawAnsi component. No Ink/React dependency.
6
+ *
7
+ * Source: https://github.com/endernoke/ink-picture
8
+ */
9
+
10
+ import chalk from 'chalk'
11
+
12
+ // --- Types ---
13
+
14
+ export interface PixelData {
15
+ data: Buffer
16
+ info: {
17
+ width: number
18
+ height: number
19
+ channels: number
20
+ }
21
+ }
22
+
23
+ export type TextImageProtocol = 'halfBlock' | 'braille' | 'ascii'
24
+
25
+ // --- Protocol detection ---
26
+
27
+ export interface TerminalCaps {
28
+ supportsUnicode: boolean
29
+ supportsColor: boolean
30
+ }
31
+
32
+ /**
33
+ * Select the best text-based image protocol given terminal capabilities.
34
+ * Prioritises halfBlock (best quality), then braille, then ascii (lowest).
35
+ */
36
+ export function getBestTextProtocol(caps: TerminalCaps): TextImageProtocol {
37
+ if (caps.supportsUnicode && caps.supportsColor) return 'halfBlock'
38
+ if (caps.supportsUnicode) return 'braille'
39
+ return 'ascii'
40
+ }
41
+
42
+ // --- ASCII renderer ---
43
+
44
+ const ASCII_CHARS =
45
+ "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "
46
+
47
+ export function renderAscii(
48
+ pixels: PixelData,
49
+ colored: boolean = true,
50
+ ): string {
51
+ const { data, info } = pixels
52
+ const { width, height, channels } = info
53
+
54
+ let result = ''
55
+ for (let y = 0; y < height; y++) {
56
+ for (let x = 0; x < width; x++) {
57
+ const pixelIndex = (y * width + x) * channels
58
+
59
+ const r = data[pixelIndex]!
60
+ const g = data[pixelIndex + 1]!
61
+ const b = data[pixelIndex + 2]!
62
+ const a = channels === 4 ? (data[pixelIndex + 3] ?? 255) : 255
63
+
64
+ const sum = r + g + b + a
65
+ const intensity = sum === 0 ? 0 : sum / (255 * 4)
66
+ const char =
67
+ ASCII_CHARS[
68
+ ASCII_CHARS.length -
69
+ 1 -
70
+ Math.floor(intensity * (ASCII_CHARS.length - 1))
71
+ ]!
72
+
73
+ result += colored ? chalk.rgb(r, g, b)(char) : char
74
+ }
75
+
76
+ result += '\n'
77
+ }
78
+
79
+ return result.slice(0, -1)
80
+ }
81
+
82
+ // --- Half-block renderer ---
83
+
84
+ const HALF_BLOCK = '\u2584'
85
+
86
+ export function renderHalfBlock(pixels: PixelData): string {
87
+ const { data, info } = pixels
88
+ const { width, height, channels } = info
89
+
90
+ let result = ''
91
+ for (let y = 0; y < height - 1; y += 2) {
92
+ for (let x = 0; x < width; x++) {
93
+ const topPixelIndex = (y * width + x) * channels
94
+ const bottomPixelIndex = ((y + 1) * width + x) * channels
95
+
96
+ const r = data[topPixelIndex]!
97
+ const g = data[topPixelIndex + 1]!
98
+ const b = data[topPixelIndex + 2]!
99
+ const a = channels === 4 ? (data[topPixelIndex + 3] ?? 255) : 255
100
+
101
+ const r2 = data[bottomPixelIndex]!
102
+ const g2 = data[bottomPixelIndex + 1]!
103
+ const b2 = data[bottomPixelIndex + 2]!
104
+
105
+ result +=
106
+ a === 0
107
+ ? chalk.reset(' ')
108
+ : chalk.bgRgb(r, g, b).rgb(r2, g2, b2)(HALF_BLOCK)
109
+ }
110
+
111
+ result += '\n'
112
+ }
113
+
114
+ return result.slice(0, -1)
115
+ }
116
+
117
+ // --- Braille renderer ---
118
+
119
+ export function renderBraille(pixels: PixelData): string {
120
+ const { data, info } = pixels
121
+ const { width, height, channels } = info
122
+
123
+ let result = ''
124
+ for (let y = 0; y < height - 3; y += 4) {
125
+ for (let x = 0; x < width - 1; x += 2) {
126
+ const dot1Index = (y * width + x) * channels
127
+ const dot2Index = ((y + 1) * width + x) * channels
128
+ const dot3Index = ((y + 2) * width + x) * channels
129
+ const dot4Index = (y * width + x + 1) * channels
130
+ const dot5Index = ((y + 1) * width + x + 1) * channels
131
+ const dot6Index = ((y + 2) * width + x + 1) * channels
132
+ const dot7Index = ((y + 3) * width + x) * channels
133
+ const dot8Index = ((y + 3) * width + x + 1) * channels
134
+
135
+ const getLuminance = (index: number) => {
136
+ const r = data[index]!
137
+ const g = data[index + 1]!
138
+ const b = data[index + 2]!
139
+ const a = channels === 4 ? (data[index + 3] ?? 255) : 255
140
+ const alpha = a / 255
141
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
142
+ const alphaAdjustedLuminance =
143
+ luminance * alpha + 255 * (1 - alpha)
144
+ return alphaAdjustedLuminance > 128 ? 1 : 0
145
+ }
146
+
147
+ const dot1 = getLuminance(dot1Index)
148
+ const dot2 = getLuminance(dot2Index)
149
+ const dot3 = getLuminance(dot3Index)
150
+ const dot4 = getLuminance(dot4Index)
151
+ const dot5 = getLuminance(dot5Index)
152
+ const dot6 = getLuminance(dot6Index)
153
+ const dot7 = getLuminance(dot7Index)
154
+ const dot8 = getLuminance(dot8Index)
155
+
156
+ const brailleChar = String.fromCharCode(
157
+ 0x2800 +
158
+ (dot8 << 7) +
159
+ (dot7 << 6) +
160
+ (dot6 << 5) +
161
+ (dot5 << 4) +
162
+ (dot4 << 3) +
163
+ (dot3 << 2) +
164
+ (dot2 << 1) +
165
+ dot1,
166
+ )
167
+ result += brailleChar
168
+ }
169
+ result += '\n'
170
+ }
171
+
172
+ return result.slice(0, -1)
173
+ }
174
+
175
+ /**
176
+ * Render pixel data as a string using the specified text protocol.
177
+ */
178
+ export function renderImage(
179
+ pixels: PixelData,
180
+ protocol: TextImageProtocol,
181
+ colored: boolean = true,
182
+ ): string {
183
+ switch (protocol) {
184
+ case 'halfBlock':
185
+ return renderHalfBlock(pixels)
186
+ case 'braille':
187
+ return renderBraille(pixels)
188
+ case 'ascii':
189
+ return renderAscii(pixels, colored)
190
+ }
191
+ }