From 86c6265e16d9705c37bab9fe4bbeb9fec189d45b Mon Sep 17 00:00:00 2001 From: Anatoli Tsinovoy Date: Thu, 6 Aug 2026 08:09:56 +0300 Subject: [PATCH 1/2] fix(tui): read refreshed tmux appearance cache --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/terminal.ts | 40 ++++++++++++------- packages/tui/test/terminal-appearance.test.ts | 31 ++++++++++++++ 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 98fdcca42..06cf200ff 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed explicit display resets inside tmux retaining the stale attach-time light/dark palette: after the passthrough OSC 11 probe updates tmux's background cache, OMP now reads that refreshed cache and consumes both probe replies instead of leaving terminal escape data in the editor. + ## [17.2.9] - 2026-08-05 ### Fixed diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index d5204fe9c..10a6303f4 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 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 timers or 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 @@ -500,7 +501,7 @@ export function isConPTYHosted(): boolean { /** Discriminated owner of an outstanding DA1 sentinel in the unified probe FIFO. */ type Da1SentinelOwner = | { kind: "keyboard" } - | { kind: "osc11" } + | { kind: "osc11"; route: Osc11QueryRoute; token?: TerminalAppearanceRequestToken } | { kind: "privateMode"; mode: number } | { kind: "osc99Probe"; id: string }; @@ -671,13 +672,13 @@ 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 a query and sentinel to the outer + * terminal. tmux consumes the color response to refresh its cache and forwards + * the sentinel; that ordered barrier starts one direct query to read the new + * cached value. Startup and Mode 2031 probes remain direct. No timers are + * armed. Suppressed while inactive, headless, or after terminal teardown. */ refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void { if (!this.#active || this.#headless || this.#dead) return; @@ -1033,12 +1034,21 @@ export class ProcessTerminal implements Terminal { const owner = this.#da1SentinelOwners.shift()!; switch (owner.kind) { case "osc11": { + const needsTmuxCacheRead = owner.route === "tmux" && this.#osc11Pending; if (this.#osc11Pending) { - // DA1 arrived before the OSC 11 reply: terminal does not support OSC 11. + // DA1 reached the pane before an OSC 11 reply. End this + // stage; a tmux passthrough may have cached the reply. this.#osc11Pending = false; this.#osc11ActiveToken = undefined; this.#osc11ResponseBuffer = ""; } + if (needsTmuxCacheRead && !this.#dead) { + // tmux consumes the outer terminal's OSC 11 response to + // refresh its cache, but forwards the passthrough DA1 reply. + // Use that ordered barrier to query the refreshed cache. + this.#startOsc11Query("direct", owner.token); + break; + } // Start a queued OSC 11 query once the prior cycle is fully drained. if ( this.#osc11QueuedQuery !== undefined && @@ -1208,7 +1218,7 @@ export class ProcessTerminal implements Terminal { this.#osc11Pending = true; this.#osc11ActiveToken = token; this.#osc11ResponseBuffer = ""; - this.#da1SentinelOwners.push({ kind: "osc11" }); + this.#da1SentinelOwners.push({ kind: "osc11", route, token }); if (route === "tmux") { this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07\x1b[c")); return; diff --git a/packages/tui/test/terminal-appearance.test.ts b/packages/tui/test/terminal-appearance.test.ts index 39c27f263..5fa011fb0 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(() => { @@ -376,6 +377,36 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { terminal.stop(); }); + it("re-queries tmux after passthrough refresh updates its background cache", () => { + 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); + + // tmux consumes the outer terminal's OSC 11 response to refresh its own + // cache, but forwards the passthrough DA1 response to the pane. That + // ordered barrier must trigger a direct query against the refreshed cache. + process.stdin.emit("data", "\x1b[?1;2c"); + 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[] = []; From 54481bebb77f1826612825542e8d603c53f85794 Mon Sep 17 00:00:00 2001 From: Anatoli Tsinovoy Date: Thu, 6 Aug 2026 09:11:34 +0300 Subject: [PATCH 2/2] fix(tui): avoid tmux DA1 passthrough --- packages/tui/CHANGELOG.md | 2 +- packages/tui/src/terminal.ts | 51 +++++++++++-------- packages/tui/test/terminal-appearance.test.ts | 15 +++--- 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 06cf200ff..90c470c06 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed explicit display resets inside tmux retaining the stale attach-time light/dark palette: after the passthrough OSC 11 probe updates tmux's background cache, OMP now reads that refreshed cache and consumes both probe replies instead of leaving terminal escape data in the editor. +- 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 10a6303f4..81235e64e 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -462,10 +462,10 @@ export interface Terminal { * 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. Direct terminals need one query; tmux needs a - * passthrough query to update its cache followed by one 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 timers or periodic probes are armed. + * 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 @@ -501,7 +501,7 @@ export function isConPTYHosted(): boolean { /** Discriminated owner of an outstanding DA1 sentinel in the unified probe FIFO. */ type Da1SentinelOwner = | { kind: "keyboard" } - | { kind: "osc11"; route: Osc11QueryRoute; token?: TerminalAppearanceRequestToken } + | { kind: "osc11" } | { kind: "privateMode"; mode: number } | { kind: "osc99Probe"; id: string }; @@ -518,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; @@ -597,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(); @@ -674,11 +676,12 @@ export class ProcessTerminal implements Terminal { /** * 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 a query and sentinel to the outer - * terminal. tmux consumes the color response to refresh its cache and forwards - * the sentinel; that ordered barrier starts one direct query to read the new - * cached value. Startup and Mode 2031 probes remain direct. No timers are - * armed. Suppressed while inactive, headless, or after terminal teardown. + * 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; @@ -1034,21 +1037,12 @@ export class ProcessTerminal implements Terminal { const owner = this.#da1SentinelOwners.shift()!; switch (owner.kind) { case "osc11": { - const needsTmuxCacheRead = owner.route === "tmux" && this.#osc11Pending; if (this.#osc11Pending) { - // DA1 reached the pane before an OSC 11 reply. End this - // stage; a tmux passthrough may have cached the reply. + // DA1 arrived before OSC 11 response: terminal doesn't support OSC 11. this.#osc11Pending = false; this.#osc11ActiveToken = undefined; this.#osc11ResponseBuffer = ""; } - if (needsTmuxCacheRead && !this.#dead) { - // tmux consumes the outer terminal's OSC 11 response to - // refresh its cache, but forwards the passthrough DA1 reply. - // Use that ordered barrier to query the refreshed cache. - this.#startOsc11Query("direct", owner.token); - break; - } // Start a queued OSC 11 query once the prior cycle is fully drained. if ( this.#osc11QueuedQuery !== undefined && @@ -1218,11 +1212,20 @@ export class ProcessTerminal implements Terminal { this.#osc11Pending = true; this.#osc11ActiveToken = token; this.#osc11ResponseBuffer = ""; - this.#da1SentinelOwners.push({ kind: "osc11", route, token }); 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 } @@ -1559,6 +1562,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 5fa011fb0..728c3d70f 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -372,12 +372,13 @@ 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("re-queries tmux after passthrough refresh updates its background cache", () => { + 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(); @@ -391,10 +392,12 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { terminal.refreshAppearance?.(token); expect(queryCount()).toBe(beforeRefresh); - // tmux consumes the outer terminal's OSC 11 response to refresh its own - // cache, but forwards the passthrough DA1 response to the pane. That - // ordered barrier must trigger a direct query against the refreshed cache. - process.stdin.emit("data", "\x1b[?1;2c"); + // 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");