/** * Pure-JS SIXEL encoder for Windows Terminal (1.22+) and other SIXEL terminals. * Decodes PNG/JPEG via pngjs/jpeg-js, resizes, quantizes, emits DEC SIXEL. */ import { PNG } from "pngjs"; import jpeg from "jpeg-js"; export interface RgbaImage { width: number; height: number; data: Uint8Array; } function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); } export function decodeBase64Image( base64: string, mimeType: string, ): RgbaImage | null { try { const buf = Buffer.from(base64, "base64"); if (mimeType === "image/png" || (buf[0] === 0x89 && buf[1] === 0x50)) { const png = PNG.sync.read(buf); return { width: png.width, height: png.height, data: png.data }; } if (mimeType === "image/jpeg" || (buf[0] === 0xff && buf[1] === 0xd8)) { const jpg = jpeg.decode(buf, { useTArray: true, formatAsRGBA: true }); return { width: jpg.width, height: jpg.height, data: jpg.data as Uint8Array, }; } return null; } catch { return null; } } /** Nearest-neighbor resize to target pixel size. */ export function resizeNearest( img: RgbaImage, tw: number, th: number, ): RgbaImage { const w = Math.max(1, Math.floor(tw)); const h = Math.max(1, Math.floor(th)); const out = new Uint8Array(w * h * 4); for (let y = 0; y < h; y++) { const sy = Math.min(img.height - 1, Math.floor((y * img.height) / h)); for (let x = 0; x < w; x++) { const sx = Math.min(img.width - 1, Math.floor((x * img.width) / w)); const si = (sy * img.width + sx) * 4; const di = (y * w + x) * 4; const a = img.data[si + 3]! / 255; // composite on black out[di] = Math.round(img.data[si]! * a); out[di + 1] = Math.round(img.data[si + 1]! * a); out[di + 2] = Math.round(img.data[si + 2]! * a); out[di + 3] = 255; } } return { width: w, height: h, data: out }; } function colorKey(r: number, g: number, b: number): number { // 5-bit per channel key for quantization buckets return ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3); } /** * Build a palette of up to `maxColors` (SIXEL supports 256). * Uses frequency-ranked 5-bit buckets for speed. */ function buildPalette( img: RgbaImage, maxColors: number, ): Array<[number, number, number]> { const freq = new Map< number, { n: number; r: number; g: number; b: number } >(); const n = img.width * img.height; for (let i = 0; i < n; i++) { const o = i * 4; const r = img.data[o]!; const g = img.data[o + 1]!; const b = img.data[o + 2]!; const k = colorKey(r, g, b); const e = freq.get(k); if (e) { e.n++; e.r += r; e.g += g; e.b += b; } else { freq.set(k, { n: 1, r, g, b }); } } const ranked = [...freq.values()].sort((a, b) => b.n - a.n); const palette: Array<[number, number, number]> = []; const limit = Math.min(maxColors, ranked.length); for (let i = 0; i < limit; i++) { const e = ranked[i]!; palette.push([ Math.round(e.r / e.n), Math.round(e.g / e.n), Math.round(e.b / e.n), ]); } if (palette.length === 0) palette.push([0, 0, 0]); return palette; } function nearestPalette( palette: Array<[number, number, number]>, r: number, g: number, b: number, ): number { let best = 0; let bestD = Infinity; for (let i = 0; i < palette.length; i++) { const p = palette[i]!; const dr = p[0] - r; const dg = p[1] - g; const db = p[2] - b; const d = dr * dr + dg * dg + db * db; if (d < bestD) { bestD = d; best = i; } } return best; } /** * Encode RGBA image to a SIXEL DCS sequence. * Format: ESC P q ... ESC \ */ export function encodeSixelRgba(img: RgbaImage, maxColors = 64): string { const palette = buildPalette(img, maxColors); // Map every pixel to palette index const idx = new Uint8Array(img.width * img.height); for (let i = 0; i < idx.length; i++) { const o = i * 4; idx[i] = nearestPalette( palette, img.data[o]!, img.data[o + 1]!, img.data[o + 2]!, ); } const parts: string[] = []; // DCS start + raster attributes "Pan;Pad;Ph;Pv // Pan/Pad = 1 (pixel aspect), Ph/Pv = pixel size parts.push(`\x1bP0;0;0q"1;1;${img.width};${img.height}`); // Define palette: #Pc;Pu;Px;Py;Pz Pu=2 means RGB 0-100 for (let i = 0; i < palette.length; i++) { const [r, g, b] = palette[i]!; const pr = Math.round((r * 100) / 255); const pg = Math.round((g * 100) / 255); const pb = Math.round((b * 100) / 255); parts.push(`#${i};2;${pr};${pg};${pb}`); } // Encode in 6-pixel-high bands const bands = Math.ceil(img.height / 6); for (let band = 0; band < bands; band++) { const y0 = band * 6; // For each palette color used in this band, emit a color plane const used = new Set(); for (let y = 0; y < 6; y++) { const yy = y0 + y; if (yy >= img.height) break; for (let x = 0; x < img.width; x++) { used.add(idx[yy * img.width + x]!); } } let firstColor = true; for (const color of used) { if (!firstColor) parts.push("$"); // CR: back to start of line firstColor = false; parts.push(`#${color}`); // Build sixel chars for this color let runChar = -1; let runLen = 0; const flush = () => { if (runLen <= 0 || runChar < 0) return; const ch = String.fromCharCode(0x3f + runChar); if (runLen >= 4) { parts.push(`!${runLen}${ch}`); } else { parts.push(ch.repeat(runLen)); } runLen = 0; }; for (let x = 0; x < img.width; x++) { let bits = 0; for (let y = 0; y < 6; y++) { const yy = y0 + y; if (yy >= img.height) break; if (idx[yy * img.width + x] === color) { bits |= 1 << y; } } if (bits === runChar) { runLen++; } else { flush(); runChar = bits; runLen = 1; } } flush(); } parts.push("-"); // LF: next band } parts.push("\x1b\\"); // ST return parts.join(""); } /** * Produce SIXEL thumbnail lines for TUI components. * Returns one "line" containing the full SIXEL sequence + reserved blank lines * so the TUI accounts for image height (similar to pi-tui Image direct placement). */ export function sixelThumbnailLines( base64: string, mimeType: string, maxCols: number, maxRows: number, cellW = 10, cellH = 20, ): { lines: string[]; rows: number } | null { const decoded = decodeBase64Image(base64, mimeType); if (!decoded) return null; const maxW = Math.max(8, Math.floor(maxCols)); const maxH = Math.max(2, Math.floor(maxRows)); // Fit into cell grid const maxPxW = maxW * cellW; // SIXEL band height is 6px — round down to multiple of 6 within row budget const rawPxH = maxH * cellH; const maxPxH = Math.max(6, Math.floor(rawPxH / 6) * 6); const scale = Math.min(maxPxW / decoded.width, maxPxH / decoded.height, 1); let tw = Math.max(1, Math.round(decoded.width * scale)); let th = Math.max(6, Math.round(decoded.height * scale)); th = Math.max(6, Math.floor(th / 6) * 6); // keep aspect after rounding height tw = Math.max(1, Math.round((decoded.width * th) / decoded.height)); const resized = resizeNearest(decoded, tw, th); const sequence = encodeSixelRgba(resized, 64); const rows = Math.max(1, Math.ceil(th / cellH)); // Pi TUI only skips width checks for Kitty/iTerm image lines (isImageLine). // ESM exports are read-only so we cannot patch isImageLine. Prefix a no-op // Kitty delete-all marker so the line is classified as an image line. // Windows Terminal ignores Kitty APC; SIXEL still paints. const kittyMark = "\x1b_Ga=d,d=A,q=2\x1b\\"; // Reserve rows like pi-tui Image does for direct graphics placement. // Mark reserved rows too so differential render treats them as image slots. const lines: string[] = []; for (let i = 0; i < rows - 1; i++) lines.push(`${kittyMark}\x1b[0m`); // Save cursor, move up into reserved block, emit sixel, restore. const up = rows > 1 ? `\x1b[${rows - 1}A` : ""; if (rows > 1) { lines.push(`${kittyMark}\x1b7${up}${sequence}\x1b8`); } else { lines.push(`${kittyMark}${sequence}`); } return { lines, rows }; } export function shouldPreferSixel( env: NodeJS.ProcessEnv = process.env, ): boolean { // User force const force = env.PI_FORCE_IMAGE_PROTOCOL?.toLowerCase(); if (force === "sixel") return true; if (force === "off" || force === "none") return false; // Windows Terminal (native or WSL host) if (env.WT_SESSION) return true; // Explicit opt-in if (env.PI_IMAGE_PLACEHOLDER_SIXEL === "1") return true; return false; } void clamp;