diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4b0da1256..506c880d8 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -13,6 +13,9 @@ ### Fixed - Fixed Herdr panes losing native scrollback when TUI transcript replacement or resize redraws emitted destructive terminal-history clears. +### Fixed + +- Fixed explicit display resets inside tmux retaining the stale attach-time light/dark palette and leaking terminal capability bytes into the editor. OMP now lets the passthrough OSC 11 probe update tmux's background cache without sending a passthrough DA1 sentinel, then reads that refreshed cache directly. ## [17.2.9] - 2026-08-05 diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 0540371e6..537123db2 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -459,12 +459,13 @@ export interface Terminal { callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void, ): (() => void) | void; /** - * Issue a single OSC 11 background-color re-query, driving the appearance + * Start a bounded OSC 11 background-color refresh cycle, driving appearance * callbacks through the same parse/dedup pipeline used at startup and on Mode - * 2031 notifications. Bounded: one probe per call, no timers. Invoked on the - * user's explicit display-reset gesture so terminals that cannot - * deliver end-to-end Mode 2031 notifications still pick up a light/dark switch - * without a restart. + * 2031 notifications. Direct terminals need one query; tmux needs a + * passthrough query to update its cache followed by one delayed direct cache + * read. Invoked on the user's explicit display-reset gesture so terminals + * without end-to-end Mode 2031 notifications pick up a light/dark switch + * without a restart. No periodic probes are armed. * * A caller-provided token must be propagated unchanged to callbacks and * returned when the request is accepted. This lets callers establish ownership @@ -517,6 +518,7 @@ function parseOsc99KeyValues(section: string): Map { } const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const; type Osc11QueryRoute = "direct" | "tmux"; +const TMUX_OSC11_CACHE_REFRESH_DELAY_MS = 100; function isXtermScrollToBottomMode(mode: number): boolean { return mode === 1010 || mode === 1011; @@ -596,6 +598,7 @@ export class ProcessTerminal implements Terminal { #osc11QueuedQuery?: { route: Osc11QueryRoute; token?: TerminalAppearanceRequestToken }; #nextAppearanceRequestToken = 1; #osc11ResponseBuffer = ""; + #osc11TmuxRefreshTimer?: Timer; #osc99PendingId: string | undefined; #osc99ResponseBuffer = ""; #osc99Capabilities = new Map(); @@ -671,13 +674,14 @@ export class ProcessTerminal implements Terminal { } /** - * Re-query the terminal background via a single OSC 11 probe. Reuses the - * 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 inactive, headless, or after the terminal is torn - * down. + * Re-query the terminal background through the startup DA1-sentinel FIFO, + * pending/queued gating, parsing, dedup, and appearance callbacks. Inside + * tmux, only this explicit path first passes an OSC 11 query to the outer + * terminal, waits briefly for tmux to consume the response into its cache, + * then reads that cache with a direct query. The outer query deliberately has + * no DA1 sentinel: multiplexers can decode a fragmented DA1 response as a key + * sequence and leak the remaining bytes into the editor. Startup and Mode 2031 + * probes remain direct. Suppressed while inactive, headless, or after teardown. */ refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void { if (!this.#active || this.#headless || this.#dead) return; @@ -1041,7 +1045,7 @@ export class ProcessTerminal implements Terminal { switch (owner.kind) { case "osc11": { if (this.#osc11Pending) { - // DA1 arrived before the OSC 11 reply: terminal does not support OSC 11. + // DA1 arrived before OSC 11 response: terminal doesn't support OSC 11. this.#osc11Pending = false; this.#osc11ActiveToken = undefined; this.#osc11ResponseBuffer = ""; @@ -1221,11 +1225,20 @@ export class ProcessTerminal implements Terminal { this.#osc11Pending = true; this.#osc11ActiveToken = token; this.#osc11ResponseBuffer = ""; - this.#da1SentinelOwners.push({ kind: "osc11" }); if (route === "tmux") { - this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07\x1b[c")); + this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07")); + this.#osc11TmuxRefreshTimer = setTimeout(() => { + this.#osc11TmuxRefreshTimer = undefined; + if (this.#dead || !this.#osc11Pending) return; + this.#startDirectOsc11Query(); + }, TMUX_OSC11_CACHE_REFRESH_DELAY_MS); return; } + this.#startDirectOsc11Query(); + } + + #startDirectOsc11Query(): void { + this.#da1SentinelOwners.push({ kind: "osc11" }); this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated) this.#safeWrite("\x1b[c"); // DA1 sentinel } @@ -1562,6 +1575,10 @@ export class ProcessTerminal implements Terminal { clearTimeout(this.#mode2031DebounceTimer); this.#mode2031DebounceTimer = undefined; } + if (this.#osc11TmuxRefreshTimer) { + clearTimeout(this.#osc11TmuxRefreshTimer); + this.#osc11TmuxRefreshTimer = undefined; + } this.#appearanceCallbacks = []; this.#appearanceReportCallbacks = []; this.#osc11Pending = false; diff --git a/packages/tui/test/terminal-appearance.test.ts b/packages/tui/test/terminal-appearance.test.ts index bb23ca791..c01a40de0 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -47,6 +47,7 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true }); previousHeadless = setTerminalHeadless(false); + delete Bun.env.TMUX; }); afterEach(() => { @@ -371,11 +372,44 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { 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\\"); + expect(writes).toContain("\x1bPtmux;\x1b\x1b]11;?\x07\x1b\\"); terminal.stop(); }); + it("reads tmux's refreshed cache without passing a DA1 reply through tmux", () => { + vi.useFakeTimers(); + Bun.env.TMUX = "/tmp/tmux-1000/default,1234,0"; + const { terminal, received, queryCount } = setupTerminal(); + + process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07"); + for (let i = 0; i < 7; i++) process.stdin.emit("data", "\x1b[?1;2c"); + const reports: Array<{ appearance: string; token: number | undefined }> = []; + terminal.onAppearanceReport?.((appearance, token) => reports.push({ appearance, token })); + const beforeRefresh = queryCount(); + + const token = 42; + terminal.refreshAppearance?.(token); + expect(queryCount()).toBe(beforeRefresh); + + // A fragmented outer DA1 reply can be decoded by tmux as an Alt+[ key + // followed by printable capability bytes. Wait for the OSC 11 response to + // reach tmux's cache, then query that cache directly with a local sentinel. + vi.advanceTimersByTime(99); + expect(queryCount()).toBe(beforeRefresh); + vi.advanceTimersByTime(1); + expect(queryCount()).toBe(beforeRefresh + 1); + + process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07"); + process.stdin.emit("data", "\x1b[?1;2c"); + const detected = terminal.appearance; + terminal.stop(); + + expect(detected).toBe("light"); + expect(reports).toEqual([{ appearance: "light", token }]); + expect(received).toEqual([]); + }); + it("refreshAppearance() re-evaluates a changed background through the callback pipeline", () => { const { terminal } = setupTerminal(); const appearances: string[] = [];