flake-update-20260505
  1/**
  2 * Kitty Image Display Extension
  3 *
  4 * Displays images directly in the Kitty terminal using the Kitty Graphics Protocol
  5 * (raw escape sequences via process.stdout). No kitten or external tools required
  6 * for PNG files. Other formats are auto-converted to PNG via python3 or ImageMagick.
  7 *
  8 * Provides:
  9 * - show_image tool: LLM can display any image in the terminal
 10 * - /show-image command: quick display from the command line
 11 */
 12
 13import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 14import { Text } from "@mariozechner/pi-tui";
 15import { Type } from "@sinclair/typebox";
 16import { readFileSync, existsSync, statSync } from "node:fs";
 17import { execSync, execFileSync } from "node:child_process";
 18import { extname, resolve } from "node:path";
 19import { homedir } from "node:os";
 20
 21// ═══════════════════════════════════════════════════════════════════════════
 22// Kitty Graphics Protocol Implementation
 23// ═══════════════════════════════════════════════════════════════════════════
 24
 25const CHUNK_SIZE = 4096;
 26
 27/**
 28 * Expand ~ and ~user in paths, and resolve relative paths against cwd.
 29 */
 30function expandPath(rawPath: string, cwd: string): string {
 31	let p = rawPath.trim().replace(/^@/, "");
 32	if (p.startsWith("~/") || p === "~") {
 33		p = p.replace(/^~/, homedir());
 34	}
 35	return resolve(cwd, p);
 36}
 37
 38/**
 39 * Send image bytes to Kitty using the Graphics Protocol.
 40 * Data must be raw PNG bytes.
 41 *
 42 * Uses direct transmission mode (a=T, f=100).
 43 */
 44function sendPngToKitty(pngBytes: Buffer): void {
 45	const b64 = pngBytes.toString("base64");
 46	const chunks: string[] = [];
 47
 48	for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
 49		chunks.push(b64.slice(i, i + CHUNK_SIZE));
 50	}
 51
 52	for (let i = 0; i < chunks.length; i++) {
 53		const isLast = i === chunks.length - 1;
 54		const m = isLast ? 0 : 1;
 55		// First chunk: a=T (transmit + display), f=100 (PNG format)
 56		const ctrl = i === 0 ? `a=T,f=100,m=${m}` : `m=${m}`;
 57		const seq = `\x1b_G${ctrl};${chunks[i]}\x1b\\`;
 58		process.stdout.write(seq);
 59	}
 60
 61	// Move to next line after the image
 62	process.stdout.write("\n");
 63}
 64
 65/**
 66 * Attempt to convert any image format to PNG bytes.
 67 * Tries python3 (Pillow) first, then ImageMagick `convert`.
 68 */
 69function convertToPng(imagePath: string): Buffer {
 70	// Try python3 with Pillow
 71	try {
 72		const script = `
 73import sys
 74from PIL import Image
 75import io
 76img = Image.open(sys.argv[1]).convert('RGBA')
 77buf = io.BytesIO()
 78img.save(buf, format='PNG')
 79sys.stdout.buffer.write(buf.getvalue())
 80`;
 81		const result = execFileSync("python3", ["-c", script, imagePath], {
 82			stdio: ["pipe", "pipe", "pipe"],
 83			timeout: 15000,
 84		});
 85		return result;
 86	} catch {
 87		// Pillow not available, try ImageMagick
 88	}
 89
 90	try {
 91		const result = execSync(`convert "${imagePath}" PNG:-`, {
 92			stdio: ["pipe", "pipe", "pipe"],
 93			timeout: 15000,
 94		});
 95		return result;
 96	} catch {
 97		throw new Error("Could not convert image. Install Pillow (pip install Pillow) or ImageMagick.");
 98	}
 99}
