feat(theme): poll terminal OSC colors to track live theme changes

Terminals answer OSC 10/11/12 color queries but never push changes, so
detect theme flips with a 60 s poll (legacy-tmux fallback for servers
< 3.6). Re-queries palette + default fg/bg, updates the system palette
and re-detects dark/light mode.
This commit is contained in:
2026-08-17 20:50:14 -04:00
parent 9ddfd21685
commit 4990eae60f

View File

@@ -120,6 +120,14 @@ const EMPTY_TERMINAL_COLORS: TerminalColors = {
/** Cached macOS appearance (dark/light), independent of the terminal. */
let cachedOsMode: "dark" | "light" | null = null;
/**
* How often to re-query the terminal for theme changes (OSC 10/11/12).
* Terminals only answer these queries — they never push a color change —
* so detection is a slow poll. 60 s keeps CPU cost unmeasurable while
* still tracking theme flips within a reasonable delay.
*/
const SYSTEM_THEME_POLL_MS = 60_000;
/**
* Detect the terminal's dark/light mode.
*
@@ -215,7 +223,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
});
}
async function resolveSystemTheme() {
/**
* Query the terminal's colors via OSC (palette + default fg/bg), with a
* legacy-tmux fallback for servers < 3.6 that don't forward OSC replies.
* Returns null when the terminal cannot answer.
*/
async function queryTerminalColors(): Promise<TerminalColors | null> {
if (process.env.TMUX) {
await waitForCapabilities();
}
@@ -254,6 +267,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
}
return colors;
}
async function resolveSystemTheme() {
const colors = await queryTerminalColors();
// ── dark/light mode detection ─────────────────────────────────────────
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
// real one from the terminal's background color (OSC 11) or, when that
@@ -299,8 +318,55 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
}
/**
* Poll for terminal theme changes: re-query OSC colors, update the
* system palette when it differs, and re-detect dark/light mode.
* Runs on a slow timer (see SYSTEM_THEME_POLL_MS); most polls change
* nothing and only pay the idle query round-trip.
*/
async function pollSystemTheme() {
if (!store.ready) return;
const colors = await queryTerminalColors();
if (!colors) return;
const current = store.system;
const changed =
!current ||
current.defaultBackground !== colors.defaultBackground ||
current.defaultForeground !== colors.defaultForeground ||
current.palette.join(",") !== colors.palette.join(",");
if (changed) {
setStore(
produce((draft) => {
draft.system = colors;
}),
);
}
// Refresh the OS-appearance fallback only when the terminal cannot
// report a background (e.g. tmux without OSC forwarding), so the
// common path never spawns a subprocess.
if (process.platform === "darwin" && !colors.defaultBackground) {
cachedOsMode = null;
}
const detectedMode = detectSystemMode(colors);
if (detectedMode && detectedMode !== store.mode) {
setStore("mode", detectedMode);
emitThemeModeChanged(detectedMode);
}
}
onMount(init);
// Poll the terminal for theme changes (see pollSystemTheme). Registered
// once per provider init — SIGUSR2 re-runs the inner `init`, not this
// closure, so the timer cannot stack.
const pollTimer = setInterval(() => {
void pollSystemTheme();
}, SYSTEM_THEME_POLL_MS);
onCleanup(() => clearInterval(pollTimer));
// Setup SIGUSR2 signal handler for dynamic theme reload
// This allows external tools to trigger a theme refresh by sending:
// `kill -USR2 <pid>`