From 79c56475e0fdbc92e8485191287b985a4b17c74f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 3 Jan 2026 16:58:49 +0100 Subject: [PATCH] Fix non-PNG images not displaying in Kitty protocol terminals JPEG/GIF/WebP images were not rendering in terminals using the Kitty graphics protocol (Kitty, Ghostty, WezTerm) because it requires PNG format (f=100). Non-PNG images are now converted to PNG using sharp before being sent to the terminal. --- packages/coding-agent/CHANGELOG.md | 4 ++ .../interactive/components/tool-execution.ts | 53 +++++++++++++++++-- .../coding-agent/src/utils/image-convert.ts | 26 +++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/src/utils/image-convert.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9a1ab8e2..012662d2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- JPEG/GIF/WebP images not displaying in terminals using Kitty graphics protocol (Kitty, Ghostty, WezTerm). The protocol requires PNG format, so non-PNG images are now converted before display. + ## [0.32.2] - 2026-01-03 ### Added diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 7df4baf4..dc6fe453 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -14,6 +14,7 @@ import stripAnsi from "strip-ansi"; import type { CustomTool } from "../../../core/custom-tools/types.js"; import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; +import { convertToPng } from "../../../utils/image-convert.js"; import { sanitizeBinaryOutput } from "../../../utils/shell.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { renderDiff } from "./diff.js"; @@ -68,6 +69,8 @@ export class ToolExecutionComponent extends Container { // Cached edit diff preview (computed when args arrive, before tool executes) private editDiffPreview?: EditDiffResult | EditDiffError; private editDiffArgsKey?: string; // Track which args the preview is for + // Cached converted images for Kitty protocol (which requires PNG), keyed by index + private convertedImages: Map = new Map(); constructor( toolName: string, @@ -157,6 +160,39 @@ export class ToolExecutionComponent extends Container { this.result = result; this.isPartial = isPartial; this.updateDisplay(); + // Convert non-PNG images to PNG for Kitty protocol (async) + this.maybeConvertImagesForKitty(); + } + + /** + * Convert non-PNG images to PNG for Kitty graphics protocol. + * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display. + */ + private maybeConvertImagesForKitty(): void { + const caps = getCapabilities(); + // Only needed for Kitty protocol + if (caps.images !== "kitty") return; + if (!this.result) return; + + const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; + + for (let i = 0; i < imageBlocks.length; i++) { + const img = imageBlocks[i]; + if (!img.data || !img.mimeType) continue; + // Skip if already PNG or already converted + if (img.mimeType === "image/png") continue; + if (this.convertedImages.has(i)) continue; + + // Convert async + const index = i; + convertToPng(img.data, img.mimeType).then((converted) => { + if (converted) { + this.convertedImages.set(index, converted); + this.updateDisplay(); + this.ui.requestRender(); + } + }); + } } setExpanded(expanded: boolean): void { @@ -249,14 +285,25 @@ export class ToolExecutionComponent extends Container { const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; const caps = getCapabilities(); - for (const img of imageBlocks) { + for (let i = 0; i < imageBlocks.length; i++) { + const img = imageBlocks[i]; if (caps.images && this.showImages && img.data && img.mimeType) { + // Use converted PNG for Kitty protocol if available + const converted = this.convertedImages.get(i); + const imageData = converted?.data ?? img.data; + const imageMimeType = converted?.mimeType ?? img.mimeType; + + // For Kitty, skip non-PNG images that haven't been converted yet + if (caps.images === "kitty" && imageMimeType !== "image/png") { + continue; + } + const spacer = new Spacer(1); this.addChild(spacer); this.imageSpacers.push(spacer); const imageComponent = new Image( - img.data, - img.mimeType, + imageData, + imageMimeType, { fallbackColor: (s: string) => theme.fg("toolOutput", s) }, { maxWidthCells: 60 }, ); diff --git a/packages/coding-agent/src/utils/image-convert.ts b/packages/coding-agent/src/utils/image-convert.ts new file mode 100644 index 00000000..89a651ce --- /dev/null +++ b/packages/coding-agent/src/utils/image-convert.ts @@ -0,0 +1,26 @@ +/** + * Convert image to PNG format for terminal display. + * Kitty graphics protocol requires PNG format (f=100). + */ +export async function convertToPng( + base64Data: string, + mimeType: string, +): Promise<{ data: string; mimeType: string } | null> { + // Already PNG, no conversion needed + if (mimeType === "image/png") { + return { data: base64Data, mimeType }; + } + + try { + const sharp = (await import("sharp")).default; + const buffer = Buffer.from(base64Data, "base64"); + const pngBuffer = await sharp(buffer).png().toBuffer(); + return { + data: pngBuffer.toString("base64"), + mimeType: "image/png", + }; + } catch { + // Sharp not available or conversion failed + return null; + } +}