Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand Down
172 changes: 139 additions & 33 deletions packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<box paddingLeft={4} paddingRight={4} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.theme.text} attributes={TextAttributes.BOLD}>
Appearance
</text>
<text fg={theme.theme.textMuted}>←/→</text>
</box>
<box flexDirection="row" gap={2} paddingTop={1}>
<For each={APPEARANCE_OPTIONS}>
{(option) => {
const isSelected = createMemo(() => theme.colorScheme() === option.value)
const isEnabled = createMemo(() => isOptionEnabled(option.value, props.modeSupport))
return (
<box flexDirection="row" gap={1} onMouseUp={() => isEnabled() && theme.setColorScheme(option.value)}>
<text
fg={!isEnabled() ? theme.theme.border : isSelected() ? theme.theme.primary : theme.theme.textMuted}
>
{isSelected() ? "●" : "○"}
</text>
<text fg={!isEnabled() ? theme.theme.border : isSelected() ? theme.theme.text : theme.theme.textMuted}>
{option.label}
</text>
</box>
)
}}
</For>
</box>
</box>
)
}

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<string>
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 (
<DialogSelect
title="Themes"
options={options}
current={initial}
onMove={(opt) => {
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)
}}
/>
<box flexDirection="column">
<AppearanceSelector modeSupport={currentModeSupport()} />
<DialogSelect
title="Theme"
options={options}
current={initial}
onMove={(opt) => {
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)
}}
/>
</box>
)
}
115 changes: 113 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/theme.tsx
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down Expand Up @@ -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) {
Expand All @@ -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, HexColor | RefName>): 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<string, HexColor | RefName>
Expand Down Expand Up @@ -280,16 +333,29 @@ 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,
})

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() {
Expand Down Expand Up @@ -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)
})
Expand All @@ -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)
Expand Down
Loading