Repository navigation
Sync Omarchy themes to OpenCode without interrupting it #8684
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
2d9442a
Sync Omarchy themes to OpenCode without interrupting it
ekollof cc8f050
Address review: config-dir resolution, staged-file race, prune scope
ekollof 96ef922
Address review: strict ownership, serialized applies, stable plugin o…
ekollof 1c2d8eb
Resolve the OpenCode config dir with truthy env fallbacks in the TUI …
ekollof 980bf0d
Keep the OpenCode plugin off the registry until Omarchy is selected
ekollof ae960f7
Make OpenCode follow Omarchy theme changes by default
ekollof 54d2a63
Do not treat OpenCode's system theme as Omarchy live-retint
ekollof 111a15a
Do not replace OpenCode tui.json when jq fails
ekollof 35497e9
Re-stage a current theme predating opencode.json in the migration
ekollof 5455a56
React to every themes-dir event in the OpenCode watcher
ekollof 7c7ca59
Support OpenCode 2 theme refresh and migrated configuration
ekollof File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| #!/bin/bash | ||
|
|
||
| # omarchy:summary=Sync the generated Omarchy theme to opencode | ||
| # omarchy:args=[--activate] | ||
| # omarchy:hidden=true | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| OPENCODE_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/opencode.json" | ||
| # Mirror how opencode itself finds its config directory. | ||
| OPENCODE_CONFIG_DIR="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" | ||
| OPENCODE_THEMES_DIR="$OPENCODE_CONFIG_DIR/themes" | ||
| OPENCODE_THEME_PATH="$OPENCODE_THEMES_DIR/omarchy.json" | ||
| OPENCODE_TUI_CONFIG="$OPENCODE_CONFIG_DIR/tui.json" | ||
| OPENCODE_TUI_PLUGIN="$OPENCODE_CONFIG_DIR/tui-plugins/omarchy-theme.ts" | ||
| OPENCODE_PLUGIN_SOURCE="$OMARCHY_PATH/config/opencode/tui-plugins/omarchy-theme.ts" | ||
| OPENCODE_V2_PLUGIN="$OPENCODE_CONFIG_DIR/omarchy-theme" | ||
| OPENCODE_CLI_CONFIG="$OPENCODE_CONFIG_DIR/cli.json" | ||
| OPENCODE_ACTIVATE=0 | ||
|
|
||
| usage() { | ||
| echo "Usage: omarchy-theme-set-opencode [--activate]" | ||
| } | ||
|
|
||
| for arg in "$@"; do | ||
| case "$arg" in | ||
| --activate) | ||
| OPENCODE_ACTIVATE=1 | ||
| ;; | ||
| -h | --help) | ||
| usage | ||
| exit 0 | ||
| ;; | ||
| *) | ||
| usage >&2 | ||
| exit 1 | ||
| ;; | ||
| esac | ||
| done | ||
|
|
||
| if [[ ! -f $OPENCODE_SOURCE_PATH ]]; then | ||
| if (( OPENCODE_ACTIVATE == 1 )); then | ||
| echo "opencode theme source missing: $OPENCODE_SOURCE_PATH" >&2 | ||
| echo "Run omarchy-theme-refresh after selecting an Omarchy theme." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| exit 0 | ||
| fi | ||
|
|
||
| if (( OPENCODE_ACTIVATE == 0 )) && [[ ! -d $OPENCODE_CONFIG_DIR ]]; then | ||
| exit 0 | ||
| fi | ||
|
|
||
| write_tui_config() { | ||
| local tmp | ||
|
|
||
| mkdir -p "$OPENCODE_CONFIG_DIR" | ||
|
|
||
| if [[ -f $OPENCODE_TUI_CONFIG ]]; then | ||
| tmp=$(mktemp "$OPENCODE_TUI_CONFIG.XXXXXX") | ||
| # Append; plugin order affects initialization, so never rewrite the rest. | ||
| if ! jq --arg plugin "$OPENCODE_TUI_PLUGIN" \ | ||
| '.theme = "omarchy" | .plugin = (if ((.plugin // []) | index($plugin)) then .plugin else ((.plugin // []) + [$plugin]) end)' \ | ||
| "$OPENCODE_TUI_CONFIG" >"$tmp"; then | ||
| rm -f "$tmp" | ||
| return 1 | ||
| fi | ||
| mv "$tmp" "$OPENCODE_TUI_CONFIG" | ||
| else | ||
| cat >"$OPENCODE_TUI_CONFIG" <<EOF | ||
| { | ||
| "\$schema": "https://opencode.ai/tui.json", | ||
| "theme": "omarchy", | ||
| "plugin": ["$OPENCODE_TUI_PLUGIN"] | ||
| } | ||
| EOF | ||
| fi | ||
| } | ||
|
|
||
| install_plugin() { | ||
| [[ -f $OPENCODE_PLUGIN_SOURCE ]] || return 0 | ||
|
|
||
| mkdir -p "$(dirname "$OPENCODE_TUI_PLUGIN")" | ||
| ln -sfn "$OPENCODE_PLUGIN_SOURCE" "$OPENCODE_TUI_PLUGIN" | ||
| } | ||
|
|
||
| write_cli_config() { | ||
| local tmp | ||
| mkdir -p "$OPENCODE_V2_PLUGIN" | ||
| printf '%s\n' 'export { default } from "../tui-plugins/omarchy-theme.ts"' >"$OPENCODE_V2_PLUGIN/tui.js" | ||
| tmp=$(mktemp "$OPENCODE_CLI_CONFIG.XXXXXX") | ||
| if ! jq --arg legacy "$OPENCODE_TUI_PLUGIN" --arg plugin "$OPENCODE_V2_PLUGIN" --argjson activate "$OPENCODE_ACTIVATE" \ | ||
| 'if $activate == 1 then .theme = ((.theme // {}) + {name: "omarchy"}) else . end | .plugins = (((.plugins // []) | map(select(. != $legacy and . != $plugin))) + [$plugin])' \ | ||
| "$OPENCODE_CLI_CONFIG" >"$tmp"; then | ||
| rm -f "$tmp" | ||
| return 1 | ||
| fi | ||
| mv "$tmp" "$OPENCODE_CLI_CONFIG" | ||
| } | ||
|
|
||
| # The omarchy-theme TUI plugin watches the theme file and retints running | ||
| # sessions live, so running agents are never interrupted. opencode itself | ||
| # picks the file up at its next start. | ||
| mkdir -p "$OPENCODE_THEMES_DIR" | ||
| tmp=$(mktemp "$OPENCODE_THEME_PATH.XXXXXX") | ||
| cp "$OPENCODE_SOURCE_PATH" "$tmp" | ||
| mv "$tmp" "$OPENCODE_THEME_PATH" | ||
|
|
||
| if (( OPENCODE_ACTIVATE == 1 )); then | ||
| install_plugin | ||
| write_tui_config | ||
| fi | ||
| # Repair migrated V1 registration on ordinary syncs too, without changing a | ||
| # user's selected theme or touching any other plugin (including Herdr). | ||
| if [[ -f $OPENCODE_CLI_CONFIG ]]; then | ||
| install_plugin | ||
| write_cli_config | ||
| fi |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| import fs from "node:fs" | ||
| import os from "node:os" | ||
| import path from "node:path" | ||
| import type { TuiPlugin } from "@opencode-ai/plugin/tui" | ||
|
|
||
| // Hot-reloads the Omarchy theme into running opencode TUIs. Omarchy rewrites | ||
| // themes/omarchy.json on every theme change; this watches the file and swaps | ||
| // the live theme, so running agents are never interrupted. | ||
| // | ||
| // Live retint is the default for "omarchy" and generated omarchy-<hash> | ||
| // names. OpenCode's built-in "system" theme is terminal-adaptive (ANSI / | ||
| // "none") and does not read omarchy.json -- picking it opts out of live | ||
| // retint. Any other picker choice opts out too. | ||
| // | ||
| // theme.install() re-upserts content only while the theme is unknown to the | ||
| // registry, so each new palette is installed under a content-hashed name and | ||
| // older copies are pruned again. Install and prune only run while this session | ||
| // is opted in -- otherwise the plugin stays off the registry. | ||
|
|
||
| const THEME_NAME = "omarchy" | ||
| const DEBOUNCE_MS = 250 | ||
|
|
||
| const REQUIRED_COLORS = [ | ||
| "primary", | ||
| "secondary", | ||
| "accent", | ||
| "error", | ||
| "warning", | ||
| "success", | ||
| "info", | ||
| "text", | ||
| "textMuted", | ||
| "background", | ||
| "backgroundPanel", | ||
| "backgroundElement", | ||
| "border", | ||
| "borderActive", | ||
| "borderSubtle", | ||
| ] | ||
|
|
||
| function themesDir() { | ||
| // Truthy fallbacks on purpose: an empty-but-set OPENCODE_CONFIG_DIR or | ||
| // XDG_CONFIG_HOME must resolve the same way omarchy-theme-set-opencode's | ||
| // "${VAR:-...}" defaults do, or the watcher and the sync command diverge. | ||
| const config = | ||
| process.env.OPENCODE_CONFIG_DIR || | ||
| path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode") | ||
| return path.join(config, "themes") | ||
| } | ||
|
|
||
| function contentHash(text: string) { | ||
| let hash = 0x811c9dc5 | ||
| for (let i = 0; i < text.length; i++) { | ||
| hash ^= text.charCodeAt(i) | ||
| hash = Math.imul(hash, 0x01000193) | ||
| } | ||
| return (hash >>> 0).toString(16).padStart(8, "0") | ||
| } | ||
|
|
||
| const plugin: TuiPlugin = async (api) => { | ||
| const dir = themesDir() | ||
| const file = path.join(dir, `${THEME_NAME}.json`) | ||
| const pruned = new Set([dir, path.join(path.dirname(dir), ".opencode", "themes")]) | ||
| let watcher: fs.FSWatcher | undefined | ||
| let pending: ReturnType<typeof setTimeout> | undefined | ||
|
|
||
| const generatedName = new RegExp(`^${THEME_NAME}-[0-9a-f]{8}$`) | ||
| const owned = () => { | ||
| const selected = api.theme.selected | ||
| return selected === THEME_NAME || generatedName.test(selected) | ||
| } | ||
|
|
||
| const apply = async () => { | ||
| if (!owned()) return | ||
|
|
||
| let text: string | ||
| try { | ||
| text = fs.readFileSync(file, "utf8") | ||
| } catch { | ||
| return | ||
| } | ||
|
|
||
| let data: unknown | ||
| try { | ||
| data = JSON.parse(text) | ||
| } catch { | ||
| return | ||
| } | ||
| if (typeof data !== "object" || data === null || Array.isArray(data)) return | ||
| const colors = (data as Record<string, unknown>).theme | ||
| if (typeof colors !== "object" || colors === null) return | ||
| if (REQUIRED_COLORS.some((key) => (colors as Record<string, unknown>)[key] === undefined)) return | ||
| // Unresolved theme-template tokens would crash resolveTheme(); skip the file. | ||
| if (JSON.stringify(colors).includes("{{")) return | ||
|
|
||
| const name = `${THEME_NAME}-${contentHash(text)}` | ||
| const stale = (candidate: string) => | ||
| candidate !== `${name}.json` && new RegExp(`^${THEME_NAME}-[0-9a-f]{8}\\.json$`).test(candidate) | ||
|
|
||
| try { | ||
| if (!api.theme.has(name)) { | ||
| // A per-apply temp directory keeps concurrent opencode sessions from | ||
| // racing over one shared staged file; the file's basename must stay | ||
| // `${name}.json` because theme.install() derives the theme name from it. | ||
| const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), `${THEME_NAME}-theme-`)) | ||
| try { | ||
| const staged = path.join(stagedDir, `${name}.json`) | ||
| fs.writeFileSync(staged, text) | ||
| await api.theme.install(staged) | ||
| } finally { | ||
| fs.rmSync(stagedDir, { recursive: true, force: true }) | ||
| } | ||
| } | ||
| if (api.theme.selected !== name) api.theme.set(name) | ||
| for (const candidate of pruned) { | ||
| for (const entry of fs.existsSync(candidate) ? fs.readdirSync(candidate) : []) { | ||
| if (stale(entry)) fs.rmSync(path.join(candidate, entry), { force: true }) | ||
| } | ||
| } | ||
| } catch {} | ||
| } | ||
|
|
||
| // Serialize applies: theme.install() awaits, so a newer file event must not | ||
| // interleave with an apply still finishing -- otherwise a stale invocation | ||
| // could resume afterwards and set an older palette or prune the newer copy. | ||
| // Each apply re-reads the file when it starts, so the last one always wins. | ||
| let chain: Promise<void> = Promise.resolve() | ||
| const enqueueApply = () => (chain = chain.then(apply).catch(() => {})) | ||
|
|
||
| const schedule = () => { | ||
| if (pending) clearTimeout(pending) | ||
| pending = setTimeout(() => { | ||
| pending = undefined | ||
| void enqueueApply() | ||
| }, DEBOUNCE_MS) | ||
| } | ||
|
|
||
| try { | ||
| fs.mkdirSync(dir, { recursive: true }) | ||
| watcher = fs.watch(dir, (_event, _filename) => { | ||
| // React to any change in the directory, not just to omarchy.json | ||
| // itself: opencode runs on Bun, whose fs.watch does not reliably | ||
| // report the atomic rename that replaces omarchy.json (only the | ||
| // temp-file write/rename precursors show up). The debounced apply | ||
| // below re-reads omarchy.json itself, so unrelated files changing | ||
| // here cost at most one cheap re-read. | ||
| schedule() | ||
| }) | ||
| } catch { | ||
| return | ||
| } | ||
|
|
||
| api.lifecycle.onDispose(() => { | ||
| if (pending) clearTimeout(pending) | ||
| watcher?.close() | ||
| }) | ||
|
|
||
| await enqueueApply() | ||
| } | ||
|
|
||
| export default { | ||
| id: "omarchy-theme", | ||
| tui: plugin, | ||
| // OpenCode 2 uses setup/cleanup and exposes resolved theme tokens rather | ||
| // than the old install/set API. Its SIGUSR2 listener refreshes custom theme | ||
| // files without reloading server instances. Emit locally: never signal the | ||
| // shared server, which has no TUI theme handler. | ||
| setup() { | ||
| const dir = themesDir() | ||
| fs.mkdirSync(dir, { recursive: true }) | ||
| let pending: ReturnType<typeof setTimeout> | undefined | ||
| const watcher = fs.watch(dir, () => { | ||
| if (pending) clearTimeout(pending) | ||
| pending = setTimeout(() => { | ||
| pending = undefined | ||
| process.emit("SIGUSR2") | ||
| }, DEBOUNCE_MS) | ||
| }) | ||
| return () => { | ||
| if (pending) clearTimeout(pending) | ||
| watcher.close() | ||
| } | ||
| }, | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| { | ||
| "$schema": "https://opencode.ai/theme.json", | ||
| "theme": { | ||
| "primary": "{{ accent }}", | ||
| "secondary": "{{ cyan }}", | ||
| "accent": "{{ accent }}", | ||
| "error": "{{ red }}", | ||
| "warning": "{{ yellow }}", | ||
| "success": "{{ green }}", | ||
| "info": "{{ blue }}", | ||
| "text": "{{ foreground }}", | ||
| "textMuted": "{{ muted }}", | ||
| "selectedListItemText": "{{ selection_foreground }}", | ||
| "background": "{{ background }}", | ||
| "backgroundPanel": "{{ mix background foreground 5% }}", | ||
| "backgroundElement": "{{ mix background foreground 9% }}", | ||
| "backgroundMenu": "{{ mix background foreground 6% }}", | ||
| "border": "{{ mix background foreground 18% }}", | ||
| "borderActive": "{{ muted }}", | ||
| "borderSubtle": "{{ mix background foreground 12% }}", | ||
| "diffAdded": "{{ green }}", | ||
| "diffRemoved": "{{ red }}", | ||
| "diffContext": "{{ muted }}", | ||
| "diffHunkHeader": "{{ muted }}", | ||
| "diffHighlightAdded": "{{ green }}", | ||
| "diffHighlightRemoved": "{{ red }}", | ||
| "diffAddedBg": "{{ mix background green 14% }}", | ||
| "diffRemovedBg": "{{ mix background red 14% }}", | ||
| "diffContextBg": "{{ background }}", | ||
| "diffLineNumber": "{{ mix background foreground 25% }}", | ||
| "diffAddedLineNumberBg": "{{ mix background green 8% }}", | ||
| "diffRemovedLineNumberBg": "{{ mix background red 8% }}", | ||
| "markdownText": "{{ foreground }}", | ||
| "markdownHeading": "{{ bright_foreground }}", | ||
| "markdownLink": "{{ blue }}", | ||
| "markdownLinkText": "{{ cyan }}", | ||
| "markdownCode": "{{ cyan }}", | ||
| "markdownBlockQuote": "{{ muted }}", | ||
| "markdownEmph": "{{ yellow }}", | ||
| "markdownStrong": "{{ bright_foreground }}", | ||
| "markdownHorizontalRule": "{{ mix background foreground 20% }}", | ||
| "markdownListItem": "{{ accent }}", | ||
| "markdownListEnumeration": "{{ cyan }}", | ||
| "markdownImage": "{{ blue }}", | ||
| "markdownImageText": "{{ cyan }}", | ||
| "markdownCodeBlock": "{{ foreground }}", | ||
| "syntaxComment": "{{ muted }}", | ||
| "syntaxKeyword": "{{ magenta }}", | ||
| "syntaxFunction": "{{ blue }}", | ||
| "syntaxVariable": "{{ foreground }}", | ||
| "syntaxString": "{{ green }}", | ||
| "syntaxNumber": "{{ orange }}", | ||
| "syntaxType": "{{ yellow }}", | ||
| "syntaxOperator": "{{ cyan }}", | ||
| "syntaxPunctuation": "{{ muted }}", | ||
| "thinkingOpacity": 0.6 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.