feat: register ImageShowTool as a proper buildTool with prompt + UI
Browse filesConvert ImageShowTool from standalone utilities to a full Tool definition
using buildTool(), add prompt.ts for LLM-facing description, and refactor
UI.tsx into tool render functions. Standalone executable moved to
standalone.tsx to avoid side effects on import.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/tools/ImageShowTool/ImageShowTool.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
|
|
| 1 |
import { Jimp } from "jimp";
|
|
|
|
| 2 |
import { loadImageFromUrl } from "../../ink-picture/utils/jimpURL.ts";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export const CELL_WIDTH = 8;
|
| 5 |
export const CELL_HEIGHT = 16;
|
|
@@ -46,3 +58,125 @@ export function calculateDimensions(
|
|
| 46 |
pixelHeight: finalH_pixels,
|
| 47 |
};
|
| 48 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod/v4'
|
| 2 |
import { Jimp } from "jimp";
|
| 3 |
+
import { buildTool, type ToolDef } from '../../Tool.js'
|
| 4 |
import { loadImageFromUrl } from "../../ink-picture/utils/jimpURL.ts";
|
| 5 |
+
import { lazySchema } from '../../utils/lazySchema.js'
|
| 6 |
+
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
| 7 |
+
import { IMAGE_SHOW_TOOL_NAME, DESCRIPTION } from './prompt.js'
|
| 8 |
+
import {
|
| 9 |
+
getToolUseSummary,
|
| 10 |
+
renderToolResultMessage,
|
| 11 |
+
renderToolUseMessage,
|
| 12 |
+
} from './UI.js'
|
| 13 |
+
|
| 14 |
+
// ── Utility functions (kept for standalone UI and external use) ──
|
| 15 |
|
| 16 |
export const CELL_WIDTH = 8;
|
| 17 |
export const CELL_HEIGHT = 16;
|
|
|
|
| 58 |
pixelHeight: finalH_pixels,
|
| 59 |
};
|
| 60 |
}
|
| 61 |
+
|
| 62 |
+
// ── Tool definition ──
|
| 63 |
+
|
| 64 |
+
const inputSchema = lazySchema(() =>
|
| 65 |
+
z.strictObject({
|
| 66 |
+
src: z.string().describe('Image source — local file path or HTTPS URL'),
|
| 67 |
+
}),
|
| 68 |
+
)
|
| 69 |
+
type InputSchema = ReturnType<typeof inputSchema>
|
| 70 |
+
|
| 71 |
+
const outputSchema = lazySchema(() =>
|
| 72 |
+
z.object({
|
| 73 |
+
src: z.string().describe('The image source that was displayed'),
|
| 74 |
+
success: z.boolean().describe('Whether the image was displayed successfully'),
|
| 75 |
+
}),
|
| 76 |
+
)
|
| 77 |
+
type OutputSchema = ReturnType<typeof outputSchema>
|
| 78 |
+
|
| 79 |
+
export type Output = z.infer<OutputSchema>
|
| 80 |
+
|
| 81 |
+
export const ImageShowTool = buildTool({
|
| 82 |
+
name: IMAGE_SHOW_TOOL_NAME,
|
| 83 |
+
searchHint: 'display an image in the terminal',
|
| 84 |
+
maxResultSizeChars: 10_000,
|
| 85 |
+
shouldDefer: true,
|
| 86 |
+
async description(input) {
|
| 87 |
+
const { src } = input as { src: string }
|
| 88 |
+
try {
|
| 89 |
+
const url = new URL(src)
|
| 90 |
+
return `Codev wants to display image from ${url.hostname}`
|
| 91 |
+
} catch {
|
| 92 |
+
return `Codev wants to display image: ${src}`
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
userFacingName() {
|
| 96 |
+
return 'Image'
|
| 97 |
+
},
|
| 98 |
+
getToolUseSummary,
|
| 99 |
+
getActivityDescription(input) {
|
| 100 |
+
const { src } = input as { src: string }
|
| 101 |
+
try {
|
| 102 |
+
const url = new URL(src)
|
| 103 |
+
return `Showing image from ${url.hostname}`
|
| 104 |
+
} catch {
|
| 105 |
+
return `Showing image: ${src}`
|
| 106 |
+
}
|
| 107 |
+
},
|
| 108 |
+
get inputSchema(): InputSchema {
|
| 109 |
+
return inputSchema()
|
| 110 |
+
},
|
| 111 |
+
get outputSchema(): OutputSchema {
|
| 112 |
+
return outputSchema()
|
| 113 |
+
},
|
| 114 |
+
isConcurrencySafe() {
|
| 115 |
+
return true
|
| 116 |
+
},
|
| 117 |
+
isReadOnly() {
|
| 118 |
+
return true
|
| 119 |
+
},
|
| 120 |
+
async checkPermissions(_input, _context): Promise<PermissionDecision> {
|
| 121 |
+
return {
|
| 122 |
+
behavior: 'allow',
|
| 123 |
+
updatedInput: _input,
|
| 124 |
+
decisionReason: { type: 'other', reason: 'ImageShowTool is read-only' },
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
async prompt(_options) {
|
| 128 |
+
return DESCRIPTION
|
| 129 |
+
},
|
| 130 |
+
async validateInput(input) {
|
| 131 |
+
const { src } = input
|
| 132 |
+
if (!src || src.trim().length === 0) {
|
| 133 |
+
return {
|
| 134 |
+
result: false,
|
| 135 |
+
message: 'Error: "src" is required and cannot be empty.',
|
| 136 |
+
meta: { reason: 'missing_src' },
|
| 137 |
+
errorCode: 1,
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
return { result: true }
|
| 141 |
+
},
|
| 142 |
+
renderToolUseMessage,
|
| 143 |
+
renderToolResultMessage,
|
| 144 |
+
async call({ src }) {
|
| 145 |
+
try {
|
| 146 |
+
const image = await loadImage(src)
|
| 147 |
+
const dims = calculateDimensions(
|
| 148 |
+
image.bitmap.width,
|
| 149 |
+
image.bitmap.height,
|
| 150 |
+
80,
|
| 151 |
+
)
|
| 152 |
+
void dims
|
| 153 |
+
return {
|
| 154 |
+
data: {
|
| 155 |
+
src,
|
| 156 |
+
success: true,
|
| 157 |
+
} satisfies Output,
|
| 158 |
+
}
|
| 159 |
+
} catch (error) {
|
| 160 |
+
return {
|
| 161 |
+
data: {
|
| 162 |
+
src,
|
| 163 |
+
success: false,
|
| 164 |
+
} satisfies Output,
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
},
|
| 168 |
+
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
| 169 |
+
return {
|
| 170 |
+
tool_use_id: toolUseID,
|
| 171 |
+
type: 'tool_result',
|
| 172 |
+
content: [
|
| 173 |
+
{
|
| 174 |
+
type: 'text',
|
| 175 |
+
text: output.success
|
| 176 |
+
? `Image displayed: ${output.src}`
|
| 177 |
+
: `Failed to display image: ${output.src}`,
|
| 178 |
+
},
|
| 179 |
+
],
|
| 180 |
+
}
|
| 181 |
+
},
|
| 182 |
+
} satisfies ToolDef<InputSchema, Output>)
|
src/tools/ImageShowTool/UI.tsx
CHANGED
|
@@ -1,76 +1,57 @@
|
|
| 1 |
-
|
| 2 |
-
/
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
calculateDimensions,
|
| 14 |
-
type ImageDimensions,
|
| 15 |
-
} from "./ImageShowTool.ts";
|
| 16 |
-
|
| 17 |
-
const args = process.argv.slice(2);
|
| 18 |
-
const IMAGE_PATH = getImagePath(args);
|
| 19 |
-
|
| 20 |
-
function App() {
|
| 21 |
-
const { exit } = useApp();
|
| 22 |
-
const [dimensions, setDimensions] = useState<ImageDimensions | null>(null);
|
| 23 |
-
const [err, setErr] = useState(false);
|
| 24 |
-
|
| 25 |
-
useEffect(() => {
|
| 26 |
-
(async () => {
|
| 27 |
-
try {
|
| 28 |
-
const image = await loadImage(IMAGE_PATH);
|
| 29 |
-
const dims = calculateDimensions(
|
| 30 |
-
image.bitmap.width,
|
| 31 |
-
image.bitmap.height,
|
| 32 |
-
process.stdout.columns ?? 80
|
| 33 |
-
);
|
| 34 |
-
setDimensions(dims);
|
| 35 |
-
} catch (e) {
|
| 36 |
-
console.error(e);
|
| 37 |
-
setErr(true);
|
| 38 |
-
exit();
|
| 39 |
-
}
|
| 40 |
-
})();
|
| 41 |
-
}, [exit]);
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
-
|
| 59 |
return (
|
| 60 |
-
<
|
| 61 |
-
<
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
pixelWidth={dimensions.pixelWidth}
|
| 67 |
-
pixelHeight={dimensions.pixelHeight}
|
| 68 |
-
alt={isUrl(IMAGE_PATH) ? "url-image" : "local-image"}
|
| 69 |
-
/>
|
| 70 |
-
</InkPictureProvider>
|
| 71 |
-
</Box>
|
| 72 |
-
);
|
| 73 |
}
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { MessageResponse } from '../../components/MessageResponse.js'
|
| 3 |
+
import { Box, Text } from '../../ink.js'
|
| 4 |
+
|
| 5 |
+
export function renderToolUseMessage(
|
| 6 |
+
{ src }: { src?: string },
|
| 7 |
+
{ verbose }: { theme?: string; verbose: boolean },
|
| 8 |
+
): React.ReactNode {
|
| 9 |
+
if (!src) return null
|
| 10 |
+
if (verbose) return `src: "${src}"`
|
| 11 |
+
return src
|
| 12 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
export function renderToolUseProgressMessage(): React.ReactNode {
|
| 15 |
+
return (
|
| 16 |
+
<MessageResponse height={1}>
|
| 17 |
+
<Text dimColor>Rendering image…</Text>
|
| 18 |
+
</MessageResponse>
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
|
| 22 |
+
export function renderToolResultMessage(
|
| 23 |
+
{ src, success }: { src: string; success: boolean },
|
| 24 |
+
_progressMessages: unknown[],
|
| 25 |
+
{ verbose }: { verbose: boolean },
|
| 26 |
+
): React.ReactNode {
|
| 27 |
+
if (!success) {
|
| 28 |
+
return (
|
| 29 |
+
<MessageResponse height={1}>
|
| 30 |
+
<Text color="red">Failed to display: {src}</Text>
|
| 31 |
+
</MessageResponse>
|
| 32 |
+
)
|
| 33 |
}
|
| 34 |
+
if (verbose) {
|
| 35 |
+
return (
|
| 36 |
+
<Box flexDirection="column">
|
| 37 |
+
<MessageResponse height={1}>
|
| 38 |
+
<Text>
|
| 39 |
+
Image displayed: <Text bold>{src}</Text>
|
| 40 |
+
</Text>
|
| 41 |
+
</MessageResponse>
|
| 42 |
+
</Box>
|
| 43 |
+
)
|
| 44 |
}
|
|
|
|
| 45 |
return (
|
| 46 |
+
<MessageResponse height={1}>
|
| 47 |
+
<Text>
|
| 48 |
+
Image displayed: <Text bold>{src}</Text>
|
| 49 |
+
</Text>
|
| 50 |
+
</MessageResponse>
|
| 51 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
+
export function getToolUseSummary(input: { src?: string } | undefined): string | null {
|
| 55 |
+
if (!input?.src) return null
|
| 56 |
+
return input.src.length > 80 ? input.src.slice(0, 77) + '...' : input.src
|
| 57 |
+
}
|
src/tools/ImageShowTool/prompt.ts
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const IMAGE_SHOW_TOOL_NAME = 'ImageShow'
|
| 2 |
+
|
| 3 |
+
export const DESCRIPTION = `
|
| 4 |
+
ImageShow — Display images directly in the terminal.
|
| 5 |
+
|
| 6 |
+
This tool renders PNG/JPEG/GIF/WebP images in the terminal using Kitty sixel protocol via ink-picture. It supports both local file paths and HTTPS URLs.
|
| 7 |
+
|
| 8 |
+
**When to use this tool:**
|
| 9 |
+
- User asks to display, view, or show an image
|
| 10 |
+
- Search results include image thumbnails that should be displayed inline
|
| 11 |
+
- User references a local image path or URL and wants to see it
|
| 12 |
+
- Any situation where visual inspection of an image is helpful
|
| 13 |
+
|
| 14 |
+
**Input:**
|
| 15 |
+
- \`src\`: Image source — either a local file path (e.g. \`/home/user/pic.png\`) or a HTTPS URL (e.g. \`https://example.com/image.jpg\`)
|
| 16 |
+
|
| 17 |
+
**Supported formats:** PNG, JPEG, GIF, WebP
|
| 18 |
+
|
| 19 |
+
**Usage examples:**
|
| 20 |
+
- Display a local image: \`src: "/home/yuki/Pictures/wallpaper.jpg"\`
|
| 21 |
+
- Display a remote image: \`src: "https://example.com/photo.png"\`
|
| 22 |
+
- Display Google Street View: \`src: "https://maps.googleapis.com/maps/api/streetview?size=800x400&location=37.7749,-122.4194&key=..."\`
|
| 23 |
+
|
| 24 |
+
**Rendering details:**
|
| 25 |
+
- Image width is auto-calculated as ~16.18% of terminal width (golden ratio)
|
| 26 |
+
- Height is derived from aspect ratio, with a minimum of 3 character cells
|
| 27 |
+
- Supports sixel-capable terminals (Kitty, WezTerm, etc.)
|
| 28 |
+
|
| 29 |
+
**Combining with other tools:**
|
| 30 |
+
- WebSearch results with images → ImageShow displays them inline automatically
|
| 31 |
+
- LocationTool place photos → ImageShow renders them
|
| 32 |
+
- WebFetchTool image URLs → ImageShow displays the fetched content
|
| 33 |
+
`
|
src/tools/ImageShowTool/standalone.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bun
|
| 2 |
+
/**
|
| 3 |
+
* Standalone visual test: display a local image or URL using ink-picture + Ink.
|
| 4 |
+
*
|
| 5 |
+
* Usage:
|
| 6 |
+
* bun run src/tools/ImageShowTool/standalone.tsx [path|url]
|
| 7 |
+
*
|
| 8 |
+
* Examples:
|
| 9 |
+
* bun run src/tools/ImageShowTool/standalone.tsx ~/Pictures/image.png
|
| 10 |
+
* bun run src/tools/ImageShowTool/standalone.tsx https://example.com/image.jpg
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { useEffect, useState } from "react";
|
| 14 |
+
import { render, Box, Text, useApp } from "ink";
|
| 15 |
+
import Image, { InkPictureProvider } from "../../ink-picture/index.ts";
|
| 16 |
+
import {
|
| 17 |
+
getImagePath,
|
| 18 |
+
isUrl,
|
| 19 |
+
loadImage,
|
| 20 |
+
calculateDimensions,
|
| 21 |
+
type ImageDimensions,
|
| 22 |
+
} from "./ImageShowTool.ts";
|
| 23 |
+
|
| 24 |
+
const args = process.argv.slice(2);
|
| 25 |
+
const IMAGE_PATH = getImagePath(args);
|
| 26 |
+
|
| 27 |
+
function App() {
|
| 28 |
+
const { exit } = useApp();
|
| 29 |
+
const [dimensions, setDimensions] = useState<ImageDimensions | null>(null);
|
| 30 |
+
const [err, setErr] = useState(false);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
(async () => {
|
| 34 |
+
try {
|
| 35 |
+
const image = await loadImage(IMAGE_PATH);
|
| 36 |
+
const dims = calculateDimensions(
|
| 37 |
+
image.bitmap.width,
|
| 38 |
+
image.bitmap.height,
|
| 39 |
+
process.stdout.columns ?? 80
|
| 40 |
+
);
|
| 41 |
+
setDimensions(dims);
|
| 42 |
+
} catch (e) {
|
| 43 |
+
console.error(e);
|
| 44 |
+
setErr(true);
|
| 45 |
+
exit();
|
| 46 |
+
}
|
| 47 |
+
})();
|
| 48 |
+
}, [exit]);
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
const handleSigint = () => exit();
|
| 52 |
+
process.on("SIGINT", handleSigint);
|
| 53 |
+
return () => {
|
| 54 |
+
process.off("SIGINT", handleSigint);
|
| 55 |
+
};
|
| 56 |
+
}, [exit]);
|
| 57 |
+
|
| 58 |
+
if (err) {
|
| 59 |
+
return <Text color="red">Failed to fetch: {IMAGE_PATH}</Text>;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if (!dimensions) {
|
| 63 |
+
return <Text>Loading...</Text>;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<Box flexDirection="column">
|
| 68 |
+
<InkPictureProvider>
|
| 69 |
+
<Image
|
| 70 |
+
src={IMAGE_PATH}
|
| 71 |
+
width={dimensions.width}
|
| 72 |
+
height={dimensions.height}
|
| 73 |
+
pixelWidth={dimensions.pixelWidth}
|
| 74 |
+
pixelHeight={dimensions.pixelHeight}
|
| 75 |
+
alt={isUrl(IMAGE_PATH) ? "url-image" : "local-image"}
|
| 76 |
+
/>
|
| 77 |
+
</InkPictureProvider>
|
| 78 |
+
</Box>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const { waitUntilExit } = render(<App />);
|
| 83 |
+
await waitUntilExit();
|