import { execSync } from "node:child_process"; import { homedir } from "node:os"; import { isAbsolute } from "node:path"; import { pathToFileURL } from "node:url"; export type ImageProtocol = "kitty" | "iterm2" | null; export interface TerminalCapabilities { images: ImageProtocol; trueColor: boolean; hyperlinks: boolean; } export interface CellDimensions { widthPx: number; heightPx: number; } export interface ImageDimensions { widthPx: number; heightPx: number; } export interface ImageRenderOptions { maxWidthCells?: number; maxHeightCells?: number; preserveAspectRatio?: boolean; /** Kitty image ID. If provided, reuses/replaces existing image with this ID. */ imageId?: number; /** Whether Kitty should apply its default cursor movement after placement. */ moveCursor?: boolean; } let cachedCapabilities: TerminalCapabilities | null = null; let capabilityOverrides: Partial = {}; // Default cell dimensions - updated by TUI when terminal responds to query let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }; export function getCellDimensions(): CellDimensions { return cellDimensions; } export function setCellDimensions(dims: CellDimensions): void { cellDimensions = dims; } /** * Checks whether the attached tmux client forwards OSC 8 hyperlinks to the * outer terminal. tmux only re-emits them when its `client_termfeatures` lists * `hyperlinks`, and strips them otherwise. On any error fallbacks `false`. */ function probeTmuxHyperlinks(): boolean { try { const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", { encoding: "utf8", timeout: 250, stdio: ["ignore", "pipe", "ignore"], }); return termfeatures .split(",") .map((feature) => feature.trim()) .includes("hyperlinks"); } catch { return false; } } function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; const isWindowsConsole = process.platform === "win32"; // Emit OSC 8 hyperlinks only when tmux confirms it forwards. // Image protocols are unreliable under tmux, so leave `images: null`. if (process.env.TMUX || term.startsWith("tmux")) { return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() }; } // screen does not forward OSC 8 hyperlinks, so keep them off there. if (term.startsWith("screen")) { return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") { return { images: "kitty", trueColor: true, hyperlinks: true }; } if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) { return { images: "kitty", trueColor: true, hyperlinks: true }; } if (process.env.WEZTERM_PANE || termProgram === "wezterm") { return { images: "kitty", trueColor: true, hyperlinks: true }; } // Warp supports the Kitty graphics protocol and OSC 8 hyperlinks. if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) { return { images: "kitty", trueColor: true, hyperlinks: true }; } if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") { return { images: "iterm2", trueColor: true, hyperlinks: true }; } if (process.env.WT_SESSION) { return { images: null, trueColor: true, hyperlinks: true }; } if (termProgram === "alacritty" || termProgram === "vscode" || termProgram === "zed") { return { images: null, trueColor: true, hyperlinks: true }; } if (terminalEmulator === "jetbrains-jediterm") { return { images: null, trueColor: true, hyperlinks: false }; } // Windows Terminal does not always set WT_SESSION, for example when it hosts // a cmd.exe launched directly from Win+R. Modern Windows consoles support // truecolor; keep hyperlinks off unless we positively detected support above. if (isWindowsConsole) { return { images: null, trueColor: true, hyperlinks: false }; } // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just // text" on terminals that swallow it, which means the URL disappears from // the rendered output. Default to the legacy `text (url)` behavior unless we // have positively identified a hyperlink-capable terminal above. return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } function parseBooleanCapabilityOverride(value: string | undefined): boolean | undefined { return value === "1" ? true : value === "0" ? false : undefined; } export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities { const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS); const detected = detectCapabilitiesFromEnvironment( hyperlinks === undefined ? tmuxForwardsHyperlink : () => hyperlinks, ); const imageProtocol = process.env.PI_IMAGE_PROTOCOL?.toLowerCase(); const images = imageProtocol === "kitty" || imageProtocol === "iterm2" ? imageProtocol : imageProtocol === "none" || imageProtocol === "0" ? null : undefined; const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR); return { ...detected, ...(images !== undefined ? { images } : {}), ...(trueColor !== undefined ? { trueColor } : {}), ...(hyperlinks !== undefined ? { hyperlinks } : {}), }; } export function getCapabilities(): TerminalCapabilities { if (!cachedCapabilities) { const hyperlinks = capabilityOverrides.hyperlinks; cachedCapabilities = { ...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks), ...capabilityOverrides, }; } return cachedCapabilities; } export function resetCapabilitiesCache(): void { cachedCapabilities = null; } /** Override selected auto-detected capabilities. */ export function setCapabilityOverrides(overrides: Partial): void { if ( capabilityOverrides.images === overrides.images && capabilityOverrides.trueColor === overrides.trueColor && capabilityOverrides.hyperlinks === overrides.hyperlinks ) { return; } capabilityOverrides = { ...overrides }; cachedCapabilities = null; } /** Override the cached capabilities. Useful in tests to exercise both code paths. */ export function setCapabilities(caps: TerminalCapabilities): void { cachedCapabilities = caps; } const KITTY_PREFIX = "\x1b_G"; const ITERM2_PREFIX = "\x1b]1337;File="; export function isImageLine(line: string): boolean { // Fast path: sequence at line start (single-row images) if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) { return true; } // Slow path: sequence elsewhere (multi-row images have cursor-up prefix) return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX); } /** * Generate a random image ID for Kitty graphics protocol. * Uses random IDs to avoid collisions between different module instances * (e.g., main app vs extensions). */ export function allocateImageId(): number { // Use random ID in range [1, 0xffffffff] to avoid collisions return Math.floor(Math.random() * 0xfffffffe) + 1; } export function encodeKitty( base64Data: string, options: { columns?: number; rows?: number; imageId?: number; /** Whether Kitty should apply its default cursor movement after placement. Default: true. */ moveCursor?: boolean; } = {}, ): string { const CHUNK_SIZE = 4096; const params: string[] = ["a=T", "f=100", "q=2"]; if (options.moveCursor === false) params.push("C=1"); if (options.columns) params.push(`c=${options.columns}`); if (options.rows) params.push(`r=${options.rows}`); if (options.imageId) params.push(`i=${options.imageId}`); if (base64Data.length <= CHUNK_SIZE) { return `\x1b_G${params.join(",")};${base64Data}\x1b\\`; } const chunks: string[] = []; let offset = 0; let isFirst = true; while (offset < base64Data.length) { const chunk = base64Data.slice(offset, offset + CHUNK_SIZE); const isLast = offset + CHUNK_SIZE >= base64Data.length; if (isFirst) { chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`); isFirst = false; } else if (isLast) { chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`); } else { chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`); } offset += CHUNK_SIZE; } return chunks.join(""); } /** * Delete a Kitty graphics image by ID. * Uses uppercase 'I' to also free the image data. */ export function deleteKittyImage(imageId: number): string { return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`; } /** * Delete all visible Kitty graphics images. * Uses uppercase 'A' to also free the image data. */ export function deleteAllKittyImages(): string { return "\x1b_Ga=d,d=A,q=2\x1b\\"; } /** Delete all visible Kitty placements while retaining their uploaded image data. */ export function deleteAllKittyPlacements(): string { return "\x1b_Ga=d,d=a,q=2\x1b\\"; } export function encodeITerm2( base64Data: string, options: { width?: number | string; height?: number | string; name?: string; preserveAspectRatio?: boolean; inline?: boolean; } = {}, ): string { const params: string[] = [ `inline=${options.inline !== false ? 1 : 0}`, `size=${Buffer.byteLength(base64Data, "base64")}`, ]; if (options.width !== undefined) params.push(`width=${options.width}`); if (options.height !== undefined) params.push(`height=${options.height}`); if (options.name) { const nameBase64 = Buffer.from(options.name).toString("base64"); params.push(`name=${nameBase64}`); } if (options.preserveAspectRatio === false) { params.push("preserveAspectRatio=0"); } return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`; } export interface ImageCellSize { columns: number; rows: number; } export interface KittyImageMetadata extends ImageCellSize { imageId: number; widthPx: number; heightPx: number; } interface RegisteredKittyImageMetadata extends KittyImageMetadata { transmissionGeneration: number; } export interface KittyImagePlacement { imageId: number; transmissionGeneration: number; transmissionBytes: number; estimatedDecodedBytes: number; sequence: string; replacementLine: string; } const kittyImageMetadata = new Map(); let kittyTransmissionGeneration = 0; export function registerKittyImageMetadata(metadata: KittyImageMetadata): void { kittyTransmissionGeneration += 1; kittyImageMetadata.delete(metadata.imageId); kittyImageMetadata.set(metadata.imageId, { ...metadata, transmissionGeneration: kittyTransmissionGeneration }); if (kittyImageMetadata.size > 1000) { const oldestImageId = kittyImageMetadata.keys().next().value; if (oldestImageId !== undefined) kittyImageMetadata.delete(oldestImageId); } } function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined { const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; if (!controls) return undefined; const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1]; return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10)); } export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined { const metadata = getRegisteredKittyImageMetadata(line); if (!metadata) return undefined; return { imageId: metadata.imageId, columns: metadata.columns, rows: metadata.rows, widthPx: metadata.widthPx, heightPx: metadata.heightPx, }; } const KITTY_PLACEMENT_CONTROL_KEYS = new Set([ "i", "p", "x", "y", "w", "h", "X", "Y", "c", "r", "C", "U", "z", "P", "Q", "H", "V", ]); /** Build a placement-only command for an image line emitted by {@link renderImage}. */ export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined { const match = /\x1b_G([^;]*);/.exec(line); const metadata = getRegisteredKittyImageMetadata(line); if (!match || !metadata) return undefined; let commandStart = match.index; let commandControls = match[1]; let transmissionEnd: number; while (true) { const terminator = line.indexOf("\x1b\\", commandStart + KITTY_PREFIX.length); if (terminator === -1) return undefined; transmissionEnd = terminator + 2; if (!/(?:^|,)m=1(?:,|$)/.test(commandControls)) break; commandStart = transmissionEnd; if (!line.startsWith(KITTY_PREFIX, commandStart)) return undefined; const controlsEnd = line.indexOf(";", commandStart + KITTY_PREFIX.length); if (controlsEnd === -1) return undefined; commandControls = line.slice(commandStart + KITTY_PREFIX.length, controlsEnd); } const controls = match[1] .split(",") .filter((control) => KITTY_PLACEMENT_CONTROL_KEYS.has(control.split("=", 1)[0] ?? "")); const sequence = `\x1b_Ga=p,q=2,${controls.join(",")}\x1b\\`; return { imageId: metadata.imageId, transmissionGeneration: metadata.transmissionGeneration, transmissionBytes: transmissionEnd - match.index, estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4, sequence, replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`, }; } export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows: number): string { const metadata = getKittyImageMetadata(line); const match = /\x1b_G([^;]*);/.exec(line); if (!metadata || !match || hiddenRows < 0 || hiddenRows >= metadata.rows || visibleRows <= 0) return line; const croppedRows = Math.min(visibleRows, metadata.rows - hiddenRows); if (hiddenRows === 0 && croppedRows === metadata.rows) return line; const sourceY = Math.floor((metadata.heightPx * hiddenRows) / metadata.rows); const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + croppedRows)) / metadata.rows); const sourceHeight = Math.max(1, Math.min(metadata.heightPx, sourceEnd) - sourceY); const controls = match[1].split(",").filter((control) => !/^[yhr]=/.test(control)); controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${croppedRows}`); return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`; } export function calculateImageCellSize( imageDimensions: ImageDimensions, maxWidthCells: number, maxHeightCells?: number, cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, ): ImageCellSize { const maxWidth = Math.max(1, Math.floor(maxWidthCells)); const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells)); const imageWidth = Math.max(1, imageDimensions.widthPx); const imageHeight = Math.max(1, imageDimensions.heightPx); const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth; const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight; const scale = Math.min(widthScale, heightScale); const scaledWidthPx = imageWidth * scale; const scaledHeightPx = imageHeight * scale; const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx); const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); return { columns: Math.max(1, Math.min(maxWidth, columns)), rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)), }; } export function calculateImageRows( imageDimensions: ImageDimensions, targetWidthCells: number, cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, ): number { return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows; } export function getPngDimensions(base64Data: string): ImageDimensions | null { try { const buffer = Buffer.from(base64Data, "base64"); if (buffer.length < 24) { return null; } if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) { return null; } const width = buffer.readUInt32BE(16); const height = buffer.readUInt32BE(20); return { widthPx: width, heightPx: height }; } catch { return null; } } export function getJpegDimensions(base64Data: string): ImageDimensions | null { try { const buffer = Buffer.from(base64Data, "base64"); if (buffer.length < 2) { return null; } if (buffer[0] !== 0xff || buffer[1] !== 0xd8) { return null; } let offset = 2; while (offset < buffer.length - 9) { if (buffer[offset] !== 0xff) { offset++; continue; } const marker = buffer[offset + 1]; if (marker >= 0xc0 && marker <= 0xc2) { const height = buffer.readUInt16BE(offset + 5); const width = buffer.readUInt16BE(offset + 7); return { widthPx: width, heightPx: height }; } if (offset + 3 >= buffer.length) { return null; } const length = buffer.readUInt16BE(offset + 2); if (length < 2) { return null; } offset += 2 + length; } return null; } catch { return null; } } export function getGifDimensions(base64Data: string): ImageDimensions | null { try { const buffer = Buffer.from(base64Data, "base64"); if (buffer.length < 10) { return null; } const sig = buffer.slice(0, 6).toString("ascii"); if (sig !== "GIF87a" && sig !== "GIF89a") { return null; } const width = buffer.readUInt16LE(6); const height = buffer.readUInt16LE(8); return { widthPx: width, heightPx: height }; } catch { return null; } } export function getWebpDimensions(base64Data: string): ImageDimensions | null { try { const buffer = Buffer.from(base64Data, "base64"); if (buffer.length < 30) { return null; } const riff = buffer.slice(0, 4).toString("ascii"); const webp = buffer.slice(8, 12).toString("ascii"); if (riff !== "RIFF" || webp !== "WEBP") { return null; } const chunk = buffer.slice(12, 16).toString("ascii"); if (chunk === "VP8 ") { if (buffer.length < 30) return null; const width = buffer.readUInt16LE(26) & 0x3fff; const height = buffer.readUInt16LE(28) & 0x3fff; return { widthPx: width, heightPx: height }; } else if (chunk === "VP8L") { if (buffer.length < 25) return null; const bits = buffer.readUInt32LE(21); const width = (bits & 0x3fff) + 1; const height = ((bits >> 14) & 0x3fff) + 1; return { widthPx: width, heightPx: height }; } else if (chunk === "VP8X") { if (buffer.length < 30) return null; const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1; const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1; return { widthPx: width, heightPx: height }; } return null; } catch { return null; } } export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null { if (mimeType === "image/png") { return getPngDimensions(base64Data); } if (mimeType === "image/jpeg") { return getJpegDimensions(base64Data); } if (mimeType === "image/gif") { return getGifDimensions(base64Data); } if (mimeType === "image/webp") { return getWebpDimensions(base64Data); } return null; } export function renderImage( base64Data: string, imageDimensions: ImageDimensions, options: ImageRenderOptions = {}, ): { sequence: string; columns: number; rows: number; imageId?: number } | null { const caps = getCapabilities(); if (!caps.images) { return null; } const maxWidth = options.maxWidthCells ?? 80; const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); if (caps.images === "kitty") { if (options.imageId !== undefined) { registerKittyImageMetadata({ imageId: options.imageId, columns: size.columns, rows: size.rows, widthPx: imageDimensions.widthPx, heightPx: imageDimensions.heightPx, }); } const sequence = encodeKitty(base64Data, { columns: size.columns, rows: size.rows, imageId: options.imageId, moveCursor: options.moveCursor, }); return { sequence, columns: size.columns, rows: size.rows, imageId: options.imageId }; } if (caps.images === "iterm2") { const sequence = encodeITerm2(base64Data, { width: size.columns, height: "auto", preserveAspectRatio: options.preserveAspectRatio ?? true, }); return { sequence, columns: size.columns, rows: size.rows }; } return null; } /** * Wrap text in an OSC 8 hyperlink sequence. * The text is rendered as a clickable hyperlink in terminals that support OSC 8 * (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others). * In terminals that do not support OSC 8, the escape sequences are ignored * and only the plain text is displayed. * * @param text - The visible text to display * @param url - The URL to link to */ export function hyperlink(text: string, url: string): string { return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`; } /** Shorten home-prefixed absolute paths to ~/... for compact display. */ function shortenImagePath(filename: string): string { const home = homedir(); if (home && (filename === home || filename.startsWith(`${home}/`) || filename.startsWith(`${home}\\`))) { return `~${filename.slice(home.length)}`; } return filename; } /** * Text fallback when the terminal cannot render inline images. * Absolute paths are shown shortened (~/...) and, when OSC 8 hyperlinks are * available, linked to file:// so the full path remains openable. */ export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string { const parts: string[] = []; if (filename) { const display = shortenImagePath(filename); if (getCapabilities().hyperlinks && isAbsolute(filename)) { parts.push(hyperlink(display, pathToFileURL(filename).href)); } else { parts.push(display); } } parts.push(`[${mimeType}]`); if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`); return `[Image: ${parts.join(" ")}]`; }