Description
Built-in /themes calls theme.set(value) for every cursor move (onMove), every search-filter keystroke, and on cancel (onCleanup restoring initial). Because theme.set unconditionally writes kv.json["theme"], every /themes invocation thrashes the persisted global theme on every preview, and corrupts it whenever the dialog is dismissed without onCleanup running cleanly OR whenever theme.selected (in-memory active) and kv.get("theme") (persisted) have diverged at dialog-open time.
This is adjacent to but distinct from #19436 / PR #23188 (kommander's atomicity + flock for kv writes). That fix prevents file corruption from concurrent writes; it does not change the fact that preview and cancel write kv at all. Our bug survives those changes — the most-recent-writer-wins clobber is intact, just no longer producing partial JSON.
Related but unrelated root causes (different code paths, included for triage convenience):
- #27784 and #28753 — chosen theme not persisted across restart (likely 1.15.x load-order regression).
- #21871 — TUI shows wrong theme on startup when system dark/light mode changes (
ThemeProvider init prefers cached theme_mode over OSC 11).
- #23573 — transparent theme renders with extra backdrop on startup.
- #19536 — same multi-TUI clobber pattern but for
prompt-history.jsonl.
Root cause (and suggested fix)
packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx:
const initial = theme.selected
onCleanup(() => {
if (!confirmed) theme.set(initial)
})
<DialogSelect
current={initial}
onMove={(opt) => theme.set(opt.value)}
onSelect={(opt) => { theme.set(opt.value); confirmed = true; dialog.clear() }}
/>
theme.set (packages/opencode/src/cli/cmd/tui/context/theme.tsx):
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
return true
}
Two coupled issues:
- No ephemeral setter — the dialog can only preview/restore active by going through
theme.set, which has the kv side effect.
initial is theme.selected (active), not kv.get("theme") (persisted). When active and kv have diverged, restoring active necessarily clobbers kv.
onSelect writing kv reflects user intent. onMove and the cancel-restore writing kv do not.
Suggested fix
Option A (~3 LoC): capture original kv at dialog open, restore it on cancel via direct kv.set:
const kv = useKV()
const originalKv = kv.get("theme")
const initial = theme.selected
onCleanup(() => {
if (!confirmed) {
theme.set(initial)
kv.set("theme", originalKv)
}
})
Handles cancel correctly. Does not fix onMove thrashing — every preview still writes kv mid-dialog.
Option B (~10 LoC, preferred): add an ephemeral setter to the theme context:
preview(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
return true
}
DialogThemeList uses theme.preview for onMove and cancel-restore; theme.set only on confirm. Eliminates kv writes during cycle-through previews.
Exposing theme.preview on TuiTheme (in @opencode-ai/plugin/tui) would also give plugin authors a documented way to apply a theme without the kv side effect — currently they have to capture kv before and restore after every theme.set.
Plugins
N/A
OpenCode version
1.14.41
Steps to reproduce
Reproduction A — simplest, single TUI, no plugins, no tui.json:
- Ensure no
tui.json files exist in the walked-up path from your CWD.
- Run
/themes, set theme to zenburn. Confirm ~/.local/share/opencode/kv.json shows {"theme": "zenburn"}.
- Run
/themes again. Cycle the cursor through several themes. In a second terminal, watch kv.json — it changes on every keystroke.
- Without selecting, kill the OpenCode process with SIGKILL (or close the terminal abruptly so
onCleanup doesn't run).
kv.json now shows whatever was last previewed. Original zenburn is gone.
Reproduction B — multi-TUI, no plugins, no tui.json:
- Open TUI A.
/themes → zenburn. Confirm kv is zenburn.
- Open TUI B. Confirm B starts on
zenburn.
- In B,
/themes → cobalt2. kv is cobalt2.
- Open a third TUI to verify — starts on
cobalt2. Close it.
- Switch back to TUI A (still alive, in-memory still showing
zenburn). /themes, cycle through any themes, press Esc.
- Open a new TUI. Starts on
zenburn, not cobalt2. A's cancel-restore wrote its stale initial back to kv, blowing away B's update.
Reproduction C — single TUI with tui.json pin:
/themes → zenburn.
- Create
~/tui.json with {"$schema": "https://opencode.ai/tui.json", "theme": "carbonfox"}.
- Restart TUI from
~. Active is carbonfox (tui.json wins). kv is still zenburn.
/themes, cycle through previews, press Esc.
- kv now shows
carbonfox — the active value, not the original global.
Screenshot and/or share link
N/A
Operating System
Amazon Linux 2
Terminal
tmux (tmux-256color), inside an SSH session.
Description
Built-in
/themescallstheme.set(value)for every cursor move (onMove), every search-filter keystroke, and on cancel (onCleanuprestoringinitial). Becausetheme.setunconditionally writeskv.json["theme"], every/themesinvocation thrashes the persisted global theme on every preview, and corrupts it whenever the dialog is dismissed withoutonCleanuprunning cleanly OR whenevertheme.selected(in-memory active) andkv.get("theme")(persisted) have diverged at dialog-open time.This is adjacent to but distinct from #19436 / PR #23188 (kommander's atomicity + flock for kv writes). That fix prevents file corruption from concurrent writes; it does not change the fact that preview and cancel write kv at all. Our bug survives those changes — the most-recent-writer-wins clobber is intact, just no longer producing partial JSON.
Related but unrelated root causes (different code paths, included for triage convenience):
ThemeProviderinit prefers cachedtheme_modeover OSC 11).prompt-history.jsonl.Root cause (and suggested fix)
packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx:theme.set(packages/opencode/src/cli/cmd/tui/context/theme.tsx):Two coupled issues:
theme.set, which has the kv side effect.initialistheme.selected(active), notkv.get("theme")(persisted). When active and kv have diverged, restoring active necessarily clobbers kv.onSelectwriting kv reflects user intent.onMoveand the cancel-restore writing kv do not.Suggested fix
Option A (~3 LoC): capture original kv at dialog open, restore it on cancel via direct
kv.set:Handles cancel correctly. Does not fix
onMovethrashing — every preview still writes kv mid-dialog.Option B (~10 LoC, preferred): add an ephemeral setter to the theme context:
DialogThemeListusestheme.previewforonMoveand cancel-restore;theme.setonly on confirm. Eliminates kv writes during cycle-through previews.Exposing
theme.previewonTuiTheme(in@opencode-ai/plugin/tui) would also give plugin authors a documented way to apply a theme without the kv side effect — currently they have to capture kv before and restore after everytheme.set.Plugins
N/A
OpenCode version
1.14.41
Steps to reproduce
Reproduction A — simplest, single TUI, no plugins, no tui.json:
tui.jsonfiles exist in the walked-up path from your CWD./themes, set theme tozenburn. Confirm~/.local/share/opencode/kv.jsonshows{"theme": "zenburn"}./themesagain. Cycle the cursor through several themes. In a second terminal, watchkv.json— it changes on every keystroke.onCleanupdoesn't run).kv.jsonnow shows whatever was last previewed. Originalzenburnis gone.Reproduction B — multi-TUI, no plugins, no tui.json:
/themes→zenburn. Confirm kv iszenburn.zenburn./themes→cobalt2. kv iscobalt2.cobalt2. Close it.zenburn)./themes, cycle through any themes, press Esc.zenburn, notcobalt2. A's cancel-restore wrote its staleinitialback to kv, blowing away B's update.Reproduction C — single TUI with
tui.jsonpin:/themes→zenburn.~/tui.jsonwith{"$schema": "https://opencode.ai/tui.json", "theme": "carbonfox"}.~. Active iscarbonfox(tui.json wins). kv is stillzenburn./themes, cycle through previews, press Esc.carbonfox— the active value, not the original global.Screenshot and/or share link
N/A
Operating System
Amazon Linux 2
Terminal
tmux (tmux-256color), inside an SSH session.