chenbhao Claude Opus 4.6 commited on
Commit
ac60486
·
1 Parent(s): 9ec41c1

refactor: simplify ImageShowTool with jimp/ink-picture and aspect-ratio-aware rendering

Browse files

Replace manual fetch/read/detect helpers with Jimp + loadImageFromUrl,
delegating image loading to the same infrastructure used by ink-picture.
Return naturalWidth/naturalHeight from call() so renderToolResultMessage
can compute pixel dimensions from the original aspect ratio via the
pixelWidth/pixelHeight props now supported by the Image component.

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

src/tools/ImageShowTool/ImageShowTool.tsx CHANGED
@@ -1,11 +1,12 @@
1
- import { readFile } from 'fs/promises'
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, type TerminalInfo } from 'src/ink-picture/index.ts'
 
9
 
10
  const IMAGE_TOOL_NAME = 'ImageShow'
11
 
@@ -25,91 +26,9 @@ const inputSchema = () =>
25
 
26
  type Input = z.infer<ReturnType<typeof inputSchema>>
27
 
28
- /** Detect format from URL/file extension */
29
- function detectFormat(url: string): string {
30
- const clean = url.split('?')[0]!.split('#')[0]!
31
- const ext = clean.split('.').pop()?.toLowerCase() ?? ''
32
- if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) {
33
- return ext === 'jpg' ? 'jpeg' : ext
34
- }
35
- return 'png'
36
- }
37
-
38
- /** Read a local file and return its buffer + detected format */
39
- async function readLocalImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
40
- try {
41
- const format = detectFormat(url)
42
- const buffer = await readFile(url)
43
- return { buffer, format }
44
- } catch {
45
- return null
46
- }
47
- }
48
-
49
- /** Download an image from URL and return its buffer + detected format */
50
- async function fetchImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
51
- try {
52
- const response = await fetch(url, {
53
- headers: {
54
- 'User-Agent': 'Mozilla/5.0 (compatible; Codev/1.0)',
55
- },
56
- redirect: 'follow',
57
- })
58
-
59
- if (!response.ok) {
60
- logForDebugging(`ImageShow: HTTP ${response.status} for ${url}`)
61
- return null
62
- }
63
-
64
- const contentType = response.headers.get('content-type') ?? ''
65
- const format = detectFormat(url !== contentType ? url : contentType)
66
-
67
- const reader = response.body?.getReader()
68
- if (!reader) return null
69
-
70
- const chunks: Uint8Array[] = []
71
- let totalSize = 0
72
- const MAX_SIZE = 10_000_000
73
-
74
- while (true) {
75
- const { done, value } = await reader.read()
76
- if (done) break
77
- totalSize += value.byteLength
78
- if (totalSize > MAX_SIZE) {
79
- logForDebugging(`ImageShow: image too large (${totalSize} bytes) for ${url}`)
80
- reader.cancel()
81
- return null
82
- }
83
- chunks.push(value)
84
- }
85
-
86
- const combinedLength = chunks.reduce((acc, c) => acc + c.byteLength, 0)
87
- const combined = new Uint8Array(combinedLength)
88
- let offset = 0
89
- for (const chunk of chunks) {
90
- combined.set(chunk, offset)
91
- offset += chunk.byteLength
92
- }
93
-
94
- return { buffer: Buffer.from(combined.buffer), format }
95
- } catch (err) {
96
- logForDebugging(`ImageShow: fetch error ${err} for ${url}`)
97
- return null
98
- }
99
- }
100
-
101
- /** Load image: local file or remote URL */
102
- async function loadImage(url: string): Promise<{ buffer: Buffer; format: string } | null> {
103
- // Expand ~ to home directory
104
- const normalizedUrl = url.startsWith('~')
105
- ? url.replace(/^~(?=$|\/)/, homedir())
106
- : url
107
- if (normalizedUrl.startsWith('file://') || normalizedUrl.startsWith('/') || normalizedUrl.startsWith('.')) {
108
- const path = normalizedUrl.startsWith('file://') ? normalizedUrl.slice(7) : normalizedUrl
109
- return readLocalImage(path)
110
- }
111
- return fetchImage(normalizedUrl)
112
- }
113
 
