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}