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 /**