114
  /**
115
  * Lightweight synchronous Kitty graphics protocol detection.
@@ -131,6 +50,26 @@ function getToolUseSummary(input: Partial<Input>): string | null {
131
  return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
132
  }
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  export const ImageShowTool = buildTool({
135
  name: IMAGE_TOOL_NAME,
136
  description:
@@ -216,6 +155,8 @@ export const ImageShowTool = buildTool({
216
  message: string
217
  src?: string
218
  alt?: string
 
 
219
  },
220
  _progressMessages,
221
  _options,
@@ -227,9 +168,27 @@ export const ImageShowTool = buildTool({
227
  if (content.src) {
228
  const cols = process.stdout.columns ?? 80
229
  const rows = process.stdout.rows ?? 40
230
- // Reasonable image size: 60% of terminal width, 40% of terminal height
231
- const imgWidth = Math.floor(cols * 0.6)
232
- const imgHeight = Math.floor(rows * 0.4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
  // Sync terminal info prevents InkPictureProvider's async terminal
235
  // query from delaying the first render and ensures the correct
@@ -243,8 +202,10 @@ export const ImageShowTool = buildTool({
243
  <InkPictureProvider terminalInfo={terminalInfo}>
244
  <Image
245
  src={content.src}
246
- width={imgWidth}
247
  height={imgHeight}
 
 
248
  alt={content.alt}
249
  />
250
  </InkPictureProvider>
@@ -262,58 +223,58 @@ export const ImageShowTool = buildTool({
262
  base64?: string
263
  src?: string
264
  alt?: string
 
 
265
  }
266
  }> {
267
  const url = input.url
268
  const alt = input.alt ?? url.split('/').pop() ?? 'image'
269
 
270
- // Normalize src for ink-picture rendering
271
- let src = url
272
- if (src.startsWith('~')) {
273
- src = src.replace(/^~(?=$|\/)/, homedir())
274
- }
275
- if (src.startsWith('file://')) {
276
- src = src.slice(7)
277
- }
278
-
279
  logForDebugging(`ImageShow: loading ${url}`)
280
 
281
- const result = await loadImage(url)
282
-
283
- if (!result) {
284
- return {
285
- data: {
286
- success: false,
287
- message: `Failed to load image from: ${url}`,
288
- },
289
- }
290
- }
291
 
292
- const { buffer, format } = result
293
 
294
- const mediaType =
295
- format === 'jpeg'
296
- ? 'image/jpeg'
297
- : format === 'gif'
298
- ? 'image/gif'
299
- : format === 'webp'
300
- ? 'image/webp'
301
- : 'image/png'
 
302
 
303
- logForDebugging(`ImageShow: loaded ${buffer.length} byte ${format} image`)
304
 
305
- return {
306
- data: {
307
- success: true,
308
- message: `Displayed: ${alt} (${buffer.length} bytes, ${format})`,
309
- imageData: {
 
 
 
310
  base64: buffer.toString('base64'),
311
- mediaType,
 
 
 
312
  },
313
- base64: buffer.toString('base64'),
314
- src,
315
- alt,
316
- },
 
 
 
 
 
317
  }
318
  },
319
  }) satisfies ToolDef<any, any>
 
 
1
  import { homedir } from 'os'
2
  import React from 'react'
3
  import { z } from 'zod/v4'
4
+ import { Jimp } from 'jimp'
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, type TerminalInfo } from 'src/ink-picture/index.ts'
9
+ import { loadImageFromUrl } from 'src/ink-picture/utils/jimpURL.ts'
10
 
11
  const IMAGE_TOOL_NAME = 'ImageShow'
12
 
 
26
 
27
  type Input = z.infer<ReturnType<typeof inputSchema>>
28
 
29
+ /** Standard terminal cell size in pixels */
30
+ const CELL_WIDTH = 8
31
+ const CELL_HEIGHT = 16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  /**
34
  * Lightweight synchronous Kitty graphics protocol detection.
 
50
  return input?.url ? `Show: ${input.url.split('/').pop() ?? input.url}` : null
51
  }
52
 
53
+ /** Normalize image source path: expand ~, strip file:// */
54
+ function normalizeSrc(url: string): string {
55
+ let src = url
56
+ if (src.startsWith('~')) {
57
+ src = src.replace(/^~(?=$|\/)/, homedir())
58
+ }
59
+ if (src.startsWith('file://')) {
60
+ src = src.slice(7)
61
+ }
62
+ return src
63
+ }
64
+
65
+ /** Load image from local path or URL, returning Jimp instance */
66
+ async function loadImage(src: string): Promise<Jimp> {
67
+ if (src.startsWith('http://') || src.startsWith('https://')) {
68
+ return loadImageFromUrl(src)
69
+ }
70
+ return Jimp.read(src)
71
+ }
72
+
73
  export const ImageShowTool = buildTool({
74
  name: IMAGE_TOOL_NAME,
75
  description:
 
155
  message: string
156
  src?: string
157
  alt?: string
158
+ naturalWidth?: number
159
+ naturalHeight?: number
160
  },
161
  _progressMessages,
162
  _options,
 
168
  if (content.src) {
169
  const cols = process.stdout.columns ?? 80
170
  const rows = process.stdout.rows ?? 40
171
+
172
+ // Target: 60% of terminal width
173
+ const targetW_chars = Math.floor(cols * 0.6)
174
+ const targetW_pixels = targetW_chars * CELL_WIDTH
175
+
176
+ // Compute height from original aspect ratio if available
177
+ let pixelHeight: number
178
+ let imgHeight: number
179
+
180
+ if (content.naturalWidth && content.naturalHeight) {
181
+ const targetH_pixels = Math.floor(
182
+ targetW_pixels * (content.naturalHeight / content.naturalWidth),
183
+ )
184
+ const minH_pixels = 3 * CELL_HEIGHT
185
+ pixelHeight = Math.max(targetH_pixels, minH_pixels)
186
+ imgHeight = Math.ceil(pixelHeight / CELL_HEIGHT)
187
+ } else {
188
+ // Fallback: 40% of terminal height
189
+ imgHeight = Math.floor(rows * 0.4)
190
+ pixelHeight = imgHeight * CELL_HEIGHT
191
+ }
192
 
193
  // Sync terminal info prevents InkPictureProvider's async terminal
194
  // query from delaying the first render and ensures the correct
 
202
  <InkPictureProvider terminalInfo={terminalInfo}>
203
  <Image
204
  src={content.src}
205
+ width={targetW_chars}
206
  height={imgHeight}
207
+ pixelWidth={targetW_pixels}
208
+ pixelHeight={pixelHeight}
209
  alt={content.alt}
210
  />
211
  </InkPictureProvider>
 
223
  base64?: string
224
  src?: string
225
  alt?: string
226
+ naturalWidth?: number
227
+ naturalHeight?: number
228
  }
229
  }> {
230
  const url = input.url
231
  const alt = input.alt ?? url.split('/').pop() ?? 'image'
232
 
233
+ const src = normalizeSrc(url)
 
 
 
 
 
 
 
 
234
  logForDebugging(`ImageShow: loading ${url}`)
235
 
236
+ try {
237
+ const image = await loadImage(src)
238
+ const naturalWidth = image.bitmap.width
239
+ const naturalHeight = image.bitmap.height
 
 
 
 
 
 
240
 
241
+ const buffer = await image.getBuffer('image/png')
242
 
243
+ if (buffer.length > 10_000_000) {
244
+ logForDebugging(`ImageShow: image too large (${buffer.length} bytes)`)
245
+ return {
246
+ data: {
247
+ success: false,
248
+ message: `Image too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Max 10 MB.`,
249
+ },
250
+ }
251
+ }
252
 
253
+ logForDebugging(`ImageShow: loaded ${buffer.length} byte PNG, ${naturalWidth}x${naturalHeight}`)
254
 
255
+ return {
256
+ data: {
257
+ success: true,
258
+ message: `Displayed: ${alt} (${buffer.length} bytes, PNG, ${naturalWidth}x${naturalHeight})`,
259
+ imageData: {
260
+ base64: buffer.toString('base64'),
261
+ mediaType: 'image/png',
262
+ },
263
  base64: buffer.toString('base64'),
264
+ src,
265
+ alt,
266
+ naturalWidth,
267
+ naturalHeight,
268
  },
269
+ }
270
+ } catch (err) {
271
+ logForDebugging(`ImageShow: load error ${err} for ${url}`)
272
+ return {
273
+ data: {
274
+ success: false,
275
+ message: `Failed to fetch: ${url}`,
276
+ },
277
+ }
278
  }
279
  },
