Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/runtime/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export interface OpensessionsConfig {
detailPanelHeights?: Record<string, number>;
/** 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 = {
Expand Down
65 changes: 64 additions & 1 deletion packages/runtime/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import {
} from "./sidebar-coordinator";
import { loadConfig, saveConfig } from "../config";
import type { SessionFilterMode } from "../config";
import {
readMacSystemAppearance,
themeForSystemMode,
watchMacSystemAppearance,
type SystemAppearanceWatcher,
} from "../system-theme";
import {
clampSidebarWidth,
} from "./sidebar-width-sync";
Expand Down Expand Up @@ -304,6 +310,12 @@ 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 systemThemeWatcher: SystemAppearanceWatcher | 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 });
Expand Down Expand Up @@ -727,6 +739,13 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa
}

let broadcastPending = false;
// Hash of the last bytes published to "sidebar". Many call sites trigger
// broadcastState() but most do not actually change observable state (e.g.
// theme polls, focus moves that resolve to the same session, agent
// updates that produce identical metadata). Hashing the serialized
// payload and skipping the publish when unchanged kills redundant fan-out
// to all WS clients without changing the wire protocol.
let lastBroadcastHash: bigint | null = null;

function broadcastState() {
if (broadcastPending) return;
Expand All @@ -744,6 +763,9 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa
lastState = computeState();
syncGitWatchers(lastState.sessions, broadcastState);
const msg = JSON.stringify(lastState);
const hash = Bun.hash(msg);
if (hash === lastBroadcastHash) return;
lastBroadcastHash = hash;
server.publish("sidebar", msg);
}

Expand Down Expand Up @@ -2059,7 +2081,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":
Expand Down Expand Up @@ -2165,6 +2196,7 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa
clearProgrammaticAdjustmentTimer();
if (portPollTimer) clearInterval(portPollTimer);
if (paneScanTimer) clearInterval(paneScanTimer);
if (systemThemeWatcher) systemThemeWatcher.stop();
for (const timer of pendingHighlightResets.values()) clearTimeout(timer);
pendingHighlightResets.clear();
for (const watcher of gitHeadWatchers.values()) watcher.close();
Expand Down Expand Up @@ -2606,6 +2638,37 @@ export function startServer(mux: MuxProvider, extraProviders?: MuxProvider[], wa

startIdleTimerIfNeeded("server booted without clients");

// --- macOS system-appearance follower -----------------------------------
// When `autoThemeFollowsSystem` is set, watch the macOS Appearance plist
// and flip between the configured dark/light themes on change. Push-based
// (kqueue) — replaces the previous 3-second polling loop that spawned a
// `defaults` subprocess on every tick.
if (config.autoThemeFollowsSystem && process.platform === "darwin") {
autoThemeFollowing = true;
const darkTheme = config.darkTheme ?? "catppuccin-mocha";
const lightTheme = config.lightTheme ?? "catppuccin-latte";

async function syncSystemTheme(mode?: "dark" | "light") {
const observed = mode ?? (await readMacSystemAppearance());
currentSystemMode = observed;
// Re-read the per-mode theme each cycle so a manual override via the
// `set-theme` handler (which writes to `darkTheme` / `lightTheme`) is
// honoured on the next event instead of being silently overwritten.
const fresh = loadConfig();
const dark = fresh.darkTheme ?? darkTheme;
const light = fresh.lightTheme ?? lightTheme;
const desired = themeForSystemMode(observed, dark, light);
if (desired === currentTheme) return;
log("system-theme", "switching", { mode: observed, from: currentTheme, to: desired });
currentTheme = desired;
broadcastState();
}

systemThemeWatcher = watchMacSystemAppearance((mode) => { void syncSystemTheme(mode); });
log("system-theme", "watcher started", { darkTheme, lightTheme });
}
// ------------------------------------------------------------------------

process.on("SIGINT", () => { cleanup(); process.exit(0); });
process.on("SIGTERM", () => { cleanup(); process.exit(0); });

Expand Down
115 changes: 115 additions & 0 deletions packages/runtime/src/system-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* macOS system-appearance helpers.
*
* On macOS, the global "Appearance" preference (System Settings → Appearance)
* flips between Light and Dark. We expose three helpers:
* - `readMacSystemAppearance()` reads the current setting via `defaults`.
* - `themeForSystemMode()` maps a mode + configured theme names to the
* theme the server should apply.
* - `watchMacSystemAppearance()` invokes a callback on every detected
* appearance change. Push-based via kqueue file watch on the underlying
* plist; falls back to a slow safety poll for atomic-rename cases.
*/

import { watch } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

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<SystemAppearanceMode> {
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;
}

export interface SystemAppearanceWatcher {
stop(): void;
}

/**
* Watch the macOS Appearance setting and fire `onChange` when it flips.
*
* macOS rewrites `~/Library/Preferences/.GlobalPreferences.plist` whenever
* any global preference (including AppleInterfaceStyle) changes. We watch
* that file with kqueue (zero-overhead push) and re-read appearance on
* every event. Most events are unrelated to appearance (e.g. other prefs
* being written) so we suppress the callback unless the *value* actually
* changed.
*
* A 60s safety poll covers the rare case where the plist is replaced via
* atomic rename — kqueue loses the inode and the watcher goes silent.
*
* On non-darwin platforms returns a no-op watcher.
*/
export function watchMacSystemAppearance(
onChange: (mode: SystemAppearanceMode) => void | Promise<void>,
opts?: { safetyPollMs?: number },
): SystemAppearanceWatcher {
if (process.platform !== "darwin") {
return { stop() {} };
}

const plistPath = join(homedir(), "Library", "Preferences", ".GlobalPreferences.plist");
let lastMode: SystemAppearanceMode | null = null;
let stopped = false;

async function check() {
if (stopped) return;
const mode = await readMacSystemAppearance();
if (mode !== lastMode) {
lastMode = mode;
await onChange(mode);
}
}

let watcher: ReturnType<typeof watch> | null = null;
try {
watcher = watch(plistPath, () => { void check(); });
} catch {
// fall through — safety poll alone keeps us correct
}

const safetyMs = opts?.safetyPollMs ?? 60_000;
const safetyTimer = setInterval(() => { void check(); }, safetyMs);

// Initial read so the consumer learns the starting mode without waiting.
void check();

return {
stop() {
stopped = true;
try { watcher?.close(); } catch {}
clearInterval(safetyTimer);
},
};
}
28 changes: 28 additions & 0 deletions packages/runtime/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
81 changes: 81 additions & 0 deletions packages/runtime/test/system-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, test, expect } from "bun:test";

import {
readMacSystemAppearance,
themeForSystemMode,
watchMacSystemAppearance,
} 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 });
}
});
});

describe("watchMacSystemAppearance", () => {
test("returns a no-op watcher on non-darwin", () => {
const original = process.platform;
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
try {
let calls = 0;
const w = watchMacSystemAppearance(() => { calls++; });
expect(typeof w.stop).toBe("function");
w.stop();
expect(calls).toBe(0);
} finally {
Object.defineProperty(process, "platform", { value: original, configurable: true });
}
});

test("stop() is idempotent on non-darwin", () => {
const original = process.platform;
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
try {
const w = watchMacSystemAppearance(() => {});
w.stop();
w.stop();
} finally {
Object.defineProperty(process, "platform", { value: original, configurable: true });
}
});

test("on darwin, fires callback with the initial mode", async () => {
if (process.platform !== "darwin") return;
let received: "dark" | "light" | null = null;
const w = watchMacSystemAppearance((mode) => { received = mode; }, { safetyPollMs: 60_000 });
// Initial check is queued via void check() — give it a tick to land.
await new Promise((r) => setTimeout(r, 100));
w.stop();
expect(received === "dark" || received === "light").toBe(true);
});
});