diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index db53b131b..a74f11d5d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -83,6 +83,9 @@ ### Fixed - Fixed switching from a vision model to a text-only model mid-session sending historical image blocks to the new provider, which rejected them; image content is now replaced with a text placeholder in outbound requests when the active model lacks image input ([#5400](https://github.com/can1357/oh-my-pi/issues/5400)). +### Fixed + +- Fixed inline images in Agent Hub transcripts by routing replayed assistant and tool-result images through the shared image budget and Kitty placeholder renderer. ([#5381](https://github.com/can1357/oh-my-pi/issues/5381)) ## [16.5.0] - 2026-07-13 diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts index 391be5535..f3b666cfe 100644 --- a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -280,7 +280,7 @@ export class ChatTranscriptBuilder { hideThinkingBlock, () => this.deps.requestRender(), this.deps.getMessageRenderer ? undefined : [], // placeholder for thinkingRenderers - undefined, // placeholder for imageBudget + this.deps.ui.imageBudget, proseOnlyThinking, ); this.container.addChild(assistantComponent); @@ -352,8 +352,9 @@ export class ChatTranscriptBuilder { content.name, content.arguments, { - // Images can't be sliced through the scroll viewport; keep them off. - showImages: false, + // Stable ids and Kitty placeholder cells keep images anchored + // while the transcript viewport scrolls and reflows. + showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), liveRegion: this.container, diff --git a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts index 1a08a339d..03d427eb4 100644 --- a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts +++ b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts @@ -16,6 +16,15 @@ import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/component import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries"; +import { + getKittyGraphics, + ImageBudget, + ImageProtocol, + setKittyGraphics, + setTerminalImageProtocol, + TERMINAL, + type TUI, +} from "@oh-my-pi/pi-tui"; import { removeSyncWithRetries } from "@oh-my-pi/pi-utils"; const TS = new Date().toISOString(); @@ -64,6 +73,60 @@ function buildJsonl(): string { return `${lines.join("\n")}\n`; } +function buildImageJsonl(): string { + const usage = { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + const entries = [ + JSON.stringify({ type: "session", version: CURRENT_SESSION_VERSION, id: "adv", timestamp: TS, cwd: "/tmp" }), + JSON.stringify({ + type: "message", + id: "a0", + parentId: null, + timestamp: TS, + message: { + role: "assistant", + content: [ + { type: "toolCall", id: "image-call", name: "eval", arguments: { language: "py", code: "display" } }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "gpt-5.5", + usage, + stopReason: "toolUse", + timestamp: 1, + }, + }), + JSON.stringify({ + type: "message", + id: "t0", + parentId: "a0", + timestamp: TS, + message: { + role: "toolResult", + toolCallId: "image-call", + toolName: "eval", + content: [ + { type: "text", text: "displayed image" }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + mimeType: "image/png", + }, + ], + isError: false, + timestamp: 2, + }, + }), + ]; + return `${entries.join("\n")}\n`; +} + function messageLine(id: string, content: string): string { return JSON.stringify({ type: "message", @@ -74,7 +137,7 @@ function messageLine(id: string, content: string): string { }); } -function makeViewer(file: string, remote?: AgentHubRemote) { +function makeViewer(file: string, remote?: AgentHubRemote, ui?: TUI) { const agents = new AgentRegistry(); agents.register({ id: "Main/advisor", @@ -88,7 +151,7 @@ function makeViewer(file: string, remote?: AgentHubRemote) { return new AgentTranscriptViewer({ agentId: "Main/advisor", registry: agents, - ui: { requestRender: () => {}, requestComponentRender: () => {} } as never, + ui: ui ?? ({ requestRender: () => {}, requestComponentRender: () => {} } as never), cwd: "/tmp", remote, expandKeys: ["ctrl+o"], @@ -166,6 +229,37 @@ describe("AgentTranscriptViewer", () => { }); }); + it("renders tool-result images through the shared Kitty placeholder budget", async () => { + await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-image-")); + const file = path.join(dir, "__advisor.jsonl"); + fs.writeFileSync(file, buildImageJsonl()); + const previousProtocol = TERMINAL.imageProtocol; + const previousGraphics = getKittyGraphics(); + setTerminalImageProtocol(ImageProtocol.Kitty); + setKittyGraphics({ unicodePlaceholders: true }); + const imageBudget = new ImageBudget(8, () => {}); + const ui = { + imageBudget, + requestRender: () => {}, + requestComponentRender: () => {}, + } as unknown as TUI; + const viewer = makeViewer(file, undefined, ui); + try { + imageBudget.beginPass(); + const rendered = viewer.render(80).join("\n"); + imageBudget.endPass(); + expect(rendered).toContain("a=p,U=1"); + expect(rendered).toContain("\u{10eeee}"); + expect(imageBudget.takeTransmits().join("")).toContain("a=t"); + } finally { + viewer.dispose(); + setKittyGraphics(previousGraphics); + setTerminalImageProtocol(previousProtocol); + removeSyncWithRetries(dir); + } + }); + it("clears stale content when the transcript file is deleted while open", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); const file = path.join(dir, "__advisor.jsonl"); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index affd93221..0bc8f15b7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -16,6 +16,7 @@ - Fixed native Windows Terminal sessions failing to detect mid-run light/dark theme changes when Mode 2031 appearance notifications are unavailable ([#5091](https://github.com/can1357/oh-my-pi/issues/5091)). - Hid empty HTML comment separators in Markdown-rendered TUI output instead of displaying them literally ([#4911](https://github.com/can1357/oh-my-pi/issues/4911)). - Fixed automatic file completion treating punctuation, trailing spaces, and ambiguous slash-command text as paths, immediately dismissing slash autocomplete on Backspace and requiring selection before applying a sole forced file match ([#5376](https://github.com/can1357/oh-my-pi/issues/5376)). +- Fixed Kitty graphics under tmux by wrapping every graphics command in DCS passthrough, preserving quiet mode across continuation chunks, and using Unicode placeholder cells for explicitly forced Kitty rendering so images follow pane scrolling and reflow. ([#5381](https://github.com/can1357/oh-my-pi/issues/5381)) ## [16.5.0] - 2026-07-13 diff --git a/packages/tui/src/kitty-graphics.ts b/packages/tui/src/kitty-graphics.ts index 0b403d217..e2139c975 100644 --- a/packages/tui/src/kitty-graphics.ts +++ b/packages/tui/src/kitty-graphics.ts @@ -15,6 +15,8 @@ * forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller. */ +import { wrapTmuxPassthroughIfNeeded } from "./tmux"; + /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */ export const KITTY_PLACEHOLDER = "\u{10eeee}"; @@ -60,18 +62,15 @@ export interface KittyGraphicsFeatures { * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` + * U+10EEEE with row/column diacritics). * - * Only `kitty` (the protocol's origin) and `ghostty` ship a working - * implementation; WezTerm advertises Kitty graphics but treats placeholder - * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support" - * still unchecked), and the tmux/screen fallback can land on any outer - * terminal. Enabling placeholders on those paths emits a `columns × rows` - * grid of U+10EEEE per image per frame; the cells render as boxed fallback - * glyphs and re-emit on every repaint, which is exactly the - * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877. + * Kitty and Ghostty advertise placeholder support directly. A tmux session + * cannot use cursor-positioned placements because the outer terminal does not + * know pane scroll/reflow state, so an explicit `PI_FORCE_IMAGE_PROTOCOL=kitty` + * also opts into placeholders there — matching `timg -pk`. Automatic tmux + * fallback stays off because the unknown outer terminal may render U+10EEEE as + * literal PUA boxes (#1877). * - * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a - * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g. - * for a wezterm nightly that has merged placeholder support). + * `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard + * opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else. */ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean { const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase(); @@ -79,6 +78,7 @@ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: N const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase(); if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true; if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false; + if (env.TMUX && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true; return terminalId === "kitty" || terminalId === "ghostty"; } @@ -120,7 +120,7 @@ export function encodeKittyVirtualPlacement(opts: { const params = ["a=p", "U=1", "q=2", `i=${opts.imageId}`]; if (opts.placementId) params.push(`p=${opts.placementId}`); params.push(`c=${opts.columns}`, `r=${opts.rows}`); - return `\x1b_G${params.join(",")}\x1b\\`; + return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`); } /** diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 22aec8608..971293196 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -9,6 +9,9 @@ import { renderKittyPlaceholderLines, setKittyGraphics, } from "./kitty-graphics"; +import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux"; + +export { isInsideTmux, wrapTmuxPassthrough } from "./tmux"; export enum ImageProtocol { Kitty = "\x1b_G", @@ -142,15 +145,6 @@ export class TerminalInfo { } } -/** - * Whether the agent process is running inside a tmux session. Read fresh on - * each call so tests can toggle `Bun.env.TMUX` per case without re-importing - * the module and so a tmux session attached/detached mid-run is observed. - */ -export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean { - return Boolean(env.TMUX); -} - /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean { // TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session @@ -172,22 +166,6 @@ export function isInsideZellij(env: NodeJS.ProcessEnv = Bun.env): boolean { return Boolean(env.ZELLIJ); } -/** - * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each - * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips - * the envelope and forwards the unwrapped payload to the outer terminal only - * when the user opts in with `set -g allow-passthrough on`; otherwise tmux - * silently consumes the envelope, which is identical to the pre-wrap baseline - * (tmux already swallowed the bare OSC). - * - * Used by `TerminalInfo.sendNotification` and the OSC 99 capability probe in - * `terminal.ts` to keep notifications alive for terminals that understand - * OSC 9 / OSC 99 (kitty, ghostty, wezterm, iterm2) when running under tmux. - */ -export function wrapTmuxPassthrough(payload: string): string { - return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`; -} - export function isNotificationSuppressed(): boolean { const value = $env.PI_NOTIFICATIONS; if (!value) return false; @@ -628,7 +606,7 @@ export function setCellDimensions(dims: CellDimensions): void { function chunkKittyApc(leadParams: string, base64Data: string): string { const CHUNK_SIZE = 4096; if (base64Data.length <= CHUNK_SIZE) { - return `\x1b_G${leadParams};${base64Data}\x1b\\`; + return wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams};${base64Data}\x1b\\`); } const chunks: string[] = []; @@ -640,12 +618,12 @@ function chunkKittyApc(leadParams: string, base64Data: string): string { const isLast = offset + CHUNK_SIZE >= base64Data.length; if (isFirst) { - chunks.push(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`); + chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`)); isFirst = false; } else if (isLast) { - chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`); + chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=0;${chunk}\x1b\\`)); } else { - chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`); + chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=1;${chunk}\x1b\\`)); } offset += CHUNK_SIZE; @@ -699,7 +677,7 @@ export function encodeKittyPlacement(options: { if (options.placementId) params.push(`p=${options.placementId}`); if (options.columns) params.push(`c=${options.columns}`); if (options.rows) params.push(`r=${options.rows}`); - return `\x1b_G${params.join(",")}\x1b\\`; + return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`); } /** @@ -710,7 +688,7 @@ export function encodeKittyPlacement(options: { * this is the only way to actually purge a placed image. */ export function encodeKittyDeleteImage(imageId: number): string { - return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`; + return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`); } export function encodeITerm2( diff --git a/packages/tui/src/tmux.ts b/packages/tui/src/tmux.ts new file mode 100644 index 000000000..266e10b16 --- /dev/null +++ b/packages/tui/src/tmux.ts @@ -0,0 +1,14 @@ +/** Whether the process is running inside a tmux session. */ +export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean { + return Boolean(env.TMUX); +} + +/** Wrap a control sequence in tmux's DCS passthrough envelope. */ +export function wrapTmuxPassthrough(payload: string): string { + return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`; +} + +/** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */ +export function wrapTmuxPassthroughIfNeeded(payload: string, env: NodeJS.ProcessEnv = Bun.env): string { + return isInsideTmux(env) ? wrapTmuxPassthrough(payload) : payload; +} diff --git a/packages/tui/test/image-budget.test.ts b/packages/tui/test/image-budget.test.ts index f095883dd..c9388fd7e 100644 --- a/packages/tui/test/image-budget.test.ts +++ b/packages/tui/test/image-budget.test.ts @@ -10,6 +10,7 @@ import { } from "@oh-my-pi/pi-tui/kitty-graphics"; import { type CellDimensions, + encodeKitty, encodeKittyDeleteImage, encodeKittyPlacement, encodeKittyTransmit, @@ -17,6 +18,7 @@ import { ImageProtocol, setCellDimensions, TERMINAL, + wrapTmuxPassthrough, } from "@oh-my-pi/pi-tui/terminal-capabilities"; import { VirtualTerminal } from "./virtual-terminal"; @@ -26,6 +28,17 @@ const terminal = TERMINAL as unknown as MutableTerminalInfo; const BASE64_ONE_PIXEL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg=="; +const ORIGINAL_TMUX = Bun.env.TMUX; + +beforeEach(() => { + delete Bun.env.TMUX; +}); + +afterEach(() => { + if (ORIGINAL_TMUX === undefined) delete Bun.env.TMUX; + else Bun.env.TMUX = ORIGINAL_TMUX; +}); + /** Drive one render pass against the budget with `count` images (ids 1..count, stable across passes). */ function pass(budget: ImageBudget, count: number): { suppressed: boolean[]; reset: boolean; purge: readonly number[] } { budget.beginPass(); @@ -273,6 +286,40 @@ describe("encodeKittyDeleteImage", () => { }); }); +describe("tmux Kitty graphics passthrough", () => { + beforeEach(() => { + Bun.env.TMUX = "/tmp/tmux-1000/default,1,0"; + }); + + it("wraps every Kitty graphics command in a tmux DCS envelope", () => { + const expected = (payload: string) => `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`; + + expect(encodeKitty("AA==", { columns: 1, rows: 1 })).toBe(expected("\x1b_Ga=T,f=100,q=2,C=1,c=1,r=1;AA==\x1b\\")); + expect(encodeKittyTransmit("AA==", 9)).toBe(expected("\x1b_Ga=t,f=100,q=2,i=9;AA==\x1b\\")); + expect(encodeKittyPlacement({ imageId: 9, placementId: 9, columns: 3, rows: 2 })).toBe( + expected("\x1b_Ga=p,q=2,C=1,i=9,p=9,c=3,r=2\x1b\\"), + ); + expect(encodeKittyVirtualPlacement({ imageId: 9, placementId: 9, columns: 3, rows: 2 })).toBe( + expected("\x1b_Ga=p,U=1,q=2,i=9,p=9,c=3,r=2\x1b\\"), + ); + expect(encodeKittyDeleteImage(9)).toBe(expected("\x1b_Ga=d,d=I,i=9,q=2\x1b\\")); + }); + + it("wraps each quiet chunk of a multi-part Kitty transmission separately", () => { + const sequence = encodeKittyTransmit("A".repeat(4097), 9); + expect(sequence.match(/\x1bPtmux;/gu)).toHaveLength(2); + expect(sequence.match(/\x1b\x1b_G/gu)).toHaveLength(2); + expect(sequence.match(/\x1b\x1b\\\x1b\\/gu)).toHaveLength(2); + expect(sequence).toContain("\x1b\x1b_Gq=2,m=0;"); + }); + + it("leaves Kitty graphics commands bare outside tmux", () => { + delete Bun.env.TMUX; + expect(encodeKittyTransmit("AA==", 9)).toBe("\x1b_Ga=t,f=100,q=2,i=9;AA==\x1b\\"); + expect(wrapTmuxPassthrough("\x1b_Gpayload\x1b\\")).toBe("\x1bPtmux;\x1b\x1b_Gpayload\x1b\x1b\\\x1b\\"); + }); +}); + describe("Image budget integration", () => { const originalProtocol = TERMINAL.imageProtocol; const originalGraphics = { ...getKittyGraphics() }; diff --git a/packages/tui/test/image-render.test.ts b/packages/tui/test/image-render.test.ts index c512fb873..795146656 100644 --- a/packages/tui/test/image-render.test.ts +++ b/packages/tui/test/image-render.test.ts @@ -20,6 +20,7 @@ const BASE64_DUMMY = "AA=="; const SQUARE_DIMENSIONS = { widthPx: 100, heightPx: 100 }; const BASE64_ONE_PIXEL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg=="; +const ORIGINAL_TMUX = Bun.env.TMUX; function parseKittyParam(sequence: string, key: "c" | "r" | "C"): number | null { const match = sequence.match(new RegExp(`${key}=(\\d+)`)); @@ -38,6 +39,7 @@ describe("terminal image rendering", () => { const originalGraphics = { ...getKittyGraphics() }; beforeEach(() => { + delete Bun.env.TMUX; originalCellDims = { ...getCellDimensions() }; setCellDimensions({ widthPx: 10, heightPx: 10 }); terminal.imageProtocol = null; @@ -48,6 +50,8 @@ describe("terminal image rendering", () => { setCellDimensions(originalCellDims); terminal.imageProtocol = originalProtocol; setKittyGraphics(originalGraphics); + if (ORIGINAL_TMUX === undefined) delete Bun.env.TMUX; + else Bun.env.TMUX = ORIGINAL_TMUX; }); it("fits Kitty images within max width and max height while preserving aspect ratio", () => { diff --git a/packages/tui/test/kitty-graphics.test.ts b/packages/tui/test/kitty-graphics.test.ts index 4f682abb8..6d3a9ff87 100644 --- a/packages/tui/test/kitty-graphics.test.ts +++ b/packages/tui/test/kitty-graphics.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { visibleWidth } from "@oh-my-pi/pi-natives"; import { detectKittyUnicodePlaceholdersSupport, @@ -13,9 +13,16 @@ import { } from "@oh-my-pi/pi-tui/kitty-graphics"; const ORIGINAL = { ...getKittyGraphics() }; +const ORIGINAL_TMUX = Bun.env.TMUX; + +beforeEach(() => { + delete Bun.env.TMUX; +}); afterEach(() => { setKittyGraphics(ORIGINAL); + if (ORIGINAL_TMUX === undefined) delete Bun.env.TMUX; + else Bun.env.TMUX = ORIGINAL_TMUX; }); describe("kitty Unicode placeholder encoding", () => { @@ -101,6 +108,14 @@ describe("detectKittyUnicodePlaceholdersSupport", () => { expect(detectKittyUnicodePlaceholdersSupport("alacritty", env())).toBe(false); }); + it("uses scroll-aware placeholders when Kitty is explicitly forced through tmux", () => { + const forced = env({ TMUX: "/tmp/tmux-1000/default,1,0", PI_FORCE_IMAGE_PROTOCOL: "kitty" }); + expect(detectKittyUnicodePlaceholdersSupport("base", forced)).toBe(true); + expect(detectKittyUnicodePlaceholdersSupport("wezterm", forced)).toBe(true); + // Automatic tmux fallback remains conservative when the outer terminal is unknown. + expect(detectKittyUnicodePlaceholdersSupport("base", env({ TMUX: "/tmp/tmux-1000/default,1,0" }))).toBe(false); + }); + it("honors PI_NO_KITTY_PLACEHOLDERS=1 as a hard off override on supporting terminals", () => { expect(detectKittyUnicodePlaceholdersSupport("kitty", env({ PI_NO_KITTY_PLACEHOLDERS: "1" }))).toBe(false); expect(detectKittyUnicodePlaceholdersSupport("ghostty", env({ PI_NO_KITTY_PLACEHOLDERS: "true" }))).toBe(false); @@ -119,4 +134,13 @@ describe("detectKittyUnicodePlaceholdersSupport", () => { expect(detectKittyUnicodePlaceholdersSupport("kitty", env({ PI_KITTY_PLACEHOLDERS: "0" }))).toBe(false); expect(detectKittyUnicodePlaceholdersSupport("ghostty", env({ PI_KITTY_PLACEHOLDERS: "off" }))).toBe(false); }); + + it("placeholder opt-out beats forced Kitty under tmux", () => { + const forcedOff = env({ + TMUX: "/tmp/tmux-1000/default,1,0", + PI_FORCE_IMAGE_PROTOCOL: "kitty", + PI_KITTY_PLACEHOLDERS: "0", + }); + expect(detectKittyUnicodePlaceholdersSupport("base", forcedOff)).toBe(false); + }); });