|
10 | 10 | // survives tmux and any VT100-ish terminal. |
11 | 11 |
|
12 | 12 | import { spawn } from "node:child_process" |
| 13 | +import { unlink, writeFile } from "node:fs/promises" |
| 14 | +import { tmpdir } from "node:os" |
| 15 | +import { join } from "node:path" |
13 | 16 |
|
14 | 17 | export type ImageProtocol = "kitty" | "halfblock" |
15 | 18 |
|
@@ -196,10 +199,65 @@ function wrapForMultiplexer(sequence: string): string { |
196 | 199 | return `${ESC}Ptmux;${escaped}${ESC}\\` |
197 | 200 | } |
198 | 201 |
|
199 | | -// Build the Kitty Graphics Protocol upload: transmit the PNG (base64, chunked) |
200 | | -// with a virtual placement (U=1) so it is anchored to Unicode placeholders |
201 | | -// rather than the cursor. The image is sized to exactly cols×rows cells. |
202 | | -function buildKittyTransmit( |
| 202 | +// Build the Kitty Graphics Protocol upload. Rather than streaming the PNG |
| 203 | +// inline as dozens of base64 chunks — which, under tmux, must each be wrapped |
| 204 | +// in DCS passthrough and is prone to partial delivery (the image then decodes |
| 205 | +// only its top scanlines) — we write the PNG to a temporary file and transmit |
| 206 | +// just its path. This is a single short escape regardless of image size, the |
| 207 | +// approach yazi uses for robustness under multiplexers. |
| 208 | +// |
| 209 | +// t=t = temporary file: the terminal reads the pixel data then deletes the |
| 210 | +// file itself. Kitty/Ghostty only honor this when the path lives in a known |
| 211 | +// temp dir AND contains the literal string `tty-graphics-protocol`, so the |
| 212 | +// filename is constructed accordingly. U=1 anchors a virtual placement to the |
| 213 | +// Unicode placeholders; q=2 suppresses responses; c/r size it in cells. |
| 214 | +// |
| 215 | +// We still keep the file around briefly as a fallback target and best-effort |
| 216 | +// delete it after a short delay in case the terminal could not (e.g. an older |
| 217 | +// build, or the path safety check failing). |
| 218 | +async function buildKittyTransmit( |
| 219 | + png: Buffer, |
| 220 | + imageId: number, |
| 221 | + cols: number, |
| 222 | + rows: number, |
| 223 | + multiplexed: boolean, |
| 224 | +): Promise<string> { |
| 225 | + const filePath = await writeGraphicsTempFile(png, imageId) |
| 226 | + if (!filePath) { |
| 227 | + // Could not stage a temp file; fall back to inline chunked transfer. |
| 228 | + return buildKittyTransmitInline(png, imageId, cols, rows, multiplexed) |
| 229 | + } |
| 230 | + const encodedPath = Buffer.from(filePath, "utf8").toString("base64") |
| 231 | + const control = `a=T,U=1,q=2,f=100,t=t,i=${imageId},c=${cols},r=${rows}` |
| 232 | + const apc = `${ESC}_G${control};${encodedPath}${ESC}\\` |
| 233 | + return multiplexed ? wrapForMultiplexer(apc) : apc |
| 234 | +} |
| 235 | + |
| 236 | +// Stage the PNG in a temp file whose path satisfies the Kitty `t=t` safety |
| 237 | +// rules (lives under the system temp dir and contains the magic substring). |
| 238 | +// Returns null if the write fails so the caller can fall back to inline data. |
| 239 | +async function writeGraphicsTempFile( |
| 240 | + png: Buffer, |
| 241 | + imageId: number, |
| 242 | +): Promise<string | null> { |
| 243 | + const name = `tty-graphics-protocol-braincode-${process.pid}-${imageId}-${Date.now()}.png` |
| 244 | + const filePath = join(tmpdir(), name) |
| 245 | + try { |
| 246 | + await writeFile(filePath, png) |
| 247 | + } catch { |
| 248 | + return null |
| 249 | + } |
| 250 | + // The terminal deletes the file once it has read the pixels (t=t). Schedule |
| 251 | + // a best-effort cleanup in case it does not, without blocking rendering. |
| 252 | + setTimeout(() => { |
| 253 | + void unlink(filePath).catch(() => {}) |
| 254 | + }, 10_000).unref?.() |
| 255 | + return filePath |
| 256 | +} |
| 257 | + |
| 258 | +// Inline fallback: transmit the PNG (base64, chunked) with a virtual placement |
| 259 | +// (U=1). Used only when a temp file cannot be staged. |
| 260 | +function buildKittyTransmitInline( |
203 | 261 | png: Buffer, |
204 | 262 | imageId: number, |
205 | 263 | cols: number, |
@@ -339,7 +397,7 @@ export async function buildImagePreview( |
339 | 397 | rows, |
340 | 398 | imageId, |
341 | 399 | fgColor: placeholderFgColor(imageId), |
342 | | - transmit: buildKittyTransmit( |
| 400 | + transmit: await buildKittyTransmit( |
343 | 401 | png, |
344 | 402 | imageId, |
345 | 403 | cols, |
|
0 commit comments