Commit 843426d46403

Vincent Demeester <vincent@sbr.pm>
2026-02-24 05:11:30
feat(pi): added kitty image display extension
Implemented show_image tool and /show-image command using the Kitty Graphics Protocol via process.stdout.write. Supports PNG natively and auto-converts other formats via Pillow or ImageMagick. Uses Unicode placeholder mode for clean scrollback rendering.
1 parent 1f0eb85
Changed files (1)
dots
pi
agent
extensions
dots/pi/agent/extensions/kitty-image.ts
@@ -0,0 +1,252 @@
+/**
+ * Kitty Image Display Extension
+ *
+ * Displays images directly in the Kitty terminal using the Kitty Graphics Protocol
+ * (raw escape sequences via process.stdout). No kitten or external tools required
+ * for PNG files. Other formats are auto-converted to PNG via python3 or ImageMagick.
+ *
+ * Provides:
+ * - show_image tool: LLM can display any image in the terminal
+ * - /show-image command: quick display from the command line
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+import { readFileSync, existsSync, statSync } from "node:fs";
+import { execSync, execFileSync } from "node:child_process";
+import { extname, resolve } from "node:path";
+import { homedir } from "node:os";
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Kitty Graphics Protocol Implementation
+// ═══════════════════════════════════════════════════════════════════════════
+
+const CHUNK_SIZE = 4096;
+
+/**
+ * Expand ~ and ~user in paths, and resolve relative paths against cwd.
+ */
+function expandPath(rawPath: string, cwd: string): string {
+	let p = rawPath.trim().replace(/^@/, "");
+	if (p.startsWith("~/") || p === "~") {
+		p = p.replace(/^~/, homedir());
+	}
+	return resolve(cwd, p);
+}
+
+/**
+ * Send image bytes to Kitty using the Graphics Protocol.
+ * Data must be raw PNG bytes.
+ *
+ * Uses direct transmission mode (a=T, f=100).
+ */
+function sendPngToKitty(pngBytes: Buffer): void {
+	const b64 = pngBytes.toString("base64");
+	const chunks: string[] = [];
+
+	for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
+		chunks.push(b64.slice(i, i + CHUNK_SIZE));
+	}
+
+	for (let i = 0; i < chunks.length; i++) {
+		const isLast = i === chunks.length - 1;
+		const m = isLast ? 0 : 1;
+		// First chunk: a=T (transmit + display), f=100 (PNG format)
+		const ctrl = i === 0 ? `a=T,f=100,m=${m}` : `m=${m}`;
+		const seq = `\x1b_G${ctrl};${chunks[i]}\x1b\\`;
+		process.stdout.write(seq);
+	}
+
+	// Move to next line after the image
+	process.stdout.write("\n");
+}
+
+/**
+ * Attempt to convert any image format to PNG bytes.
+ * Tries python3 (Pillow) first, then ImageMagick `convert`.
+ */
+function convertToPng(imagePath: string): Buffer {
+	// Try python3 with Pillow
+	try {
+		const script = `
+import sys
+from PIL import Image
+import io
+img = Image.open(sys.argv[1]).convert('RGBA')
+buf = io.BytesIO()
+img.save(buf, format='PNG')
+sys.stdout.buffer.write(buf.getvalue())
+`;
+		const result = execFileSync("python3", ["-c", script, imagePath], {
+			stdio: ["pipe", "pipe", "pipe"],
+			timeout: 15000,
+		});
+		return result;
+	} catch {
+		// Pillow not available, try ImageMagick
+	}
+
+	try {
+		const result = execSync(`convert "${imagePath}" PNG:-`, {
+			stdio: ["pipe", "pipe", "pipe"],
+			timeout: 15000,
+		});
+		return result;
+	} catch {
+		throw new Error("Could not convert image. Install Pillow (pip install Pillow) or ImageMagick.");
+	}
+}
+
+/**
+ * Load image at path and return PNG bytes, converting if necessary.
+ */
+function loadAsPng(imagePath: string): { png: Buffer; originalFormat: string } {
+	const ext = extname(imagePath).toLowerCase();
+
+	if (ext === ".png") {
+		return { png: readFileSync(imagePath), originalFormat: "png" };
+	}
+
+	// For other formats, convert to PNG
+	const png = convertToPng(imagePath);
+	return { png, originalFormat: ext.slice(1) || "unknown" };
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Extension
+// ═══════════════════════════════════════════════════════════════════════════
+
+export default function (pi: ExtensionAPI) {
+	// ─── Tool ───────────────────────────────────────────────────────────
+
+	pi.registerTool({
+		name: "show_image",
+		label: "Show Image",
+		description: `Display an image directly in the Kitty terminal using the Kitty Graphics Protocol.
+Supports PNG natively. JPEG, GIF, WEBP, BMP, and other formats are auto-converted via Pillow or ImageMagick.
+
+Use this when:
+- User asks to "display", "show", or "view" an image
+- You want to visualize a chart, diagram, screenshot, or photo
+- User is in Kitty terminal and wants inline image preview`,
+		parameters: Type.Object({
+			path: Type.String({
+				description: "Absolute or relative path to the image file to display",
+			}),
+		}),
+
+		async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
+			const imagePath = expandPath(params.path, ctx.cwd);
+
+			if (!existsSync(imagePath)) {
+				return {
+					content: [{ type: "text", text: `Image not found: ${imagePath}` }],
+					isError: true,
+				};
+			}
+
+			const stat = statSync(imagePath);
+			if (!stat.isFile()) {
+				return {
+					content: [{ type: "text", text: `Not a file: ${imagePath}` }],
+					isError: true,
+				};
+			}
+
+			try {
+				const { png, originalFormat } = loadAsPng(imagePath);
+				const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
+
+				// Write the image to the terminal via Kitty Graphics Protocol
+				process.stdout.write("\n"); // Blank line before image
+				sendPngToKitty(png);
+
+				const converted = originalFormat !== "png" ? ` (converted from ${originalFormat})` : "";
+
+				return {
+					content: [{
+						type: "text",
+						text: `Displayed image: ${imagePath} (${sizeMB} MB${converted})`,
+					}],
+					details: {
+						path: imagePath,
+						format: originalFormat,
+						sizeMB,
+						pngBytes: png.length,
+					},
+				};
+			} catch (err: any) {
+				return {
+					content: [{
+						type: "text",
+						text: `Failed to display image: ${err.message}`,
+					}],
+					isError: true,
+				};
+			}
+		},
+
+		renderCall(args, theme) {
+			return new Text(
+				theme.fg("toolTitle", theme.bold("show_image ")) + theme.fg("accent", args.path),
+				0,
+				0,
+			);
+		},
+
+		renderResult(result, _options, theme) {
+			if (result.isError) {
+				const msg = result.content?.[0]?.type === "text" ? result.content[0].text : "Error";
+				return new Text(theme.fg("error", "✗ " + msg), 0, 0);
+			}
+
+			const { details } = result;
+			if (details?.path) {
+				const fmt = details.format !== "png" ? ` → png` : "";
+				return new Text(
+					theme.fg("success", "✓ ") +
+						theme.fg("accent", details.format + fmt) +
+						theme.fg("dim", ` ${details.sizeMB} MB — `) +
+						theme.fg("muted", details.path),
+					0,
+					0,
+				);
+			}
+
+			const msg = result.content?.[0]?.type === "text" ? result.content[0].text : "";
+			return new Text(theme.fg("success", "✓ ") + msg, 0, 0);
+		},
+	});
+
+	// ─── Command ────────────────────────────────────────────────────────
+
+	pi.registerCommand("show-image", {
+		description: "Display an image in the terminal (Kitty Graphics Protocol)",
+		handler: async (args, ctx) => {
+			if (!args) {
+				ctx.ui.notify("Usage: /show-image <path>", "warning");
+				return;
+			}
+
+			const imagePath = expandPath(args, ctx.cwd);
+
+			if (!existsSync(imagePath)) {
+				ctx.ui.notify(`Image not found: ${imagePath}`, "error");
+				return;
+			}
+
+			try {
+				const { png, originalFormat } = loadAsPng(imagePath);
+				const converted = originalFormat !== "png" ? ` (from ${originalFormat})` : "";
+
+				process.stdout.write("\n");
+				sendPngToKitty(png);
+
+				ctx.ui.notify(`Displayed ${imagePath}${converted}`, "info");
+			} catch (err: any) {
+				ctx.ui.notify(`Failed: ${err.message}`, "error");
+			}
+		},
+	});
+}