| import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core" |
| import { useRenderer } from "@opentui/solid" |
| import { |
| DEFAULT_THEMES, |
| addTheme, |
| allThemes, |
| generateSubtleSyntax, |
| generateSyntax, |
| generateSystem, |
| hasTheme, |
| isTheme, |
| resolveTheme, |
| selectedForeground, |
| setCustomThemes, |
| setSystemTheme, |
| subscribeThemes, |
| terminalMode, |
| tint, |
| upsertTheme, |
| type ThemeJson, |
| } from "../theme" |
| import { createEffect, createMemo, onCleanup, onMount } from "solid-js" |
| import { createStore, produce } from "solid-js/store" |
| import { createSimpleContext } from "./helper" |
| import { useKV } from "./kv" |
| import { useTuiConfig } from "../config" |
| import { Global } from "@opencode-ai/core/global" |
| import { Glob } from "@opencode-ai/core/util/glob" |
| import { readFile } from "node:fs/promises" |
| import path from "node:path" |
|
|
| export type ThemeSource = Readonly<{ |
| discover(): Promise<Record<string, unknown>> |
| subscribeRefresh?(refresh: () => void): () => void |
| }> |
|
|
| const themeSource: ThemeSource = { |
| async discover() { |
| const directories = [Global.Path.config] |
| for (let current = process.cwd(); ; current = path.dirname(current)) { |
| directories.push(path.join(current, ".opencode")) |
| if (path.dirname(current) === current) break |
| } |
| return discoverThemes(directories) |
| }, |
| subscribeRefresh(refresh) { |
| process.on("SIGUSR2", refresh) |
| return () => process.off("SIGUSR2", refresh) |
| }, |
| } |
|
|
| export async function discoverThemes(directories: string[]) { |
| const result: Record<string, unknown> = {} |
| for (const directory of directories) { |
| const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true }) |
| for (const file of files) { |
| result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown |
| } |
| } |
| return result |
| } |
|
|
| export { |
| DEFAULT_THEMES, |
| addTheme, |
| allThemes, |
| generateSubtleSyntax, |
| generateSyntax, |
| generateSystem, |
| hasTheme, |
| isTheme, |
| resolveTheme, |
| selectedForeground, |
| terminalMode, |
| tint, |
| upsertTheme, |
| type Theme, |
| type ThemeJson, |
| type SyntaxStyleOverrides, |
| } from "../theme" |
|
|
| const THEME_REFRESH_DELAYS = [250, 1000] as const |
|
|
| type State = { |
| themes: Record<string, ThemeJson> |
| mode: "dark" | "light" |
| lock: "dark" | "light" | undefined |
| active: string |
| ready: boolean |
| } |
|
|
| const [store, setStore] = createStore<State>({ |
| themes: allThemes(), |
| mode: "dark", |
| lock: undefined, |
| active: "opencode", |
| ready: false, |
| }) |
|
|
| subscribeThemes((themes) => setStore("themes", themes)) |
|
|
| export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ |
| name: "Theme", |
| init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => { |
| const renderer = useRenderer() |
| const config = useTuiConfig() |
| const kv = useKV() |
| const themes = props.source ?? themeSource |
| const pick = (value: unknown) => { |
| if (value === "dark" || value === "light") return value |
| return |
| } |
|
|
| setStore( |
| produce((draft) => { |
| const lock = pick(kv.get("theme_mode_lock")) |
| const mode = lock ?? pick(renderer.themeMode) ?? props.mode |
| if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined) |
| draft.mode = mode |
| draft.lock = lock |
| const active = config.theme ?? kv.get("theme", "opencode") |
| draft.active = typeof active === "string" ? active : "opencode" |
| draft.ready = false |
| }), |
| ) |
|
|
| createEffect(() => { |
| const theme = config.theme |
| if (theme) setStore("active", theme) |
| }) |
|
|
| function syncCustomThemes() { |
| return themes |
| .discover() |
| .then((themes) => { |
| setCustomThemes( |
| Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => { |
| if (isTheme(theme)) result[name] = theme |
| return result |
| }, {}), |
| ) |
| }) |
| .catch(() => setStore("active", "opencode")) |
| } |
|
|
| onMount(() => { |
| void Promise.allSettled([resolveSystemTheme(store.mode), syncCustomThemes()]).finally(() => { |
| setStore("ready", true) |
| }) |
| }) |
|
|
| let systemThemeSignature: string | undefined |
| let systemThemeMode: "dark" | "light" | undefined |
| let hasResolvedSystemTheme = false |
| function resolveSystemTheme(mode: "dark" | "light" = store.mode) { |
| return renderer |
| .getPalette({ size: 16 }) |
| .then((colors: TerminalColors) => { |
| if (!colors.palette[0]) { |
| if (hasResolvedSystemTheme) return |
| setSystemTheme(undefined) |
| if (store.active === "system") setStore("active", "opencode") |
| return |
| } |
| const next = store.lock ?? terminalMode(colors) ?? mode |
| if (store.mode !== next) setStore("mode", next) |
| const signature = JSON.stringify(colors) |
| hasResolvedSystemTheme = true |
| if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return |
| systemThemeSignature = signature |
| systemThemeMode = next |
| setSystemTheme(generateSystem(colors, next)) |
| }) |
| .catch(() => { |
| if (hasResolvedSystemTheme) return |
| setSystemTheme(undefined) |
| if (store.active === "system") setStore("active", "opencode") |
| }) |
| } |
|
|
| let systemRefreshRunning = false |
| let systemRefreshQueued = false |
| let systemRefreshMode = store.mode |
| function refreshSystemTheme(mode: "dark" | "light" = store.mode) { |
| systemRefreshMode = mode |
| if (systemRefreshRunning) { |
| systemRefreshQueued = true |
| return |
| } |
|
|
| systemRefreshRunning = true |
| const retry = renderer.paletteDetectionStatus === "detecting" |
| renderer.clearPaletteCache() |
| void resolveSystemTheme(mode).finally(() => { |
| systemRefreshRunning = false |
| if (!retry && !systemRefreshQueued) return |
| systemRefreshQueued = false |
| refreshSystemTheme(systemRefreshMode) |
| }) |
| } |
|
|
| function apply(mode: "dark" | "light") { |
| if (store.lock !== undefined) kv.set("theme_mode", mode) |
| if (store.mode === mode) return |
| setStore("mode", mode) |
| refreshSystemTheme(mode) |
| } |
|
|
| function pin(mode: "dark" | "light" = store.mode) { |
| setStore("lock", mode) |
| kv.set("theme_mode_lock", mode) |
| apply(mode) |
| } |
|
|
| function free() { |
| setStore("lock", undefined) |
| kv.set("theme_mode_lock", undefined) |
| kv.set("theme_mode", undefined) |
| refreshSystemTheme(renderer.themeMode ?? store.mode) |
| } |
|
|
| const handle = (mode: "dark" | "light") => { |
| if (store.lock) return |
| apply(mode) |
| } |
| renderer.on(CliRenderEvents.THEME_MODE, handle) |
|
|
| const handleThemeNotification = (sequence: string) => { |
| if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false |
| queueMicrotask(() => refreshSystemTheme()) |
| return false |
| } |
| renderer.prependInputHandler(handleThemeNotification) |
|
|
| let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = [] |
| const refresh = () => { |
| for (const timeout of themeRefreshTimeouts) clearTimeout(timeout) |
| themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) => |
| setTimeout(() => { |
| refreshSystemTheme() |
| if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes() |
| }, delay), |
| ) |
| } |
| let unsubscribeRefresh: (() => void) | undefined |
| unsubscribeRefresh = themes.subscribeRefresh?.(refresh) |
|
|
| onCleanup(() => { |
| renderer.off(CliRenderEvents.THEME_MODE, handle) |
| renderer.removeInputHandler(handleThemeNotification) |
| unsubscribeRefresh?.() |
| for (const timeout of themeRefreshTimeouts) clearTimeout(timeout) |
| themeRefreshTimeouts.length = 0 |
| }) |
|
|
| const values = createMemo(() => { |
| const active = store.themes[store.active] |
| if (active) return resolveTheme(active, store.mode) |
|
|
| const saved = kv.get("theme") |
| if (typeof saved === "string") { |
| const theme = store.themes[saved] |
| if (theme) return resolveTheme(theme, store.mode) |
| } |
|
|
| return resolveTheme(store.themes.opencode, store.mode) |
| }) |
|
|
| createEffect(() => renderer.setBackgroundColor(values().background)) |
|
|
| const syntax = createSyntaxStyleMemo(() => generateSyntax(values())) |
| const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values())) |
|
|
| return { |
| theme: new Proxy(values(), { |
| get(_target, prop) { |
| |
| return values()[prop] |
| }, |
| }), |
| get selected() { |
| return store.active |
| }, |
| all: allThemes, |
| has: hasTheme, |
| syntax, |
| subtleSyntax, |
| mode: () => store.mode, |
| locked: () => store.lock !== undefined, |
| lock: () => pin(store.mode), |
| unlock: free, |
| setMode: pin, |
| set(theme: string) { |
| if (!hasTheme(theme)) return false |
| setStore("active", theme) |
| kv.set("theme", theme) |
| return true |
| }, |
| get ready() { |
| return store.ready |
| }, |
| } |
| }, |
| }) |
|
|
| export function createSyntaxStyleMemo(factory: () => SyntaxStyle) { |
| const renderer = useRenderer() |
| const retained = new Set<SyntaxStyle>() |
| let current: SyntaxStyle | undefined |
|
|
| const release = (style: SyntaxStyle) => { |
| retained.add(style) |
| void renderer |
| .idle() |
| .catch(() => {}) |
| .finally(() => { |
| if (!retained.delete(style)) return |
| style.destroy() |
| }) |
| } |
|
|
| onCleanup(() => { |
| if (current) release(current) |
| }) |
|
|
| return createMemo(() => { |
| const previous = current |
| current = factory() |
| if (previous) release(previous) |
| return current |
| }) |
| } |
|
|