Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/omarchy-restart-opencode
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/bash

# omarchy:summary=Reload opencode configuration (used by the Omarchy theme switching).
# omarchy:summary=Reload opencode configuration (manual escape hatch; theme switching no longer uses this).

if pgrep -x opencode >/dev/null; then
killall -SIGUSR2 opencode
Expand Down
2 changes: 1 addition & 1 deletion bin/omarchy-theme-set
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ post_theme_commands=(
omarchy-restart-terminal
omarchy-restart-hyprctl
omarchy-restart-btop
omarchy-restart-opencode
omarchy-theme-set-opencode
omarchy-restart-helix
omarchy-theme-set-foot
omarchy-theme-set-tmux
Expand Down
119 changes: 119 additions & 0 deletions bin/omarchy-theme-set-opencode
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
184 changes: 184 additions & 0 deletions config/opencode/tui-plugins/omarchy-theme.ts
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)
Comment thread
ekollof marked this conversation as resolved.
} 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()
}
},
}
58 changes: 58 additions & 0 deletions default/themed/opencode.json.tpl
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
}
}
1 change: 1 addition & 0 deletions install/user/theme.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ if [[ ! -s $HOME/.local/state/omarchy/current/theme.name ]]; then
fi
fi
omarchy-theme-set-pi --activate
omarchy-theme-set-opencode --activate

mkdir -p ~/.config/btop/themes
ln -snf "$HOME/.local/state/omarchy/current/theme/btop.theme" ~/.config/btop/themes/current.theme
Loading