refactor(tui): load native V2 themes (#38430)

This commit is contained in:
James Long 2026-07-23 12:35:29 -04:00 committed by GitHub
commit 8f3465c951
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 410 additions and 161 deletions

View file

@ -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. */}

View file

@ -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) {
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>

View file

@ -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
}

View file

@ -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<Record<string, unknown>>
@ -61,7 +90,7 @@ export {
const THEME_REFRESH_DELAYS = [250, 1000] as const
type State = {
themes: Record<string, ThemeJson>
themes: Record<string, ThemeDocumentSource>
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<Record<string, ThemeJson>>((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 }
</themeContext.context.Provider>
)
}
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<SyntaxStyle>()

View file

@ -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)

View file

@ -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<string, ThemeJson> = {}
let customThemes: Record<string, ThemeJson> = {}
let systemTheme: ThemeJson | undefined
const listeners = new Set<(themes: Record<string, ThemeJson>) => void>()
export type ThemeDocumentSource = Record<string, unknown>
const pluginThemes: Record<string, ThemeDocumentSource> = {}
let customThemes: Record<string, ThemeDocumentSource> = {}
let systemTheme: ThemeDocumentSource | undefined
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
const parsed = new WeakMap<object, ThemeDocument>()
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument)
function listThemes() {
// Priority: defaults < plugin installs < custom files < generated system.
const themes = {
const themes: Record<string, ThemeDocumentSource> = {
...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 subscribeThemes(listener: (themes: Record<string, ThemeJson>) => void) {
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<string, ThemeDocumentSource>) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function setCustomThemes(themes: Record<string, ThemeJson>) {
customThemes = themes
export function setCustomThemes(themes: Record<string, unknown>) {
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)}`)
}

View file

@ -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<Theme, "_hasSelectedListItemText" | "thinkingOpacity">,
hasSelectedListItemText,
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,

View file

@ -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<string, HexColor | RefName>
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
@ -108,7 +108,7 @@ export type ThemeJson = {
}
}
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
aura,
ayu,
catppuccin,

View file

@ -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

View file

@ -16,7 +16,7 @@ export {
SyntaxDefinition,
SyntaxToken,
ThemeDefinition,
ThemeFile,
ThemeDocument,
type BackgroundDefinition,
type DiffDefinition,
type FileThemeDefinition,

View file

@ -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,

View file

@ -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<typeof ThemeFile>
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>

View file

@ -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 {

View file

@ -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<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
@ -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 {

View file

@ -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<typeof useTheme> | undefined
function Probe() {
@ -42,7 +46,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
<ThemeProvider
mode="dark"
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual, native }) }}
>
<Probe />
</ThemeProvider>
@ -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<typeof useTheme> | undefined
let failure: ThemeError | undefined
let unsubscribe: (() => void) | undefined
function Probe() {
const value = useTheme()
theme = value
unsubscribe = value.onError((error) => (failure = error))
return <text>{value.selected}</text>
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ invalid: source }) }}>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
{ 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<typeof useTheme> | undefined
function Probe() {
theme = useTheme()
return <text>{theme.selected}</text>
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "standalone", mode: "dark" } })}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ standalone }) }}>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
{ 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()
}

View file

@ -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)

View file

@ -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" } },

View file

@ -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")
})

View file

@ -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)

View file

@ -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()
}
}
})