Commit 0627d4c90979

Vincent Demeester <vincent@sbr.pm>
2026-06-05 11:15:37
feat(pi): adopt new pi 0.78 extension APIs
Updated pi coding-agent extensions to use APIs introduced between 0.75.4 and 0.78.1. - kitty-image: use exported native convertToPng (Photon/WASM, EXIF aware) for format conversion, keeping python3/ImageMagick only as a fallback, removing the hard external dependency - message-queue: detect agent busy state via InputEvent.streamingBehavior instead of solely reading isIdle(), avoiding a state-read race - custom-footer, custom-header, terminal-status: gate terminal painting on ctx.mode === "tui" instead of ctx.hasUI, since hasUI is also true in RPC mode and would leak escape sequences into non-TUI output streams
1 parent 750557e
dots/pi/agent/extensions/custom-footer.ts
@@ -267,6 +267,8 @@ export default function (pi: ExtensionAPI) {
 	});
 
 	pi.on("session_start", async (_event, ctx) => {
+		// Footer only makes sense in TUI mode (hasUI is also true in RPC).
+		if (ctx.mode !== "tui") return;
 		// Clear any existing interval
 		if (timeUpdateInterval) {
 			clearInterval(timeUpdateInterval);
@@ -364,6 +366,7 @@ export default function (pi: ExtensionAPI) {
 
 	pi.on("session_switch", async (_event, ctx) => {
 		// Re-setup footer on session switch
+		if (ctx.mode !== "tui") return;
 		if (timeUpdateInterval) {
 			clearInterval(timeUpdateInterval);
 		}
dots/pi/agent/extensions/custom-header.ts
@@ -57,7 +57,7 @@ export default function (pi: ExtensionAPI) {
 
 	// Set custom header on load
 	pi.on("session_start", async (_event, ctx) => {
-		if (ctx.hasUI) {
+		if (ctx.mode === "tui") {
 			ctx.ui.setHeader((_tui, theme) => {
 				return {
 					render(_width: number): string[] {
dots/pi/agent/extensions/kitty-image.ts
@@ -3,7 +3,8 @@
  *
  * 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.
+ * for PNG files. Other formats are auto-converted to PNG via Pi's native
+ * (Photon/WASM) converter, falling back to python3 or ImageMagick if unavailable.
  *
  * Provides:
  * - show_image tool: LLM can display any image in the terminal
@@ -11,6 +12,7 @@
  */
 
 import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { convertToPng as convertToPngNative } from "@mariozechner/pi-coding-agent";
 import { Text } from "@mariozechner/pi-tui";
 import { Type } from "@sinclair/typebox";
 import { readFileSync, existsSync, statSync } from "node:fs";
@@ -62,11 +64,36 @@ function sendPngToKitty(pngBytes: Buffer): void {
 	process.stdout.write("\n");
 }
 
+// Map file extensions to MIME types for the native converter.
+const MIME_BY_EXT: Record<string, string> = {
+	".png": "image/png",
+	".jpg": "image/jpeg",
+	".jpeg": "image/jpeg",
+	".gif": "image/gif",
+	".webp": "image/webp",
+	".bmp": "image/bmp",
+	".tiff": "image/tiff",
+	".tif": "image/tiff",
+};
+
 /**
- * Attempt to convert any image format to PNG bytes.
+ * Convert image bytes to PNG using Pi's native (Photon/WASM) converter.
+ * Returns null if the native converter is unavailable or fails.
+ */
+async function convertToPngNativeBytes(imagePath: string): Promise<Buffer | null> {
+	const ext = extname(imagePath).toLowerCase();
+	const mimeType = MIME_BY_EXT[ext] ?? "application/octet-stream";
+	const base64 = readFileSync(imagePath).toString("base64");
+	const result = await convertToPngNative(base64, mimeType);
+	if (!result) return null;
+	return Buffer.from(result.data, "base64");
+}
+
+/**
+ * Fallback: convert any image format to PNG bytes via external tools.
  * Tries python3 (Pillow) first, then ImageMagick `convert`.
  */
-function convertToPng(imagePath: string): Buffer {
+function convertToPngExternal(imagePath: string): Buffer {
 	// Try python3 with Pillow
 	try {
 		const script = `
@@ -101,15 +128,16 @@ sys.stdout.buffer.write(buf.getvalue())
 /**
  * Load image at path and return PNG bytes, converting if necessary.
  */
-function loadAsPng(imagePath: string): { png: Buffer; originalFormat: string } {
+async function loadAsPng(imagePath: string): Promise<{ 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);
+	// Prefer Pi's native converter (no external dependency), fall back to tools.
+	const native = await convertToPngNativeBytes(imagePath);
+	const png = native ?? convertToPngExternal(imagePath);
 	return { png, originalFormat: ext.slice(1) || "unknown" };
 }
 
@@ -124,7 +152,7 @@ export default function (pi: ExtensionAPI) {
 		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.
+Supports PNG natively. JPEG, GIF, WEBP, BMP, and other formats are auto-converted.
 
 Use this when:
 - User asks to "display", "show", or "view" an image
@@ -155,7 +183,7 @@ Use this when:
 			}
 
 			try {
-				const { png, originalFormat } = loadAsPng(imagePath);
+				const { png, originalFormat } = await loadAsPng(imagePath);
 				const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
 
 				// Write the image to the terminal via Kitty Graphics Protocol
@@ -237,7 +265,7 @@ Use this when:
 			}
 
 			try {
-				const { png, originalFormat } = loadAsPng(imagePath);
+				const { png, originalFormat } = await loadAsPng(imagePath);
 				const converted = originalFormat !== "png" ? ` (from ${originalFormat})` : "";
 
 				process.stdout.write("\n");
dots/pi/agent/extensions/message-queue.ts
@@ -314,7 +314,12 @@ export default function (pi: ExtensionAPI) {
 			return { action: "continue" as const };
 		}
 
-		if (ctx.isIdle()) {
+		// streamingBehavior is set ("steer"|"followUp") only when the agent is
+		// actively streaming; undefined means idle. Prefer it over isIdle() as it
+		// reflects how *this* input would be delivered, avoiding races with the
+		// idle-state read. Fall back to isIdle() if the host predates it.
+		const isStreaming = event.streamingBehavior !== undefined || !ctx.isIdle();
+		if (!isStreaming) {
 			return { action: "continue" as const };
 		}
 
dots/pi/agent/extensions/terminal-status.ts
@@ -307,9 +307,13 @@ function ringBell(): void {
 export default function (pi: ExtensionAPI) {
   // Set initial title on session start
   pi.on("session_start", async (_event, ctx) => {
-    // Detect non-interactive modes (RPC, JSON, print) to skip terminal output
-    // process.argv check in init covers most cases; ctx.hasUI is a backup
-    if (ctx.hasUI === false) isInteractive = false;
+    // Only paint the terminal in true TUI mode. ctx.hasUI is also true in RPC
+    // mode, so prefer ctx.mode to avoid emitting escape sequences into
+    // RPC/JSON/print output streams.
+    if (ctx.mode !== "tui") {
+      isInteractive = false;
+      return;
+    }
     currentBranch = getGitBranch();
     // Get initial model if available
     if (ctx.model) {