Skip to content

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

Closed as not planned

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. /themes → zenburn. Confirm kv is zenburn.
  2. Open TUI B. Confirm B starts on zenburn.
  3. In B, /themes → cobalt2. 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. /themes → zenburn.
  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.

Activity

  1. lexlian commented on May 28, 2026

    @lexlian

    I created a fix in PR #29229. The /themes dialog was calling theme.set(value) on every cursor move and filter change, which unconditionally wrote to kv.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 uses preview for cursor navigation, filter changes, and cancel-restore, and only calls set when the user confirms. Also added preview to the TuiTheme plugin interface.

  2. github-actions commented on Jul 28, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions