refactor: rewrite ImageShowTool with new architecture
Browse filesCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/tools/ImageShowTool/ImageShowTool.ts +109 -0
- src/tools/ImageShowTool/ImageShowTool.tsx +0 -288
- src/tools/ImageShowTool/UI.tsx +0 -0
- src/tools/ImageShowTool/__tests__/ImageShowTool.test.ts +0 -343
- src/tools/ImageShowTool/__tests__/ImageShowTool.test.tsx +0 -16
- src/tools/ImageShowTool/__tests__/ShowLocalImage.test.tsx +0 -72
- src/tools/ImageShowTool/__tests__/ShowUrlImage.test.tsx +0 -72
- src/tools/ImageShowTool/prompt.ts +0 -0
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
|