280
  }) satisfies ToolDef<any, any>
src/tools/ImageShowTool/__tests__/ShowLocalImage.test.tsx CHANGED
@@ -4,44 +4,65 @@
4
  *
5
  * Usage:
6
  * bun run src/tools/ImageShowTool/__tests__/ShowLocalImage.test.tsx
 
7
  */
8
 
9
- import React, { useEffect } from 'react'
 
10
  import { render, Box, Text, useApp } from 'ink'
11
- import Image, { InkPictureProvider, type TerminalInfo } from 'src/ink-picture/index.ts'
12
 
13
  const IMAGE_PATH = '/home/yuki/Pictures/Wallpapers/3god.jpg'
14
 
15
- const cols = process.stdout.columns ?? 80
16
- const rows = process.stdout.rows ?? 40
17
- const imgWidth = Math.floor(cols * 0.6)
18
- const imgHeight = Math.floor(rows * 0.4)
19
-
20
- const terminalInfo: Partial<TerminalInfo> = {
21
- supportsKittyGraphics: !!(
22
- process.env.TERM?.includes('kitty') ||
23
- process.env.KITTY_WINDOW_ID ||
24
- process.env.TERM_PROGRAM === 'kitty' ||
25
- process.env.TERM_PROGRAM === 'ghostty' ||
26
- process.env.TERM_PROGRAM === 'WezTerm' ||
27
- process.env.TERM_PROGRAM === 'konsole' ||
28
- process.env.TERM_PROGRAM === 'foot'
29
- ),
30
- supportsUnicode: true,
31
- }
32
 
