fix(coding-agent): sync WezTerm theme changes on macOS
(cherry picked from commit fa70be267daa6f98cf18e2f257041f4e6e68a61d)
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user