diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 97c910a47d4..7b162c07108 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -211,7 +211,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() @@ -536,15 +536,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 f4072c97858..4a3a9dd267c 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 saved = theme.mode() + const [mode, setMode] = createSignal(saved) onCleanup(() => { - if (!confirmed) theme.set(initial) + if (!confirmed) { + theme.set(initial) + theme.setMode(saved) + } }) + function cycle(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") { + cycle(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 2320c08ccc6..6fbceed7e6c 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 { createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "./helper" import { Glob } from "../../../../util/glob" import aura from "./theme/aura.json" with { type: "json" } @@ -43,6 +43,8 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { useTuiConfig } from "./tui-config" +type ThemeConfig = string | { light?: string; dark?: string } + type ThemeColors = { primary: RGBA secondary: RGBA @@ -174,6 +176,21 @@ export const DEFAULT_THEMES: Record = { carbonfox, } +function variants(theme: ThemeJson) { + return Object.values(theme.theme).some( + (v) => typeof v === "object" && v !== null && !(v instanceof RGBA) && "dark" in v && "light" in v, + ) +} + +function parse(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 { @@ -282,17 +299,33 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ init: (props: { mode: "dark" | "light" }) => { const config = useTuiConfig() const kv = useKV() + const renderer = useRenderer() + + const initial = parse((config.theme ?? kv.get("theme", "opencode")) as ThemeConfig, "opencode") + const [store, setStore] = createStore({ - themes: DEFAULT_THEMES, - mode: kv.get("theme_mode", props.mode), - active: (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: initial.light, + dark: initial.dark, ready: false, }) - createEffect(() => { - const theme = config.theme - if (theme) setStore("active", theme) - }) + function resolved(): "dark" | "light" { + if (store.mode !== "auto") return store.mode + return store.detectedMode + } + + function active() { + return resolved() === "light" ? store.light : store.dark + } + + function resolvedMode(): "dark" | "light" { + const json = store.themes[active()] + if (json && variants(json)) return resolved() + return "dark" + } function init() { resolveSystemTheme() @@ -305,10 +338,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 (active() !== "system") { setStore("ready", true) } }) @@ -317,18 +355,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 (active() === "system") { setStore( produce((draft) => { - draft.active = "opencode" + draft.light = "opencode" + draft.dark = "opencode" draft.ready = true }), ) @@ -337,8 +374,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, resolved()) + if (active() === "system") { draft.ready = true } }), @@ -346,15 +383,30 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) } - const renderer = useRenderer() - process.on("SIGUSR2", async () => { + // React to OS appearance changes via Mode 2031 + const handler = (mode: "dark" | "light") => { + setStore("detectedMode", mode) + if (active() === "system") { + renderer.clearPaletteCache() + resolveSystemTheme() + } + } + renderer.on("theme_mode", handler) + onCleanup(() => renderer.off("theme_mode", handler)) + + // Sync initial mode if terminal already responded to Mode 2031 query + if (renderer.themeMode) { + setStore("detectedMode", renderer.themeMode) + } + + const sigusr2 = () => { renderer.clearPaletteCache() init() - }) + } + process.on("SIGUSR2", sigusr2) + onCleanup(() => process.off("SIGUSR2", sigusr2)) - const values = createMemo(() => { - return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode) - }) + const values = createMemo(() => resolveTheme(store.themes[active()] ?? store.themes.opencode, resolvedMode())) const syntax = createMemo(() => generateSyntax(values())) const subtleSyntax = createMemo(() => generateSubtleSyntax(values())) @@ -367,7 +419,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }), get selected() { - return store.active + return active() }, all() { return store.themes @@ -377,12 +429,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 151f73cf7c0..a51b50ff80c 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/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index f9068e3f01d..e6e28e81ea3 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -27,7 +27,16 @@ export const TuiOptions = z.object({ export const TuiInfo = z .object({ $schema: z.string().optional(), - theme: z.string().optional(), + 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"), keybinds: KeybindOverride.optional(), }) .extend(TuiOptions.shape) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 038f253274e..750ceea2ec9 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -304,7 +304,7 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr ### Themes -Set your UI theme in `tui.json`. +Set your UI theme in `tui.json`. Use a string for a single theme, or an object to set different themes for dark and light mode. ```json title="tui.json" { @@ -313,6 +313,16 @@ Set your UI theme in `tui.json`. } ``` +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "theme": { + "dark": "tokyonight", + "light": "github" + } +} +``` + [Learn more here](/docs/themes). --- diff --git a/packages/web/src/content/docs/themes.mdx b/packages/web/src/content/docs/themes.mdx index 8a7c6a46ac8..8a377358e82 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="tui.json" {3-6} +{ + "$schema": "https://opencode.ai/tui.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 010e8328f41..28ef1956838 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" /themes @@ -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