33
  function App() {
34
  const { exit } = useApp()
 
 
 
 
 
 
 
35
 
36
  useEffect(() => {
37
- const timer = setTimeout(() => exit(), 3000)
38
- return () => clearTimeout(timer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  }, [exit])
40
 
 
 
 
 
 
 
 
 
 
41
  return (
42
  <Box flexDirection="column">
43
- <InkPictureProvider terminalInfo={terminalInfo}>
44
- <Image src={IMAGE_PATH} width={imgWidth} height={imgHeight} alt="3god" />
45
  </InkPictureProvider>
46
  </Box>
47
  )
 
4
  *
5
  * Usage:
6
  * bun run src/tools/ImageShowTool/__tests__/ShowLocalImage.test.tsx
7
+ * Press Ctrl+C to exit
8
  */
9
 
10
+ import { Jimp } from 'jimp'
11
+ import React, { useEffect, useState } from 'react'
12
  import { render, Box, Text, useApp } from 'ink'
13
+ import Image, { InkPictureProvider } from 'src/ink-picture/index.ts'
14
 
15
  const IMAGE_PATH = '/home/yuki/Pictures/Wallpapers/3god.jpg'
16
 
17
+ const CELL_WIDTH = 8
18
+ const CELL_HEIGHT = 16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  function App() {
21
  const { exit } = useApp()
22
+ const [dims, setDims] = useState<{
23
+ width: number
24
+ height: number
25
+ pixelWidth: number
26
+ pixelHeight: number
27
+ } | null>(null)
28
+ const [err, setErr] = useState(false)
29
 
30
  useEffect(() => {
31
+ (async () => {
32
+ try {
33
+ const image = await Jimp.read(IMAGE_PATH)
34
+ const origW = image.bitmap.width
35
+ const origH = image.bitmap.height
36
+ const cols = process.stdout.columns ?? 80
37
+
38
+ const targetW_chars = Math.floor(cols * 0.6)
39
+ const targetW_pixels = targetW_chars * CELL_WIDTH
40
+ const targetH_pixels = Math.floor(targetW_pixels * (origH / origW))
41
+ const minH_pixels = 3 * CELL_HEIGHT
42
+ const finalH_pixels = Math.max(targetH_pixels, minH_pixels)
43
+ const targetH_chars = Math.ceil(finalH_pixels / CELL_HEIGHT)
44
+
45
+ setDims({ width: targetW_chars, height: targetH_chars, pixelWidth: targetW_pixels, pixelHeight: finalH_pixels })
46
+ } catch {
47
+ setErr(true)
48
+ exit()
49
+ }
50
+ })()
51
  }, [exit])
52
 
53
+ useEffect(() => {
54
+ const handleSigint = () => exit()
55
+ process.on('SIGINT', handleSigint)
56
+ return () => process.off('SIGINT', handleSigint)
57
+ }, [exit])
58
+
59
+ if (err) return <Text color="red">Failed to load image</Text>
60
+ if (!dims) return <Text>Loading...</Text>
61
+
62
  return (
63
  <Box flexDirection="column">
64
+ <InkPictureProvider>
65
+ <Image src={IMAGE_PATH} width={dims.width} height={dims.height} pixelWidth={dims.pixelWidth} pixelHeight={dims.pixelHeight} alt="3god" />
66
  </InkPictureProvider>
67
  </Box>
68
  )
src/tools/ImageShowTool/__tests__/ShowUrlImage.test.tsx CHANGED
@@ -4,46 +4,65 @@
4
  *
5
  * Usage:
6
  * bun run src/tools/ImageShowTool/__tests__/ShowUrlImage.test.tsx
 
7
  */
8
 
9
- import React, { useEffect } from 'react'
10
  import { render, Box, Text, useApp } from 'ink'
11
- import Image, { InkPictureProvider, type TerminalInfo } from 'src/ink-picture/index.ts'
12
-
13
- const IMAGE_URL =
14
- 'https://kimi-web-img.moonshot.cn/img/images.pexels.com/8f909bffa5353ba43aac1db1f83b1bf5450a8cd5.jpeg'
15
-
16
- const cols = process.stdout.columns ?? 80
17
- const rows = process.stdout.rows ?? 40
18
- const imgWidth = Math.floor(cols * 0.6)
19
- const imgHeight = Math.floor(rows * 0.4)
20
-
21
- const terminalInfo: Partial<TerminalInfo> = {
22
- supportsKittyGraphics: !!(
23
- process.env.TERM?.includes('kitty') ||
24
- process.env.KITTY_WINDOW_ID ||
25
- process.env.TERM_PROGRAM === 'kitty' ||
26
- process.env.TERM_PROGRAM === 'ghostty' ||
27
- process.env.TERM_PROGRAM === 'WezTerm' ||
28
- process.env.TERM_PROGRAM === 'konsole' ||
29
- process.env.TERM_PROGRAM === 'foot'
30
- ),
31
- supportsUnicode: true,
32
- }
33
 
34
  function App() {
35
  const { exit } = useApp()
 
 
 
 
 
 
 
36
 
37
  useEffect(() => {
38
- // Longer timeout for remote image (network fetch)
39
- const timer = setTimeout(() => exit(), 15000)
40
- return () => clearTimeout(timer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  }, [exit])
42
 
 
 
 
 
 
 
 
 
 
43
  return (
44
  <Box flexDirection="column">
45
- <InkPictureProvider terminalInfo={terminalInfo}>
46
- <Image src={IMAGE_URL} width={imgWidth} height={imgHeight} alt="remote" />
47
  </InkPictureProvider>
48
  </Box>
49
  )
 
4
  *
5
  * Usage:
6
  * bun run src/tools/ImageShowTool/__tests__/ShowUrlImage.test.tsx
7
+ * Press Ctrl+C to exit
8
  */
9
 
10
+ import React, { useEffect, useState } from 'react'
11
  import { render, Box, Text, useApp } from 'ink'
12
+ import Image, { InkPictureProvider } from 'src/ink-picture/index.ts'
13
+ import { loadImageFromUrl } from 'src/ink-picture/utils/jimpURL.ts'
14
+
15
+ const IMAGE_URL = 'https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png'
16
+
17
+ const CELL_WIDTH = 8
18
+ const CELL_HEIGHT = 16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  function App() {
21
  const { exit } = useApp()
22
+ const [dims, setDims] = useState<{
23
+ width: number
24
+ height: number
25
+ pixelWidth: number
26
+ pixelHeight: number
27
+ } | null>(null)
28
+ const [err, setErr] = useState(false)
29
 
30
  useEffect(() => {
31
+ (async () => {
32
+ try {
33
+ const image = await loadImageFromUrl(IMAGE_URL)
34
+ const origW = image.bitmap.width
35
+ const origH = image.bitmap.height
36
+ const cols = process.stdout.columns ?? 80
37
+
38
+ const targetW_chars = Math.floor(cols * 0.6)
39
+ const targetW_pixels = targetW_chars * CELL_WIDTH
40
+ const targetH_pixels = Math.floor(targetW_pixels * (origH / origW))
41
+ const minH_pixels = 3 * CELL_HEIGHT
42
+ const finalH_pixels = Math.max(targetH_pixels, minH_pixels)
43
+ const targetH_chars = Math.ceil(finalH_pixels / CELL_HEIGHT)
44
+
45
+ setDims({ width: targetW_chars, height: targetH_chars, pixelWidth: targetW_pixels, pixelHeight: finalH_pixels })
46
+ } catch {
47
+ setErr(true)
48
+ exit()
49
+ }
50
+ })()
51
  }, [exit])
52
 
53
+ useEffect(() => {
54
+ const handleSigint = () => exit()
55
+ process.on('SIGINT', handleSigint)
56
+ return () => process.off('SIGINT', handleSigint)
57
+ }, [exit])
58
+
59
+ if (err) return <Text color="red">Failed to load image: {IMAGE_URL}</Text>
60
+ if (!dims) return <Text>Loading...</Text>
61
+
62
  return (
63
  <Box flexDirection="column">
64
+ <InkPictureProvider>
65
+ <Image src={IMAGE_URL} width={dims.width} height={dims.height} pixelWidth={dims.pixelWidth} pixelHeight={dims.pixelHeight} alt="remote" />
66
  </InkPictureProvider>
67
  </Box>
68
  )