chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
696
third_party/pi/packages/tui/src/terminal-image.ts
vendored
Normal file
696
third_party/pi/packages/tui/src/terminal-image.ts
vendored
Normal file
@@ -0,0 +1,696 @@
|
||||
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<TerminalCapabilities> = {};
|
||||
|
||||
// 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<TerminalCapabilities>): 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<number, RegisteredKittyImageMetadata>();
|
||||
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(" ")}]`;
|
||||
}
|
||||
Reference in New Issue
Block a user