Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
50 changes: 49 additions & 1 deletion packages/runtime/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -304,6 +305,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 systemThemePollTimer: ReturnType<typeof setInterval> | 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 @@ -2059,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":
Expand Down Expand Up @@ -2165,6 +2181,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();
Expand Down Expand Up @@ -2606,6 +2623,37 @@ 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") {
autoThemeFollowing = true;
const darkTheme = config.darkTheme ?? "catppuccin-mocha";
const lightTheme = config.lightTheme ?? "catppuccin-latte";

async function syncSystemTheme() {
const mode = await readMacSystemAppearance();
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;
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); });

Expand Down
50 changes: 50 additions & 0 deletions packages/runtime/src/system-theme.ts
Original file line number Diff line number Diff line change
@@ -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<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;
}
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
39 changes: 39 additions & 0 deletions packages/runtime/test/system-theme.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});