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:
@@ -120,6 +120,14 @@ const EMPTY_TERMINAL_COLORS: TerminalColors = {
|
|||||||
/** Cached macOS appearance (dark/light), independent of the terminal. */
|
/** Cached macOS appearance (dark/light), independent of the terminal. */
|
||||||
let cachedOsMode: "dark" | "light" | null = null;
|
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.
|
* 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) {
|
if (process.env.TMUX) {
|
||||||
await waitForCapabilities();
|
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 ─────────────────────────────────────────
|
// ── dark/light mode detection ─────────────────────────────────────────
|
||||||
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
|
// 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
|
// 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);
|
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
|
// Setup SIGUSR2 signal handler for dynamic theme reload
|
||||||
// This allows external tools to trigger a theme refresh by sending:
|
// This allows external tools to trigger a theme refresh by sending:
|
||||||
// `kill -USR2 <pid>`
|
// `kill -USR2 <pid>`
|
||||||
|
|||||||
Reference in New Issue
Block a user