diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index be74683c1..c4a9c757a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed idle Loader animations on WSL repeatedly entering render scheduling after an expired ConPTY post-paint settle window instead of resuming direct component writes ([#6024](https://github.com/can1357/oh-my-pi/issues/6024)). - Prevented canonical slash commands and their matching aliases from appearing as duplicate autocomplete rows ([#6131](https://github.com/can1357/oh-my-pi/issues/6131)). +- Fixed Ctrl+L appearance refreshes inside tmux reading tmux's stale OSC 11 background cache instead of querying the outer terminal when passthrough is enabled ([#6066](https://github.com/can1357/oh-my-pi/issues/6066)). ## [17.0.5] - 2026-07-18 diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index aa640676a..d0e48aa92 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -18,6 +18,7 @@ import { setOsc99Supported, TERMINAL, } from "./terminal-capabilities"; +import { isInsideTmux, wrapTmuxPassthrough } from "./tmux"; import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } from "./utils"; const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; @@ -456,6 +457,7 @@ function parseOsc99KeyValues(section: string): Map { return values; } const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const; +type Osc11QueryRoute = "direct" | "tmux"; function isXtermScrollToBottomMode(mode: number): boolean { return mode === 1010 || mode === 1011; @@ -509,7 +511,7 @@ export class ProcessTerminal implements Terminal { #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = []; #appearance: TerminalAppearance | undefined; #osc11Pending = false; - #osc11QueryQueued = false; + #osc11QueuedRoute?: Osc11QueryRoute; #osc11ResponseBuffer = ""; #osc99PendingId: string | undefined; #osc99ResponseBuffer = ""; @@ -572,15 +574,15 @@ export class ProcessTerminal implements Terminal { /** * Re-query the terminal background via a single OSC 11 probe. Reuses the - * startup query path — same DA1-sentinel FIFO, pending/queued gating, parsing, - * dedup, and appearance callbacks — so a light/dark switch is picked up - * without a restart on terminals lacking end-to-end Mode 2031 notifications. - * Bounded to one probe per call; no timers are armed. Suppressed while headless - * or after the terminal is torn down. + * startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and + * appearance callbacks. Inside tmux, only this explicit path wraps the query + * and sentinel together for passthrough to the outer terminal; startup and + * Mode 2031 probes remain direct. Bounded to one probe per call; no timers are + * armed. Suppressed while headless or after the terminal is torn down. */ refreshAppearance(): void { if (this.#headless || this.#dead) return; - this.#queryBackgroundColor(); + this.#queryBackgroundColor(isInsideTmux() ? "tmux" : "direct"); } onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void { @@ -917,13 +919,14 @@ export class ProcessTerminal implements Terminal { } // Start a queued OSC 11 query once the prior cycle is fully drained. if ( - this.#osc11QueryQueued && + this.#osc11QueuedRoute !== undefined && !this.#osc11Pending && !this.#da1SentinelOwners.some(o => o.kind === "osc11") && !this.#dead ) { - this.#osc11QueryQueued = false; - this.#startOsc11Query(); + const route = this.#osc11QueuedRoute; + this.#osc11QueuedRoute = undefined; + this.#startOsc11Query(route); } break; } @@ -1058,23 +1061,28 @@ export class ProcessTerminal implements Terminal { * DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11, * the terminal does not support OSC 11. */ - #queryBackgroundColor(): void { + #queryBackgroundColor(route: Osc11QueryRoute = "direct"): void { if (this.#dead) return; // Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been // consumed yet. Starting a new query while a DA1 is outstanding would // increment the sentinel counter, and the old DA1 arrival would then - // prematurely clear the new query's pending state. + // prematurely clear the new query's pending state. Preserve a requested + // tmux passthrough route when coalescing direct and explicit queries. if (this.#osc11Pending || this.#da1SentinelOwners.some(o => o.kind === "osc11")) { - this.#osc11QueryQueued = true; + if (this.#osc11QueuedRoute !== "tmux") this.#osc11QueuedRoute = route; return; } - this.#startOsc11Query(); + this.#startOsc11Query(route); } - #startOsc11Query(): void { + #startOsc11Query(route: Osc11QueryRoute): void { this.#osc11Pending = true; this.#osc11ResponseBuffer = ""; this.#da1SentinelOwners.push({ kind: "osc11" }); + if (route === "tmux") { + this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07\x1b[c")); + return; + } this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated) this.#safeWrite("\x1b[c"); // DA1 sentinel } @@ -1397,7 +1405,7 @@ export class ProcessTerminal implements Terminal { this.#appearanceCallbacks = []; this.#osc11Pending = false; this.#clearWindowsTerminalAppearancePoll(); - this.#osc11QueryQueued = false; + this.#osc11QueuedRoute = undefined; this.#osc11ResponseBuffer = ""; this.#osc99PendingId = undefined; this.#osc99ResponseBuffer = ""; diff --git a/packages/tui/test/terminal-appearance.test.ts b/packages/tui/test/terminal-appearance.test.ts index dcd2900df..96d98a147 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -19,6 +19,7 @@ const originalWslDistroName = Bun.env.WSL_DISTRO_NAME; const originalWslInterop = Bun.env.WSL_INTEROP; const originalWtSession = Bun.env.WT_SESSION; const originalTermProgram = Bun.env.TERM_PROGRAM; +const originalTmux = Bun.env.TMUX; // These suites drive the real ProcessTerminal start()/probe pipeline, so they // opt out of the test-default headless suppression and restore it per case. @@ -60,6 +61,7 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { restoreEnv("WSL_DISTRO_NAME", originalWslDistroName); restoreEnv("WT_SESSION", originalWtSession); restoreEnv("TERM_PROGRAM", originalTermProgram); + restoreEnv("TMUX", originalTmux); }); function setupTerminal() { @@ -282,6 +284,22 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { terminal.stop(); }); + it("passes an explicit appearance refresh through tmux without changing the startup probe", () => { + Bun.env.TMUX = "/tmp/tmux-1000/default,1234,0"; + const { terminal, writes } = setupTerminal(); + + expect(writes).toContain("\x1b]11;?\x07"); + expect(writes).toContain("\x1b[c"); + + process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07"); + for (let i = 0; i < 7; i++) process.stdin.emit("data", "\x1b[?1;2c"); + terminal.refreshAppearance?.(); + + expect(writes).toContain("\x1bPtmux;\x1b\x1b]11;?\x07\x1b\x1b[c\x1b\\"); + + terminal.stop(); + }); + it("refreshAppearance() re-evaluates a changed background through the callback pipeline", () => { const { terminal } = setupTerminal(); const appearances: string[] = [];