Skip to content

Sync Omarchy themes to OpenCode without interrupting it - #8684

Open
ekollof wants to merge 11 commits into
omacom:quattrofrom
ekollof:opencode-theme-hot-reload
Open

Sync Omarchy themes to OpenCode without interrupting it#8684
ekollof wants to merge 11 commits into
omacom:quattrofrom
ekollof:opencode-theme-hot-reload

Conversation

@ekollof

@ekollof ekollof commented Aug 27, 2026

Copy link
Copy Markdown

Problem

omarchy-theme-set runs omarchy-restart-opencode after every theme change, which killall -SIGUSR2 opencode. In opencode, that signal reaches two handlers:

  • the theme context: re-queries the terminal palette and re-reads theme files (fine), and
  • the TUI main loop: asks the worker to reload, which disposes every instance — interrupting any agent running in a session.

So switching themes kills in-flight agent work in opencode.

Fix

OpenCode themes are plain JSON files in ~/.config/opencode/themes/, and an OpenCode TUI plugin can hot-swap the live theme in-process. That makes the same shape as the Claude Code/Pi integrations work here — sync a watched theme file, no signal:

  • default/themed/opencode.json.tpl — new template rendering OpenCode's theme JSON from each theme's colors.toml, so every stock and community theme is covered.
  • bin/omarchy-theme-set-opencode — copies the rendered theme into ~/.config/opencode/themes/omarchy.json (atomic). Replaces omarchy-restart-opencode in the theme's post commands. --activate also installs the shipped TUI plugin, points tui.json at "theme": "omarchy", and preserves any existing tui.json fields.
  • config/opencode/tui-plugins/omarchy-theme.ts — the TUI plugin. It watches the synced file and retints running sessions live; sessions and agents are never touched. It installs each new palette under a content-hashed name (OpenCode's theme.install() only upserts content while a theme is unknown to its registry) and prunes older copies. Selecting Omarchy in the /theme picker — or --activate — opts a session in; otherwise the sync is inert at runtime, and users of theme: "system" or their own theme keep exactly what they chose.
  • Migration wires the plugin into tui.json for existing installs and selects "theme": "omarchy" so desktop theme changes live-retint OpenCode (same as every other themed app). Pick a different theme in OpenCode to opt out. OpenCode's built-in "system" theme is terminal-adaptive and will not follow; stay on "omarchy" for live retint.
  • omarchy-restart-opencode stays (it remains a manual escape hatch for config changes); it is just no longer part of theme switching.

Testing

  • test/cli gains coverage for the template output, the sync respecting OPENCODE_CONFIG_DIR, skip-when-missing, activation preserving existing tui.json fields, and fresh tui.json generation.
  • test/shell.d/theme-staging-test.sh: opencode.json classified as colour-only.
  • Locally, with the plugin active: switching Omarchy themes retints a running opencode TUI within ~300 ms (verified by capturing the pane's SGR attributes before/after), with no SIGUSR2 sent and agents unaffected.

Closes

Closes #8002 — theme change interrupts/kills running opencode sessions.
Closes #8389 — theme change aborts in-flight opencode agent work.

Copilot AI balanced review requested due to automatic review settings August 27, 2026 23:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds live OpenCode theme synchronization without restarting sessions or interrupting agents.

Changes:

  • Adds an OpenCode theme template, synchronization command, and hot-reload plugin.
  • Activates the integration for new installs and migrates existing installations.
  • Replaces restart-based theme updates and adds CLI/template coverage.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
bin/omarchy-theme-set Uses theme synchronization instead of restarting OpenCode.
bin/omarchy-theme-set-opencode Synchronizes and optionally activates the OpenCode theme integration.
config/opencode/tui-plugins/omarchy-theme.ts Watches and hot-swaps generated themes.
default/themed/opencode.json.tpl Defines the generated OpenCode palette.
install/user/theme.sh Activates integration during user setup.
migrations/1787871792.sh Migrates existing OpenCode installations.
test/cli Tests generation, synchronization, and activation.
test/shell.d/theme-staging-test.sh Classifies the generated theme as color-only.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread migrations/1787871792.sh Outdated
# itself is not switched here: point tui.json at "omarchy" -- the theme picker
# also lists it -- or run `omarchy-theme-set-opencode --activate` to opt in.

OPENCODE_DIR="$HOME/.config/opencode"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration and the sync script now resolve the config directory the way opencode itself does -- OPENCODE_CONFIG_DIR, then XDG_CONFIG_HOME, then ~/.config. New migration test covers the XDG tree.

Comment on lines +90 to +94
const staged = path.join(os.tmpdir(), "omarchy-theme", `${name}.json`)
fs.mkdirSync(path.dirname(staged), { recursive: true })
fs.writeFileSync(staged, text)
await api.theme.install(staged)
fs.rmSync(staged, { force: true })

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: each apply now stages the palette in a mkdtemp directory removed in a finally block, so concurrent sessions no longer share one staged file. The staged basename still carries the theme name since theme.install() derives it from the filename.

Comment thread migrations/1787871792.sh
Comment on lines +28 to +34
if [[ -f $tui_config ]]; then
if ! jq -e --arg plugin "$plugin_target" '.plugin | index($plugin)' "$tui_config" >/dev/null 2>&1; then
tmp=$(mktemp "$tui_config.XXXXXX")
jq --arg plugin "$plugin_target" '.plugin = ((.plugin // []) + [$plugin] | unique)' "$tui_config" >"$tmp"
mv "$tmp" "$tui_config"
fi
fi

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the migration now creates a plugin-only tui.json (schema + plugin, no theme key) when the install has none, so the plugin registers without changing the user's theme. Covered in the new migration test.

Comment on lines +85 to +86
const stale = (candidate: string) =>
candidate.startsWith(`${THEME_NAME}-`) && candidate !== `${name}.json` && candidate.endsWith(".json")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: pruning now matches only the generated omarchy-<8 hex>.json shape, so user themes like omarchy-custom.json are left alone.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 3 comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

config/opencode/tui-plugins/omarchy-theme.ts:40

  • The shell command treats empty OPENCODE_CONFIG_DIR/XDG_CONFIG_HOME values as unset via :-, but the plugin's nullish checks accept empty strings. In that environment the command writes under ~/.config/opencode, while the plugin watches a relative opencode/themes directory, so live retinting never occurs. Use truthy fallback here to keep both paths identical.
  const config =
    process.env.OPENCODE_CONFIG_DIR ??
    path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "opencode")


const owned = () => {
const selected = api.theme.selected
return selected === THEME_NAME || selected.startsWith(`${THEME_NAME}-`)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed -- owned() now uses the same strict omarchy-<8 hex> pattern as the pruning predicate, so selecting a user theme like omarchy-custom is fully inert.

try {
const staged = path.join(stagedDir, `${name}.json`)
fs.writeFileSync(staged, text)
await api.theme.install(staged)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed -- applies now run serialized on a promise chain (each re-reads the file when it starts, so the newest event wins) and a stale invocation can no longer resume after a newer one and set an older palette or prune the newer copy.

Comment thread migrations/1787871792.sh Outdated
if [[ -f $tui_config ]]; then
if ! jq -e --arg plugin "$plugin_target" '.plugin | index($plugin)' "$tui_config" >/dev/null 2>&1; then
tmp=$(mktemp "$tui_config.XXXXXX")
jq --arg plugin "$plugin_target" '.plugin = ((.plugin // []) + [$plugin] | unique)' "$tui_config" >"$tmp"

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed -- both the migration and --activate now append without unique, so existing plugin ordering is never rewritten. The migration test gained an order-preservation assertion.

@ekollof

ekollof commented Aug 28, 2026

Copy link
Copy Markdown
Author

Addressed the remaining Copilot finding from the second review round: the TUI plugin's themesDir() now uses truthy fallbacks for OPENCODE_CONFIG_DIR/XDG_CONFIG_HOME (e674e80), matching the ${VAR:-} semantics in omarchy-theme-set-opencode. An empty-but-set env var previously made the watcher and the sync command resolve different trees, so live retinting never fired. ./test/cli, the migration test, and the staging test all pass on the new commit.

Emiel Kollof added 7 commits September 3, 2026 12:03
omarchy-theme-set sent SIGUSR2 to opencode after every theme change.
That signal makes opencode reload its worker, which disposes all
instances and interrupts any agent running in a session.

OpenCode themes are plain JSON in ~/.config/opencode/themes, and a TUI
plugin can hot-swap the live theme in-process. So this follows the
Claude Code/Pi integration shape and drops the signal:

* An opencode.json template renders from each theme's colors.toml, so
  every stock and community theme is covered.
* omarchy-theme-set-opencode copies the result into
  ~/.config/opencode/themes/omarchy.json and replaces the restart in
  the theme's post commands. With --activate it also installs the
  shipped omarchy-theme TUI plugin and points tui.json at the theme.
* The plugin watches the synced file and retints running sessions
  live; running agents are never touched. Selecting Omarchy in the
  /theme picker (or --activate) opts a session in; without that the
  sync is a no-op at runtime.
* A migration wires the plugin into tui.json for existing installs,
  without switching their theme.
* Resolve the opencode config directory the way opencode does --
  OPENCODE_CONFIG_DIR, then XDG_CONFIG_HOME, then ~/.config -- in the
  sync script and the migration, so XDG installs are migrated into the
  tree opencode actually reads.
* Stage the theme in a mkdtemp directory removed in finally: every
  running session stages the same palette at the same deterministic
  path, so one session's cleanup could pull the file out from under
  another session's install.
* Create a plugin-only tui.json when the install has none, which is
  most existing installs; omitting "theme" keeps their current choice.
* Restrict pruning to the generated omarchy-<8 hex>.json shape so a
  user theme like omarchy-custom.json survives every apply.
* Skip theme files whose colors carry unresolved {{ }} template
  tokens; resolveTheme() throws on them and would take the TUI down.
* Cover the migration with opencode-theme-migration-test.sh.
…rder

* owned() now matches the same generated omarchy-<8 hex> shape as
  pruning, so picking a user theme named omarchy-something is inert
  and the next synced palette does not take over.
* Applies are serialized on a promise chain: theme.install() awaits,
  so a newer file event could otherwise interleave with an apply
  still finishing and let a stale invocation set an older palette or
  prune the newer copy. Each apply re-reads the file, so the last
  event wins.
* Register the plugin by appending, not unique: plugin order affects
  initialization, so the migration and --activate must not reorder
  the user's existing list.
* The cli suite's skip-when-missing invocation now unsets
  XDG_CONFIG_HOME/OPENCODE_CONFIG_DIR; with XDG support the script
  would otherwise resolve the real config directory on machines
  where it is set.
…plugin

An empty-but-set OPENCODE_CONFIG_DIR or XDG_CONFIG_HOME previously made the
plugin watch a different tree than omarchy-theme-set-opencode writes to, so
live retinting never fired. Match the sync script's ${VAR:-} semantics and
document why the fallbacks are truthy.
Skip theme.install/prune unless this session already has Omarchy selected,
so migrating the plugin in does not list hashed omarchy-* themes for users
who never opted in. Treat empty OPENCODE_CONFIG_DIR as unset in the CLI
test, and drop the stale "used by theme switching" note on the restart
command.
Live retint is the point: the migration now selects the Omarchy theme, and
the plugin treats "system" as follow-the-desktop (that path used to depend
on SIGUSR2). Picking any other theme in OpenCode still opts out.
system is terminal-adaptive (ANSI / none) and never reads omarchy.json.
Live retint stays on the omarchy theme; picking system is an opt-out.
@ekollof
ekollof force-pushed the opencode-theme-hot-reload branch from ec8ee2b to 67e763e Compare September 3, 2026 10:03
# Conflicts:
#	test/shell.d/theme-staging-test.sh
@marvreichmann

Copy link
Copy Markdown

Independent verification of this PR, from arriving at the same design separately while debugging opencode lagging a theme behind under a terminal multiplexer. Tested on omarchy 4.0.3 (this branch is based on 4.0.0.alpha), Arch, foot 1.28.0 + opencode 1.18.29.

To avoid patching packaged files I applied the two portable pieces through the supported override paths — default/themed/opencode.json.tpl~/.config/omarchy/themed/, bin/omarchy-theme-set-opencode~/.local/bin/, and a theme-set.d hook standing in for the post_theme_commands entry.

What verified clean

  • Template renders with 0 unresolved {{ }} placeholders and is valid JSON; emitted values match colors.toml exactly (background #222222, accent #686868).
  • The atomic write does what it claims — inotifywait on the themes dir shows MODIFY on the omarchy.json.XXXXXX temp followed by MOVED_TO omarchy.json, never a partial omarchy.json.
  • --activate sets .theme and appends the plugin without disturbing $schema or existing keys.
  • The plugin's import resolves: @opencode-ai/plugin 1.18.22 exports a ./tui subpath.
  • Applies cleanly on 4.0.3 despite the branch age; nothing in the two files depends on anything that moved.

Evidence for the plugin approach specifically

The design argument for the TUI plugin over a plain file sync holds up under measurement. inotifywait on ~/.config/opencode/tui.json records zero reads across a SIGUSR2 reload, while themes/omarchy.json is opened once per running instance in the same window. So opencode re-reads theme files on the signal but re-reads the selection only at startup.

The consequence is that a file-sync-only integration cannot retint a session that was already running when the integration was installed — it needs a restart to pick up "theme": "omarchy" even though the file underneath is correct. That's a real failure I hit before finding this PR, and it's the gap the watcher closes. Worth weighing against the simpler competing PRs.

Context that may help triage: there are currently three open PRs covering this ground — this one, #8264, and #11402 — and none has a review. This is the only one of the three that addresses live retint, install/migration activation, and the theme-staging-test.sh registration.

Not verified: live retint inside a long-running session with the plugin loaded. I could confirm the plugin file installs and its import resolves, but not observe an in-process swap, so that part of the claim is untested here rather than disputed.

Separately, and not this PR's problem: the reason opencode was singled out in my case is that under a multiplexer whose PTYs are owned by a detached server, omarchy-theme-set-foot never reaches the panes — it walks pgrep -P <foot_pid> only. Pinning opencode to a generated theme file, which is what this PR does, routes around that entirely. I've filed that separately.

@ekollof

ekollof commented Sep 12, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough independent verification — especially the inotifywait traces. They pin down exactly why the plugin exists: opencode re-reads theme files on SIGUSR2 but the selection only at startup, so a file-sync-only integration can never retint an already-running session. That matches the failure you hit before finding this PR.

To close the one open loop: live retint in a long-running session is covered. I verified it with an opencode TUI running under tmux across several consecutive palette swaps (rewriting themes/omarchy.json each time): capturing the pane's SGR attributes before/after shows the repaint landing every time, with no restart and — the point of the whole PR — no signals sent. Sessions and in-flight agents stay untouched throughout.

Two notes on the current head, since the branch moved on from the revision you tested:

  • The migration now selects "theme": "omarchy" by default (opt-out), and the plugin stays completely off the theme registry unless Omarchy is selected — users who never opt in see no omarchy-* entries at all.
  • system explicitly opts out: it is terminal-adaptive (ANSI/none) and never reads omarchy.json, so live retint only ever follows the generated file.

Agreed on the foot/multiplexer point too — pane-walking with pgrep -P can't reach panes owned by a detached server, while a generated theme file sidesteps per-pane delivery entirely. Good that it's filed separately.

On the three-PR overlap: this remains the only one of the three covering live retint plus activation, so they compose rather than conflict — happy to adjust if maintainers would rather converge on one approach.

Emiel Kollof added 2 commits September 12, 2026 16:16
Every existing install has a staged theme from before opencode.json.tpl
shipped, so the migration's sync would find no source file and leave
no themes/omarchy.json behind -- while tui.json already points at it.
Re-stage once when the recorded theme still exists (cf. 1787481315);
a removed theme is left alone so the migration cannot fail the update.
@ekollof

ekollof commented Sep 12, 2026

Copy link
Copy Markdown
Author

Follow-up pushed: the migration now handles staged themes that predate this PR.

Every existing install has a staged theme from before opencode.json.tpl shipped, so the migration's sync would find no source file — while tui.json already points at the theme. The migration now runs omarchy-theme-refresh once first when the staged opencode.json is missing and the recorded theme still exists (same precedent as 1787481315 and the T3 installer). A removed theme is left alone, so a dangling theme.name can never fail the update. Three migration-test cases cover it: stale stages refresh then sync, current stages skip the refresh, removed themes are untouched.

Also merged upstream/quattro in cleanly — nothing upstream touched these files — and re-ran the suites (120 cli, 14 migration, staging tests green).

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

Labels

None yet

Projects

None yet

3 participants