chenbhao Claude Opus 4.6 commited on
Commit
0afdca0
·
1 Parent(s): c0f64e8

refactor: rewrite ImageShowTool with new architecture

Browse files

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

src/tools/ImageShowTool/ImageShowTool.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Visual test: display a local image or URL using ink-picture + Ink.
4
+ *
5
+ * Usage:
6
+ * bun run src/ink-picture/__tests__/LocalPicture.test.tsx [path|url]
7
+ *
8
+ * Examples:
9
+ * bun run src/ink-picture/__tests__/LocalPicture.test.tsx ~/Pictures/IMAGE/image.png
10
+ * bun run src/ink-picture/__tests__/LocalPicture.test.tsx https://example.com/image.jpg
11
+ */
12
+
13
+ import { Jimp } from "jimp";
14
+ import React, { useEffect, useState } from "react";
15
+ import { render, Box, Text, useApp } from "ink";
16
+ import Image, { InkPictureProvider } from "../../ink-picture/index.ts";
17
+ import { loadImageFromUrl } from "../../ink-picture/utils/jimpURL.ts";
18
+
19
+ // 终端字符尺寸(像素)
20
+ const CELL_WIDTH = 8;
21
+ const CELL_HEIGHT = 16;
22
+
23
+ // 获取命令行参数
24
+ const args = process.argv.slice(2);
25
+ const IMAGE_PATH = args[0] || "/home/yuki/Pictures/Wallpapers/3god.jpg";
26
+
27
+ // 判断是 URL 还是本地路径
28
+ const isUrl = IMAGE_PATH.startsWith("http://") || IMAGE_PATH.startsWith("https://");
29
+
30
+ // 加载图片(支持本地和 URL)
31
+ async function loadImage(path: string) {
32
+ if (isUrl) {
33
+ return loadImageFromUrl(path);
34
+ } else {
35
+ return Jimp.read(path);
36
+ }
37
+ }
38
+
39
+ function App() {
40
+ const { exit } = useApp();
41
+ const [dimensions, setDimensions] = useState<{
42
+ width: number;
43
+ height: number;
44
+ pixelWidth: number;
45
+ pixelHeight: number;
46
+ } | null>(null);
47
+ const [err, setErr] = useState(false);
48
+
49
+ useEffect(() => {
50
+ (async () => {
51
+ try {
52
+ const image = await loadImage(IMAGE_PATH);
53
+ const origW = image.bitmap.width;
54
+ const origH = image.bitmap.height;
55
+ const cols = process.stdout.columns ?? 80;
56
+
57
+ const targetW_chars = Math.floor(cols * 0.1618);
58
+ const targetW_pixels = targetW_chars * CELL_WIDTH;
59
+ const targetH_pixels = Math.floor(targetW_pixels * (origH / origW));
60
+ const minH_pixels = 3 * CELL_HEIGHT;
61
+ const finalH_pixels = Math.max(targetH_pixels, minH_pixels);
62
+ const targetH_chars = Math.ceil(finalH_pixels / CELL_HEIGHT);
63
+
64
+ setDimensions({
65
+ width: targetW_chars,
66
+ height: targetH_chars,
67
+ pixelWidth: targetW_pixels,
68
+ pixelHeight: finalH_pixels,
69
+ });
70
+ } catch (e) {
71
+ console.error(e);
72
+ setErr(true);
73
+ exit();
74
+ }
75
+ })();
76
+ }, []);
77
+
78
+ useEffect(() => {
79
+ const handleSigint = () => exit();
80
+ process.on("SIGINT", handleSigint);
81
+ return () => process.off("SIGINT", handleSigint);
82
+ }, [exit]);
83
+
84
+ if (err) {
85
+ return <Text color="red" > Failed to fetch: { IMAGE_PATH } </Text>;
86
+ }
87
+
88
+ if (!dimensions) {
89
+ return <Text>Loading...</Text>;
90
+ }
91
+
92
+ return (
93
+ <Box flexDirection= "column" >
94
+ <InkPictureProvider>
95
+ <Image
96
+ src={ IMAGE_PATH }
97
+ width = { dimensions.width }
98
+ height = { dimensions.height }
99
+ pixelWidth = { dimensions.pixelWidth }
100
+ pixelHeight = { dimensions.pixelHeight }
101
+ alt = { isUrl? "url-image": "local-image" }
102
+ />
103
+ </InkPictureProvider>
104
+ </Box>
105
+ );
106
+ }
107
+
108
+ const { waitUntilExit } = render(<App />);
109
+ await waitUntilExit();
src/tools/ImageShowTool/ImageShowTool.tsx DELETED
@@ -1,288 +0,0 @@
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
-
13
- const inputSchema = () =>
14
- z.strictObject({
15
- url: z
16
- .string()
17
- .describe(
18
- 'Image URL (https://...) or local file path (e.g. /tmp/image.png). ' +
19
- 'Supported formats: PNG, JPEG, GIF, WebP.',
20
- ),
21
- alt: z
22
- .string()
23
- .optional()
24
- .describe('Alt text shown as link when image cannot be displayed.'),
25
- })
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.
35
- * Uses only env vars — no module dependencies, no async queries.
36
- */
37
- function detectKittySync(): boolean {
38
- return !!(
39
- process.env.TERM?.includes('kitty') ||
40
- process.env.KITTY_WINDOW_ID ||
41
- process.env.TERM_PROGRAM === 'kitty' ||
42
- process.env.TERM_PROGRAM === 'ghostty' ||
43
- process.env.TERM_PROGRAM === 'WezTerm' ||
44
- process.env.TERM_PROGRAM === 'konsole' ||
45
- process.env.TERM_PROGRAM === 'foot'
46
- )
47
- }
48
-
49
- function getToolUseSummary(input: Partial<Input>): string | null {
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:
76
- 'Display an image (PNG/JPEG/GIF/WebP) directly in the terminal. ' +
77
- 'Renders with full-resolution via Kitty Graphics Protocol when supported, ' +
78
- 'with automatic fallback to text-based rendering (half-block, braille, ascii).',
79
-
80
- getToolUseSummary,
81
- getActivityDescription(input) {
82
- return input?.url ? `Showing image: ${input.url}` : 'Showing image'
83
- },
84
-
85
- isEnabled() {
86
- return true
87
- },
88
-
89
- get inputSchema() {
90
- return inputSchema()
91
- },
92
-
93
- async validateInput(input) {
94
- if (!input?.url) {
95
- return { result: false, message: 'Missing url', errorCode: 1 }
96
- }
97
- return { result: true }
98
- },
99
-
100
- async prompt(_options): Promise<string> {
101
- 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.`
102
- },
103
-
104
- async checkPermissions(): Promise<{ behavior: 'allow' }> {
105
- return { behavior: 'allow' }
106
- },
107
-
108
- isReadOnly() {
109
- return true
110
- },
111
-
112
- isConcurrencySafe() {
113
- return true
114
- },
115
-
116
- mapToolResultToToolResultBlockParam(
117
- content: {
118
- success: boolean
119
- message: string
120
- imageData?: { base64: string; mediaType: string }
121
- },
122
- toolUseID: string,
123
- ) {
124
- if (content.success && content.imageData) {
125
- return {
126
- tool_use_id: toolUseID,
127
- type: 'tool_result' as const,
128
- content: [
129
- {
130
- type: 'image' as const,
131
- source: {
132
- type: 'base64' as const,
133
- data: content.imageData.base64,
134
- media_type: content.imageData.mediaType as 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp',
135
- },
136
- },
137
- ],
138
- }
139
- }
140
- return {
141
- tool_use_id: toolUseID,
142
- type: 'tool_result' as const,
143
- content: [
144
- {
145
- type: 'text' as const,
146
- text: content.message,
147
- },
148
- ],
149
- }
150
- },
151
-
152
- renderToolResultMessage(
153
- content: {
154
- success: boolean
155
- message: string
156
- src?: string
157
- alt?: string
158
- naturalWidth?: number
159
- naturalHeight?: number
160
- base64?: string
161
- },
162
- _progressMessages,
163
- _options,
164
- ): React.ReactNode {
165
- if (!content.success) {
166
- return null
167
- }
168
-
169
- if (content.src) {
170
- const cols = process.stdout.columns ?? 80
171
- const rows = process.stdout.rows ?? 40
172
-
173
- // Target: 60% of terminal width
174
- const targetW_chars = Math.floor(cols * 0.6)
175
- const targetW_pixels = targetW_chars * CELL_WIDTH
176
-
177
- // Compute height from original aspect ratio if available
178
- let pixelHeight: number
179
- let imgHeight: number
180
-
181
- if (content.naturalWidth && content.naturalHeight) {
182
- const targetH_pixels = Math.floor(
183
- targetW_pixels * (content.naturalHeight / content.naturalWidth),
184
- )
185
- const minH_pixels = 3 * CELL_HEIGHT
186
- pixelHeight = Math.max(targetH_pixels, minH_pixels)
187
- imgHeight = Math.ceil(pixelHeight / CELL_HEIGHT)
188
- } else {
189
- // Fallback: 40% of terminal height
190
- imgHeight = Math.floor(rows * 0.4)
191
- pixelHeight = imgHeight * CELL_HEIGHT
192
- }
193
-
194
- // Use the already-loaded PNG buffer to avoid a second file read
195
- // inside the Image component. This is more reliable in the compiled binary.
196
- const imageSrc = content.base64
197
- ? Buffer.from(content.base64, 'base64')
198
- : content.src
199
-
200
- // Sync terminal info prevents InkPictureProvider's async terminal
201
- // query from delaying the first render and ensures the correct
202
- // protocol is used from frame one.
203
- const terminalInfo: Partial<TerminalInfo> = {
204
- supportsKittyGraphics: detectKittySync(),
205
- supportsUnicode: true,
206
- }
207
-
208
- return (
209
- <InkPictureProvider terminalInfo={terminalInfo}>
210
- <Image
211
- src={imageSrc}
212
- width={targetW_chars}
213
- height={imgHeight}
214
- pixelWidth={targetW_pixels}
215
- pixelHeight={pixelHeight}
216
- alt={content.alt}
217
- />
218
- </InkPictureProvider>
219
- )
220
- }
221
-
222
- return <Text dimColor>{content.message}</Text>
223
- },
224
-
225
- async call(input: Input): Promise<{
226
- data: {
227
- success: boolean
228
- message: string
229
- imageData?: { base64: string; mediaType: string }
230
- base64?: string
231
- src?: string
232
- alt?: string
233
- naturalWidth?: number
234
- naturalHeight?: number
235
- }
236
- }> {
237
- const url = input.url
238
- const alt = input.alt ?? url.split('/').pop() ?? 'image'
239
-
240
- const src = normalizeSrc(url)
241
- logForDebugging(`ImageShow: loading ${url}`)
242
-
243
- try {
244
- const image = await loadImage(src)
245
- const naturalWidth = image.bitmap.width
246
- const naturalHeight = image.bitmap.height
247
-
248
- const buffer = await image.getBuffer('image/png')
249
-
250
- if (buffer.length > 10_000_000) {
251
- logForDebugging(`ImageShow: image too large (${buffer.length} bytes)`)
252
- return {
253
- data: {
254
- success: false,
255
- message: `Image too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Max 10 MB.`,
256
- },
257
- }
258
- }
259
-
260
- logForDebugging(`ImageShow: loaded ${buffer.length} byte PNG, ${naturalWidth}x${naturalHeight}`)
261
-
262
- return {
263
- data: {
264
- success: true,
265
- message: `Displayed: ${alt} (${buffer.length} bytes, PNG, ${naturalWidth}x${naturalHeight})`,
266
- imageData: {
267
- base64: buffer.toString('base64'),
268
- mediaType: 'image/png',
269
- },
270
- base64: buffer.toString('base64'),
271
- src,
272
- alt,
273
- naturalWidth,
274
- naturalHeight,
275
- },
276
- }
277
- } catch (err) {
278
- const errMsg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
279
- logForDebugging(`ImageShow: load error ${errMsg} for ${url}`)
280
- return {
281
- data: {
282
- success: false,
283
- message: `Failed to fetch: ${url} (${errMsg})`,
284
- },
285
- }
286
- }
287
- },
288
- }) satisfies ToolDef<any, any>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/tools/ImageShowTool/UI.tsx ADDED
File without changes
src/tools/ImageShowTool/__tests__/ImageShowTool.test.ts DELETED
@@ -1,343 +0,0 @@
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/tools/ImageShowTool/__tests__/ImageShowTool.test.tsx DELETED
@@ -1,16 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { ImageShowTool } from '../ImageShowTool';
3
-
4
- // Remote image test to verify ImageShowTool loading and rendering logic
5
- describe('ImageShowTool', () => {
6
- it('loads and processes a remote image successfully', async () => {
7
- const url = 'https://upload.wikimedia.org/wikipedia/en/7/7d/Lenna_%28test_image%29.png';
8
- const result = await ImageShowTool.call({ url });
9
- expect(result.data.success).toBe(true);
10
- // Ensure the returned data includes image dimensions
11
- expect(typeof result.data.naturalWidth).toBe('number');
12
- expect(typeof result.data.naturalHeight).toBe('number');
13
- // Base64 representation should be present
14
- expect(typeof result.data.base64).toBe('string');
15
- });
16
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/tools/ImageShowTool/__tests__/ShowLocalImage.test.tsx DELETED
@@ -1,72 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Visual test: display a local image using ink-picture + Ink.
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
- )
69
- }
70
-
71
- const { waitUntilExit } = render(<App />)
72
- await waitUntilExit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/tools/ImageShowTool/__tests__/ShowUrlImage.test.tsx DELETED
@@ -1,72 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Visual test: display a remote image using ink-picture + Ink.
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
- )
69
- }
70
-
71
- const { waitUntilExit } = render(<App />)
72
- await waitUntilExit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/tools/ImageShowTool/prompt.ts ADDED
File without changes