Skip to content
Closed
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
11 changes: 1 addition & 10 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 52 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -15,11 +18,23 @@ export function DialogThemeList() {
let confirmed = false
let ref: DialogSelectRef<string>
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 (
<DialogSelect
title="Themes"
Expand All @@ -45,6 +60,41 @@ export function DialogThemeList() {
const first = ref.filtered[0]
if (first) theme.set(first.value)
}}
onKeyboard={(evt) => {
if (evt.name === "left" || evt.name === "right") {
cycleMode(evt.name === "right" ? 1 : -1)
return true
}
}}
header={
<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={MODES}>
{(m) => (
<box
flexDirection="row"
gap={1}
onMouseUp={() => {
setMode(m)
theme.setMode(m)
}}
>
<text fg={mode() === m ? theme.theme.primary : theme.theme.textMuted}>
{mode() === m ? "●" : "○"}
</text>
<text fg={mode() === m ? theme.theme.text : theme.theme.textMuted}>{m}</text>
</box>
)}
</For>
</box>
</box>
}
/>
)
}
120 changes: 98 additions & 22 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, onCleanup, onMount } 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 @@ -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
Expand Down Expand Up @@ -173,6 +175,21 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
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 {
Expand Down Expand Up @@ -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<string, ThemeJson>,
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()
Expand All @@ -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)
}
})
Expand All @@ -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
}),
)
Expand All @@ -336,24 +389,39 @@ 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
}
}),
)
})
}

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()))
Expand All @@ -366,7 +434,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
},
}),
get selected() {
return store.active
return activeThemeName()
},
all() {
return store.themes
Expand All @@ -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() {
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -28,6 +28,8 @@ export interface DialogSelectProps<T> {
onTrigger: (option: DialogSelectOption<T>) => void
}[]
current?: T
header?: JSX.Element
onKeyboard?: (evt: KeyEvent) => boolean | void
}

export interface DialogSelectOption<T = any> {
Expand Down Expand Up @@ -187,6 +189,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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)
Expand Down Expand Up @@ -263,6 +267,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
/>
</box>
</box>
{props.header}
<Show
when={grouped().length > 0}
fallback={
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
14 changes: 12 additions & 2 deletions packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
```

Expand Down
Loading
Loading