diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 75c713b3a180..8b439aced716 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -19,6 +19,7 @@ import { upsertTheme, type ThemeJson, } from "../theme" +import { win32SystemTheme } from "../terminal-win32" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -160,7 +161,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.active === "system") setStore("active", DEFAULT_THEME) return } - const next = store.lock ?? terminalMode(colors) ?? mode + // On Windows Terminal the buffer background (OSC 11) is independent + // of the system dark/light mode. The title bar follows the system + // theme but the background colour does not change. Use the Windows + // registry value as the authoritative source when available. + let next = store.lock ?? terminalMode(colors) ?? mode + if (process.platform === "win32") { + const winMode = win32SystemTheme() + if (winMode) next = winMode + } if (store.mode !== next) setStore("mode", next) const signature = JSON.stringify(colors) hasResolvedSystemTheme = true @@ -243,12 +252,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ let unsubscribeRefresh: (() => void) | undefined unsubscribeRefresh = themes.subscribeRefresh?.(refresh) + let winThemeWatcher: ReturnType | undefined + if (process.platform === "win32") { + let lastWinTheme = win32SystemTheme() + winThemeWatcher = setInterval(() => { + const current = win32SystemTheme() + if (current && current !== lastWinTheme) { + lastWinTheme = current + if (!store.lock) apply(current) + } + }, 3000) + winThemeWatcher.unref() + } + onCleanup(() => { renderer.off(CliRenderEvents.THEME_MODE, handle) renderer.removeInputHandler(handleThemeNotification) unsubscribeRefresh?.() for (const timeout of themeRefreshTimeouts) clearTimeout(timeout) themeRefreshTimeouts.length = 0 + if (winThemeWatcher) clearInterval(winThemeWatcher) }) const values = createMemo(() => { diff --git a/packages/tui/src/terminal-win32.ts b/packages/tui/src/terminal-win32.ts index 1aaa80aecd69..a4fb1d5fba28 100644 --- a/packages/tui/src/terminal-win32.ts +++ b/packages/tui/src/terminal-win32.ts @@ -128,3 +128,46 @@ export function win32InstallCtrlCGuard() { return unhook } + +/** + * Detect Windows system dark/light mode from the registry. + * + * Reads `AppsUseLightTheme` from: + * HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize + * + * Returns `"dark"` when the value is `0`, `"light"` when `1`, or `undefined` + * if the query fails (registry key absent on older Windows, non-Windows platform). + */ +/** + * Parse the `AppsUseLightTheme` DWORD out of `reg query` output. Anchored to the + * value name so an unrelated hex token elsewhere in the output can't be picked up. + * `0` means dark mode, anything else light. + */ +export function parseWin32Theme(output: string): "dark" | "light" | undefined { + const match = output.match(/AppsUseLightTheme\s+REG_DWORD\s+0x([0-9a-f]+)/i) + if (!match) return undefined + return parseInt(match[1]!, 16) === 0 ? "dark" : "light" +} + +export function win32SystemTheme(): "dark" | "light" | undefined { + if (process.platform !== "win32") return undefined + if (typeof Bun === "undefined") return undefined + try { + // timeout: reg.exe can hang (AV interception, contested handle) and this runs + // synchronously on the render thread. windowsHide: avoid a console flash. + const result = Bun.spawnSync( + [ + "reg", + "query", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "/v", + "AppsUseLightTheme", + ], + { timeout: 1500, windowsHide: true }, + ) + return parseWin32Theme(result.stdout?.toString() ?? "") + } catch { + // reg.exe missing (older Windows) or spawn failure — treat as unknown. + } + return undefined +} diff --git a/packages/tui/test/win32-theme.test.ts b/packages/tui/test/win32-theme.test.ts new file mode 100644 index 000000000000..320e32cb2932 --- /dev/null +++ b/packages/tui/test/win32-theme.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test" +import { parseWin32Theme, win32SystemTheme } from "../src/terminal-win32" + +test("win32SystemTheme returns a valid dark/light value on Windows, undefined elsewhere", () => { + const result = win32SystemTheme() + if (process.platform === "win32") { + // On Windows the registry may or may not be readable; both outcomes are valid. + // If readable, it must be "dark" or "light". + expect(["dark", "light", undefined]).toContain(result) + } else { + expect(result).toBeUndefined() + } +}) + +test("parseWin32Theme parses AppsUseLightTheme DWORD from reg output", () => { + const key = "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize" + expect(parseWin32Theme(`${key}\r\n AppsUseLightTheme REG_DWORD 0x0\r\n`)).toBe("dark") + expect(parseWin32Theme(`${key}\r\n AppsUseLightTheme REG_DWORD 0x1\r\n`)).toBe("light") + expect(parseWin32Theme(" AppsUseLightTheme REG_DWORD 0x00000000")).toBe("dark") + expect(parseWin32Theme(" AppsUseLightTheme REG_DWORD 0x00000001")).toBe("light") + expect(parseWin32Theme("no match here")).toBeUndefined() + expect(parseWin32Theme("")).toBeUndefined() + // Anchored: a stray hex token that is NOT the AppsUseLightTheme value is ignored. + expect(parseWin32Theme("SomeOtherValue REG_DWORD 0x1\r\n(no AppsUseLightTheme line)")).toBeUndefined() +}) + +test("win32SystemTheme is idempotent - second call returns same value as first", () => { + const a = win32SystemTheme() + const b = win32SystemTheme() + expect(a).toBe(b) +})