Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 });
}
});
});