File size: 7,783 Bytes
67fd3a5 f0b3c03 050f245 e7b07b3 0afdca0 e7b07b3 67fd3a5 e7b07b3 999003d 02a4f30 0afdca0 999003d 02a4f30 0afdca0 999003d 67fd3a5 999003d 02a4f30 0afdca0 02a4f30 0afdca0 f0b3c03 02a4f30 f0b3c03 0afdca0 050f245 0afdca0 02a4f30 8b35d5c 02a4f30 8b35d5c fcdf1d0 8b35d5c fcdf1d0 8b35d5c 02a4f30 8b35d5c 02a4f30 0afdca0 e7b07b3 999003d 67fd3a5 e7b07b3 bfb2909 e7b07b3 f0b3c03 e7b07b3 f0b3c03 e7b07b3 f0b3c03 e7b07b3 f0b3c03 8b35d5c e7b07b3 8b35d5c e7b07b3 184c51a 67fd3a5 f0b3c03 67fd3a5 e7b07b3 999003d 67fd3a5 999003d e7b07b3 999003d e7b07b3 999003d e7b07b3 f0b3c03 e7b07b3 999003d e7b07b3 999003d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | import { execFileSync } from 'child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import { z } from 'zod/v4'
import { Jimp } from "jimp";
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { whichSync } from '../../utils/which.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
import { IMAGE_SHOW_TOOL_NAME, DESCRIPTION } from './prompt.js'
import {
getToolUseSummary,
renderToolResultMessage,
renderToolUseMessage,
} from './UI.js'
// ── Constants ──
export const CELL_WIDTH = 8;
export const CELL_HEIGHT = 16;
// ── Types ──
export interface ImageDimensions {
width: number; // 字符宽度
height: number; // 字符高度
pixelWidth: number; // 像素宽度
pixelHeight: number; // 像素高度
}
export interface ImageShowOutput {
src: string
success: boolean
width?: number
height?: number
pixelWidth?: number
pixelHeight?: number
kittySequence?: string
}
// ── Utilities ──
export function getImagePath(args: string[]): string {
return args[0] || "/home/yuki/Pictures/Wallpapers/3god.jpg";
}
export function isUrl(path: string): boolean {
return path.startsWith("http://") || path.startsWith("https://");
}
/**
* Download a URL to a temporary file, return the path.
* Caller should delete the file after use.
*/
export async function downloadToTemp(url: string): Promise<string> {
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ImageShowTool/1.0)',
'Accept': 'image/*',
},
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const buffer = Buffer.from(await res.arrayBuffer())
const hash = crypto.createHash('md5').update(url).digest('hex')
const tmp = `/tmp/img_${hash}`
fs.writeFileSync(tmp, buffer)
return tmp
}
export async function loadImage(path: string) {
if (isUrl(path)) {
// Download to temp file, then read with Jimp
const tmp = await downloadToTemp(path)
try {
return await Jimp.read(fs.readFileSync(tmp))
} finally {
fs.unlinkSync(tmp)
}
} else {
// Read file to Buffer first, then pass to Jimp.read().
// Jimp v1.x has inconsistent path resolution in some runtimes
// (Bun, ESM contexts), so this avoids relying on Jimp's internal
// file detection by feeding the raw buffer directly.
const buffer = fs.readFileSync(path);
return Jimp.read(buffer);
}
}
export function calculateDimensions(
imageWidth: number,
imageHeight: number,
terminalRows: number
): ImageDimensions {
// Height = 16.18% of terminal height; width derived from aspect ratio.
const targetH_chars = Math.floor(terminalRows * 0.1618);
const targetH_pixels = targetH_chars * CELL_HEIGHT;
const minW_chars = 3;
// Pixel width from aspect ratio, then back-compute char width to guarantee
// pixelWidth === width * CELL_WIDTH — no rounding gap between the
// placeholder Box and the actual Kitty image.
const targetW_pixels = Math.floor(targetH_pixels * (imageWidth / imageHeight));
const targetW_chars = Math.max(Math.ceil(targetW_pixels / CELL_WIDTH), minW_chars);
const finalPixelWidth = targetW_chars * CELL_WIDTH;
return {
width: targetW_chars,
height: targetH_chars,
pixelWidth: finalPixelWidth,
pixelHeight: targetH_pixels,
};
}
// ── Tool definition ──
const inputSchema = lazySchema(() =>
z.strictObject({
src: z.string().describe('Image source — local file path or HTTPS URL'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
src: z.string(),
success: z.boolean(),
width: z.number().optional(),
height: z.number().optional(),
pixelWidth: z.number().optional(),
pixelHeight: z.number().optional(),
kittySequence: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const ImageShowTool = buildTool({
name: IMAGE_SHOW_TOOL_NAME,
searchHint: 'display an image in the terminal',
maxResultSizeChars: 10_000,
shouldDefer: false,
async description(input) {
const { src } = input as { src: string }
try {
const url = new URL(src)
return `Codev wants to display image from ${url.hostname}`
} catch {
return `Codev wants to display image: ${src}`
}
},
userFacingName() {
return 'Image'
},
getToolUseSummary,
getActivityDescription(input) {
const { src } = input as { src: string }
try {
const url = new URL(src)
return `Showing image from ${url.hostname}`
} catch {
return `Showing image: ${src}`
}
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
// Kitty 协议序列是带内数据,并发写入终端会互相覆盖导致图片不显示
isConcurrencySafe() {
return false
},
isReadOnly() {
return true
},
async checkPermissions(_input, _context): Promise<PermissionDecision> {
return {
behavior: 'allow',
updatedInput: _input,
decisionReason: { type: 'other', reason: 'ImageShowTool is read-only' },
}
},
async prompt(_options) {
return DESCRIPTION
},
async validateInput(input) {
const { src } = input
if (!src || src.trim().length === 0) {
return {
result: false,
message: 'Error: "src" is required and cannot be empty.',
meta: { reason: 'missing_src' },
errorCode: 1,
}
}
return { result: true }
},
renderToolUseMessage,
renderToolResultMessage,
async call({ src }) {
let tmpFile: string | undefined
try {
// For URLs: download to temp file so timg can read it directly
let imagePath = src
if (isUrl(src)) {
tmpFile = await downloadToTemp(src)
imagePath = tmpFile
}
// Read with Jimp for dimension calculation (from buffer, no MIME restriction)
const image = await Jimp.read(fs.readFileSync(imagePath))
const rows = process.stdout.rows ?? 24
const dims = calculateDimensions(
image.bitmap.width,
image.bitmap.height,
rows,
)
// Generate full-resolution Kitty protocol sequence via timg
let kittySequence: string | undefined
try {
const timgPath = whichSync('timg')
if (timgPath) {
kittySequence = execFileSync(
timgPath,
['-p', 'kitty', '-g', `${dims.width}x${dims.height}`, imagePath],
{
encoding: 'utf8',
timeout: 30000,
maxBuffer: 50 * 1024 * 1024,
},
)
}
} catch {
// timg not available — fall back to text display
}
return {
data: {
src,
success: true,
...dims,
kittySequence,
} satisfies ImageShowOutput,
}
} catch (error) {
console.error(`[ImageShowTool] Failed to display ${src}:`, error)
return {
data: {
src,
success: false,
} satisfies ImageShowOutput,
}
} finally {
if (tmpFile) {
try { fs.unlinkSync(tmpFile) } catch {}
}
}
},
mapToolResultToToolResultBlockParam(output: ImageShowOutput, toolUseID: string) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
{
type: 'text',
text: output.success
? `Image displayed: ${output.src}`
: `Failed to display image: ${output.src}`,
},
],
}
},
} satisfies ToolDef<InputSchema, ImageShowOutput>)
|