From 2427688f92a3bdd839433c7bd61b97c05d824a4d Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Thu, 23 Apr 2026 21:41:41 +0100 Subject: [PATCH 1/2] feat(themes): follow macOS Appearance and auto-switch dark/light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in background poller that reads the macOS Appearance setting (`defaults read -g AppleInterfaceStyle`) every few seconds and flips the active theme between a configured dark and light pair when the user toggles System Settings → Appearance. Config: autoThemeFollowsSystem: true darkTheme: "catppuccin-mocha" (default) lightTheme: "catppuccin-latte" (default) macOS-gated — no-op on Linux/Windows. Uses the same broadcast path as the manual theme picker so every connected sidebar re-renders in sync. Poller is a 3s interval cleaned up on shutdown. Extracted pure mapping and side-effectful read into `system-theme.ts` for testability. Covered by 5 new tests. --- packages/runtime/src/config.ts | 6 +++ packages/runtime/src/server/index.ts | 27 ++++++++++++ packages/runtime/src/system-theme.ts | 50 ++++++++++++++++++++++ packages/runtime/test/config.test.ts | 28 ++++++++++++ packages/runtime/test/system-theme.test.ts | 39 +++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 packages/runtime/src/system-theme.ts create mode 100644 packages/runtime/test/system-theme.test.ts diff --git a/packages/runtime/src/config.ts b/packages/runtime/src/config.ts index a0552fa..a2eba7d 100644 --- a/packages/runtime/src/config.ts +++ b/packages/runtime/src/config.ts @@ -25,6 +25,12 @@ export interface OpensessionsConfig { detailPanelHeights?: Record; /** Default session filter: "all" (default), "active" (any agent), "running" (running agents only) */ sessionFilter?: SessionFilterMode; + /** macOS only: automatically follow the system Appearance setting and switch themes */ + autoThemeFollowsSystem?: boolean; + /** Theme to use when the macOS system Appearance is Dark (default: "catppuccin-mocha") */ + darkTheme?: string; + /** Theme to use when the macOS system Appearance is Light (default: "catppuccin-latte") */ + lightTheme?: string; } const DEFAULTS: OpensessionsConfig = { diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 7718c8d..6274851 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -24,6 +24,7 @@ import { } from "./sidebar-coordinator"; import { loadConfig, saveConfig } from "../config"; import type { SessionFilterMode } from "../config"; +import { readMacSystemAppearance, themeForSystemMode } from "../system-theme"; import { clampSidebarWidth, } from "./sidebar-width-sync"; @@ -304,6 +305,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa const config = loadConfig(); let currentTheme: string | undefined = typeof config.theme === "string" ? config.theme : undefined; let currentFilter: SessionFilterMode | undefined = config.sessionFilter; + let systemThemePollTimer: ReturnType | null = null; const initialSidebarWidth = clampSidebarWidth(config.sidebarWidth ?? 26); let sidebarPosition: "left" | "right" = config.sidebarPosition ?? "left"; const sidebarCoordinator = createSidebarCoordinator({ width: initialSidebarWidth }); @@ -2165,6 +2167,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa clearProgrammaticAdjustmentTimer(); if (portPollTimer) clearInterval(portPollTimer); if (paneScanTimer) clearInterval(paneScanTimer); + if (systemThemePollTimer) clearInterval(systemThemePollTimer); for (const timer of pendingHighlightResets.values()) clearTimeout(timer); pendingHighlightResets.clear(); for (const watcher of gitHeadWatchers.values()) watcher.close(); @@ -2606,6 +2609,30 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa startIdleTimerIfNeeded("server booted without clients"); + // --- macOS system-appearance follower ----------------------------------- + // When `autoThemeFollowsSystem` is set, poll the macOS Appearance setting + // every few seconds and flip between the configured dark/light themes. + // macOS does not expose a CLI change-notification; polling is cheap. + if (config.autoThemeFollowsSystem && process.platform === "darwin") { + const darkTheme = config.darkTheme ?? "catppuccin-mocha"; + const lightTheme = config.lightTheme ?? "catppuccin-latte"; + + async function syncSystemTheme() { + const mode = await readMacSystemAppearance(); + const desired = themeForSystemMode(mode, darkTheme, lightTheme); + if (desired === currentTheme) return; + log("system-theme", "switching", { mode, from: currentTheme, to: desired }); + currentTheme = desired; + saveConfig({ theme: desired }); + broadcastState(); + } + + void syncSystemTheme(); + systemThemePollTimer = setInterval(() => { void syncSystemTheme(); }, 3000); + log("system-theme", "poller started", { darkTheme, lightTheme }); + } + // ------------------------------------------------------------------------ + process.on("SIGINT", () => { cleanup(); process.exit(0); }); process.on("SIGTERM", () => { cleanup(); process.exit(0); }); diff --git a/packages/runtime/src/system-theme.ts b/packages/runtime/src/system-theme.ts new file mode 100644 index 0000000..af3754c --- /dev/null +++ b/packages/runtime/src/system-theme.ts @@ -0,0 +1,50 @@ +/** + * macOS system-appearance helpers. + * + * On macOS, the global "Appearance" preference (System Settings → Appearance) + * flips between Light and Dark. We expose two helpers: + * - `readMacSystemAppearance()` reads the current setting via `defaults`. + * - `themeForSystemMode()` maps a mode + configured theme names to the + * theme the server should apply. + * + * The pair is enough for a simple polling loop in the server. macOS does not + * expose a CLI change-notification, so polling every few seconds is the + * pragmatic approach; the calls are cheap (one `defaults` subprocess). + */ + +export type SystemAppearanceMode = "dark" | "light"; + +/** + * Read the current macOS Appearance setting. + * + * `defaults read -g AppleInterfaceStyle` returns "Dark" when Dark mode is + * active and exits non-zero with an empty stdout when Light is active + * (the key is simply absent). We map both absent/unreadable cases to "light". + * + * Safe to call on non-macOS platforms — returns "light" and does not throw. + */ +export async function readMacSystemAppearance(): Promise { + if (process.platform !== "darwin") return "light"; + try { + const proc = Bun.spawn(["defaults", "read", "-g", "AppleInterfaceStyle"], { + stdout: "pipe", + stderr: "pipe", + }); + const out = (await new Response(proc.stdout).text()).trim(); + return out === "Dark" ? "dark" : "light"; + } catch { + return "light"; + } +} + +/** + * Map a detected system appearance to the theme name the server should set. + * Pure — trivially testable. + */ +export function themeForSystemMode( + mode: SystemAppearanceMode, + darkTheme: string, + lightTheme: string, +): string { + return mode === "dark" ? darkTheme : lightTheme; +} diff --git a/packages/runtime/test/config.test.ts b/packages/runtime/test/config.test.ts index a09d1f8..65d0c0b 100644 --- a/packages/runtime/test/config.test.ts +++ b/packages/runtime/test/config.test.ts @@ -98,6 +98,34 @@ describe("Config", () => { const { rmSync } = require("fs"); rmSync(tmpDir, { recursive: true, force: true }); }); + + test("loadConfig round-trips auto-theme fields", async () => { + const tmpDir = `/tmp/opensessions-test-${Date.now()}`; + const configDir = join(tmpDir, ".config", "opensessions"); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + autoThemeFollowsSystem: true, + darkTheme: "tokyo-night", + lightTheme: "catppuccin-latte", + }), + ); + + const config = loadConfig(tmpDir); + expect(config.autoThemeFollowsSystem).toBe(true); + expect(config.darkTheme).toBe("tokyo-night"); + expect(config.lightTheme).toBe("catppuccin-latte"); + + const { rmSync } = require("fs"); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("loadConfig leaves auto-theme fields unset when absent", () => { + const config = loadConfig("/tmp/nonexistent-dir-" + Date.now()); + expect(config.autoThemeFollowsSystem).toBeUndefined(); + expect(config.darkTheme).toBeUndefined(); + expect(config.lightTheme).toBeUndefined(); + }); }); describe("Themes", () => { diff --git a/packages/runtime/test/system-theme.test.ts b/packages/runtime/test/system-theme.test.ts new file mode 100644 index 0000000..64060d4 --- /dev/null +++ b/packages/runtime/test/system-theme.test.ts @@ -0,0 +1,39 @@ +import { describe, test, expect } from "bun:test"; + +import { readMacSystemAppearance, themeForSystemMode } from "../src/system-theme"; + +describe("themeForSystemMode", () => { + test("dark mode → dark theme", () => { + expect(themeForSystemMode("dark", "catppuccin-mocha", "catppuccin-latte")) + .toBe("catppuccin-mocha"); + }); + + test("light mode → light theme", () => { + expect(themeForSystemMode("light", "catppuccin-mocha", "catppuccin-latte")) + .toBe("catppuccin-latte"); + }); + + test("respects custom theme names", () => { + expect(themeForSystemMode("dark", "tokyo-night", "github-light")).toBe("tokyo-night"); + expect(themeForSystemMode("light", "tokyo-night", "github-light")).toBe("github-light"); + }); +}); + +describe("readMacSystemAppearance", () => { + test("returns 'light' on non-darwin without throwing", async () => { + // The helper short-circuits on non-darwin platforms, so this is a + // portable sanity check. On darwin it will read the real setting. + const result = await readMacSystemAppearance(); + expect(["dark", "light"]).toContain(result); + }); + + test("on non-darwin platforms returns 'light' deterministically", async () => { + const original = process.platform; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + try { + expect(await readMacSystemAppearance()).toBe("light"); + } finally { + Object.defineProperty(process, "platform", { value: original, configurable: true }); + } + }); +}); From 723ff0163a3f3b1870fc51e4bfc60cefbc6d3f33 Mon Sep 17 00:00:00 2001 From: panosAthDbx Date: Tue, 28 Apr 2026 12:11:28 +0200 Subject: [PATCH 2/2] fix(themes): persist manual override per system appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `autoThemeFollowsSystem` is on and the user manually picks a theme via `set-theme`, route the persistence to `darkTheme` or `lightTheme` based on the current macOS appearance instead of `theme`. Previously the choice was written to `theme`, which the poll loop ignored, so the manual override was silently overwritten on the next 3s tick. Also re-load `darkTheme` / `lightTheme` from disk inside the poll cycle so an override made via `set-theme` is picked up immediately on the next poll. Drops the `saveConfig({ theme: desired })` write inside the poll loop — that was clobbering the user's static-mode `theme` field whenever auto-follow ran. Co-authored-by: Isaac --- packages/runtime/src/server/index.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/server/index.ts b/packages/runtime/src/server/index.ts index 6274851..d15cfdc 100644 --- a/packages/runtime/src/server/index.ts +++ b/packages/runtime/src/server/index.ts @@ -306,6 +306,11 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa let currentTheme: string | undefined = typeof config.theme === "string" ? config.theme : undefined; let currentFilter: SessionFilterMode | undefined = config.sessionFilter; let systemThemePollTimer: ReturnType | null = null; + // Tracks the most recently observed macOS appearance while auto-follow is active. + // Used by the `set-theme` handler so a manual override is persisted to the + // appearance-specific slot, not to `theme` (which would be clobbered next poll). + let autoThemeFollowing = false; + let currentSystemMode: "dark" | "light" | undefined; const initialSidebarWidth = clampSidebarWidth(config.sidebarWidth ?? 26); let sidebarPosition: "left" | "right" = config.sidebarPosition ?? "left"; const sidebarCoordinator = createSidebarCoordinator({ width: initialSidebarWidth }); @@ -2061,7 +2066,16 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa break; case "set-theme": currentTheme = cmd.theme; - saveConfig({ theme: cmd.theme }); + if (autoThemeFollowing) { + // When auto-follow is active, persist the manual choice to the + // appearance-specific slot so the next poll cycle does not silently + // overwrite it. Falls back to `theme` if mode hasn't been read yet. + if (currentSystemMode === "dark") saveConfig({ darkTheme: cmd.theme }); + else if (currentSystemMode === "light") saveConfig({ lightTheme: cmd.theme }); + else saveConfig({ theme: cmd.theme }); + } else { + saveConfig({ theme: cmd.theme }); + } broadcastState(); break; case "set-filter": @@ -2614,16 +2628,23 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa // every few seconds and flip between the configured dark/light themes. // macOS does not expose a CLI change-notification; polling is cheap. if (config.autoThemeFollowsSystem && process.platform === "darwin") { + autoThemeFollowing = true; const darkTheme = config.darkTheme ?? "catppuccin-mocha"; const lightTheme = config.lightTheme ?? "catppuccin-latte"; async function syncSystemTheme() { const mode = await readMacSystemAppearance(); - const desired = themeForSystemMode(mode, darkTheme, lightTheme); + currentSystemMode = mode; + // Re-read the per-mode theme each cycle so a manual override via the + // `set-theme` handler (which writes to `darkTheme` / `lightTheme`) is + // picked up on the next poll instead of being silently overwritten. + const fresh = loadConfig(); + const dark = fresh.darkTheme ?? darkTheme; + const light = fresh.lightTheme ?? lightTheme; + const desired = themeForSystemMode(mode, dark, light); if (desired === currentTheme) return; log("system-theme", "switching", { mode, from: currentTheme, to: desired }); currentTheme = desired; - saveConfig({ theme: desired }); broadcastState(); }