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
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 @@ -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()
Expand Down Expand Up @@ -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",
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 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 (
<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") {
cycle(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>
}
/>
)
}
110 changes: 85 additions & 25 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 { createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -174,6 +176,21 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
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 {
Expand Down Expand Up @@ -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<string, ThemeJson>,
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()
Expand All @@ -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)
}
})
Expand All @@ -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
}),
)
Expand All @@ -337,24 +374,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, resolved())
if (active() === "system") {
draft.ready = true
}
}),
)
})
}

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()))
Expand All @@ -367,7 +419,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
},
}),
get selected() {
return store.active
return active()
},
all() {
return store.themes
Expand All @@ -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() {
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/tui-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
{
Expand All @@ -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).

---
Expand Down
Loading
Loading