diff --git a/packages/docs/script/generate-theme-tokens.ts b/packages/docs/script/generate-theme-tokens.ts index 0c441075f962..25f614d119b3 100644 --- a/packages/docs/script/generate-theme-tokens.ts +++ b/packages/docs/script/generate-theme-tokens.ts @@ -2,7 +2,7 @@ import { Schema, SchemaAST } from "effect" import { format } from "prettier" -import { ThemeDefinition, ThemeFile } from "../../tui/src/theme/v2/schema" +import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema" const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx" const root = requireObject(ThemeDefinition.ast) @@ -52,8 +52,8 @@ const example = { default: "#101014", }, }, -} satisfies ThemeFile -Schema.decodeUnknownSync(ThemeFile)(example) +} satisfies ThemeDocument +Schema.decodeUnknownSync(ThemeDocument)(example) const output = await format( `{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 5e600b65e845..91b286e7986d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -65,6 +65,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" +import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme } from "./context/theme" import { Home } from "./routes/home" import { Session } from "./routes/session" @@ -337,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + diff --git a/packages/tui/src/component/theme-error-toast.tsx b/packages/tui/src/component/theme-error-toast.tsx new file mode 100644 index 000000000000..f10a11bdc942 --- /dev/null +++ b/packages/tui/src/component/theme-error-toast.tsx @@ -0,0 +1,20 @@ +import { onCleanup } from "solid-js" +import { useTheme } from "../context/theme" +import { useToast } from "../ui/toast" + +export function ThemeErrorToast() { + const theme = useTheme() + const toast = useToast() + + onCleanup( + theme.onError(({ name, error }) => + toast.show({ + variant: "error", + title: `Failed to load theme: ${name}`, + message: error.message, + }), + ), + ) + + return null +} diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 16ffdeaccbee..9b53e8d07a2a 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -5,21 +5,20 @@ import { addTheme, allThemes, hasTheme, - isTheme, + parseTheme, selectedForeground, setCustomThemes, setSystemTheme, subscribeThemes, upsertTheme, type Theme, - type ThemeJson, + type ThemeDocumentSource, } from "../theme" import { generateSyntax } from "../theme/v2/syntax" import { generateSystem, terminalMode } from "../theme/system" import { discoverThemes, themeDirectories } from "../theme/discovery" import { createComponentTheme, type ComponentTheme } from "../theme/v2/component" -import { resolveThemeFile } from "../theme/v2/resolve" -import { migrateV1 } from "../theme/v2/v1-migrate" +import { resolveThemeDocument } from "../theme/v2/resolve" import { themeModes } from "../theme/v2/select" import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" @@ -29,6 +28,36 @@ import { Global } from "@opencode-ai/util/global" import { DevTools } from "../devtools" 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> @@ -61,7 +90,7 @@ export { const THEME_REFRESH_DELAYS = [250, 1000] as const type State = { - themes: Record + themes: Record mode: "dark" | "light" lock: "dark" | "light" | undefined active: string @@ -84,6 +113,7 @@ type ThemeService = { unlock(): void setMode(mode?: "dark" | "light", persist?: boolean): boolean set(theme: string): boolean + onError(handler: ThemeErrorHandler): () => void readonly ready: boolean } @@ -139,12 +169,7 @@ const themeContext = createSimpleContext({ return themes .discover() .then((themes) => { - setCustomThemes( - Object.entries(themes).reduce>((result, [name, theme]) => { - if (isTheme(theme)) result[name] = theme - return result - }, {}), - ) + setCustomThemes(themes) }) .catch(() => setStore("active", "opencode")) } @@ -269,30 +294,26 @@ const themeContext = createSimpleContext({ }) const initStarted = performance.now() - const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode) - const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode")) - const file = createMemo(() => migrateV1(source())) - const modes = createMemo(() => themeModes(file())) - const mode = () => { - const supported = modes() - if (supported.includes(store.mode)) return store.mode - return supported[0] ?? store.mode - } - const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName())) + const selected = createMemo(() => { + const name = store.themes[store.active] ? store.active : "opencode" + try { + return loadTheme(store.themes[name], name, store.mode) + } catch (error) { + if (name === "opencode") throw error + themeErrors.emit(name, error) + setStore("active", "opencode") + return loadTheme(store.themes.opencode, "opencode", store.mode) + } + }) + const modes = () => selected().modes + const mode = () => selected().mode + const valuesV2 = () => selected().theme valuesV2() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const themeV2 = createComponentTheme(valuesV2, mode) const contextsV2 = { - elevated: createComponentTheme(() => { - const theme = valuesV2().contexts["@context:elevated"] - if (!theme) throw new Error("Theme context is not defined: elevated") - return theme - }, mode), - overlay: createComponentTheme(() => { - const theme = valuesV2().contexts["@context:overlay"] - if (!theme) throw new Error("Theme context is not defined: overlay") - return theme - }, mode), + elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode), + overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode), } createEffect(() => renderer.setBackgroundColor(valuesV2().background.default)) @@ -331,6 +352,7 @@ const themeContext = createSimpleContext({ .catch(() => {}) return true }, + onError: themeErrors.onError, get ready() { return store.ready }, @@ -354,6 +376,14 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName } ) } + +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() diff --git a/packages/tui/src/mini/theme.ts b/packages/tui/src/mini/theme.ts index 736eed08bb2f..3c5afed16e6a 100644 --- a/packages/tui/src/mini/theme.ts +++ b/packages/tui/src/mini/theme.ts @@ -10,7 +10,7 @@ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" import { ansiToRgba } from "../theme/color" import { resolveThemeColors } from "../theme/resolve" import { terminalMode } from "../theme/system" -import type { ThemeJson } from "../theme/v1" +import type { ThemeV1Json } from "../theme/v1" import type { EntryKind, RunTuiConfig } from "./types" type Tone = { @@ -184,7 +184,7 @@ function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number) return nearestIndexed(indexed, mixed) } -export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent { +export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): TuiThemeCurrent { const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code))) return { ...resolved.theme, @@ -246,7 +246,7 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => return map(RGBA.fromInts(gray, gray, gray)) } -export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson { +export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json { const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const bg = RGBA.defaultBackground(bg_snapshot) diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index ef522b43c283..d128a75539fc 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -1,16 +1,25 @@ +import { Schema } from "effect" import { resolveThemeColors } from "./resolve" -import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1" +import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1" +import { resolveThemeDocument, themeDecodeError } from "./v2/resolve" +import { ThemeDocument } from "./v2/schema" +import { migrateV1 } from "./v2/v1-migrate" -export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1" +export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1" +export { resolveThemeDocument, type ThemeDocument } -const pluginThemes: Record = {} -let customThemes: Record = {} -let systemTheme: ThemeJson | undefined -const listeners = new Set<(themes: Record) => void>() +export type ThemeDocumentSource = Record + +const pluginThemes: Record = {} +let customThemes: Record = {} +let systemTheme: ThemeDocumentSource | undefined +const listeners = new Set<(themes: Record) => void>() +const parsed = new WeakMap() +const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument) function listThemes() { // Priority: defaults < plugin installs < custom files < generated system. - const themes = { + const themes: Record = { ...DEFAULT_THEMES, ...pluginThemes, ...customThemes, @@ -31,23 +40,40 @@ export function allThemes() { return listThemes() } -export function isTheme(theme: unknown): theme is ThemeJson { - if (typeof theme !== "object" || theme === null || Array.isArray(theme)) return false - const value = Reflect.get(theme, "theme") - return typeof value === "object" && value !== null && !Array.isArray(value) +export function isThemeSource(source: unknown): source is ThemeDocumentSource { + if (typeof source !== "object" || source === null || Array.isArray(source)) return false + return "theme" in source || "version" in source +} + +export function parseTheme(source: ThemeDocumentSource, name = "theme") { + const cached = parsed.get(source) + if (cached) return cached + + const version = source.version ?? 1 + const document = + version === 1 + ? migrateV1(source as ThemeV1Json) + : version === 2 + ? decodeV2Theme(source, name) + : unsupportedThemeVersion(version) + + parsed.set(source, document) + return document } -export function subscribeThemes(listener: (themes: Record) => void) { +export function subscribeThemes(listener: (themes: Record) => void) { listeners.add(listener) return () => listeners.delete(listener) } -export function setCustomThemes(themes: Record) { - customThemes = themes +export function setCustomThemes(themes: Record) { + customThemes = Object.fromEntries( + Object.entries(themes).filter((entry): entry is [string, ThemeDocumentSource] => isThemeSource(entry[1])), + ) syncThemes() } -export function setSystemTheme(theme: ThemeJson | undefined) { +export function setSystemTheme(theme: ThemeDocumentSource | undefined) { systemTheme = theme syncThemes() } @@ -59,7 +85,7 @@ export function hasTheme(name: string) { export function addTheme(name: string, theme: unknown) { if (!name) return false - if (!isTheme(theme)) return false + if (!isThemeSource(theme)) return false if (hasTheme(name)) return false pluginThemes[name] = theme syncThemes() @@ -68,7 +94,7 @@ export function addTheme(name: string, theme: unknown) { export function upsertTheme(name: string, theme: unknown) { if (!name) return false - if (!isTheme(theme)) return false + if (!isThemeSource(theme)) return false if (customThemes[name] !== undefined) { customThemes[name] = theme } else { @@ -78,7 +104,7 @@ export function upsertTheme(name: string, theme: unknown) { return true } -export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { +export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme { const resolved = resolveThemeColors(theme, mode) return { ...resolved.theme, @@ -86,3 +112,15 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { thinkingOpacity: resolved.thinkingOpacity, } } + +function decodeV2Theme(source: ThemeDocumentSource, name: string) { + try { + return decodeThemeDocument(source) + } catch (error) { + throw themeDecodeError(error, name) + } +} + +function unsupportedThemeVersion(version: unknown): never { + throw new Error(`Unsupported theme version: ${String(version)}`) +} diff --git a/packages/tui/src/theme/resolve.ts b/packages/tui/src/theme/resolve.ts index b8cf36866c44..6d028e8fa95b 100644 --- a/packages/tui/src/theme/resolve.ts +++ b/packages/tui/src/theme/resolve.ts @@ -1,9 +1,9 @@ import { RGBA } from "@opentui/core" import { ansiToRgba } from "./color" -import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1" +import type { ColorValue, Theme, ThemeColor, ThemeV1Json } from "./v1" export function resolveThemeColors( - theme: ThemeJson, + theme: ThemeV1Json, mode: "dark" | "light", resolveAnsi: (code: number) => RGBA = ansiToRgba, ) { @@ -43,7 +43,9 @@ export function resolveThemeColors( ? resolveColor(theme.theme.selectedListItemText!) : resolved.background!, backgroundMenu: - theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu), + theme.theme.backgroundMenu === undefined + ? resolved.backgroundElement! + : resolveColor(theme.theme.backgroundMenu), } satisfies Omit, hasSelectedListItemText, thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6, diff --git a/packages/tui/src/theme/v1.ts b/packages/tui/src/theme/v1.ts index 483882e274a0..5cf1df1b9f28 100644 --- a/packages/tui/src/theme/v1.ts +++ b/packages/tui/src/theme/v1.ts @@ -98,7 +98,7 @@ export type Variant = { light: HexColor | RefName } export type ColorValue = HexColor | RefName | Variant | RGBA | number -export type ThemeJson = { +export type ThemeV1Json = { $schema?: string defs?: Record theme: Omit, "selectedListItemText" | "backgroundMenu"> & { @@ -108,7 +108,7 @@ export type ThemeJson = { } } -export const DEFAULT_THEMES: Record = { +export const DEFAULT_THEMES: Record = { aura, ayu, catppuccin, diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts index 666f45014cfa..e9eb352a07ff 100644 --- a/packages/tui/src/theme/v2/defaults.ts +++ b/packages/tui/src/theme/v2/defaults.ts @@ -1,4 +1,4 @@ -import type { HueName, ThemeFile } from "./schema" +import type { HueName, ThemeDocument } from "./schema" export const DEFAULT_CATEGORICAL = [ "blue", @@ -437,4 +437,4 @@ export const DEFAULT_THEME = { }, }, }, -} satisfies ThemeFile +} satisfies ThemeDocument diff --git a/packages/tui/src/theme/v2/index.ts b/packages/tui/src/theme/v2/index.ts index 953112da50dd..b6cfbb98f524 100644 --- a/packages/tui/src/theme/v2/index.ts +++ b/packages/tui/src/theme/v2/index.ts @@ -16,7 +16,7 @@ export { SyntaxDefinition, SyntaxToken, ThemeDefinition, - ThemeFile, + ThemeDocument, type BackgroundDefinition, type DiffDefinition, type FileThemeDefinition, diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts index 74d06e8f1ada..500f853dbd82 100644 --- a/packages/tui/src/theme/v2/resolve.ts +++ b/packages/tui/src/theme/v2/resolve.ts @@ -11,7 +11,7 @@ import { HueAlias, HueStep, ThemeDefinition, - ThemeFile, + ThemeDocument, } from "./schema" import type { ActionStateKey, @@ -26,7 +26,6 @@ import type { import { selectTheme, selectThemeMode } from "./select" const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition) -const decodeThemeFileSchema = Schema.decodeUnknownSync(ThemeFile) function decodeThemeDefinition(input: unknown) { try { @@ -36,27 +35,18 @@ function decodeThemeDefinition(input: unknown) { } } -function decodeThemeFile(input: unknown, name: string) { - try { - return decodeThemeFileSchema(input) - } catch (error) { - throw themeDecodeError(error, name) - } -} - -function themeDecodeError(error: unknown, name: string) { +export function themeDecodeError(error: unknown, name: string) { const message = Schema.isSchemaError(error) ? error.message : String(error) const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value" return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error }) } -export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") { - const decoded = decodeThemeFile(file, name) - const selected = selectThemeMode(decoded, mode) +export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") { + const selected = selectThemeMode(document, mode) const definition = selected.expanded ? selected.theme : expandTheme(selected.theme) const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode)) const core = expandTokens(fallback()) - const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition) + const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition) if (!merged["hue"]) throw new Error("Standalone themes must provide hues") return resolveExpandedTheme({ ...merged, diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts index 76a33f7a0999..2256a3a1d64c 100644 --- a/packages/tui/src/theme/v2/schema.ts +++ b/packages/tui/src/theme/v2/schema.ts @@ -251,8 +251,8 @@ const FileMetadata = { version: Schema.Literal(2), standalone: Schema.optional(Schema.Boolean), } -export const ThemeFile = Schema.Union([ +export const ThemeDocument = Schema.Union([ Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }), Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }), ]) -export type ThemeFile = Schema.Schema.Type +export type ThemeDocument = Schema.Schema.Type diff --git a/packages/tui/src/theme/v2/select.ts b/packages/tui/src/theme/v2/select.ts index c763179f53af..17d47488fa14 100644 --- a/packages/tui/src/theme/v2/select.ts +++ b/packages/tui/src/theme/v2/select.ts @@ -5,45 +5,45 @@ import type { Mode, ModeDefinition, ThemeDefinition, - ThemeFile, + ThemeDocument, } from "./index" export function selectTheme( - file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition }, + document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition }, mode?: Mode, ): ThemeDefinition -export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition -export function selectTheme(file: ThemeFile, mode?: Mode) { - return selectThemeMode(file, mode).theme +export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition +export function selectTheme(document: ThemeDocument, mode?: Mode) { + return selectThemeMode(document, mode).theme } export function selectThemeMode( - file: ThemeFile, + document: ThemeDocument, mode: Mode = "light", ): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } { - const modes = themeModes(file) + const modes = themeModes(document) const selectedMode = modes.includes(mode) ? mode : modes[0] - const selected = file[selectedMode] + const selected = document[selectedMode] if (!selected) throw new Error("Theme must provide at least one mode") - if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes") + if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes") if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false } const otherMode = selectedMode === "light" ? "dark" : "light" - const other = file[otherMode] + const other = document[otherMode] if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`) const merged = mergeTheme(expandTheme(other), expandTheme(selected)) if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`) return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true } } -export function themeModes(file: ThemeFile): readonly Mode[] { - if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme") - if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme") - return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined) +export function themeModes(document: ThemeDocument): readonly Mode[] { + if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme") + if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme") + return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined) } -export function supportsThemeMode(file: ThemeFile, mode: Mode) { - return themeModes(file).includes(mode) +export function supportsThemeMode(document: ThemeDocument, mode: Mode) { + return themeModes(document).includes(mode) } function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition { diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index 42e7c31b962d..4fdea0d135ee 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -1,8 +1,8 @@ import { RGBA } from "@opentui/core" import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color" -import type { Theme, ThemeJson } from "../index" +import type { Theme, ThemeV1Json } from "../v1" import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults" -import type { FileThemeDefinition, Mode, ThemeFile } from "./index" +import type { FileThemeDefinition, Mode, ThemeDocument } from "./index" import { HueStep } from "./schema" type ThemeColor = Exclude @@ -14,7 +14,7 @@ const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "succes const minimumChroma = 0.03 const lightThreshold = 0.6 -export function migrateV1(theme: ThemeJson): ThemeFile { +export function migrateV1(theme: ThemeV1Json): ThemeDocument { const light = resolveV1(theme, "light") const dark = resolveV1(theme, "dark") if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) { @@ -234,7 +234,7 @@ function ambiguous(color: RGBA, chroma = toOklch(color).c) { return color.toInts()[3] === 0 || chroma < minimumChroma } -function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme { +function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme { const defs = theme.defs ?? {} function resolveColor(value: unknown, chain: string[] = []): RGBA { diff --git a/packages/tui/test/cli/tui/theme-mode.test.tsx b/packages/tui/test/cli/tui/theme-mode.test.tsx index cb195e384bb1..0b8123f05c7a 100644 --- a/packages/tui/test/cli/tui/theme-mode.test.tsx +++ b/packages/tui/test/cli/tui/theme-mode.test.tsx @@ -1,10 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { DEFAULT_THEMES } from "../../../src/theme" +import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" +import { selectTheme } from "../../../src/theme/v2/select" import { ConfigProvider } from "../../../src/config" -import { ThemeProvider, useTheme } from "../../../src/context/theme" +import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme" async function wait(fn: () => boolean) { const started = Date.now() @@ -24,6 +27,7 @@ test("uses an available mode while retaining the pinned preference", async () => const darkOnly = structuredClone(DEFAULT_THEMES.opencode) darkOnly.theme.background = "#111111" darkOnly.theme.text = "#eeeeee" + const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const let theme: ReturnType | undefined function Probe() { @@ -42,7 +46,7 @@ test("uses an available mode while retaining the pinned preference", async () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }} + source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual, native }) }} > @@ -66,6 +70,85 @@ test("uses an available mode while retaining the pinned preference", async () => expect(current().set("dual")).toBeTrue() await wait(() => current().mode() === "dark") expect(current().modes()).toEqual(["light", "dark"]) + expect(current().set("native")).toBeTrue() + await wait(() => current().selected === "native") + expect(current().modes()).toEqual(["dark"]) + expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue() + } finally { + app.renderer.destroy() + } +}) + +test.each([ + ["schema", { version: 2, light: { categorical: [] } }], + ["mode merging", { version: 2, light: { mergeMode: true } }], + ["token reference", { version: 2, light: { text: { default: "$missing" } } }], +] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => { + let theme: ReturnType | undefined + let failure: ThemeError | undefined + let unsubscribe: (() => void) | undefined + + function Probe() { + const value = useTheme() + theme = value + unsubscribe = value.onError((error) => (failure = error)) + return {value.selected} + } + + const app = await testRender( + () => ( + + Promise.resolve({ invalid: source }) }}> + + + + ), + { width: 20, height: 2 }, + ) + app.renderer.start() + + try { + await wait(() => theme?.ready === true) + expect(theme?.selected).toBe("opencode") + expect(failure?.name).toBe("invalid") + expect(failure?.error).toBeInstanceOf(Error) + expect(failure?.error.message.length).toBeGreaterThan(0) + } finally { + unsubscribe?.() + app.renderer.destroy() + } +}) + +test("contextual themes fall back to a standalone theme's base view", async () => { + const standalone = { + version: 2, + standalone: true, + dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue }, + } as const + let theme: ReturnType | undefined + + function Probe() { + theme = useTheme() + return {theme.selected} + } + + const app = await testRender( + () => ( + + Promise.resolve({ standalone }) }}> + + + + ), + { width: 20, height: 2 }, + ) + app.renderer.start() + + try { + await wait(() => theme?.ready === true) + if (!theme) throw new Error("Theme provider is not mounted") + expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default) + expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/theme.test.ts b/packages/tui/test/theme.test.ts index b2208f42c491..05d13d8601ef 100644 --- a/packages/tui/test/theme.test.ts +++ b/packages/tui/test/theme.test.ts @@ -2,7 +2,16 @@ import { expect, test } from "bun:test" import { mkdir, writeFile } from "node:fs/promises" import path from "node:path" import type { TerminalColors } from "@opentui/core" -import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme" +import { + DEFAULT_THEMES, + addTheme, + allThemes, + hasTheme, + parseTheme, + resolveTheme, + setCustomThemes, + upsertTheme, +} from "../src/theme" import { discoverThemes, themeDirectories } from "../src/theme/discovery" import { terminalMode } from "../src/theme/system" import { tmpdir } from "./fixture/fixture" @@ -10,7 +19,7 @@ import { tmpdir } from "./fixture/fixture" test("addTheme writes into module theme store", () => { const name = `plugin-theme-${Date.now()}` expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true) - expect(allThemes()[name]).toBeDefined() + expect(allThemes()[name]).toBe(DEFAULT_THEMES.opencode) }) test("addTheme keeps first theme for duplicate names", () => { @@ -22,15 +31,92 @@ test("addTheme keeps first theme for duplicate names", () => { expect(addTheme(name, one)).toBe(true) expect(addTheme(name, two)).toBe(false) - expect(allThemes()[name]!.theme.primary).toBe("#101010") + expect(allThemes()[name]).toBe(one) }) -test("addTheme ignores entries without a theme object", () => { +test("addTheme ignores values without a V1 theme or version", () => { const name = `plugin-theme-invalid-${Date.now()}` expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false) + expect(addTheme(name, { light: {} })).toBe(false) expect(allThemes()[name]).toBeUndefined() }) +test("addTheme defers validation of versioned sources", () => { + const name = `plugin-theme-versioned-${Date.now()}` + expect(addTheme(name, { version: 2 })).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`) +}) + +test("parseTheme delegates malformed V1 sources and rejects unknown versions", () => { + expect(() => parseTheme({})).toThrow() + expect(() => parseTheme({ version: 3 })).toThrow("Unsupported theme version: 3") +}) + +test("parses unversioned and explicit V1 themes lazily once", () => { + const unversioned = structuredClone(DEFAULT_THEMES.opencode) + const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 } + const first = parseTheme(unversioned, "unversioned") + const second = parseTheme(explicit, "explicit") + + expect(first.version).toBe(2) + expect(second.version).toBe(2) + expect(parseTheme(unversioned, "unversioned")).toBe(first) + expect(parseTheme(explicit, "explicit")).toBe(second) +}) + +test("decodes native V2 themes lazily once", () => { + const name = `plugin-theme-v2-${Date.now()}` + const source = { version: 2, light: { categorical: ["red"] } } as const + + expect(addTheme(name, source)).toBe(true) + expect(allThemes()[name]).toBe(source) + const document = parseTheme(allThemes()[name]!, name) + expect(document.light?.categorical).toEqual(["red"]) + expect(parseTheme(allThemes()[name]!, name)).toBe(document) +}) + +test("defers invalid V2 errors until parsing", () => { + const name = `plugin-theme-invalid-v2-${Date.now()}` + expect(addTheme(name, { version: 2, light: { categorical: [] } })).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`) +}) + +test("defers invalid V1 errors until parsing", () => { + const name = `plugin-theme-invalid-v1-${Date.now()}` + const source = structuredClone(DEFAULT_THEMES.opencode) + source.defs = { ...source.defs, one: "two", two: "one" } + source.theme.primary = "one" + + expect(addTheme(name, source)).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow("Circular color reference") +}) + +test("replacement sources receive independent parse caches", () => { + const name = `plugin-theme-replace-${Date.now()}` + const first = structuredClone(DEFAULT_THEMES.opencode) + const second = structuredClone(DEFAULT_THEMES.opencode) + second.theme.primary = "#123456" + + expect(addTheme(name, first)).toBe(true) + const previous = parseTheme(allThemes()[name]!, name) + expect(upsertTheme(name, second)).toBe(true) + const next = parseTheme(allThemes()[name]!, name) + expect(next).not.toBe(previous) + expect(parseTheme(allThemes()[name]!, name)).toBe(next) +}) + +test("custom themes retain precedence over plugin themes", () => { + const name = `plugin-theme-precedence-${Date.now()}` + const plugin = structuredClone(DEFAULT_THEMES.opencode) + const custom = structuredClone(DEFAULT_THEMES.opencode) + + expect(addTheme(name, plugin)).toBe(true) + setCustomThemes({ [name]: custom }) + expect(allThemes()[name]).toBe(custom) + setCustomThemes({}) + expect(allThemes()[name]).toBe(plugin) +}) + test("hasTheme checks theme presence", () => { const name = `plugin-theme-has-${Date.now()}` expect(hasTheme(name)).toBe(false) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index e91a79d2b20a..9d42b4034136 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -1,16 +1,21 @@ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { parseTheme, type ThemeDocumentSource } from "../../../src/theme" import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" -import type { ThemeDefinition } from "../../../src/theme/v2" -import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve" +import type { Mode, ThemeDefinition } from "../../../src/theme/v2" +import { resolveTheme, resolveThemeDocument } from "../../../src/theme/v2/resolve" import { selectTheme } from "../../../src/theme/v2/select" const light = selectTheme(DEFAULT_THEME, "light") const dark = selectTheme(DEFAULT_THEME, "dark") -test("resolves one-mode files with defaults for the available mode", () => { - const resolvedLight = resolveThemeFile({ version: 2, light: {} }, "dark") - const resolvedDark = resolveThemeFile({ version: 2, dark: {} }, "light") +function resolveSource(source: ThemeDocumentSource, mode?: Mode, name?: string) { + return resolveThemeDocument(parseTheme(source, name), mode) +} + +test("resolves one-mode documents with defaults for the available mode", () => { + const resolvedLight = resolveSource({ version: 2, light: {} }, "dark") + const resolvedDark = resolveSource({ version: 2, dark: {} }, "light") expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue() expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue() @@ -18,26 +23,19 @@ test("resolves one-mode files with defaults for the available mode", () => { expect(resolvedDark.categorical.length).toBeGreaterThan(0) }) -test("rejects theme files without a mode", () => { - // @ts-expect-error Runtime decoding also enforces the at-least-one-mode invariant. - expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme") +test("rejects theme documents without a mode", () => { + expect(() => resolveSource({ version: 2 })).toThrow("Invalid theme") }) test("validates and resolves categorical hues in configured order", () => { - const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light") + const theme = resolveSource({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light") expect(theme.categorical[0]).toBe(theme.hue.accent) expect(theme.categorical[1]).toBe(theme.hue.red) expect(theme.categorical[2]).toBe(theme.hue.interactive) expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical) - expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") - expect(() => - resolveThemeFile( - // @ts-expect-error Runtime decoding rejects unknown categorical hue names. - { version: 2, light: { categorical: ["magenta"] } }, - "light", - ), - ).toThrow("Invalid theme") + expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") + expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme") }) test("uses the default categorical order for direct definitions", () => { @@ -93,7 +91,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => { ...light, hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" }, }) - const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") + const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") expect(aliased.hue.blue).not.toBe(aliased.hue.red) expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue() @@ -131,8 +129,8 @@ test("steps by hue source when adjacent colors have equal values", () => { expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400]) }) -test("merges partial files with the selected OpenCode defaults", () => { - const theme = resolveThemeFile( +test("merges partial documents with the selected OpenCode defaults", () => { + const theme = resolveSource( { version: 2, light: { @@ -150,7 +148,7 @@ test("merges partial files with the selected OpenCode defaults", () => { }) test("expands user structural fallbacks before merging defaults", () => { - const expanded = resolveThemeFile( + const expanded = resolveSource( { version: 2, light: { @@ -161,7 +159,7 @@ test("expands user structural fallbacks before merging defaults", () => { }, "light", ) - const isolatedState = resolveThemeFile( + const isolatedState = resolveSource( { version: 2, light: { @@ -181,9 +179,9 @@ test("expands user structural fallbacks before merging defaults", () => { }) test("standalone themes skip OpenCode defaults and use the red core fallback", () => { - const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const - const lightTheme = resolveThemeFile(file, "light") - const darkTheme = resolveThemeFile(file, "dark") + const document = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const + const lightTheme = resolveSource(document, "light") + const darkTheme = resolveSource(document, "dark") expect(lightTheme.text.default.toInts()).toEqual([255, 0, 0, 255]) expect(lightTheme.background.default.toInts()).toEqual([255, 0, 0, 255]) @@ -192,7 +190,7 @@ test("standalone themes skip OpenCode defaults and use the red core fallback", ( }) test("uses defaults for the selected mode when it merges the other mode", () => { - const theme = resolveThemeFile( + const theme = resolveSource( { version: 2, light: { hue: light.hue, background: { default: "#123456" } }, @@ -217,7 +215,7 @@ test("resolves matched action variants and states", () => { }) test("resolves elevated hover surfaces from direct colors", () => { - const theme = resolveThemeFile( + const theme = resolveSource( { version: 2, light: { background: { surface: { offset: "#123456", overlay: "#234567" } } }, @@ -231,7 +229,7 @@ test("resolves elevated hover surfaces from direct colors", () => { }) test("resolves transparent colors", () => { - const theme = resolveThemeFile({ + const theme = resolveSource({ version: 2, light: { background: { formfield: { default: "transparent" } } }, dark: { background: { formfield: { default: "transparent" } } }, @@ -241,7 +239,7 @@ test("resolves transparent colors", () => { test("reports theme decoding failures as native errors", () => { expect(() => - resolveThemeFile( + resolveSource( { version: 2, light: { text: { default: "opaque" } }, diff --git a/packages/tui/test/theme/v2/select.test.ts b/packages/tui/test/theme/v2/select.test.ts index 792321de13a5..00f0bd437dee 100644 --- a/packages/tui/test/theme/v2/select.test.ts +++ b/packages/tui/test/theme/v2/select.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" +import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select" const hue = {} as HueDefinition @@ -11,20 +11,20 @@ const dark = { } satisfies ThemeDefinition test("requires and selects independent light and dark themes", () => { - const file = { version: 2, light, dark } satisfies ThemeFile - expect(selectTheme(file)).toBe(light) - expect(selectTheme(file, "light")).toBe(light) - expect(selectTheme(file, "dark")).toBe(dark) - expect(selectThemeMode(file, "dark").mode).toBe("dark") + const document = { version: 2, light, dark } satisfies ThemeDocument + expect(selectTheme(document)).toBe(light) + expect(selectTheme(document, "light")).toBe(light) + expect(selectTheme(document, "dark")).toBe(dark) + expect(selectThemeMode(document, "dark").mode).toBe("dark") }) test("merges an expanded mode override over the other mode", () => { - const file = { + const document = { version: 2, light, dark: { mergeMode: true, text: { default: "#ffffff" } }, - } satisfies ThemeFile - const selected = selectTheme(file, "dark") + } satisfies ThemeDocument + const selected = selectTheme(document, "dark") expect(selected.hue).toBeDefined() expect(selected.text?.default).toBe("#ffffff") @@ -41,8 +41,8 @@ test("replaces categorical order in a merge mode", () => { }) test("selects the available mode when the requested mode is missing", () => { - const lightOnly = { version: 2, light } satisfies ThemeFile - const darkOnly = { version: 2, dark } satisfies ThemeFile + const lightOnly = { version: 2, light } satisfies ThemeDocument + const darkOnly = { version: 2, dark } satisfies ThemeDocument expect(themeModes(lightOnly)).toEqual(["light"]) expect(themeModes(darkOnly)).toEqual(["dark"]) @@ -62,10 +62,10 @@ test("rejects a merge mode without its base mode", () => { }) test("rejects mutual mode merging", () => { - const file = { + const document = { version: 2, light: { mergeMode: true }, dark: { mergeMode: true }, - } satisfies ThemeFile - expect(() => selectTheme(file)).toThrow("cannot both merge") + } satisfies ThemeDocument + expect(() => selectTheme(document)).toThrow("cannot both merge") }) diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts index a44322d71a20..f98a013f5ef1 100644 --- a/packages/tui/test/theme/v2/types.test.ts +++ b/packages/tui/test/theme/v2/types.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" +import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" const text = { default: "$hue.neutral.900", @@ -51,11 +51,11 @@ const definition = { "@context:overlay": { background: { default: "$hue.neutral.300" } }, } satisfies ThemeDefinition -const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile -const lightOnly = { version: 2, light: definition } satisfies ThemeFile -const darkOnly = { version: 2, dark: definition } satisfies ThemeFile -// @ts-expect-error A theme file must provide at least one mode. -const empty = { version: 2 } satisfies ThemeFile +const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument +const lightOnly = { version: 2, light: definition } satisfies ThemeDocument +const darkOnly = { version: 2, dark: definition } satisfies ThemeDocument +// @ts-expect-error A theme document must provide at least one mode. +const empty = { version: 2 } satisfies ThemeDocument test("supports property-first definitions, variants, states, and contexts", () => { expect(text.action.primary.$hovered).toBe("$hue.neutral.200") @@ -68,7 +68,7 @@ test("supports property-first definitions, variants, states, and contexts", () = expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800") expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300") expect(definition.categorical).toEqual(["blue", "accent"]) - expect(file.light).toBe(definition) + expect(document.light).toBe(definition) expect(lightOnly.light).toBe(definition) expect(darkOnly.dark).toBe(definition) expect(empty.version).toBe(2) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 84c3639b09f6..ced1ae4800ac 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme" -import { resolveThemeFile } from "../../../src/theme/v2/resolve" +import { resolveThemeDocument } from "../../../src/theme/v2/resolve" import { selectThemeMode, themeModes } from "../../../src/theme/v2/select" import { migrateV1 } from "../../../src/theme/v2/v1-migrate" import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults" @@ -9,7 +9,7 @@ test("migrates resolved V1 modes into literal V2 tokens", () => { const migrated = migrateV1(DEFAULT_THEMES.opencode) if (!migrated.light || !migrated.dark) throw new Error("Expected both modes") const legacy = resolveV1(DEFAULT_THEMES.opencode, "light") - const resolved = resolveThemeFile(migrated, "light") + const resolved = resolveThemeDocument(migrated, "light") expect(migrated.standalone).toBeTrue() expect(migrated.light.categorical?.length).toBeGreaterThan(0) @@ -83,8 +83,8 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou expect(migrated.light.hue?.purple).toBe("$hue.gray") expect(migrated.light.hue?.accent).toBe("$hue.gray") expect(migrated.light.hue?.interactive).toBe("$hue.gray") - expect(() => resolveThemeFile(migrated, "light")).not.toThrow() - expect(() => resolveThemeFile(migrated, "dark")).not.toThrow() + expect(() => resolveThemeDocument(migrated, "light")).not.toThrow() + expect(() => resolveThemeDocument(migrated, "dark")).not.toThrow() }) test("orders categorical hues by V1 semantic color mapping", () => { @@ -185,7 +185,7 @@ test("migrates every built-in V1 theme in its supported modes", () => { for (const source of Object.values(DEFAULT_THEMES)) { const migrated = migrateV1(source) for (const mode of themeModes(migrated)) { - expect(resolveThemeFile(migrated, mode).text.default).toBeDefined() + expect(resolveThemeDocument(migrated, mode).text.default).toBeDefined() } } })