Skip to content

Commit 77260c7

Browse files
committed
feat: use temp-file path for kitty image transmit under multiplexers
1 parent 6b298ad commit 77260c7

2 files changed

Lines changed: 99 additions & 5 deletions

File tree

apps/cli/src/image-preview.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { describe, expect, test } from "bun:test"
2+
import { existsSync } from "node:fs"
3+
import { writeFile } from "node:fs/promises"
4+
import { tmpdir } from "node:os"
5+
import { join } from "node:path"
26
import {
37
detectTerminalCapability,
48
fitCells,
@@ -72,4 +76,36 @@ describe("buildImagePreview", () => {
7276
})
7377
expect(preview).toBeNull()
7478
})
79+
80+
test("kitty transmit uses a temp-file path, not inline chunks", async () => {
81+
// 1x1 red PNG.
82+
const png = Buffer.from(
83+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
84+
"base64",
85+
)
86+
const srcPath = join(tmpdir(), `image-preview-test-${process.pid}.png`)
87+
await writeFile(srcPath, png)
88+
89+
const preview = await buildImagePreview(srcPath, {
90+
maxCols: 20,
91+
maxRows: 10,
92+
capability: { protocol: "kitty", multiplexed: true },
93+
})
94+
95+
// Skip when image tooling (sips/ImageMagick) is unavailable on the host.
96+
if (!preview || preview.protocol !== "kitty") return
97+
98+
const transmit = preview.transmit ?? ""
99+
// Single passthrough-wrapped escape, not dozens of chunks.
100+
expect((transmit.match(/\x1bPtmux;/g) ?? []).length).toBe(1)
101+
expect(transmit).toContain("t=t")
102+
expect(transmit).not.toContain("t=d")
103+
104+
const unescaped = transmit.replaceAll("\x1b\x1b", "\x1b")
105+
const match = /t=t,i=\d+,c=\d+,r=\d+;([A-Za-z0-9+/=]+)/.exec(unescaped)
106+
expect(match).not.toBeNull()
107+
const filePath = Buffer.from(match![1]!, "base64").toString("utf8")
108+
expect(filePath).toContain("tty-graphics-protocol")
109+
expect(existsSync(filePath)).toBe(true)
110+
})
75111
})

apps/cli/src/image-preview.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
// survives tmux and any VT100-ish terminal.
1111

1212
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"
1316

1417
export type ImageProtocol = "kitty" | "halfblock"
1518

@@ -196,10 +199,65 @@ function wrapForMultiplexer(sequence: string): string {
196199
return `${ESC}Ptmux;${escaped}${ESC}\\`
197200
}
198201

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(
203261
png: Buffer,
204262
imageId: number,
205263
cols: number,
@@ -339,7 +397,7 @@ export async function buildImagePreview(
339397
rows,
340398
imageId,
341399
fgColor: placeholderFgColor(imageId),
342-
transmit: buildKittyTransmit(
400+
transmit: await buildKittyTransmit(
343401
png,
344402
imageId,
345403
cols,

0 commit comments

Comments
 (0)