100
101/**
102 * Load image at path and return PNG bytes, converting if necessary.
103 */
104function loadAsPng(imagePath: string): { png: Buffer; originalFormat: string } {
105	const ext = extname(imagePath).toLowerCase();
106
107	if (ext === ".png") {
108		return { png: readFileSync(imagePath), originalFormat: "png" };
109	}
110
111	// For other formats, convert to PNG
112	const png = convertToPng(imagePath);
113	return { png, originalFormat: ext.slice(1) || "unknown" };
114}
115
116// ═══════════════════════════════════════════════════════════════════════════
117// Extension
118// ═══════════════════════════════════════════════════════════════════════════
119
120export default function (pi: ExtensionAPI) {
121	// ─── Tool ───────────────────────────────────────────────────────────
122
123	pi.registerTool({
124		name: "show_image",
125		label: "Show Image",
126		description: `Display an image directly in the Kitty terminal using the Kitty Graphics Protocol.
127Supports PNG natively. JPEG, GIF, WEBP, BMP, and other formats are auto-converted via Pillow or ImageMagick.
128
129Use this when:
130- User asks to "display", "show", or "view" an image
131- You want to visualize a chart, diagram, screenshot, or photo
132- User is in Kitty terminal and wants inline image preview`,
133		parameters: Type.Object({
134			path: Type.String({
135				description: "Absolute or relative path to the image file to display",
136			}),
137		}),
138
139		async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
140			const imagePath = expandPath(params.path, ctx.cwd);
141
142			if (!existsSync(imagePath)) {
143				return {
144					content: [{ type: "text", text: `Image not found: ${imagePath}` }],
145					isError: true,
146				};
147			}
148
149			const stat = statSync(imagePath);
150			if (!stat.isFile()) {
151				return {
152					content: [{ type: "text", text: `Not a file: ${imagePath}` }],
153					isError: true,
154				};
155			}
156
157			try {
158				const { png, originalFormat } = loadAsPng(imagePath);
159				const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
160
161				// Write the image to the terminal via Kitty Graphics Protocol
162				process.stdout.write("\n"); // Blank line before image
163				sendPngToKitty(png);
164
165				const converted = originalFormat !== "png" ? ` (converted from ${originalFormat})` : "";
166
167				return {
168					content: [{
169						type: "text",
170						text: `Displayed image: ${imagePath} (${sizeMB} MB${converted})`,
171					}],
172					details: {
173						path: imagePath,
174						format: originalFormat,
175						sizeMB,
176						pngBytes: png.length,
177					},
178				};
179			} catch (err: any) {
180				return {
181					content: [{
182						type: "text",
183						text: `Failed to display image: ${err.message}`,
184					}],
185					isError: true,
186				};
187			}
188		},
189
190		renderCall(args, theme) {
191			return new Text(
192				theme.fg("toolTitle", theme.bold("show_image ")) + theme.fg("accent", args.path),
193				0,
194				0,
195			);
196		},
197
198		renderResult(result, _options, theme) {
199			if (result.isError) {
200				const msg = result.content?.[0]?.type === "text" ? result.content[0].text : "Error";
201				return new Text(theme.fg("error", "✗ " + msg), 0, 0);
202			}
203
204			const { details } = result;
205			if (details?.path) {
206				const fmt = details.format !== "png" ? ` → png` : "";
207				return new Text(
208					theme.fg("success", "✓ ") +
209						theme.fg("accent", details.format + fmt) +
210						theme.fg("dim", ` ${details.sizeMB} MB — `) +
211						theme.fg("muted", details.path),
212					0,
213					0,
214				);
215			}
216
217			const msg = result.content?.[0]?.type === "text" ? result.content[0].text : "";
218			return new Text(theme.fg("success", "✓ ") + msg, 0, 0);
219		},
220	});
221
222	// ─── Command ────────────────────────────────────────────────────────
223
224	pi.registerCommand("show-image", {
225		description: "Display an image in the terminal (Kitty Graphics Protocol)",
226		handler: async (args, ctx) => {
227			if (!args) {
228				ctx.ui.notify("Usage: /show-image <path>", "warning");
229				return;
230			}
231
232			const imagePath = expandPath(args, ctx.cwd);
233
234			if (!existsSync(imagePath)) {
235				ctx.ui.notify(`Image not found: ${imagePath}`, "error");
236				return;
237			}
238
239			try {
240				const { png, originalFormat } = loadAsPng(imagePath);
241				const converted = originalFormat !== "png" ? ` (from ${originalFormat})` : "";
242
243				process.stdout.write("\n");
244				sendPngToKitty(png);
245
246				ctx.ui.notify(`Displayed ${imagePath}${converted}`, "info");
247			} catch (err: any) {
248				ctx.ui.notify(`Failed: ${err.message}`, "error");
249			}
250		},
251	});
252}