From 070ff41f4cb1c9100bfea818fbcd657b8586171a Mon Sep 17 00:00:00 2001 From: Alex Jaden Date: Tue, 28 Jul 2026 22:09:54 +0500 Subject: [PATCH] fix(coding-agent): sync WezTerm theme changes on macOS (cherry picked from commit fa70be267daa6f98cf18e2f257041f4e6e68a61d) --- packages/coding-agent/CHANGELOG.md | 3 + .../src/modes/interactive-mode.ts | 5 + .../coding-agent/src/modes/theme/theme.ts | 157 ++++++++- .../src/modes/utils/ui-helpers.ts | 13 +- .../test/theme-auto-detection.test.ts | 331 +++++++++++++++++- .../test/theme-lazy-status-color.test.ts | 63 +++- packages/tui/CHANGELOG.md | 3 + packages/tui/src/terminal.ts | 29 +- packages/tui/test/terminal-appearance.test.ts | 46 ++- 9 files changed, 611 insertions(+), 39 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d97a1f633..607347014 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -59,6 +59,9 @@ - `xd://` device docs now render the parameter schema as a comment-annotated TypeScript type (via `jsonSchemaToTypeScript`, the same renderer the in-band tool inventory uses) instead of a raw JSON Schema dump, shrinking system-prompt device sections while keeping descriptions inline. - Added a `/vision [on|off|auto|status]` slash command for session-scoped control of the `inspect_image` vision-delegation tool, modeled on `/computer`: `on`/`off` force the tool for the current session only, `auto` returns to the persisted setting, and `status` reports the effective mode, session override, tool state, and active-model image capability. - Replaced the `inspect_image.enabled` boolean with the tri-state `inspect_image.mode` (`auto`|`on`|`off`, default `auto`). In `auto` the tool is registered only when the active model lacks native image input, so vision-capable models (e.g. `kimi-code/k3`) read images inline with their own capabilities instead of delegating to a separate vision model; the tool set is re-evaluated on every model switch with a status notice when it flips. The `read` tool now follows the effective state dynamically rather than the raw setting, so it returns decoded image blocks again whenever `inspect_image` is hidden. Existing `inspect_image.enabled: true/false` configs migrate to `inspect_image.mode: on/off`. +### Fixed + +- Fixed automatic light/dark theme switching in direct WezTerm sessions on macOS when DEC Mode 2031 is unsupported. Native appearance notifications now repaint the auto theme immediately, while a bounded, front-loaded OSC 11 validation burst keeps the terminal background authoritative and reconciles fixed terminal themes only after an actual response. Persistent update notifications now resolve their warning, muted, and accent colors at render time so they also recolor when the active theme changes ([#6923](https://github.com/can1357/oh-my-pi/pull/6923) by [@Sairen777](https://github.com/Sairen777)). ## [17.1.6] - 2026-07-27 diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 713ab4801..f64c96af9 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -202,6 +202,7 @@ import { onTerminalAppearanceChange, onThemeChange, setMarkdownMermaidRendering, + startMacOSAppearanceReprobeFallback, theme, } from "./theme/theme"; import type { @@ -995,6 +996,10 @@ export class InteractiveMode implements InteractiveModeContext { // Load initial todos await this.#loadTodoList(); + if (process.platform === "darwin" && TERMINAL.id === "wezterm" && !isInsideTerminalMultiplexer()) { + this.#eventBusUnsubscribers.push(startMacOSAppearanceReprobeFallback(this.ui.terminal)); + } + // Start the UI. Cold `omp` launch opts into clearing on the first paint so // the initial welcome frame does not append over the previous run's scrollback. this.ui.start({ clearScrollback: options.clearInitialTerminalHistory === true }); diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 3cba15992..29d547d14 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -9,7 +9,15 @@ import { highlightCode as nativeHighlightCode, supportsLanguage as nativeSupportsLanguage, } from "@oh-my-pi/pi-natives"; -import type { EditorTheme, MarkdownTheme, SelectListTheme, SettingsListTheme, SymbolTheme } from "@oh-my-pi/pi-tui"; +import type { + EditorTheme, + MarkdownTheme, + SelectListTheme, + SettingsListTheme, + SymbolTheme, + Terminal, + TerminalAppearance, +} from "@oh-my-pi/pi-tui"; import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; import chalk from "chalk"; @@ -2493,28 +2501,165 @@ async function startThemeWatcher(): Promise { } /** - * Shared logic for re-evaluating the auto-detected theme. - * Called from SIGWINCH, terminal appearance change handler, and macOS fallback observer. + * Load and apply an already-resolved auto-theme name. */ -function reevaluateAutoTheme(debugLabel: string, event: ThemeChangeEvent = {}): void { - if (!autoDetectedTheme) return; - const resolved = getDefaultTheme(); +function applyResolvedAutoTheme(resolved: string, debugLabel: string, event: ThemeChangeEvent): void { if (resolved === currentThemeName) return; currentThemeName = resolved; + const requestId = ++themeLoadRequestId; loadTheme(resolved, getCurrentThemeOptions()) .then(loadedTheme => { + if (requestId !== themeLoadRequestId) return; theme = loadedTheme; notifyThemeChange(event); }) .catch(err => { + if (requestId !== themeLoadRequestId) return; logger.debug(`Theme switch on ${debugLabel} failed`, { error: String(err) }); }); } +/** + * Shared logic for re-evaluating the auto-detected theme. + * An explicit appearance is provisional input and does not alter terminal-reported state. + */ +function reevaluateAutoTheme(debugLabel: string, event: ThemeChangeEvent = {}, appearance?: "dark" | "light"): void { + if (!autoDetectedTheme) return; + const resolved = + appearance === undefined ? getDefaultTheme() : appearance === "dark" ? autoDarkTheme : autoLightTheme; + applyResolvedAutoTheme(resolved, debugLabel, event); +} + // ============================================================================ // macOS Appearance Fallback Observer // ============================================================================ +type MacOSAppearanceReprobeTerminal = Pick< + Terminal, + "appearance" | "onAppearanceChange" | "onAppearanceReport" | "onPrivateModeReport" | "refreshAppearance" +>; + +const MACOS_APPEARANCE_REPROBE_DELAYS_MS = [25, 50, 100, 250, 500, 1000] as const; +const MACOS_APPEARANCE_RECONCILE_DELAY_MS = 1100; + +/** + * Fall back to native macOS appearance notifications when the terminal + * explicitly confirms that Mode 2031 notifications are unsupported. + * + * Native notifications provisionally repaint from the host appearance and + * synchronously trigger an OSC 11 probe, followed by a bounded burst of six + * retries. A changed terminal classification cancels the sequence; otherwise + * a confirmed terminal classification is restored at the validation deadline. + */ +export function startMacOSAppearanceReprobeFallback(terminal: MacOSAppearanceReprobeTerminal): () => void { + let disposed = false; + let observerStartAttempted = false; + let observer: MacAppearanceObserver | undefined; + let probeGeneration = 0; + let probeSequenceActive = false; + let probeBaseline: TerminalAppearance | undefined; + let probeResponseConfirmed = false; + const probeTimers = new Set(); + let reconciliationTimer: Timer | undefined; + + const cancelProbeSequence = (): void => { + probeGeneration++; + probeSequenceActive = false; + probeBaseline = undefined; + probeResponseConfirmed = false; + if (reconciliationTimer) { + clearTimeout(reconciliationTimer); + reconciliationTimer = undefined; + } + for (const timer of probeTimers) { + clearTimeout(timer); + } + probeTimers.clear(); + }; + + const scheduleProbeSequence = (): void => { + cancelProbeSequence(); + if (disposed || !autoDetectedTheme) return; + + probeSequenceActive = true; + probeBaseline = terminal.appearance; + probeResponseConfirmed = false; + const generation = probeGeneration; + terminal.refreshAppearance?.(); + if (disposed || generation !== probeGeneration || !autoDetectedTheme) return; + for (const delay of MACOS_APPEARANCE_REPROBE_DELAYS_MS) { + const timer = setTimeout(() => { + probeTimers.delete(timer); + if (disposed || generation !== probeGeneration) return; + if (!autoDetectedTheme) { + cancelProbeSequence(); + return; + } + terminal.refreshAppearance?.(); + }, delay); + timer.unref?.(); + probeTimers.add(timer); + } + reconciliationTimer = setTimeout(() => { + reconciliationTimer = undefined; + if (disposed || generation !== probeGeneration) return; + const appearance = probeResponseConfirmed ? terminal.appearance : undefined; + cancelProbeSequence(); + if (!autoDetectedTheme || !appearance) return; + reevaluateAutoTheme("macOS appearance reconciliation", {}, appearance); + }, MACOS_APPEARANCE_RECONCILE_DELAY_MS); + reconciliationTimer.unref?.(); + }; + + const unsubscribeAppearanceReport = terminal.onAppearanceReport?.(() => { + if (disposed || !probeSequenceActive) return; + probeResponseConfirmed = true; + }); + + terminal.onAppearanceChange(appearance => { + if (disposed || !probeSequenceActive || appearance === probeBaseline) return; + cancelProbeSequence(); + }); + + terminal.onPrivateModeReport?.((mode, supported, confirmed) => { + if (disposed || observerStartAttempted || mode !== 2031 || supported || confirmed !== true) { + return; + } + + observerStartAttempted = true; + try { + observer = MacAppearanceObserver.start((err, appearance) => { + if (disposed) return; + if (err) { + cancelProbeSequence(); + return; + } + if (appearance === "dark" || appearance === "light") { + reevaluateAutoTheme("macOS provisional appearance", {}, appearance); + } + scheduleProbeSequence(); + }); + } catch (err) { + logger.warn("Failed to start macOS appearance reprobe observer", { err }); + } + }); + + return () => { + if (disposed) return; + disposed = true; + cancelProbeSequence(); + if (unsubscribeAppearanceReport) unsubscribeAppearanceReport(); + const activeObserver = observer; + observer = undefined; + if (!activeObserver) return; + try { + activeObserver.stop(); + } catch (err) { + logger.debug("Failed to stop macOS appearance reprobe observer", { err }); + } + }; +} + var macObserver: { stop(): void } | undefined; function startMacAppearanceObserver(): void { diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 2a4eb48e9..c19fc21e6 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -728,14 +728,13 @@ export class UiHelpers { showNewVersionNotification(newVersion: string): void { const block = new TranscriptBlock(); block.addChild(new DynamicBorder(text => theme.fg("warning", text))); + const title = "Update Available"; + const prefix = `New version ${newVersion} is available. Run: `; + const command = "omp update"; block.addChild( - new Text( - theme.bold(theme.fg("warning", "Update Available")) + - "\n" + - theme.fg("muted", `New version ${newVersion} is available. Run: `) + - theme.fg("accent", "omp update"), - 1, - 0, + new Text(`${title}\n${prefix}${command}`, 1, 0).setStyleFn( + () => + `${theme.bold(theme.fg("warning", title))}\n${theme.fg("muted", prefix)}${theme.fg("accent", command)}`, ), ); block.addChild(new DynamicBorder(text => theme.fg("warning", text))); diff --git a/packages/coding-agent/test/theme-auto-detection.test.ts b/packages/coding-agent/test/theme-auto-detection.test.ts index ea129cbac..f60618061 100644 --- a/packages/coding-agent/test/theme-auto-detection.test.ts +++ b/packages/coding-agent/test/theme-auto-detection.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import * as nativesModule from "@oh-my-pi/pi-natives"; -import { MacOSAppearance } from "@oh-my-pi/pi-natives"; +import { type MacAppearanceObserver, MacOSAppearance } from "@oh-my-pi/pi-natives"; +import type { Terminal, TerminalAppearance } from "@oh-my-pi/pi-tui"; const originalPlatform = process.platform; const originalColorfgbg = Bun.env.COLORFGBG; @@ -43,6 +44,84 @@ const withThemeTestGlobals = (globals: ThemeTestGlobals = {}) => { }; }; +type AppearanceTerminal = Pick< + Terminal, + "appearance" | "onAppearanceChange" | "onAppearanceReport" | "onPrivateModeReport" | "refreshAppearance" +>; +type AppearanceChangeCallback = (appearance: TerminalAppearance) => void; +type AppearanceReportCallback = (appearance: TerminalAppearance) => void; +type PrivateModeReportCallback = (mode: number, supported: boolean, confirmed?: boolean) => void; + +class FakeAppearanceTerminal implements AppearanceTerminal { + appearance: TerminalAppearance | undefined; + readonly refreshAppearance = vi.fn(() => {}); + #appearanceChangeCallbacks: AppearanceChangeCallback[] = []; + #appearanceReportCallbacks: AppearanceReportCallback[] = []; + #privateModeReportCallbacks: PrivateModeReportCallback[] = []; + + constructor(appearance?: TerminalAppearance) { + this.appearance = appearance; + } + + onAppearanceChange(callback: AppearanceChangeCallback): void { + this.#appearanceChangeCallbacks.push(callback); + if (this.appearance) callback(this.appearance); + } + + onAppearanceReport(callback: AppearanceReportCallback): () => void { + this.#appearanceReportCallbacks.push(callback); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + const index = this.#appearanceReportCallbacks.indexOf(callback); + if (index !== -1) this.#appearanceReportCallbacks.splice(index, 1); + }; + } + + onPrivateModeReport(callback: PrivateModeReportCallback): void { + this.#privateModeReportCallbacks.push(callback); + } + + reportAppearance(appearance: TerminalAppearance): void { + const changed = this.appearance !== appearance; + this.appearance = appearance; + for (const callback of this.#appearanceReportCallbacks) callback(appearance); + if (changed) { + for (const callback of this.#appearanceChangeCallbacks) callback(appearance); + } + } + + reportPrivateMode(mode: number, supported: boolean, confirmed?: boolean): void { + for (const callback of this.#privateModeReportCallbacks) callback(mode, supported, confirmed); + } +} + +function mockMacAppearanceObserver() { + const stop = vi.fn(); + const observer: MacAppearanceObserver = { stop }; + let callback: ((err: null | Error, appearance: MacOSAppearance) => void) | undefined; + const start = vi.spyOn(nativesModule.MacAppearanceObserver, "start").mockImplementation(nextCallback => { + callback = nextCallback; + return observer; + }); + + return { + start, + stop, + emit(appearance: MacOSAppearance): void { + if (!callback) throw new Error("Mac appearance observer has not started"); + callback(null, appearance); + }, + }; +} + +async function flushThemeLoad(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + describe("theme auto-detection", () => { beforeEach(async () => { themeModule.stopThemeWatcher(); @@ -84,28 +163,20 @@ describe("theme auto-detection", () => { it("updates auto theme from the native fallback observer in Zellij", async () => { using _globals = withThemeTestGlobals({ zellij: "1" }); - const stop = vi.fn(); - let onAppearanceChange: ((appearance: "dark" | "light") => void) | undefined; + const observer = mockMacAppearanceObserver(); vi.spyOn(nativesModule, "detectMacOSAppearance").mockReturnValue(MacOSAppearance.Light); - const observerSpy = vi.spyOn(nativesModule.MacAppearanceObserver, "start").mockImplementation((( - callback: (err: null | Error, appearance: "dark" | "light") => void, - ) => { - onAppearanceChange = (appearance: "dark" | "light") => callback(null, appearance); - return { stop }; - }) as any); await themeModule.initTheme(true, undefined, undefined, "dark", "light"); - expect(observerSpy).toHaveBeenCalledTimes(1); + expect(observer.start).toHaveBeenCalledTimes(1); expect(themeModule.getCurrentThemeName()).toBe("light"); - expect(onAppearanceChange).toBeDefined(); - onAppearanceChange!("dark"); + observer.emit(MacOSAppearance.Dark); await Bun.sleep(0); expect(themeModule.getCurrentThemeName()).toBe("dark"); themeModule.stopThemeWatcher(); - expect(stop).toHaveBeenCalledTimes(1); + expect(observer.stop).toHaveBeenCalledTimes(1); }); it("Zellij fallback stays macOS-only (Linux + Zellij = honor terminal)", async () => { using _globals = withThemeTestGlobals({ platform: "linux", zellij: "1" }); @@ -128,4 +199,238 @@ describe("theme auto-detection", () => { expect(themeModule.getCurrentThemeName()).toBe("light"); expect(detectSpy).not.toHaveBeenCalled(); }); + + describe("macOS appearance reprobe fallback", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("does not start for supported or unconfirmed Mode 2031 reports", () => { + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + try { + terminal.reportPrivateMode(2031, true, true); + terminal.reportPrivateMode(2031, false, false); + terminal.reportPrivateMode(2031, false); + terminal.reportPrivateMode(2026, false, true); + + expect(observer.start).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); + + it("starts only after a confirmed unsupported Mode 2031 report", () => { + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + try { + expect(observer.start).not.toHaveBeenCalled(); + + terminal.reportPrivateMode(2031, false, true); + + expect(observer.start).toHaveBeenCalledTimes(1); + } finally { + dispose(); + } + }); + + it("switches provisionally, reprobes at every contracted delay, then reconciles unchanged terminal state", async () => { + using _globals = withThemeTestGlobals(); + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + try { + terminal.reportPrivateMode(2031, false, true); + observer.emit(MacOSAppearance.Light); + await flushThemeLoad(); + + expect(themeModule.getCurrentThemeName()).toBe("light"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(7); + + let previousElapsed = 0; + for (const [elapsed, expectedCount] of [ + [24, 1], + [25, 2], + [49, 2], + [50, 3], + [99, 3], + [100, 4], + [249, 4], + [250, 5], + [499, 5], + [500, 6], + [999, 6], + [1000, 7], + [1099, 7], + ] as const) { + vi.advanceTimersByTime(elapsed - previousElapsed); + previousElapsed = elapsed; + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(expectedCount); + } + expect(themeModule.getCurrentThemeName()).toBe("light"); + expect(vi.getTimerCount()).toBe(1); + + terminal.reportAppearance("dark"); + + vi.advanceTimersByTime(1); + await flushThemeLoad(); + + expect(themeModule.getCurrentThemeName()).toBe("dark"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(7); + expect(vi.getTimerCount()).toBe(0); + } finally { + dispose(); + } + }); + + it("keeps the provisional native theme when every OSC probe times out", async () => { + using _globals = withThemeTestGlobals(); + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + try { + terminal.reportPrivateMode(2031, false, true); + observer.emit(MacOSAppearance.Light); + await flushThemeLoad(); + + expect(themeModule.getCurrentThemeName()).toBe("light"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1100); + await flushThemeLoad(); + + expect(themeModule.getCurrentThemeName()).toBe("light"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(7); + expect(vi.getTimerCount()).toBe(0); + } finally { + dispose(); + } + }); + + it("cancels the previous probes and reconciliation when a newer native event starts a fresh sequence", async () => { + using _globals = withThemeTestGlobals(); + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + try { + terminal.reportPrivateMode(2031, false, true); + observer.emit(MacOSAppearance.Light); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(50); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(3); + + observer.emit(MacOSAppearance.Dark); + await flushThemeLoad(); + expect(themeModule.getCurrentThemeName()).toBe("dark"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(4); + expect(vi.getTimerCount()).toBe(7); + + for (const delay of [25, 25, 50, 150, 250, 500]) { + vi.advanceTimersByTime(delay); + } + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(10); + expect(vi.getTimerCount()).toBe(1); + + vi.advanceTimersByTime(100); + await flushThemeLoad(); + expect(themeModule.getCurrentThemeName()).toBe("dark"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(10); + expect(vi.getTimerCount()).toBe(0); + } finally { + dispose(); + } + }); + + it("keeps a genuinely changed terminal appearance authoritative and cancels reconciliation", async () => { + using _globals = withThemeTestGlobals(); + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + try { + terminal.reportPrivateMode(2031, false, true); + observer.emit(MacOSAppearance.Light); + await flushThemeLoad(); + expect(themeModule.getCurrentThemeName()).toBe("light"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(7); + + terminal.reportAppearance("light"); + themeModule.onTerminalAppearanceChange("light"); + await flushThemeLoad(); + expect(vi.getTimerCount()).toBe(0); + + vi.advanceTimersByTime(10_000); + await flushThemeLoad(); + expect(themeModule.getCurrentThemeName()).toBe("light"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(1); + } finally { + dispose(); + } + }); + + it("ignores native events while automatic theme selection is disabled", async () => { + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + const previousThemeName = themeModule.getCurrentThemeName(); + try { + terminal.reportPrivateMode(2031, false, true); + observer.emit(MacOSAppearance.Light); + await flushThemeLoad(); + + expect(themeModule.getCurrentThemeName()).toBe(previousThemeName); + expect(terminal.refreshAppearance).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + } finally { + dispose(); + } + }); + + it("disposer stops the observer, cancels timers, and guards retained callbacks", async () => { + using _globals = withThemeTestGlobals(); + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + const observer = mockMacAppearanceObserver(); + const terminal = new FakeAppearanceTerminal("dark"); + const dispose = themeModule.startMacOSAppearanceReprobeFallback(terminal); + let needsCleanup = true; + try { + terminal.reportPrivateMode(2031, false, true); + observer.emit(MacOSAppearance.Light); + await flushThemeLoad(); + expect(themeModule.getCurrentThemeName()).toBe("light"); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(7); + + dispose(); + needsCleanup = false; + expect(observer.stop).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + + terminal.reportPrivateMode(2031, false, true); + observer.emit(MacOSAppearance.Dark); + terminal.reportAppearance("light"); + vi.advanceTimersByTime(10_000); + await flushThemeLoad(); + + expect(observer.start).toHaveBeenCalledTimes(1); + expect(observer.stop).toHaveBeenCalledTimes(1); + expect(terminal.refreshAppearance).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } finally { + if (needsCleanup) dispose(); + } + }); + }); }); diff --git a/packages/coding-agent/test/theme-lazy-status-color.test.ts b/packages/coding-agent/test/theme-lazy-status-color.test.ts index b955f26b6..3429cd0e9 100644 --- a/packages/coding-agent/test/theme-lazy-status-color.test.ts +++ b/packages/coding-agent/test/theme-lazy-status-color.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; +import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; /** @@ -12,11 +15,15 @@ import { Text } from "@oh-my-pi/pi-tui"; */ /** Opening SGR sequence `theme.fg(color, ...)` emits, independent of color mode. */ -function fgPrefix(color: "warning"): string { +function fgPrefix(color: "accent" | "muted" | "warning"): string { const styled = themeModule.theme.fg(color, "\u0001"); return styled.slice(0, styled.indexOf("\u0001")); } +function isSingleComponent(component: Component | readonly Component[]): component is Component { + return !Array.isArray(component); +} + describe("lazy status color re-resolves on theme switch", () => { beforeEach(async () => { themeModule.stopThemeWatcher(); @@ -63,4 +70,58 @@ describe("lazy status color re-resolves on theme switch", () => { expect(out).toContain(lightPrefix); expect(out).not.toContain(darkPrefix); }); + it("recolors the presented update notification when auto-theme resolves light", async () => { + themeModule.onTerminalAppearanceChange("dark"); + await themeModule.initTheme(false, undefined, undefined, "dark-catppuccin", "light-catppuccin"); + + let presented: Component | undefined; + const context: Pick = { + present(component) { + if (!isSingleComponent(component)) throw new Error("Expected one update notification block"); + presented = component; + }, + }; + new UiHelpers(context as InteractiveModeContext).showNewVersionNotification("1.2.3"); + const notification = presented; + if (!notification) throw new Error("Update notification was not presented"); + + const darkPrefixes = { + warning: fgPrefix("warning"), + muted: fgPrefix("muted"), + accent: fgPrefix("accent"), + }; + const darkOutput = notification.render(100).join("\n"); + expect(darkOutput).toContain(darkPrefixes.warning); + expect(darkOutput).toContain(darkPrefixes.muted); + expect(darkOutput).toContain(darkPrefixes.accent); + + const switched = Promise.withResolvers(); + const off = themeModule.onThemeChange(() => switched.resolve()); + try { + themeModule.onTerminalAppearanceChange("light"); + await switched.promise; + } finally { + off(); + } + + const lightPrefixes = { + warning: fgPrefix("warning"), + muted: fgPrefix("muted"), + accent: fgPrefix("accent"), + }; + expect(lightPrefixes.warning).not.toBe(darkPrefixes.warning); + expect(lightPrefixes.muted).not.toBe(darkPrefixes.muted); + expect(lightPrefixes.accent).not.toBe(darkPrefixes.accent); + + notification.invalidate?.(); + const lightOutput = notification.render(100).join("\n"); + for (const prefix of Object.values(lightPrefixes)) expect(lightOutput).toContain(prefix); + for (const prefix of Object.values(darkPrefixes)) expect(lightOutput).not.toContain(prefix); + + const semanticLines = Bun.stripANSI(lightOutput) + .split("\n") + .map(line => line.trim()) + .filter(line => line === "Update Available" || line.startsWith("New version ")); + expect(semanticLines).toEqual(["Update Available", "New version 1.2.3 is available. Run: omp update"]); + }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a8d45c1af..f0d6a6521 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -22,6 +22,9 @@ - Streaming markdown now freezes the stable prefix through provably closed lists instead of re-lexing everything after the last non-list block on every delta - Raised the markdown render cache entry budget (32 KiB → 256 KiB) so large messages — exactly the expensive renders — are cacheable - Deduplicated terminal cursor-visibility writes to skip redundant escape sequences +### Added + +- Added response-level OSC 11 appearance subscriptions so terminal consumers can distinguish confirmed unchanged background classifications from missing replies ([#6923](https://github.com/can1357/oh-my-pi/pull/6923) by [@Sairen777](https://github.com/Sairen777)). ## [17.1.6] - 2026-07-27 diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index c250aab1f..06946ec6d 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -385,6 +385,13 @@ export interface Terminal { * already-detected appearance so late subscribers never miss it. */ onAppearanceChange(callback: (appearance: TerminalAppearance) => 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; /** * 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 @@ -497,6 +504,7 @@ export class ProcessTerminal implements Terminal { #windowsVTInputRestore?: () => void; #xtermScrollToBottomRestoreModes = new Set(); #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = []; + #appearanceReportCallbacks: Array<(appearance: TerminalAppearance) => void> = []; #appearance: TerminalAppearance | undefined; #osc11Pending = false; #osc11QueuedRoute?: Osc11QueryRoute; @@ -560,6 +568,17 @@ export class ProcessTerminal implements Terminal { } } + onAppearanceReport(callback: (appearance: TerminalAppearance) => void): () => void { + this.#appearanceReportCallbacks.push(callback); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + const index = this.#appearanceReportCallbacks.indexOf(callback); + if (index !== -1) this.#appearanceReportCallbacks.splice(index, 1); + }; + } + /** * Re-query the terminal background via a single OSC 11 probe. Reuses the * startup DA1-sentinel FIFO, pending/queued gating, parsing, dedup, and @@ -1152,8 +1171,16 @@ export class ProcessTerminal implements Terminal { }; const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex); const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light"; - if (mode === this.#appearance) return; + const changed = mode !== this.#appearance; this.#appearance = mode; + for (const cb of [...this.#appearanceReportCallbacks]) { + try { + cb(mode); + } catch { + /* ignore callback errors */ + } + } + if (!changed) return; for (const cb of this.#appearanceCallbacks) { try { cb(mode); diff --git a/packages/tui/test/terminal-appearance.test.ts b/packages/tui/test/terminal-appearance.test.ts index 96d98a147..f6a6d0f13 100644 --- a/packages/tui/test/terminal-appearance.test.ts +++ b/packages/tui/test/terminal-appearance.test.ts @@ -129,25 +129,49 @@ describe("ProcessTerminal OSC 11 appearance detection", () => { terminal.stop(); }); - it("OSC 11 updates terminal.appearance and fires callbacks with dedup", () => { + it("reports every OSC 11 response while change callbacks remain deduplicated", () => { const { terminal } = setupTerminal(); - const appearances: string[] = []; - terminal.onAppearanceChange(a => appearances.push(a)); + const reports: Array<{ reported: string; current: string | undefined }> = []; + const changes: string[] = []; + let selfUnsubscribeCalls = 0; + const unsubscribeSelf = terminal.onAppearanceReport?.(() => { + selfUnsubscribeCalls++; + unsubscribeSelf?.(); + }); + terminal.onAppearanceReport?.(() => { + throw new Error("report callback failure"); + }); + const unsubscribeCollector = terminal.onAppearanceReport?.(appearance => { + reports.push({ reported: appearance, current: terminal.appearance }); + }); + terminal.onAppearanceChange(appearance => changes.push(appearance)); - // Send dark background response + DA1 + // Complete the startup query and drain every startup probe sentinel before + // issuing explicit refreshes, so each response belongs to a real query cycle. + 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"); + + terminal.refreshAppearance?.(); process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07"); process.stdin.emit("data", "\x1b[?1;2c"); - expect(terminal.appearance).toBe("dark"); - expect(appearances).toEqual(["dark"]); - - // Send same color again — callback should NOT fire again - process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07"); + terminal.refreshAppearance?.(); + process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07"); process.stdin.emit("data", "\x1b[?1;2c"); - expect(appearances).toEqual(["dark"]); - + // Stop before asserting so a failed expectation cannot leak stdin listeners + // or terminal modes into subsequent tests. terminal.stop(); + unsubscribeCollector?.(); + unsubscribeCollector?.(); + + expect(reports).toEqual([ + { reported: "dark", current: "dark" }, + { reported: "dark", current: "dark" }, + { reported: "light", current: "light" }, + ]); + expect(selfUnsubscribeCalls).toBe(1); + expect(changes).toEqual(["dark", "light"]); }); it("replays already detected OSC 11 appearance to late subscribers", () => {