Commit 843426d46403
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");
+ }
+ },
+ });
+}