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");