diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0d5aefe7bc3b..1655cece21f6 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -194,7 +194,7 @@ function App() { const command = useCommandDialog() const sdk = useSDK() const toast = useToast() - const { theme, mode, setMode } = useTheme() + const { theme } = useTheme() const sync = useSync() const exit = useExit() const promptRef = usePromptRef() @@ -489,15 +489,6 @@ function App() { }, category: "System", }, - { - title: "Toggle appearance", - value: "theme.switch_mode", - onSelect: (dialog) => { - setMode(mode() === "dark" ? "light" : "dark") - dialog.clear() - }, - category: "System", - }, { title: "Help", value: "help.show", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx index f4072c978582..d35816952b21 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx @@ -1,7 +1,10 @@ import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" -import { onCleanup, onMount } from "solid-js" +import { createSignal, For, onCleanup } from "solid-js" +import { TextAttributes } from "@opentui/core" + +const MODES = ["auto", "dark", "light"] as const export function DialogThemeList() { const theme = useTheme() @@ -15,11 +18,23 @@ export function DialogThemeList() { let confirmed = false let ref: DialogSelectRef const initial = theme.selected + const initialMode = theme.mode() + const [mode, setMode] = createSignal(initialMode) onCleanup(() => { - if (!confirmed) theme.set(initial) + if (!confirmed) { + theme.set(initial) + theme.setMode(initialMode) + } }) + function cycleMode(direction: 1 | -1) { + const idx = MODES.indexOf(mode()) + const next = MODES[(idx + direction + MODES.length) % MODES.length]! + setMode(next) + theme.setMode(next) + } + return ( { + if (evt.name === "left" || evt.name === "right") { + cycleMode(evt.name === "right" ? 1 : -1) + return true + } + }} + header={ + + + + Appearance + + {"←/→"} + + + + {(m) => ( + { + setMode(m) + theme.setMode(m) + }} + > + + {mode() === m ? "●" : "○"} + + {m} + + )} + + + + } /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 41c5a4a831c0..c7ba4297cc55 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,6 +1,6 @@ import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import path from "path" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { useSync } from "@tui/context/sync" import { createSimpleContext } from "./helper" import aura from "./theme/aura.json" with { type: "json" } @@ -42,6 +42,8 @@ import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" +type ThemeConfig = string | { light?: string; dark?: string } + type ThemeColors = { primary: RGBA secondary: RGBA @@ -173,6 +175,21 @@ export const DEFAULT_THEMES: Record = { carbonfox, } +function hasVariants(theme: ThemeJson) { + return Object.values(theme.theme).some( + (v) => typeof v === "object" && v !== null && !(v instanceof RGBA) && "dark" in v && "light" in v, + ) +} + +function parseThemeConfig(config: ThemeConfig | undefined, fallback: string) { + if (!config) return { light: fallback, dark: fallback } + if (typeof config === "string") return { light: config, dark: config } + return { + light: config.light ?? config.dark ?? fallback, + dark: config.dark ?? config.light ?? fallback, + } +} + function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const defs = theme.defs ?? {} function resolveColor(c: ColorValue): RGBA { @@ -281,18 +298,50 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ init: (props: { mode: "dark" | "light" }) => { const sync = useSync() const kv = useKV() + const renderer = useRenderer() + + const config = parseThemeConfig( + (sync.data.config.theme ?? kv.get("theme", "opencode")) as ThemeConfig, + "opencode", + ) + const [store, setStore] = createStore({ - themes: DEFAULT_THEMES, - mode: kv.get("theme_mode", props.mode), - active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string, + themes: DEFAULT_THEMES as Record, + mode: kv.get("theme_mode", "auto") as "auto" | "dark" | "light", + detectedMode: props.mode as "dark" | "light", + light: config.light, + dark: config.dark, ready: false, }) createEffect(() => { - const theme = sync.data.config.theme - if (theme) setStore("active", theme) + const theme = sync.data.config.theme as ThemeConfig | undefined + if (!theme) return + const parsed = parseThemeConfig(theme, "opencode") + setStore( + produce((draft) => { + draft.light = parsed.light + draft.dark = parsed.dark + }), + ) }) + function effectiveMode(): "dark" | "light" { + if (store.mode !== "auto") return store.mode + return store.detectedMode + } + + function activeThemeName() { + return effectiveMode() === "light" ? store.light : store.dark + } + + function activeThemeMode(): "dark" | "light" { + const mode = effectiveMode() + const json = store.themes[activeThemeName()] + if (json && hasVariants(json)) return mode + return "dark" + } + function init() { resolveSystemTheme() getCustomThemes() @@ -304,10 +353,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ ) }) .catch(() => { - setStore("active", "opencode") + setStore( + produce((draft) => { + draft.light = "opencode" + draft.dark = "opencode" + }), + ) }) .finally(() => { - if (store.active !== "system") { + if (activeThemeName() !== "system") { setStore("ready", true) } }) @@ -316,18 +370,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ onMount(init) function resolveSystemTheme() { - console.log("resolveSystemTheme") renderer .getPalette({ size: 16, }) .then((colors) => { - console.log(colors.palette) if (!colors.palette[0]) { - if (store.active === "system") { + if (activeThemeName() === "system") { setStore( produce((draft) => { - draft.active = "opencode" + draft.light = "opencode" + draft.dark = "opencode" draft.ready = true }), ) @@ -336,8 +389,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } setStore( produce((draft) => { - draft.themes.system = generateSystem(colors, store.mode) - if (store.active === "system") { + draft.themes.system = generateSystem(colors, effectiveMode()) + if (activeThemeName() === "system") { draft.ready = true } }), @@ -345,15 +398,30 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) } - const renderer = useRenderer() - process.on("SIGUSR2", async () => { + // React to OS appearance changes via Mode 2031 + const onThemeMode = (mode: "dark" | "light") => { + setStore("detectedMode", mode) + if (activeThemeName() === "system") { + renderer.clearPaletteCache() + resolveSystemTheme() + } + } + renderer.on("theme_mode", onThemeMode) + onCleanup(() => renderer.off("theme_mode", onThemeMode)) + + // Sync initial mode if terminal already responded to Mode 2031 query + if (renderer.themeMode) { + setStore("detectedMode", renderer.themeMode) + } + + process.on("SIGUSR2", () => { renderer.clearPaletteCache() init() }) - const values = createMemo(() => { - return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode) - }) + const values = createMemo(() => + resolveTheme(store.themes[activeThemeName()] ?? store.themes.opencode, activeThemeMode()), + ) const syntax = createMemo(() => generateSyntax(values())) const subtleSyntax = createMemo(() => generateSubtleSyntax(values())) @@ -366,7 +434,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }), get selected() { - return store.active + return activeThemeName() }, all() { return store.themes @@ -376,12 +444,20 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ mode() { return store.mode }, - setMode(mode: "dark" | "light") { + setMode(mode: "auto" | "dark" | "light") { setStore("mode", mode) kv.set("theme_mode", mode) + if (mode === "auto" && renderer.themeMode) { + setStore("detectedMode", renderer.themeMode) + } }, set(theme: string) { - setStore("active", theme) + setStore( + produce((draft) => { + draft.light = theme + draft.dark = theme + }), + ) kv.set("theme", theme) }, get ready() { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 151f73cf7c0a..a51b50ff80c1 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,4 +1,4 @@ -import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type KeyEvent } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" @@ -28,6 +28,8 @@ export interface DialogSelectProps { onTrigger: (option: DialogSelectOption) => void }[] current?: T + header?: JSX.Element + onKeyboard?: (evt: KeyEvent) => boolean | void } export interface DialogSelectOption { @@ -187,6 +189,8 @@ export function DialogSelect(props: DialogSelectProps) { useKeyboard((evt) => { setStore("input", "keyboard") + if (props.onKeyboard?.(evt)) return + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) @@ -263,6 +267,7 @@ export function DialogSelect(props: DialogSelectProps) { /> + {props.header} 0} fallback={ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8f0f583ea3d6..34ef2ae41e8d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1004,7 +1004,16 @@ export namespace Config { export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), - theme: z.string().optional().describe("Theme name to use for the interface"), + theme: z + .union([ + z.string(), + z.object({ + light: z.string().optional().describe("Theme to use in light mode"), + dark: z.string().optional().describe("Theme to use in dark mode"), + }), + ]) + .optional() + .describe("Theme name or per-mode theme configuration for the interface"), keybinds: Keybinds.optional().describe("Custom keybind configurations"), logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index eeccde2f7913..90b43be360e8 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -301,12 +301,22 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr ### Themes -You can configure the theme you want to use in your OpenCode config through the `theme` option. +You can configure the theme in your OpenCode config through the `theme` option. Use a string for a single theme, or an object to set different themes for dark and light mode. ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "theme": "" + "theme": "tokyonight" +} +``` + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "theme": { + "dark": "tokyonight", + "light": "github" + } } ``` diff --git a/packages/web/src/content/docs/themes.mdx b/packages/web/src/content/docs/themes.mdx index d37ce3135569..a4fb9f57497b 100644 --- a/packages/web/src/content/docs/themes.mdx +++ b/packages/web/src/content/docs/themes.mdx @@ -70,6 +70,34 @@ You can select a theme by bringing up the theme select with the `/theme` command } ``` +To use different themes for dark and light mode: + +```json title="opencode.json" {3-6} +{ + "$schema": "https://opencode.ai/config.json", + "theme": { + "dark": "tokyonight", + "light": "github" + } +} +``` + +--- + +## Appearance mode + +OpenCode can follow your OS appearance (dark or light) and switch themes automatically. + +The theme dialog (`/theme` or `ctrl+x t`) includes an appearance selector at the top. Use `←`/`→` to cycle between modes: + +- **auto** — follows OS appearance via the terminal. When your system switches between dark and light mode, OpenCode updates instantly. +- **dark** — always use the dark variant or dark theme. +- **light** — always use the light variant or light theme. + +The default mode is `auto`. On terminals that support [Mode 2031](https://github.com/contour-terminal/contour/blob/master/docs/vt-extensions/color-palette-update-notifications.md) (Ghostty, kitty, Contour, GNOME Terminal), appearance changes are detected in real-time. On other terminals, OpenCode falls back to detecting the terminal background color at startup. + +Most built-in themes include both dark and light color variants. Themes without light variants (`aura`, `ayu`) are unaffected by the appearance mode — they always render the same way. + --- ## Custom themes diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 085eb6169f83..aa109051d559 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -232,7 +232,7 @@ Share current session. [Learn more](/docs/share). ### themes -List available themes. +List available themes and set the appearance mode. ```bash frame="none" /theme @@ -240,6 +240,8 @@ List available themes. **Keybind:** `ctrl+x t` +Use `←`/`→` in the theme dialog to switch between `auto`, `dark`, and `light` appearance modes. + --- ### thinking