/** * sl0p Sixel Images (monkeypatch) * * pi renders inline images only via the Kitty or iTerm2 graphics protocols, and * hard-disables images under tmux/screen (ImageProtocol = "kitty" | "iterm2" | * null). This extension adds a third path — SIXEL — and forces it on even under * tmux, for terminals (like this one) whose tmux has sixel support. * * How: * 1. Force pi-tui's cached terminal capabilities to a custom `images: "sixel"` * marker (setCapabilities), bypassing the tmux gate. All image code paths * read getCapabilities() at render time, so they pick this up. The marker * is not "kitty", so the tool-execution image loop stops filtering to PNG. * 2. Patch the pi-tui `Image` component's render to, when the marker is set, * encode the image to sixel with `chafa` (cell-aware: `-s x` * can't overflow the reserved rows) and lay it out iTerm2-style: reserve * rows-1 blank lines then draw the sequence on the last line after moving * the cursor up, so the TUI's line accounting stays correct. * * Notes: * - Sixel bytes survive pi-tui's line pipeline: normalizeTerminalOutput only * touches tabs/Thai-Lao, and components pre-fit their lines (no truncation). * - `chafa --polite on` avoids terminal queries that could disturb the TUI. * - Our slop-tool-boxes `read` override uses its own shell, so it renders image * blocks itself (via this same patched Image) — see slop-tool-boxes.ts. * - Requires `chafa` on PATH. If encoding fails we fall back to pi's native * text placeholder. * * Reload-safe (re-wraps from a captured original) and restores on shutdown. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Image, getCapabilities, setCapabilities, getCellDimensions, getImageDimensions, calculateImageRows, } from "@earendil-works/pi-tui"; import { execFileSync } from "child_process"; // Our capability marker. Truthy + not "kitty"/"iterm2", so pi treats images as // enabled but its kitty/iterm2 encoders are skipped and ours takes over. const SIXEL = "sixel"; const ORIG = Symbol.for("sl0p.sixel.imageRender"); // Force images on (bypassing the tmux/screen gate), preserving other caps. function forceCaps(): void { const c = getCapabilities(); setCapabilities({ images: SIXEL as any, trueColor: c?.trueColor ?? true, hyperlinks: c?.hyperlinks ?? false, }); } // Encode raw image bytes to a sixel sequence sized to fit cols×rows cells. // chafa constrains to the box (never exceeds `rows`), so our reservation holds. function encodeSixel(base64: string, cols: number, rows: number): string | null { try { const buf = Buffer.from(base64, "base64"); const out = execFileSync( "chafa", ["-f", "sixel", "-s", `${cols}x${rows}`, "--animate", "off", "--polite", "on", "-"], { input: buf, maxBuffer: 64 << 20, timeout: 8000, stdio: ["pipe", "pipe", "ignore"] }, ); const s = out.toString("latin1"); return s.includes("\x1bP") ? s : null; // DCS intro present → looks like sixel } catch { return null; } } // Render image bytes to framed body lines: reserve rows-1 blanks, then draw the // sixel on the last line after moving the cursor up into the reserved block. export function renderSixelLines( base64: string, mimeType: string, cols: number, dims?: { widthPx: number; heightPx: number }, ): string[] | null { const d = dims || getImageDimensions(base64, mimeType) || { widthPx: 800, heightPx: 600 }; const rows = Math.max(1, calculateImageRows(d as any, cols, getCellDimensions())); const seq = encodeSixel(base64, cols, rows); if (!seq) return null; const lines: string[] = []; for (let i = 0; i < rows - 1; i++) lines.push(""); const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : ""; // Prefix a self-terminating empty Kitty APC (\x1b_G ... \x1b\\). pi's TUI has a // hard "line exceeds terminal width" guard that only skips lines its // isImageLine() recognizes (Kitty/iTerm2 prefixes). A raw sixel (\x1bP...) is // not recognized and would crash the render. This marker makes isImageLine() // true (so the width guard + reset-normalization + overlay compositing all skip // the line); real terminals ignore the empty APC. The Kitty header parser finds // no i=/r= params, so it treats the line as rows:1 with no image ids. const APC_MARK = "\x1b_G\x1b\\"; lines.push(APC_MARK + moveUp + seq); return lines; } export default function (pi: ExtensionAPI) { forceCaps(); pi.on("session_start", () => forceCaps()); const proto: any = (Image as any)?.prototype; if (!proto || typeof proto.render !== "function") return; if (!(Image as any)[ORIG]) (Image as any)[ORIG] = proto.render; const orig = (Image as any)[ORIG] as (width: number) => string[]; proto.render = function patchedRender(this: any, width: number): string[] { try { if (getCapabilities().images !== SIXEL) return orig.call(this, width); if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const cols = Math.max(1, Math.min(width - 2, this.options?.maxWidthCells ?? 60)); const lines = renderSixelLines(this.base64Data, this.mimeType, cols, this.dimensions) ?? orig.call(this, width); // fall back to pi's text placeholder this.cachedLines = lines; this.cachedWidth = width; return lines; } catch { return orig.call(this, width); } }; pi.on("session_shutdown", () => { try { if ((Image as any)[ORIG]) proto.render = (Image as any)[ORIG]; } catch { /* ignore */ } }); }