import { createEffect, createMemo, onMount, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { useRenderer } from "@opentui/solid" import type { ThemeName } from "../types/settings" import type { ThemeJson } from "../types/theme-schema" import { useAppStore } from "../stores/app" import { THEME_JSON } from "../constants/themes" import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter" import { resolveTerminalTheme, loadThemes } from "../utils/theme" import { createSimpleContext } from "./helper" import { setupThemeSignalHandler, emitThemeChanged, emitThemeModeChanged } from "../utils/theme-observer" import { createTerminalPalette, type RGBA, type TerminalColors } from "@opentui/core" type ThemeResolved = { primary: RGBA secondary: RGBA accent: RGBA error: RGBA warning: RGBA success: RGBA info: RGBA text: RGBA textMuted: RGBA selectedListItemText: RGBA background: RGBA backgroundPanel: RGBA backgroundElement: RGBA backgroundMenu: RGBA border: RGBA borderActive: RGBA borderSubtle: RGBA diffAdded: RGBA diffRemoved: RGBA diffContext: RGBA diffHunkHeader: RGBA diffHighlightAdded: RGBA diffHighlightRemoved: RGBA diffAddedBg: RGBA diffRemovedBg: RGBA diffContextBg: RGBA diffLineNumber: RGBA diffAddedLineNumberBg: RGBA diffRemovedLineNumberBg: RGBA markdownText: RGBA markdownHeading: RGBA markdownLink: RGBA markdownLinkText: RGBA markdownCode: RGBA markdownBlockQuote: RGBA markdownEmph: RGBA markdownStrong: RGBA markdownHorizontalRule: RGBA markdownListItem: RGBA markdownListEnumeration: RGBA markdownImage: RGBA markdownImageText: RGBA markdownCodeBlock: RGBA syntaxComment: RGBA syntaxKeyword: RGBA syntaxFunction: RGBA syntaxVariable: RGBA syntaxString: RGBA syntaxNumber: RGBA syntaxType: RGBA syntaxOperator: RGBA syntaxPunctuation: RGBA muted?: RGBA surface?: RGBA layerBackgrounds?: { layer0: RGBA layer1: RGBA layer2: RGBA layer3: RGBA } _hasSelectedListItemText?: boolean thinkingOpacity?: number } /** * Theme context using the createSimpleContext pattern. * * This ensures children are NOT rendered until the theme is ready, * preventing "useTheme must be used within a ThemeProvider" errors. * * The key insight from opencode's implementation is that the provider * uses `` to gate rendering, so components can * safely call useTheme() without checking ready state. */ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light" }) => { const appStore = useAppStore() const renderer = useRenderer() const [store, setStore] = createStore({ themes: THEME_JSON as Record, mode: props.mode, active: appStore.state().settings.theme as string, system: undefined as undefined | TerminalColors, ready: false, }) function init() { resolveSystemTheme() loadThemes() .then((custom) => { setStore( produce((draft) => { Object.assign(draft.themes, custom) }) ) }) .catch(() => { // If custom themes fail to load, fall back to opencode theme setStore("active", "opencode") }) .finally(() => { // Only set ready if not waiting for system theme if (store.active !== "system") { setStore("ready", true) } }) } async function waitForCapabilities(timeoutMs = 300) { if (renderer.capabilities) return await new Promise((resolve) => { let done = false const onCaps = () => { if (done) return done = true renderer.off("capabilities", onCaps) clearTimeout(timer) resolve() } const timer = setTimeout(() => { if (done) return done = true renderer.off("capabilities", onCaps) resolve() }, timeoutMs) renderer.on("capabilities", onCaps) }) } async function resolveSystemTheme() { if (process.env.TMUX) { await waitForCapabilities() } let colors: TerminalColors | null = null try { colors = await renderer.getPalette({ size: 16 }) } catch { colors = null } if (!colors?.palette?.[0] && process.env.TMUX) { const writeOut = (renderer as unknown as { writeOut?: (data: string | Buffer) => boolean }).writeOut const writeFn = typeof writeOut === "function" ? writeOut.bind(renderer) : process.stdout.write.bind(process.stdout) const detector = createTerminalPalette(process.stdin, process.stdout, writeFn, true) try { const tmuxColors = await detector.detect({ size: 16, timeout: 1200 }) if (tmuxColors?.palette?.[0]) { colors = tmuxColors } } finally { detector.cleanup() } } const hasPalette = Boolean(colors?.palette?.some((value) => Boolean(value))) const hasDefaultColors = Boolean(colors?.defaultBackground || colors?.defaultForeground) if (!hasPalette && !hasDefaultColors) { // No system colors available, fall back to default // This happens when the terminal doesn't support OSC palette queries // (e.g., running inside tmux, or on unsupported terminals) if (store.active === "system") { setStore( produce((draft) => { draft.active = "opencode" draft.ready = true }) ) } return } if (colors) { setStore( produce((draft) => { draft.system = colors if (store.active === "system") { draft.ready = true } }) ) } } onMount(init) // Setup SIGUSR2 signal handler for dynamic theme reload // This allows external tools to trigger a theme refresh by sending: // `kill -USR2 ` const cleanupSignalHandler = setupThemeSignalHandler(() => { renderer.clearPaletteCache() init() }) onCleanup(cleanupSignalHandler) // Sync active theme with app store settings createEffect(() => { const theme = appStore.state().settings.theme if (theme) setStore("active", theme) }) // Emit theme change events for observers createEffect(() => { const theme = store.active const mode = store.mode if (store.ready) { emitThemeChanged(theme, mode) } }) const values = createMemo(() => { return resolveTerminalTheme(store.themes, store.active, store.mode, store.system) }) const syntax = createMemo(() => generateSyntax(values() as unknown as Record)) const subtleSyntax = createMemo(() => generateSubtleSyntax(values() as unknown as Record & { thinkingOpacity?: number }) ) return { theme: new Proxy(values(), { get(_target, prop) { // @ts-expect-error - dynamic property access return values()[prop] }, }) as ThemeResolved, get selected() { return store.active }, all() { return store.themes }, syntax, subtleSyntax, mode() { return store.mode }, setMode(mode: "dark" | "light") { setStore("mode", mode) emitThemeModeChanged(mode) }, set(theme: string) { appStore.setTheme(theme as ThemeName) }, get ready() { return store.ready }, } }, })