fix(coding-agent): sync WezTerm theme changes on macOS

(cherry picked from commit fa70be267daa6f98cf18e2f257041f4e6e68a61d)
This commit is contained in:
Alex Jaden
2026-07-29 23:08:34 +02:00
committed by can1357
parent 44907cef75
commit 070ff41f4c
9 changed files with 611 additions and 39 deletions
@@ -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 });
+151 -6
View File
@@ -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<void> {
}
/**
* 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<Timer>();
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 {
@@ -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)));