diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 2af5b21152c..47e69da483c 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -177,7 +177,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()
@@ -421,15 +421,7 @@ 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..3343d3dafa4 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,50 +1,156 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
-import { useTheme } from "../context/theme"
+import { useTheme, type ColorScheme, getThemeModeSupport, type ThemeModeSupport } from "../context/theme"
import { useDialog } from "../ui/dialog"
-import { onCleanup, onMount } from "solid-js"
+import { createMemo, createSignal, For, onCleanup } from "solid-js"
+import { TextAttributes } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+
+const APPEARANCE_OPTIONS: { value: ColorScheme; label: string }[] = [
+ { value: "system", label: "System" },
+ { value: "light", label: "Light" },
+ { value: "dark", label: "Dark" },
+]
+
+function isOptionEnabled(option: ColorScheme, support: ThemeModeSupport): boolean {
+ if (support.dark && support.light) return true
+ if (option === "system") return support.dark && support.light
+ if (option === "dark") return support.dark
+ if (option === "light") return support.light
+ return false
+}
+
+function AppearanceSelector(props: { modeSupport: ThemeModeSupport }) {
+ const theme = useTheme()
+ const enabledOptions = createMemo(() =>
+ APPEARANCE_OPTIONS.filter((opt) => isOptionEnabled(opt.value, props.modeSupport)),
+ )
+ const selectedIndex = createMemo(() => enabledOptions().findIndex((opt) => opt.value === theme.colorScheme()))
+
+ useKeyboard((evt) => {
+ const options = enabledOptions()
+ if (options.length <= 1) return
+
+ if (evt.name === "left") {
+ evt.preventDefault()
+ const prev = (selectedIndex() - 1 + options.length) % options.length
+ theme.setColorScheme(options[prev].value)
+ }
+ if (evt.name === "right") {
+ evt.preventDefault()
+ const next = (selectedIndex() + 1) % options.length
+ theme.setColorScheme(options[next].value)
+ }
+ })
+
+ return (
+
+
+
+ Appearance
+
+ ←/→
+
+
+
+ {(option) => {
+ const isSelected = createMemo(() => theme.colorScheme() === option.value)
+ const isEnabled = createMemo(() => isOptionEnabled(option.value, props.modeSupport))
+ return (
+ isEnabled() && theme.setColorScheme(option.value)}>
+
+ {isSelected() ? "●" : "○"}
+
+
+ {option.label}
+
+
+ )
+ }}
+
+
+
+ )
+}
+
+function getModeIndicator(support: ThemeModeSupport): string {
+ if (support.dark && support.light) return ""
+ if (support.dark) return "dark"
+ if (support.light) return "light"
+ return ""
+}
export function DialogThemeList() {
const theme = useTheme()
- const options = Object.keys(theme.all())
+ const allThemes = theme.all()
+ const options = Object.keys(allThemes)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
- .map((value) => ({
- title: value,
- value: value,
- }))
+ .map((value) => {
+ const support = getThemeModeSupport(allThemes[value])
+ const indicator = getModeIndicator(support)
+ return {
+ title: value,
+ value: value,
+ footer: indicator,
+ }
+ })
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef
const initial = theme.selected
+ const initialColorScheme = theme.colorScheme()
+
+ const currentModeSupport = createMemo(() => {
+ const currentTheme = allThemes[theme.selected]
+ return currentTheme ? getThemeModeSupport(currentTheme) : { dark: true, light: true }
+ })
+
+ function handleThemeChange(themeName: string) {
+ theme.set(themeName)
+ const support = getThemeModeSupport(allThemes[themeName])
+ if (!support.light && support.dark) {
+ theme.setColorScheme("dark")
+ } else if (!support.dark && support.light) {
+ theme.setColorScheme("light")
+ }
+ }
onCleanup(() => {
- if (!confirmed) theme.set(initial)
+ if (!confirmed) {
+ theme.set(initial)
+ theme.setColorScheme(initialColorScheme)
+ }
})
return (
- {
- theme.set(opt.value)
- }}
- onSelect={(opt) => {
- theme.set(opt.value)
- confirmed = true
- dialog.clear()
- }}
- ref={(r) => {
- ref = r
- }}
- onFilter={(query) => {
- if (query.length === 0) {
- theme.set(initial)
- return
- }
-
- const first = ref.filtered[0]
- if (first) theme.set(first.value)
- }}
- />
+
+
+ {
+ handleThemeChange(opt.value)
+ }}
+ onSelect={(opt) => {
+ handleThemeChange(opt.value)
+ confirmed = true
+ dialog.clear()
+ }}
+ ref={(r) => {
+ ref = r
+ }}
+ onFilter={(query) => {
+ if (query.length === 0) {
+ theme.set(initial)
+ return
+ }
+
+ const first = ref.filtered[0]
+ if (first) handleThemeChange(first.value)
+ }}
+ />
+
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 6489fc0e1ef..ec636096ade 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, onMount, onCleanup } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import aura from "./theme/aura.json" with { type: "json" }
@@ -102,6 +102,8 @@ type Theme = ThemeColors & {
thinkingOpacity: number
}
+export type ColorScheme = "dark" | "light" | "system"
+
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
if (theme._hasSelectedListItemText) {
@@ -127,6 +129,57 @@ type Variant = {
light: HexColor | RefName
}
type ColorValue = HexColor | RefName | Variant | RGBA
+
+function isVariant(value: ColorValue): value is Variant {
+ return typeof value === "object" && value !== null && !(value instanceof RGBA) && "dark" in value && "light" in value
+}
+
+export type ThemeModeSupport = {
+ dark: boolean
+ light: boolean
+}
+
+function resolveColorRef(ref: string, defs: Record): string {
+ if (ref.startsWith("#")) return ref
+ if (defs[ref]) return resolveColorRef(defs[ref], defs)
+ return ref
+}
+
+function getLuminance(hex: string): number {
+ if (!hex.startsWith("#")) return 0.5
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255
+}
+
+export function getThemeModeSupport(theme: ThemeJson): ThemeModeSupport {
+ const defs = theme.defs ?? {}
+ const bgValue = theme.theme.background
+
+ if (isVariant(bgValue)) {
+ const darkBg = resolveColorRef(bgValue.dark, defs)
+ const lightBg = resolveColorRef(bgValue.light, defs)
+ const isTransparent = darkBg === "transparent" || darkBg === "none"
+ if (isTransparent || darkBg === lightBg) {
+ const textValue = theme.theme.text
+ if (isVariant(textValue)) {
+ const darkText = resolveColorRef(textValue.dark, defs)
+ const lightText = resolveColorRef(textValue.light, defs)
+ if (darkText !== lightText) return { dark: true, light: true }
+ }
+ if (isTransparent) return { dark: true, light: true }
+ const luminance = getLuminance(darkBg)
+ return { dark: luminance < 0.5, light: luminance >= 0.5 }
+ }
+ return { dark: true, light: true }
+ }
+
+ const resolvedBg = typeof bgValue === "string" ? resolveColorRef(bgValue, defs) : ""
+ if (resolvedBg === "transparent" || resolvedBg === "none") return { dark: true, light: true }
+ const luminance = getLuminance(resolvedBg)
+ return { dark: luminance < 0.5, light: luminance >= 0.5 }
+}
type ThemeJson = {
$schema?: string
defs?: Record
@@ -280,9 +333,13 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
init: (props: { mode: "dark" | "light" }) => {
const sync = useSync()
const kv = useKV()
+ const configAppearance = sync.data.config.appearance as ColorScheme | undefined
+ const initialColorScheme = (configAppearance ?? kv.get("color_scheme", "system")) as ColorScheme
+ const initialMode = initialColorScheme === "system" ? props.mode : (initialColorScheme as "dark" | "light")
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
- mode: kv.get("theme_mode", props.mode),
+ colorScheme: initialColorScheme,
+ mode: initialMode,
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
@@ -290,6 +347,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => {
const theme = sync.data.config.theme
if (theme) setStore("active", theme)
+ const appearance = sync.data.config.appearance as ColorScheme | undefined
+ if (appearance) {
+ setStore("colorScheme", appearance)
+ if (appearance === "system") {
+ setStore("mode", props.mode)
+ } else {
+ setStore("mode", appearance)
+ }
+ }
})
function init() {
@@ -350,6 +416,39 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
init()
})
+ // Poll terminal background for real-time system theme detection
+ createEffect(() => {
+ if (store.colorScheme !== "system") return
+
+ let active = true
+
+ const checkTerminalMode = () => {
+ if (!active) return
+ renderer
+ .getPalette({ size: 1 })
+ .then((colors) => {
+ if (!active || !colors.defaultBackground) return
+ const hex = colors.defaultBackground
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
+ const detectedMode = luminance > 0.5 ? "light" : "dark"
+ if (store.colorScheme === "system" && store.mode !== detectedMode) {
+ setStore("mode", detectedMode)
+ }
+ })
+ .catch(() => {})
+ }
+
+ const interval = setInterval(checkTerminalMode, 5000)
+
+ onCleanup(() => {
+ active = false
+ clearInterval(interval)
+ })
+ })
+
const values = createMemo(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
})
@@ -375,6 +474,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() {
return store.mode
},
+ colorScheme() {
+ return store.colorScheme
+ },
+ setColorScheme(scheme: ColorScheme) {
+ setStore("colorScheme", scheme)
+ kv.set("color_scheme", scheme)
+ if (scheme === "system") {
+ setStore("mode", props.mode)
+ } else {
+ setStore("mode", scheme)
+ }
+ },
setMode(mode: "dark" | "light") {
setStore("mode", mode)
kv.set("theme_mode", mode)
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 f2f2927c000..99e9ebcf52b 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -19,6 +19,7 @@ export interface DialogSelectProps {
onMove?: (option: DialogSelectOption) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption) => void
+ onNavigateUp?: () => void
skipFilter?: boolean
keybind?: {
keybind: Keybind.Info
@@ -124,7 +125,13 @@ export function DialogSelect(props: DialogSelectProps) {
function move(direction: number) {
if (flat().length === 0) return
let next = store.selected + direction
- if (next < 0) next = flat().length - 1
+ if (next < 0) {
+ if (props.onNavigateUp) {
+ props.onNavigateUp()
+ return
+ }
+ next = flat().length - 1
+ }
if (next >= flat().length) next = 0
moveTo(next)
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index be234948424..8483ba071ee 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -792,6 +792,10 @@ export namespace Config {
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
+ appearance: z
+ .enum(["dark", "light", "system"])
+ .optional()
+ .describe("Color scheme preference: 'dark', 'light', or 'system' to follow terminal"),
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/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index ea86b022daf..fc64fbe21ec 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1534,6 +1534,10 @@ export type Config = {
* Theme name to use for the interface
*/
theme?: string
+ /**
+ * Color scheme preference: 'dark', 'light', or 'system' to follow terminal
+ */
+ appearance?: "dark" | "light" | "system"
keybinds?: KeybindsConfig
logLevel?: LogLevel
/**