Repository navigation
Description
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 (
ThemeProviderinit prefers cachedtheme_modeover 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. initialistheme.selected(active), notkv.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.jsonfiles exist in the walked-up path from your CWD. - Run
/themes, set theme tozenburn. Confirm~/.local/share/opencode/kv.jsonshows{"theme": "zenburn"}. - Run
/themesagain. Cycle the cursor through several themes. In a second terminal, watchkv.json— it changes on every keystroke. - Without selecting, kill the OpenCode process with SIGKILL (or close the terminal abruptly so
onCleanupdoesn't run). kv.jsonnow shows whatever was last previewed. Originalzenburnis gone.
Reproduction B — multi-TUI, no plugins, no tui.json:
- Open TUI A.
/themes→zenburn. Confirm kv iszenburn. - Open TUI B. Confirm B starts on
zenburn. - In B,
/themes→cobalt2. kv iscobalt2. - 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, notcobalt2. A's cancel-restore wrote its staleinitialback to kv, blowing away B's update.
Reproduction C — single TUI with tui.json pin:
/themes→zenburn.- Create
~/tui.jsonwith{"$schema": "https://opencode.ai/tui.json", "theme": "carbonfox"}. - Restart TUI from
~. Active iscarbonfox(tui.json wins). kv is stillzenburn. /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.
Activity
lexlian commentedon May 28, 2026
I created a fix in PR #29229. The
/themesdialog was callingtheme.set(value)on every cursor move and filter change, which unconditionally wrote tokv.json— causing theme thrashing during preview and potential corruption on abrupt exits.The fix adds a
theme.preview(name)method that changes the active theme without writing to kv. The dialog now usespreviewfor cursor navigation, filter changes, and cancel-restore, and only callssetwhen the user confirms. Also addedpreviewto theTuiThemeplugin interface.fix(tui): stop writing kv.json on every theme preview keystroke
github-actions commentedon Jul 28, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.