import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core" import { useRenderer } from "@opentui/solid" import { generateSyntax, resolveThemeDocument, themeModes, type ResolvedTheme, type SurfaceName, } from "@opencode/theme/tui" import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, parseTheme, selectedForeground, setCustomThemes, setSystemTheme, subscribeThemes, upsertTheme, type Theme, type ThemeDocumentSource, } from "../theme" import { generateSystem, terminalMode } from "../theme/system" import { discoverThemes } from "../theme/discovery" import { createComponentTheme, type ComponentTheme } from "../theme/component" import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useConfig } from "../config" import { useStorageOptional } from "./storage" import { DevTools } from "../devtools" import { configDirectories } from "../util/config-directories" const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" }) export type ThemeError = { name: string; error: Error } type ThemeErrorHandler = (event: ThemeError) => void function createThemeErrors() { let handler: ThemeErrorHandler | undefined let pending: ThemeError | undefined return { emit(name: string, cause: unknown) { const event = { name, error: cause instanceof Error ? cause : new Error(String(cause)) } if (handler) { handler(event) return } pending = event }, onError(next: ThemeErrorHandler) { handler = next if (pending) { next(pending) pending = undefined } return () => { if (handler === next) handler = undefined } }, } } const themeErrors = createThemeErrors() export type ThemeSource = Readonly<{ discover(): Promise> subscribeRefresh?(refresh: () => void): () => void }> export const createThemeSource = (config: string): ThemeSource => ({ async discover() { return discoverThemes(configDirectories(config, process.cwd())) }, subscribeRefresh(refresh) { process.on("SIGUSR2", refresh) return () => process.off("SIGUSR2", refresh) }, }) export { discoverThemes } from "../theme/discovery" export { DEFAULT_THEMES, addTheme, allThemes, generateSyntax, hasTheme, selectedForeground, upsertTheme, type Theme, } from "../theme" const THEME_REFRESH_DELAYS = [250, 1000] as const type State = { mode: "dark" | "light" lock: "dark" | "light" | undefined active: string ready: boolean } type Themes = { current: ComponentTheme currentTokens: Accessor readonly selected: string all: typeof allThemes has: typeof hasTheme currentSyntax: Accessor mode: Accessor<"dark" | "light"> modes: Accessor supports(mode: "dark" | "light"): boolean locked: Accessor lock(): void unlock(): void setMode(mode?: "dark" | "light", persist?: boolean): boolean set(theme: string): boolean afterPaint(): void onError(handler: ThemeErrorHandler): () => void readonly ready: boolean } type ThemeContextValue = { current: ComponentTheme themes: Themes readonly ready: boolean } const [store, setStore] = createStore({ mode: "dark", lock: undefined, active: "opencode", ready: false, }) const [themeSources, setThemeSources] = createSignal(allThemes()) subscribeThemes(setThemeSources) const themeContext = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => { const renderer = useRenderer() const configState = useConfig() const config = configState.data const themes = props.source const cache = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} }) setStore( produce((draft) => { const lock = config.theme?.mode === "dark" || config.theme?.mode === "light" ? config.theme.mode : undefined const mode = lock ?? renderer.themeMode ?? props.mode draft.mode = mode draft.lock = lock draft.active = config.theme?.name ?? "opencode" draft.ready = false }), ) createEffect(() => { const theme = config.theme?.name if (!theme) return setStore("active", theme) if (theme === "system") refreshPalette() }) createEffect(() => { const mode = config.theme?.mode if (mode === "dark" || mode === "light") { pin(mode, false) return } if (mode === "system" && store.lock !== undefined) free(false) }) function syncCustomThemes() { return themes .discover() .then((themes) => { setCustomThemes(themes) }) .catch(() => setStore("active", "opencode")) } onMount(() => { // Terminal palette queries serialize with frame output. First paint uses the cached palette or built-in fallback. void syncCustomThemes().finally(() => { tokens() setStore("ready", true) }) }) const cachedPalette = cache?.[0].colors let palette = usablePalette(cachedPalette) ? cachedPalette : undefined const applyPalette = () => { if (palette) setSystemTheme(generateSystem(palette, store.mode)) } if (palette) { const mode = store.lock ?? terminalMode(palette) ?? store.mode if (store.mode !== mode) setStore("mode", mode) applyPalette() } else setSystemTheme(undefined) let canProbe = false let probing = false let queued = false let disposed = false function refreshPalette() { if (store.active !== "system") return queued = true if (!canProbe || probing || disposed) return queued = false probing = true // Clearing does not cancel a query already owned by the renderer. Share it, then run one fresh query. const retry = renderer.paletteDetectionStatus === "detecting" renderer.clearPaletteCache() void renderer .getPalette({ size: 16 }) .then((colors) => { if (disposed || !usablePalette(colors)) return palette = colors const mode = store.lock ?? terminalMode(colors) ?? store.mode if (store.mode !== mode) setStore("mode", mode) applyPalette() void cache?.[1]((draft) => { draft.colors = colors }).catch(() => {}) }) .catch(() => {}) .finally(() => { probing = false if (disposed || (!retry && !queued)) return refreshPalette() }) } function afterPaint() { canProbe = true refreshPalette() } function apply(mode: "dark" | "light") { if (store.mode !== mode) { setStore("mode", mode) applyPalette() } refreshPalette() } function pin(mode: "dark" | "light" = store.mode, persist = true) { setStore("lock", mode) apply(mode) if (!persist) return void configState .update((draft) => { draft.theme = { ...draft.theme, mode } }) .catch(() => {}) } function free(persist = true) { setStore("lock", undefined) apply(renderer.themeMode ?? store.mode) if (!persist) return void configState .update((draft) => { draft.theme = { ...draft.theme, mode: "system" } }) .catch(() => {}) } const handleMode = (mode: "dark" | "light") => { if (store.lock) return apply(mode) } renderer.on(CliRenderEvents.THEME_MODE, handleMode) const handleThemeNotification = (sequence: string) => { if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false queueMicrotask(refreshPalette) return false } renderer.prependInputHandler(handleThemeNotification) let themeRefreshTimeouts: ReturnType[] = [] const refreshThemes = () => { for (const timeout of themeRefreshTimeouts) clearTimeout(timeout) themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) => setTimeout(() => { refreshPalette() if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes() }, delay), ) } const unsubscribeRefresh = themes.subscribeRefresh?.(refreshThemes) onCleanup(() => { disposed = true renderer.off(CliRenderEvents.THEME_MODE, handleMode) renderer.removeInputHandler(handleThemeNotification) unsubscribeRefresh?.() for (const timeout of themeRefreshTimeouts) clearTimeout(timeout) themeRefreshTimeouts.length = 0 }) const initStarted = performance.now() const selected = createMemo(() => { const sources = themeSources() const name = sources[store.active] ? store.active : "opencode" try { return loadTheme(sources[name], name, store.mode) } catch (error) { if (name === "opencode") throw error themeErrors.emit(name, error) setStore("active", "opencode") return loadTheme(sources.opencode, "opencode", store.mode) } }) const modes = () => selected().modes const mode = () => selected().mode const tokens = () => selected().theme tokens() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const current = createComponentTheme(tokens) createEffect(() => renderer.setBackgroundColor(tokens().background.base)) const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens())) const service: Themes = { current, currentTokens: tokens, currentSyntax, get selected() { return store.active }, all: allThemes, has: hasTheme, mode, modes, supports: (requested) => modes().includes(requested), locked: () => store.lock !== undefined, lock: () => pin(mode()), unlock: free, setMode(requested = mode(), persist = true) { if (!modes().includes(requested)) return false pin(requested, persist) return true }, set(theme: string) { if (!hasTheme(theme)) return false setStore("active", theme) if (theme === "system") refreshPalette() void configState .update((draft) => { draft.theme = { ...draft.theme, name: theme } }) .catch(() => {}) return true }, afterPaint, onError: themeErrors.onError, get ready() { return store.ready }, } return { current, themes: service, get ready() { return service.ready }, } }, }) export function useThemes() { return themeContext.use().themes } export function useTheme(): ComponentTheme { return themeContext.use().current } export const ThemeProvider = themeContext.provider /** Switches the ambient theme surface without remounting children; undefined inherits the enclosing view. */ export function ThemeContextProvider(props: ParentProps<{ context: SurfaceName | undefined }>) { const value = themeContext.use() const current = createComponentTheme(() => { const name = props.context return name ? value.themes.currentTokens().surface(name) : value.current }) return ( {props.children} ) } function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors { return Boolean( colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]), ) } function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") { const document = parseTheme(source, name) const modes = themeModes(document) const mode = modes.includes(requested) ? requested : (modes[0] ?? requested) return { modes, mode, theme: resolveThemeDocument(document, mode) } } export function createSyntaxStyleMemo(factory: () => SyntaxStyle) { const renderer = useRenderer() const retained = new Set() 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 }) }