diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 74bcb76e0..12203c7ca 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed automatic terminal appearance changes clearing native scrollback and snapping readers away from their current scroll position; the active output grid now repaints non-destructively, while Ctrl+L remains the explicit full-history recolor. + ## [17.2.0] - 2026-07-30 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index ab7881fa2..7d97a951e 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -404,14 +404,7 @@ export class InputController { this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit")); this.ctx.editor.setActionKeys("app.display.reset", this.ctx.keybindings.getKeys("app.display.reset")); this.ctx.editor.onDisplayReset = () => { - // Explicit user gesture (Ctrl+L): re-query the terminal background once - // so a mid-session light/dark switch is picked up even on terminals - // without an end-to-end Mode 2031 notification path (#5352). The - // appearance callback re-evaluates the auto theme; the repaint below - // then renders the resolved palette. Bounded to one OSC 11 probe per - // gesture — no timers, no periodic polling. - this.ctx.ui.terminal.refreshAppearance?.(); - this.ctx.ui.resetDisplay(); + this.ctx.resetDisplayAfterAppearanceRefresh(); }; this.ctx.editor.onExit = () => this.handleCtrlD(); this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend")); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 97ef79cc9..91094d013 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -37,6 +37,7 @@ import { TUI, visibleWidth, } from "@oh-my-pi/pi-tui"; +import type { TerminalAppearanceRequestToken } from "@oh-my-pi/pi-tui/terminal"; import { isInsideTerminalMultiplexer } from "@oh-my-pi/pi-tui/terminal-capabilities"; import { $env, @@ -429,6 +430,8 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n return ["", theme.bold(theme.fg("accent", "Subagents")), ...rows.map(line => ` ${line}`)]; } +const CTRL_L_APPEARANCE_RESPONSE_DEADLINE_MS = 2000; + export class InteractiveMode implements InteractiveModeContext { session: AgentSession; sessionManager: SessionManager; @@ -471,6 +474,8 @@ export class InteractiveMode implements InteractiveModeContext { #loopAutoSubmitTimer: NodeJS.Timeout | undefined; #todoAutoClearTimer: NodeJS.Timeout | undefined; #modelCycleClearTimer: NodeJS.Timeout | undefined; + #nextAppearanceRequestToken = 1; + #appearanceRefreshRequest: { token: TerminalAppearanceRequestToken; deadline: number } | undefined; todoPhases: TodoPhase[] = []; hideThinkingBlock = false; #sessionsWithDisplayableThinkingContent = new WeakSet(); @@ -1141,8 +1146,35 @@ export class InteractiveMode implements InteractiveModeContext { // Subscribe to terminal dark/light appearance changes. // The terminal queries background color via OSC 11 at startup and on // Mode 2031 notifications, computing luminance to detect dark/light. - this.ui.terminal.onAppearanceChange(mode => { - onTerminalAppearanceChange(mode); + const unsubscribeAppearanceReport = this.ui.terminal.onAppearanceReport?.((_mode, requestToken) => { + const request = this.#appearanceRefreshRequest; + if (request === undefined || requestToken !== request.token) return; + // ProcessTerminal dispatches report callbacks first, then synchronously + // dispatches onAppearanceChange when the reported appearance changed. + // That change callback consumes the request below before this microtask + // runs; an unchanged matching report has no change callback, so it + // consumes the one-shot here. Comparing the captured request prevents a + // newer Ctrl+L request from being cleared by this report's microtask. + queueMicrotask(() => { + if (this.#appearanceRefreshRequest === request) { + this.#appearanceRefreshRequest = undefined; + } + }); + }); + if (unsubscribeAppearanceReport) { + this.#eventBusUnsubscribers.push(unsubscribeAppearanceReport); + } + this.ui.terminal.onAppearanceChange((mode, requestToken) => { + const request = this.#appearanceRefreshRequest; + const appearanceRefreshWasRequested = + request !== undefined && requestToken === request.token && Date.now() <= request.deadline; + if (request !== undefined && requestToken === request.token) { + this.#appearanceRefreshRequest = undefined; + } + // Ctrl+L already replays immediately below. If its asynchronous OSC 11 + // response reveals a theme change, commit that change so theme loading + // performs a second full replay with the newly detected palette. + onTerminalAppearanceChange(mode, appearanceRefreshWasRequested ? {} : undefined); }); // A branch change (checkout, worktree switch, `git switch`) invalidates @@ -3932,6 +3964,7 @@ export class InteractiveMode implements InteractiveModeContext { } stop(): void { + this.#appearanceRefreshRequest = undefined; if (this.loadingAnimation) { this.#stopLoadingAnimation(false); } @@ -4752,6 +4785,27 @@ export class InteractiveMode implements InteractiveModeContext { this.#inputController.handleCtrlZ(); } + resetDisplayAfterAppearanceRefresh(): void { + const refreshAppearance = this.ui.terminal.refreshAppearance; + if (refreshAppearance) { + const token = this.#nextAppearanceRequestToken++; + const request = { + token, + deadline: Date.now() + CTRL_L_APPEARANCE_RESPONSE_DEADLINE_MS, + }; + this.#appearanceRefreshRequest = request; + const acceptedToken = refreshAppearance.call(this.ui.terminal, token); + if (acceptedToken !== token && this.#appearanceRefreshRequest === request) { + this.#appearanceRefreshRequest = undefined; + } + } else { + this.#appearanceRefreshRequest = undefined; + } + // Preserve Ctrl+L's immediate full replay when the probe is unsupported, + // receives no response, or reports an unchanged appearance. + this.ui.resetDisplay(); + } + handleDequeue(): void { this.#inputController.handleDequeue(); } diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 29d547d14..3dc838e18 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -2323,10 +2323,13 @@ export function setAutoThemeMapping(mode: "dark" | "light", themeName: string): * The terminal layer queries OSC 11 (background color) and computes luminance; * Mode 2031 notifications trigger re-queries rather than providing the value directly. */ -export function onTerminalAppearanceChange(mode: "dark" | "light"): void { +export function onTerminalAppearanceChange( + mode: "dark" | "light", + event: ThemeChangeEvent = { ephemeral: true }, +): void { if (terminalReportedAppearance === mode) return; terminalReportedAppearance = mode; - reevaluateAutoTheme("terminal appearance"); + reevaluateAutoTheme("terminal appearance", event); } export function setThemeInstance(themeInstance: Theme): void { @@ -2530,6 +2533,10 @@ function reevaluateAutoTheme(debugLabel: string, event: ThemeChangeEvent = {}, a applyResolvedAutoTheme(resolved, debugLabel, event); } +function reevaluateAutoThemeForAppearance(debugLabel: string, appearance?: "dark" | "light"): void { + reevaluateAutoTheme(debugLabel, { ephemeral: true }, appearance); +} + // ============================================================================ // macOS Appearance Fallback Observer // ============================================================================ @@ -2606,7 +2613,7 @@ export function startMacOSAppearanceReprobeFallback(terminal: MacOSAppearanceRep const appearance = probeResponseConfirmed ? terminal.appearance : undefined; cancelProbeSequence(); if (!autoDetectedTheme || !appearance) return; - reevaluateAutoTheme("macOS appearance reconciliation", {}, appearance); + reevaluateAutoThemeForAppearance("macOS appearance reconciliation", appearance); }, MACOS_APPEARANCE_RECONCILE_DELAY_MS); reconciliationTimer.unref?.(); }; @@ -2635,7 +2642,7 @@ export function startMacOSAppearanceReprobeFallback(terminal: MacOSAppearanceRep return; } if (appearance === "dark" || appearance === "light") { - reevaluateAutoTheme("macOS provisional appearance", {}, appearance); + reevaluateAutoThemeForAppearance("macOS provisional appearance", appearance); } scheduleProbeSequence(); }); @@ -2670,7 +2677,7 @@ function startMacAppearanceObserver(): void { macObserver = MacAppearanceObserver.start((err, appearance) => { if (!err && (appearance === "dark" || appearance === "light")) { macOSReportedAppearance = appearance; - reevaluateAutoTheme("macOS fallback"); + reevaluateAutoThemeForAppearance("macOS fallback"); } }); } catch (err) { @@ -2694,7 +2701,7 @@ function stopMacAppearanceObserver(): void { function startSigwinchListener(): void { stopSigwinchListener(); sigwinchHandler = () => { - reevaluateAutoTheme("SIGWINCH"); + reevaluateAutoThemeForAppearance("SIGWINCH"); }; process.on("SIGWINCH", sigwinchHandler); startMacAppearanceObserver(); diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 29d83aaef..38017b4d0 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -401,6 +401,8 @@ export interface InteractiveModeContext { handleCtrlC(): void; handleCtrlD(): void; handleCtrlZ(): void; + /** Re-query terminal appearance for an explicit display reset, then immediately replay the display. */ + resetDisplayAfterAppearanceRefresh(): void; handleDequeue(): void; handleImagePaste(): Promise; /** Queue a message for delivery only after the active agent turn would stop. */ diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 4161365e1..157db8914 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -82,6 +82,10 @@ async function createContext() { const addStartListener = vi.fn(); const terminalWrite = vi.fn(); const refreshAppearance = vi.fn(); + const resetDisplayAfterAppearanceRefresh = vi.fn(() => { + refreshAppearance(); + resetDisplay(); + }); const prompt = vi.fn(async () => {}); const retry = vi.fn(async () => true); const abort = vi.fn(async () => {}); @@ -132,6 +136,7 @@ async function createContext() { focused = editor; const ctx = { editor: editor as unknown as InteractiveModeContext["editor"], + resetDisplayAfterAppearanceRefresh, ui: { requestRender, resetDisplay, @@ -224,6 +229,7 @@ async function createContext() { abort, resetDisplay, refreshAppearance, + resetDisplayAfterAppearanceRefresh, handleBtwBranchKey, addInputListener, canBranchBtw, @@ -255,13 +261,7 @@ describe("InputController keybinding setup", () => { expect(spies.showModelSelector).toHaveBeenNthCalledWith(1, { temporaryOnly: true }); expect(spies.showModelSelector).toHaveBeenNthCalledWith(2); - expect(spies.resetDisplay).toHaveBeenCalledTimes(1); - expect(spies.refreshAppearance).toHaveBeenCalledTimes(1); - // The background re-query must run before the repaint so the appearance - // callback re-evaluates the auto theme against the fresh classification. - expect(spies.refreshAppearance.mock.invocationCallOrder[0]!).toBeLessThan( - spies.resetDisplay.mock.invocationCallOrder[0]!, - ); + expect(spies.resetDisplayAfterAppearanceRefresh).toHaveBeenCalledTimes(1); }); it("does not mark pasted shell prompts as Python mode while editing", async () => { diff --git a/packages/coding-agent/test/interactive-theme-scrollback.test.ts b/packages/coding-agent/test/interactive-theme-scrollback.test.ts index b2de740a5..8293dece6 100644 --- a/packages/coding-agent/test/interactive-theme-scrollback.test.ts +++ b/packages/coding-agent/test/interactive-theme-scrollback.test.ts @@ -4,23 +4,97 @@ import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; -import { enableAutoTheme, initTheme, previewTheme, setTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { + enableAutoTheme, + getCurrentThemeName, + getThemeEpoch, + initTheme, + previewTheme, + setTheme, + stopThemeWatcher, +} from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TUI } from "@oh-my-pi/pi-tui"; +import type { TerminalAppearance, TerminalAppearanceRequestToken } from "@oh-my-pi/pi-tui/terminal"; import { TempDir } from "@oh-my-pi/pi-utils"; import { VirtualTerminal } from "../../tui/test/virtual-terminal"; const MULTIPLEXER_ENV_KEYS = ["TMUX", "STY", "ZELLIJ", "CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "TERM"] as const; +class AppearanceVirtualTerminal extends VirtualTerminal { + #appearance?: TerminalAppearance; + #appearanceChangeCallbacks = new Set< + (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void + >(); + #appearanceReportCallbacks = new Set< + (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void + >(); + #nextAppearanceRequestToken = 0; + appearanceOnRefresh?: TerminalAppearance; + returnRefreshToken = true; + deferRefreshReport = true; + override get appearance(): TerminalAppearance | undefined { + return this.#appearance; + } + + override onAppearanceChange( + callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void, + ): () => void { + this.#appearanceChangeCallbacks.add(callback); + return () => this.#appearanceChangeCallbacks.delete(callback); + } + + onAppearanceReport( + callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void, + ): () => void { + this.#appearanceReportCallbacks.add(callback); + return () => this.#appearanceReportCallbacks.delete(callback); + } + + refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void { + const token = requestToken ?? ++this.#nextAppearanceRequestToken; + if (token > this.#nextAppearanceRequestToken) { + this.#nextAppearanceRequestToken = token; + } + const appearance = this.appearanceOnRefresh; + if (appearance !== undefined) { + const emit = () => this.emitAppearanceReport(appearance, this.returnRefreshToken ? token : undefined); + if (this.deferRefreshReport) { + queueMicrotask(emit); + } else { + emit(); + } + } + return this.returnRefreshToken ? token : undefined; + } + + emitAppearanceReport(appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken): void { + for (const callback of this.#appearanceReportCallbacks) callback(appearance, requestToken); + if (appearance === this.#appearance) return; + this.#appearance = appearance; + for (const callback of this.#appearanceChangeCallbacks) callback(appearance, requestToken); + } +} + +async function waitForThemeEpochToAdvance(previousEpoch: number): Promise { + for (let attempts = 0; attempts < 1_000; attempts++) { + if (getThemeEpoch() > previousEpoch) return; + const turn = Promise.withResolvers(); + setImmediate(turn.resolve); + await turn.promise; + } + throw new Error(`Theme epoch did not advance from ${previousEpoch}`); +} + let originalMultiplexerEnv: Partial>; describe("InteractiveMode theme scrollback refresh", () => { let tempDir: TempDir; let authStorage: AuthStorage; let session: AgentSession; let mode: InteractiveMode; - let terminal: VirtualTerminal; + let terminal: AppearanceVirtualTerminal; beforeEach(async () => { originalMultiplexerEnv = {}; @@ -53,7 +127,7 @@ describe("InteractiveMode theme scrollback refresh", () => { modelRegistry, }); mode = new InteractiveMode(session, "test"); - terminal = new VirtualTerminal(100, 20); + terminal = new AppearanceVirtualTerminal(100, 20); mode.ui = new TUI(terminal); vi.spyOn(mode.statusLine, "watchBranch").mockImplementation(() => {}); await mode.init({ suppressWelcomeIntro: true }); @@ -61,6 +135,7 @@ describe("InteractiveMode theme scrollback refresh", () => { afterEach(async () => { mode?.stop(); + stopThemeWatcher(); await setTheme("dark"); await session?.dispose(); authStorage?.close(); @@ -92,6 +167,147 @@ describe("InteractiveMode theme scrollback refresh", () => { expect(writes.join("")).toContain("\x1b[3J"); }); + it("preserves the viewport on automatic appearance changes until Ctrl+L requests a full replay", async () => { + terminal.emitAppearanceReport("dark"); + enableAutoTheme(); + await terminal.waitForRender(); + const fullRedraws = mode.ui.fullRedraws; + const writes: string[] = []; + const realWrite = terminal.write.bind(terminal); + vi.spyOn(terminal, "write").mockImplementation(data => { + writes.push(data); + realWrite(data); + }); + + const epoch = getThemeEpoch(); + terminal.emitAppearanceReport("light"); + await waitForThemeEpochToAdvance(epoch); + await terminal.waitForRender(); + expect(getCurrentThemeName()).toBe("light"); + + expect(mode.ui.fullRedraws).toBe(fullRedraws); + expect(writes.join("")).not.toContain("\x1b[3J"); + + writes.length = 0; + terminal.sendInput("\x0c"); + await terminal.waitForRender(); + + expect(mode.ui.fullRedraws).toBe(fullRedraws + 1); + expect(writes.join("")).toContain("\x1b[3J"); + }); + it("keeps a queued Ctrl+L token correlated across an unrelated automatic report", async () => { + terminal.emitAppearanceReport("dark"); + enableAutoTheme(); + await terminal.waitForRender(); + expect(getCurrentThemeName()).toBe("dark"); + + const fullRedraws = mode.ui.fullRedraws; + const writes: string[] = []; + const realWrite = terminal.write.bind(terminal); + vi.spyOn(terminal, "write").mockImplementation(data => { + writes.push(data); + realWrite(data); + }); + terminal.appearanceOnRefresh = "light"; + + const epoch = getThemeEpoch(); + terminal.sendInput("\x0c"); + // A queued automatic response may arrive before the explicit probe. It must + // neither consume the Ctrl+L correlation nor classify its own change as the + // explicit response. + terminal.emitAppearanceReport("dark"); + await waitForThemeEpochToAdvance(epoch); + await terminal.waitForRender(); + + expect(getCurrentThemeName()).toBe("light"); + expect(mode.ui.fullRedraws).toBe(fullRedraws + 2); + expect(writes.join("").split("\x1b[3J")).toHaveLength(3); + }); + + it("owns a synchronous refresh response before invoking the terminal", async () => { + terminal.emitAppearanceReport("dark"); + enableAutoTheme(); + await terminal.waitForRender(); + expect(getCurrentThemeName()).toBe("dark"); + + const fullRedraws = mode.ui.fullRedraws; + const writes: string[] = []; + const realWrite = terminal.write.bind(terminal); + vi.spyOn(terminal, "write").mockImplementation(data => { + writes.push(data); + realWrite(data); + }); + terminal.appearanceOnRefresh = "light"; + terminal.deferRefreshReport = false; + + const epoch = getThemeEpoch(); + terminal.sendInput("\x0c"); + await waitForThemeEpochToAdvance(epoch); + await terminal.waitForRender(); + + expect(getCurrentThemeName()).toBe("light"); + expect(mode.ui.fullRedraws).toBe(fullRedraws + 2); + expect(writes.join("").split("\x1b[3J")).toHaveLength(3); + }); + + it("consumes an unchanged Ctrl+L appearance report before a later automatic change", async () => { + terminal.emitAppearanceReport("dark"); + enableAutoTheme(); + await terminal.waitForRender(); + expect(getCurrentThemeName()).toBe("dark"); + + const fullRedraws = mode.ui.fullRedraws; + const writes: string[] = []; + const realWrite = terminal.write.bind(terminal); + vi.spyOn(terminal, "write").mockImplementation(data => { + writes.push(data); + realWrite(data); + }); + terminal.appearanceOnRefresh = "dark"; + + terminal.sendInput("\x0c"); + await terminal.waitForRender(); + await Promise.resolve(); + + expect(mode.ui.fullRedraws).toBe(fullRedraws + 1); + expect(writes.join("").split("\x1b[3J")).toHaveLength(2); + + const epoch = getThemeEpoch(); + terminal.emitAppearanceReport("light"); + await waitForThemeEpochToAdvance(epoch); + await terminal.waitForRender(); + + expect(getCurrentThemeName()).toBe("light"); + expect(mode.ui.fullRedraws).toBe(fullRedraws + 1); + expect(writes.join("").split("\x1b[3J")).toHaveLength(2); + }); + + it("does not arm a committed replay when a custom terminal returns no token", async () => { + terminal.emitAppearanceReport("dark"); + enableAutoTheme(); + await terminal.waitForRender(); + expect(getCurrentThemeName()).toBe("dark"); + + const fullRedraws = mode.ui.fullRedraws; + const writes: string[] = []; + const realWrite = terminal.write.bind(terminal); + vi.spyOn(terminal, "write").mockImplementation(data => { + writes.push(data); + realWrite(data); + }); + terminal.returnRefreshToken = false; + terminal.appearanceOnRefresh = "light"; + + const epoch = getThemeEpoch(); + terminal.sendInput("\x0c"); + await waitForThemeEpochToAdvance(epoch); + await terminal.waitForRender(); + + expect(getCurrentThemeName()).toBe("light"); + expect(mode.ui.fullRedraws).toBe(fullRedraws + 1); + expect(writes.join("").split("\x1b[3J")).toHaveLength(2); + }); + it("keeps theme previews as non-destructive viewport repaints", async () => { await terminal.waitForRender(); const fullRedraws = mode.ui.fullRedraws; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8fe3749e0..fd4b235da 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added request tokens to explicit OSC 11 appearance refreshes so consumers can correlate responses across queued and coalesced terminal probes. + ## [17.2.0] - 2026-07-30 ### Added diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 1e01c7eb3..01143a296 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -377,6 +377,8 @@ export function emergencyTerminalRestore(): void { } /** Terminal-reported appearance (dark/light mode). */ export type TerminalAppearance = "dark" | "light"; +/** Identity of an accepted explicit terminal appearance refresh request. */ +export type TerminalAppearanceRequestToken = number; export interface Terminal { // Start the terminal with input, resize, and host-disconnect handlers. start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void; @@ -444,24 +446,32 @@ export interface Terminal { * Subscribers registered after detection are invoked immediately with the * already-detected appearance so late subscribers never miss it. */ - onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void; + onAppearanceChange( + callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void, + ): void; /** * Register a callback fired for every valid OSC 11 appearance report, * including reports whose classification matches the current appearance. * Unlike onAppearanceChange, this does not replay an earlier report. * Optional so custom Terminals built against older pi-tui versions keep working. */ - onAppearanceReport?(callback: (appearance: TerminalAppearance) => void): (() => void) | void; + onAppearanceReport?( + callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void, + ): (() => void) | void; /** * Issue a single OSC 11 background-color re-query, driving the 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 (Ctrl+L) so terminals that cannot * deliver end-to-end Mode 2031 notifications still pick up a light/dark switch - * without a restart. Optional so custom Terminals built against older pi-tui - * versions keep working. + * without a restart. + * + * A caller-provided token must be propagated unchanged to callbacks and + * returned when the request is accepted. This lets callers establish ownership + * before implementations synchronously dispatch a cached response. Optional so + * custom Terminals built against older pi-tui versions keep working. */ - refreshAppearance?(): void; + refreshAppearance?(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void; /** The last detected terminal appearance, or undefined if not yet known. */ get appearance(): TerminalAppearance | undefined; /** @@ -574,11 +584,17 @@ export class ProcessTerminal implements Terminal { #windowsVTInputRestore?: () => void; #xtermScrollToBottomRestoreModes = new Set(); - #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = []; - #appearanceReportCallbacks: Array<(appearance: TerminalAppearance) => void> = []; + #appearanceCallbacks: Array< + (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void + > = []; + #appearanceReportCallbacks: Array< + (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void + > = []; #appearance: TerminalAppearance | undefined; #osc11Pending = false; - #osc11QueuedRoute?: Osc11QueryRoute; + #osc11ActiveToken?: TerminalAppearanceRequestToken; + #osc11QueuedQuery?: { route: Osc11QueryRoute; token?: TerminalAppearanceRequestToken }; + #nextAppearanceRequestToken = 1; #osc11ResponseBuffer = ""; #osc99PendingId: string | undefined; #osc99ResponseBuffer = ""; @@ -624,7 +640,9 @@ export class ProcessTerminal implements Terminal { return this.#appearance; } - onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void { + onAppearanceChange( + callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void, + ): void { this.#appearanceCallbacks.push(callback); // Replay an already-detected appearance: the startup OSC 11 response can // arrive before consumers (e.g. the theme bridge) subscribe, and the @@ -639,7 +657,9 @@ export class ProcessTerminal implements Terminal { } } - onAppearanceReport(callback: (appearance: TerminalAppearance) => void): () => void { + onAppearanceReport( + callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void, + ): () => void { this.#appearanceReportCallbacks.push(callback); let subscribed = true; return () => { @@ -659,9 +679,14 @@ export class ProcessTerminal implements Terminal { * armed. Suppressed while inactive, headless, or after the terminal is torn * down. */ - refreshAppearance(): void { + refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void { if (!this.#active || this.#headless || this.#dead) return; - this.#queryBackgroundColor(isInsideTmux() ? "tmux" : "direct"); + const token = requestToken ?? this.#nextAppearanceRequestToken++; + if (token >= this.#nextAppearanceRequestToken) { + this.#nextAppearanceRequestToken = token + 1; + } + this.#queryBackgroundColor(isInsideTmux() ? "tmux" : "direct", token); + return token; } onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void { @@ -1011,18 +1036,19 @@ export class ProcessTerminal implements Terminal { if (this.#osc11Pending) { // DA1 arrived before the OSC 11 reply: terminal does not support OSC 11. this.#osc11Pending = false; + this.#osc11ActiveToken = undefined; this.#osc11ResponseBuffer = ""; } // Start a queued OSC 11 query once the prior cycle is fully drained. if ( - this.#osc11QueuedRoute !== undefined && + this.#osc11QueuedQuery !== undefined && !this.#osc11Pending && !this.#da1SentinelOwners.some(o => o.kind === "osc11") && !this.#dead ) { - const route = this.#osc11QueuedRoute; - this.#osc11QueuedRoute = undefined; - this.#startOsc11Query(route); + const query = this.#osc11QueuedQuery; + this.#osc11QueuedQuery = undefined; + this.#startOsc11Query(query.route, query.token); } break; } @@ -1103,8 +1129,10 @@ export class ProcessTerminal implements Terminal { if (!osc11Match) return; const [, rHex, gHex, bHex] = osc11Match; this.#osc11Pending = false; + const requestToken = this.#osc11ActiveToken; + this.#osc11ActiveToken = undefined; this.#osc11ResponseBuffer = ""; - this.#handleOsc11Response(rHex!, gHex!, bHex!); + this.#handleOsc11Response(rHex!, gHex!, bHex!, requestToken); return; } } @@ -1157,22 +1185,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(route: Osc11QueryRoute = "direct"): void { + #queryBackgroundColor(route: Osc11QueryRoute = "direct", token?: TerminalAppearanceRequestToken): 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. Preserve a requested - // tmux passthrough route when coalescing direct and explicit queries. + // tmux passthrough route when coalescing direct and explicit queries, and + // retain the latest explicit request identity across automatic queries. if (this.#osc11Pending || this.#da1SentinelOwners.some(o => o.kind === "osc11")) { - if (this.#osc11QueuedRoute !== "tmux") this.#osc11QueuedRoute = route; + const queued = this.#osc11QueuedQuery; + this.#osc11QueuedQuery = { + route: queued?.route === "tmux" || route === "tmux" ? "tmux" : "direct", + token: token ?? queued?.token, + }; return; } - this.#startOsc11Query(route); + this.#startOsc11Query(route, token); } - #startOsc11Query(route: Osc11QueryRoute): void { + #startOsc11Query(route: Osc11QueryRoute, token?: TerminalAppearanceRequestToken): void { this.#osc11Pending = true; + this.#osc11ActiveToken = token; this.#osc11ResponseBuffer = ""; this.#da1SentinelOwners.push({ kind: "osc11" }); if (route === "tmux") { @@ -1237,7 +1271,7 @@ export class ProcessTerminal implements Terminal { * Parse an OSC 11 background color response and compute BT.601 luminance. * Handles 1-, 2-, 3-, and 4-digit XParseColor hex components. */ - #handleOsc11Response(rHex: string, gHex: string, bHex: string): void { + #handleOsc11Response(rHex: string, gHex: string, bHex: string, requestToken?: TerminalAppearanceRequestToken): void { const normalize = (hex: string): number => { const value = parseInt(hex, 16); if (Number.isNaN(value)) return 0; @@ -1250,7 +1284,7 @@ export class ProcessTerminal implements Terminal { this.#appearance = mode; for (const cb of [...this.#appearanceReportCallbacks]) { try { - cb(mode); + cb(mode, requestToken); } catch { /* ignore callback errors */ } @@ -1258,7 +1292,7 @@ export class ProcessTerminal implements Terminal { if (!changed) return; for (const cb of this.#appearanceCallbacks) { try { - cb(mode); + cb(mode, requestToken); } catch { /* ignore callback errors */ } @@ -1516,9 +1550,11 @@ export class ProcessTerminal implements Terminal { this.#mode2031DebounceTimer = undefined; } this.#appearanceCallbacks = []; + this.#appearanceReportCallbacks = []; this.#osc11Pending = false; + this.#osc11ActiveToken = undefined; this.#clearWindowsTerminalAppearancePoll(); - this.#osc11QueuedRoute = undefined; + this.#osc11QueuedQuery = 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 51fa63be9..f222c6ee7 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -129,6 +129,51 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { terminal.stop(); }); + it("preserves an explicit refresh token queued behind an automatic OSC 11 query", () => { + vi.useFakeTimers(); + const { terminal, queryCount } = setupTerminal(); + + // Seed the current appearance and drain all startup probe sentinels. + 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 events: Array<{ kind: "report" | "change"; appearance: string; token: number | undefined }> = []; + terminal.onAppearanceReport?.((appearance, token) => { + events.push({ kind: "report", appearance, token }); + }); + terminal.onAppearanceChange((appearance, token) => { + events.push({ kind: "change", appearance, token }); + }); + events.length = 0; + + // Mode 2031 starts an automatic query. The explicit refresh must queue + // behind it rather than lending its identity to the in-flight response. + process.stdin.emit("data", "\x1b[?997;1n"); + vi.advanceTimersByTime(100); + expect(queryCount()).toBe(2); + const supersededToken = 41; + const requestToken = 42; + expect(terminal.refreshAppearance?.(supersededToken)).toBe(supersededToken); + expect(terminal.refreshAppearance?.(requestToken)).toBe(requestToken); + expect(queryCount()).toBe(2); + + // The automatic response is unchanged and therefore reports without a + // change callback or request token. Its DA1 starts the queued refresh. + process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07"); + process.stdin.emit("data", "\x1b[?1;2c"); + expect(queryCount()).toBe(3); + + process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07"); + process.stdin.emit("data", "\x1b[?1;2c"); + terminal.stop(); + + expect(events).toEqual([ + { kind: "report", appearance: "dark", token: undefined }, + { kind: "report", appearance: "light", token: requestToken }, + { kind: "change", appearance: "light", token: requestToken }, + ]); + }); + it("reports every OSC 11 response while change callbacks remain deduplicated", () => { const { terminal } = setupTerminal(); const reports: Array<{ reported: string; current: string | undefined }> = [];