Skip to content

fix(tui): /themes dialog writes kv.json on every preview keystroke and on cancel #28893

@PetersonRyan

Description

@PetersonRyan

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:

  1. No ephemeral setter — the dialog can only preview/restore active by going through theme.set, which has the kv side effect.
  2. 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:

  1. Ensure no tui.json files exist in the walked-up path from your CWD.
  2. Run /themes, set theme to zenburn. Confirm ~/.local/share/opencode/kv.json shows {"theme": "zenburn"}.
  3. Run /themes again. Cycle the cursor through several themes. In a second terminal, watch kv.json — it changes on every keystroke.
  4. Without selecting, kill the OpenCode process with SIGKILL (or close the terminal abruptly so onCleanup doesn't run).
  5. kv.json now shows whatever was last previewed. Original zenburn is gone.

Reproduction B — multi-TUI, no plugins, no tui.json:

  1. Open TUI A. /themeszenburn. Confirm kv is zenburn.
  2. Open TUI B. Confirm B starts on zenburn.
  3. In B, /themescobalt2. kv is cobalt2.
  4. Open a third TUI to verify — starts on cobalt2. Close it.
  5. Switch back to TUI A (still alive, in-memory still showing zenburn). /themes, cycle through any themes, press Esc.
  6. 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:

  1. /themeszenburn.
  2. Create ~/tui.json with {"$schema": "https://opencode.ai/tui.json", "theme": "carbonfox"}.
  3. Restart TUI from ~. Active is carbonfox (tui.json wins). kv is still zenburn.
  4. /themes, cycle through previews, press Esc.
  5. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions