diff --git a/.gitignore b/.gitignore
index 181db3023..d8d9a579a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,6 @@ __pycache__/
target/
+.worktrees/
+
*.node
diff --git a/biome.json b/biome.json
index 3d40924a7..45f8ae2c0 100644
--- a/biome.json
+++ b/biome.json
@@ -44,7 +44,8 @@
"!**/node_modules/**/*",
"!**/test-sessions.ts",
"!**/template.generated.ts",
- "!**/gen/agent_pb.ts"
+ "!**/gen/agent_pb.ts",
+ "!.worktrees/**/*"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } }
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 01d4bba4e..94c5f1e27 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -4,6 +4,11 @@
### Added
- Added `abort_and_prompt` RPC command for atomic abort-and-reprompt without race conditions ([#357](https://github.com/can1357/oh-my-pi/pull/357))
+- Added automatic dark/light theme switching via SIGWINCH with separate `theme.dark`/`theme.light` settings, replacing the single `theme` setting ([#65](https://github.com/can1357/oh-my-pi/issues/65))
+
+### Breaking Changes
+
+- Replaced `theme` setting with `theme.dark` and `theme.light` (auto-migrated)
### Fixed
diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts
index 06ef10be0..3cae610e7 100644
--- a/packages/coding-agent/src/config/settings-schema.ts
+++ b/packages/coding-agent/src/config/settings-schema.ts
@@ -7,7 +7,7 @@
*
* The Settings singleton provides type-safe path-based access:
* settings.get("compaction.enabled") // => boolean
- * settings.set("theme", "dark") // sync, saves in background
+ * settings.set("theme.dark", "titanium") // sync, saves in background
*/
// ═══════════════════════════════════════════════════════════════════════════
@@ -141,10 +141,25 @@ export const SETTINGS_SCHEMA = {
// Top-level settings
// ─────────────────────────────────────────────────────────────────────────
lastChangelogVersion: { type: "string", default: undefined },
- theme: {
+ "theme.dark": {
type: "string",
- default: undefined,
- ui: { tab: "display", label: "Theme", description: "Color theme for the interface", submenu: true },
+ default: "titanium",
+ ui: {
+ tab: "display",
+ label: "Dark theme",
+ description: "Theme used when terminal has dark background",
+ submenu: true,
+ },
+ },
+ "theme.light": {
+ type: "string",
+ default: "light",
+ ui: {
+ tab: "display",
+ label: "Light theme",
+ description: "Theme used when terminal has light background",
+ submenu: true,
+ },
},
symbolPreset: {
type: "enum",
diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts
index df7ac731b..88112504a 100644
--- a/packages/coding-agent/src/config/settings.ts
+++ b/packages/coding-agent/src/config/settings.ts
@@ -5,7 +5,7 @@
* import { settings } from "./settings";
*
* const enabled = settings.get("compaction.enabled"); // sync read
- * settings.set("theme", "dark"); // sync write, saves in background
+ * settings.set("theme.dark", "titanium"); // sync write, saves in background
*
* For tests:
* const isolated = Settings.isolated({ "compaction.enabled": false });
@@ -19,7 +19,7 @@ import { YAML } from "bun";
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
import type { ModelRole } from "../config/model-registry";
import { loadCapability } from "../discovery";
-import { setColorBlindMode, setSymbolPreset, setTheme } from "../modes/theme/theme";
+import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
import { type EditMode, normalizeEditMode } from "../patch";
import { AgentStorage } from "../session/agent-storage";
import { withFileLock } from "./file-lock";
@@ -81,7 +81,7 @@ export interface SettingsOptions {
/**
* Parse a dotted path into segments.
* "compaction.enabled" → ["compaction", "enabled"]
- * "theme" → ["theme"]
+ * "theme.dark" → ["theme", "dark"]
*/
function parsePath(path: string): string[] {
return path.split(".");
@@ -531,6 +531,19 @@ export class Settings {
}
}
+ // Migrate old flat "theme" string to nested theme.dark/theme.light
+ if (typeof raw.theme === "string") {
+ const oldTheme = raw.theme;
+ if (oldTheme === "light" || oldTheme === "dark") {
+ // Built-in defaults — just remove, let new defaults apply
+ delete raw.theme;
+ } else {
+ // Custom theme — detect luminance to place in correct slot
+ const slot = isLightTheme(oldTheme) ? "light" : "dark";
+ raw.theme = { [slot]: oldTheme };
+ }
+ }
+
return raw;
}
@@ -628,13 +641,14 @@ export class Settings {
type SettingHook
= (value: SettingValue
, prev: SettingValue
) => void;
const SETTING_HOOKS: Partial>> = {
- theme: value => {
- // Theme loading is async, but we call it synchronously here.
- // The hook fires immediately, and the theme system handles async loading internally.
+ "theme.dark": value => {
if (typeof value === "string") {
- setTheme(value, false).catch(err => {
- logger.warn("Settings: theme hook failed", { theme: value, error: String(err) });
- });
+ setAutoThemeMapping("dark", value);
+ }
+ },
+ "theme.light": value => {
+ if (typeof value === "string") {
+ setAutoThemeMapping("light", value);
}
},
symbolPreset: value => {
diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts
index 1b26f0844..0b46dec54 100644
--- a/packages/coding-agent/src/main.ts
+++ b/packages/coding-agent/src/main.ts
@@ -560,7 +560,13 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise o.value === level);
return baseOpt || { value: level, label: level };
});
- } else if (def.path === "theme") {
+ } else if (def.path === "theme.dark" || def.path === "theme.light") {
options = this.context.availableThemes.map(t => ({ value: t, label: t }));
}
@@ -307,9 +307,13 @@ export class SettingsSelectorComponent extends Container {
let onPreview: ((value: string) => void) | undefined;
let onPreviewCancel: (() => void) | undefined;
- if (def.path === "theme") {
- onPreview = this.callbacks.onThemePreview;
- onPreviewCancel = () => this.callbacks.onThemePreview?.(currentValue);
+ if (def.path === "theme.dark" || def.path === "theme.light") {
+ onPreview = value => {
+ this.callbacks.onThemePreview?.(value);
+ };
+ onPreviewCancel = () => {
+ this.callbacks.onThemePreview?.(currentValue);
+ };
} else if (def.path === "statusLine.preset") {
onPreview = value => {
const presetDef = getPreset(
@@ -347,7 +351,8 @@ export class SettingsSelectorComponent extends Container {
}
// Provide status line preview for theme selection
- const getPreview = def.path === "theme" ? this.callbacks.getStatusLinePreview : undefined;
+ const isThemeSetting = def.path === "theme.dark" || def.path === "theme.light";
+ const getPreview = isThemeSetting ? this.callbacks.getStatusLinePreview : undefined;
return new SelectSubmenu(
def.label,
@@ -355,9 +360,7 @@ export class SettingsSelectorComponent extends Container {
options,
currentValue,
value => {
- // Persist
this.#setSettingValue(def.path, value);
- // Notify
this.callbacks.onChange(def.path, value);
done(value);
},
diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts
index cd9875fa6..9b5b97d09 100644
--- a/packages/coding-agent/src/modes/theme/theme.ts
+++ b/packages/coding-agent/src/modes/theme/theme.ts
@@ -1624,7 +1624,8 @@ function detectTerminalBackground(): "dark" | "light" {
}
function getDefaultTheme(): string {
- return detectTerminalBackground();
+ const bg = detectTerminalBackground();
+ return bg === "light" ? autoLightTheme : autoDarkTheme;
}
// ============================================================================
@@ -1633,9 +1634,18 @@ function getDefaultTheme(): string {
export var theme: Theme;
var currentThemeName: string | undefined;
+
+/** Get the name of the currently active theme. */
+export function getCurrentThemeName(): string | undefined {
+ return currentThemeName;
+}
var currentSymbolPresetOverride: SymbolPreset | undefined;
var currentColorBlindMode: boolean = false;
var themeWatcher: fs.FSWatcher | undefined;
+var sigwinchHandler: (() => void) | undefined;
+var autoDetectedTheme: boolean = false;
+var autoDarkTheme: string = "dark";
+var autoLightTheme: string = "light";
var onThemeChangeCallback: (() => void) | undefined;
function getCurrentThemeOptions(): CreateThemeOptions {
@@ -1646,12 +1656,16 @@ function getCurrentThemeOptions(): CreateThemeOptions {
}
export async function initTheme(
- themeName?: string,
enableWatcher: boolean = false,
symbolPreset?: SymbolPreset,
colorBlindMode?: boolean,
+ darkTheme?: string,
+ lightTheme?: string,
): Promise {
- const name = themeName ?? getDefaultTheme();
+ autoDetectedTheme = true;
+ autoDarkTheme = darkTheme ?? "dark";
+ autoLightTheme = lightTheme ?? "light";
+ const name = getDefaultTheme();
currentThemeName = name;
currentSymbolPresetOverride = symbolPreset;
currentColorBlindMode = colorBlindMode ?? false;
@@ -1659,6 +1673,7 @@ export async function initTheme(
theme = await loadTheme(name, getCurrentThemeOptions());
if (enableWatcher) {
await startThemeWatcher();
+ startSigwinchListener();
}
} catch (err) {
logger.debug("Theme loading failed, falling back to dark theme", { error: String(err) });
@@ -1672,6 +1687,7 @@ export async function setTheme(
name: string,
enableWatcher: boolean = false,
): Promise<{ success: boolean; error?: string }> {
+ autoDetectedTheme = false;
currentThemeName = name;
try {
theme = await loadTheme(name, getCurrentThemeOptions());
@@ -1694,7 +1710,51 @@ export async function setTheme(
}
}
+/**
+ * Enable auto-detection mode, switching to the appropriate dark/light theme.
+ */
+export function enableAutoTheme(): void {
+ autoDetectedTheme = true;
+ const resolved = getDefaultTheme();
+ if (resolved === currentThemeName) return;
+ currentThemeName = resolved;
+ loadTheme(resolved, getCurrentThemeOptions())
+ .then(loadedTheme => {
+ theme = loadedTheme;
+ if (onThemeChangeCallback) {
+ onThemeChangeCallback();
+ }
+ })
+ .catch(err => {
+ logger.debug("Auto theme switch failed", { error: String(err) });
+ });
+}
+
+/**
+ * Update the theme mappings for auto-detection mode.
+ * When a dark/light mapping changes and auto-detection is active, re-evaluate the theme.
+ */
+export function setAutoThemeMapping(mode: "dark" | "light", themeName: string): void {
+ if (mode === "dark") autoDarkTheme = themeName;
+ else autoLightTheme = themeName;
+ if (!autoDetectedTheme) return;
+ const resolved = getDefaultTheme();
+ if (resolved === currentThemeName) return;
+ currentThemeName = resolved;
+ loadTheme(resolved, getCurrentThemeOptions())
+ .then(loadedTheme => {
+ theme = loadedTheme;
+ if (onThemeChangeCallback) {
+ onThemeChangeCallback();
+ }
+ })
+ .catch(err => {
+ logger.debug("Auto theme mapping switch failed", { error: String(err) });
+ });
+}
+
export function setThemeInstance(themeInstance: Theme): void {
+ autoDetectedTheme = false;
theme = themeInstance;
currentThemeName = "";
stopThemeWatcher();
@@ -1836,11 +1896,41 @@ async function startThemeWatcher(): Promise {
}
}
+/** Re-check COLORFGBG on SIGWINCH and switch dark/light when using auto-detected theme. */
+function startSigwinchListener(): void {
+ stopSigwinchListener();
+ sigwinchHandler = () => {
+ if (!autoDetectedTheme) return;
+ const resolved = getDefaultTheme();
+ if (resolved === currentThemeName) return;
+ currentThemeName = resolved;
+ loadTheme(resolved, getCurrentThemeOptions())
+ .then(loadedTheme => {
+ theme = loadedTheme;
+ if (onThemeChangeCallback) {
+ onThemeChangeCallback();
+ }
+ })
+ .catch(err => {
+ logger.debug("Theme switch on SIGWINCH failed", { error: String(err) });
+ });
+ };
+ process.on("SIGWINCH", sigwinchHandler);
+}
+
+function stopSigwinchListener(): void {
+ if (sigwinchHandler) {
+ process.removeListener("SIGWINCH", sigwinchHandler);
+ sigwinchHandler = undefined;
+ }
+}
+
export function stopThemeWatcher(): void {
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
+ stopSigwinchListener();
}
// ============================================================================
@@ -1921,11 +2011,36 @@ export async function getResolvedThemeColors(themeName?: string): Promise 0.5;
+ } catch {
+ return false;
+ }
}
/**
diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts
index 8c92a8944..28a92c5f3 100644
--- a/packages/coding-agent/src/session/agent-session.ts
+++ b/packages/coding-agent/src/session/agent-session.ts
@@ -66,7 +66,7 @@ import type { Skill, SkillWarning } from "../extensibility/skills";
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
import { resolvePlanUrlToPath } from "../internal-urls";
import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
-import { theme } from "../modes/theme/theme";
+import { getCurrentThemeName, theme } from "../modes/theme/theme";
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
import type { PlanModeState } from "../plan-mode/state";
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
@@ -4040,7 +4040,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
* @returns Path to exported file
*/
async exportToHtml(outputPath?: string): Promise {
- const themeName = this.settings.get("theme");
+ const themeName = getCurrentThemeName();
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
}
diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts
index 5ade12cfc..7de45651b 100644
--- a/packages/coding-agent/test/interactive-mode-status.test.ts
+++ b/packages/coding-agent/test/interactive-mode-status.test.ts
@@ -12,7 +12,7 @@ function renderLastLine(container: Container, width = 120): string {
describe("InteractiveMode.showStatus", () => {
beforeAll(() => {
// showStatus uses the global theme instance
- initTheme("dark");
+ initTheme();
});
test("coalesces immediately-sequential status messages", () => {
diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts
index c41c1e79d..b6c52721d 100644
--- a/packages/coding-agent/test/settings-manager.test.ts
+++ b/packages/coding-agent/test/settings-manager.test.ts
@@ -64,7 +64,7 @@ describe("Settings", () => {
// Simulate external edit (e.g., user modifying DB directly or another process)
await writeSettings({
- theme: "dark",
+ theme: { dark: "anthracite" },
modelRoles: { default: "claude-sonnet" },
enabledModels: ["claude-opus-4-5", "gpt-5.2-codex"],
});
@@ -76,7 +76,7 @@ describe("Settings", () => {
const savedSettings = await readSettings();
expect(savedSettings.enabledModels).toEqual(["claude-opus-4-5", "gpt-5.2-codex"]);
expect(savedSettings.defaultThinkingLevel).toBe("high");
- expect(savedSettings.theme).toBe("dark");
+ expect(savedSettings.theme).toEqual({ dark: "anthracite" });
expect((savedSettings.modelRoles as { default?: string } | undefined)?.default).toBe("claude-sonnet");
});
@@ -93,24 +93,24 @@ describe("Settings", () => {
extensions: ["/path/to/extension.ts"],
});
- settings.set("theme", "light");
+ settings.set("theme.dark", "anthracite");
await settings.flush();
const savedSettings = await readSettings();
expect(savedSettings.shellPath).toBe("/bin/zsh");
expect(savedSettings.extensions).toEqual(["/path/to/extension.ts"]);
- expect(savedSettings.theme).toBe("light");
+ expect(savedSettings.theme).toEqual({ dark: "anthracite" });
});
it("should let in-memory changes override file changes for same key", async () => {
await writeSettings({
- theme: "dark",
+ theme: { dark: "anthracite" },
});
const settings = await Settings.init({ cwd: projectDir, agentDir });
await writeSettings({
- theme: "dark",
+ theme: { dark: "anthracite" },
defaultThinkingLevel: "low",
});
diff --git a/packages/coding-agent/test/streaming-render-debug.ts b/packages/coding-agent/test/streaming-render-debug.ts
index 0d0772e03..04713cfa7 100644
--- a/packages/coding-agent/test/streaming-render-debug.ts
+++ b/packages/coding-agent/test/streaming-render-debug.ts
@@ -12,7 +12,7 @@ import { sleep } from "bun";
// Initialize dark theme with full color support
Bun.env.COLORTERM = "truecolor";
-initTheme("dark");
+initTheme();
async function main() {
// Load the real fixture that caused the bug
diff --git a/packages/coding-agent/test/test-theme-colors.ts b/packages/coding-agent/test/test-theme-colors.ts
index f8eaeffc0..a5587ab01 100644
--- a/packages/coding-agent/test/test-theme-colors.ts
+++ b/packages/coding-agent/test/test-theme-colors.ts
@@ -2,7 +2,7 @@ import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
// Initialize with dark theme explicitly
Bun.env.COLORTERM = "truecolor";
-initTheme("dark");
+initTheme();
console.log("\n=== Foreground Colors ===\n");