Compare commits

...

1 Commits

Author SHA1 Message Date
James Long 7102f537d9 refactor(tui): load native V2 themes 2026-07-23 02:19:48 +00:00
9 changed files with 212 additions and 51 deletions
+16 -15
View File
@@ -5,21 +5,20 @@ import {
addTheme,
allThemes,
hasTheme,
isTheme,
normalizeTheme,
selectedForeground,
setCustomThemes,
setSystemTheme,
subscribeThemes,
upsertTheme,
type Theme,
type ThemeJson,
type ThemeDocument,
} 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 { resolveDecodedThemeFile } 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"
@@ -61,7 +60,7 @@ export {
const THEME_REFRESH_DELAYS = [250, 1000] as const
type State = {
themes: Record<string, ThemeJson>
themes: Record<string, ThemeDocument>
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
@@ -139,12 +138,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,16 +263,23 @@ 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 file = createMemo(() => {
const name = store.themes[store.active] ? store.active : "opencode"
try {
return normalizeTheme(store.themes[name], name)
} catch (error) {
if (name === "opencode") throw error
setStore("active", "opencode")
return normalizeTheme(store.themes.opencode, "opencode")
}
})
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 valuesV2 = createMemo(() => resolveDecodedThemeFile(file(), mode()))
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const themeV2 = createComponentTheme(valuesV2, mode)
+3 -3
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)
+48 -16
View File
@@ -1,16 +1,22 @@
import { resolveThemeColors } from "./resolve"
import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1"
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
import { decodeThemeFile } from "./v2/resolve"
import type { ThemeFile } 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"
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 ThemeDocument = ThemeV1Json | ThemeFile
const pluginThemes: Record<string, ThemeDocument> = {}
let customThemes: Record<string, ThemeDocument> = {}
let systemTheme: ThemeDocument | undefined
const listeners = new Set<(themes: Record<string, ThemeDocument>) => void>()
const normalized = new WeakMap<object, ThemeFile>()
function listThemes() {
// Priority: defaults < plugin installs < custom files < generated system.
const themes = {
const themes: Record<string, ThemeDocument> = {
...DEFAULT_THEMES,
...pluginThemes,
...customThemes,
@@ -31,23 +37,39 @@ 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 isTheme(theme: unknown): theme is ThemeDocument {
if (!isRecord(theme)) return false
const version = themeVersion(theme)
if (version === 2) return true
if (version !== 1) return false
return isRecord(theme.theme)
}
export function subscribeThemes(listener: (themes: Record<string, ThemeJson>) => void) {
export function normalizeTheme(theme: ThemeDocument, name = "theme") {
const cached = normalized.get(theme)
if (cached) return cached
const version = themeVersion(theme)
if (version !== 1 && version !== 2) throw new Error(`Unsupported theme version: ${String(version)}`)
const file = version === 1 ? migrateV1(theme as ThemeV1Json) : decodeThemeFile(theme, name)
normalized.set(theme, file)
return file
}
export function subscribeThemes(listener: (themes: Record<string, ThemeDocument>) => 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, ThemeDocument] => isTheme(entry[1])),
)
for (const theme of Object.values(customThemes)) normalized.delete(theme)
syncThemes()
}
export function setSystemTheme(theme: ThemeJson | undefined) {
export function setSystemTheme(theme: ThemeDocument | undefined) {
if (theme) normalized.delete(theme)
systemTheme = theme
syncThemes()
}
@@ -61,6 +83,7 @@ export function addTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
if (hasTheme(name)) return false
normalized.delete(theme)
pluginThemes[name] = theme
syncThemes()
return true
@@ -69,6 +92,7 @@ export function addTheme(name: string, theme: unknown) {
export function upsertTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
normalized.delete(theme)
if (customThemes[name] !== undefined) {
customThemes[name] = theme
} else {
@@ -78,7 +102,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 +110,11 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
thinkingOpacity: resolved.thinkingOpacity,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function themeVersion(theme: object) {
return "version" in theme ? theme.version : 1
}
+5 -3
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,
+2 -2
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,
+7 -4
View File
@@ -36,7 +36,7 @@ function decodeThemeDefinition(input: unknown) {
}
}
function decodeThemeFile(input: unknown, name: string) {
export function decodeThemeFile(input: unknown, name = "theme") {
try {
return decodeThemeFileSchema(input)
} catch (error) {
@@ -51,12 +51,15 @@ function themeDecodeError(error: unknown, name: string) {
}
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") {
const decoded = decodeThemeFile(file, name)
const selected = selectThemeMode(decoded, mode)
return resolveDecodedThemeFile(decodeThemeFile(file, name), mode)
}
export function resolveDecodedThemeFile(file: ThemeFile, mode?: "light" | "dark") {
const selected = selectThemeMode(file, 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 = file.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme({
...merged,
+3 -3
View File
@@ -1,6 +1,6 @@
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 { HueStep } from "./schema"
@@ -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): ThemeFile {
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 {
+38 -1
View File
@@ -1,6 +1,7 @@
/** @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 { ConfigProvider } from "../../../src/config"
@@ -24,6 +25,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 +44,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 +68,41 @@ 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("falls back to OpenCode when the configured V2 theme is invalid", async () => {
let theme: ReturnType<typeof useTheme> | undefined
function Probe() {
theme = useTheme()
return <text>{theme.selected}</text>
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
<ThemeProvider
mode="dark"
source={{ discover: () => Promise.resolve({ invalid: { version: 2, light: { categorical: [] } } }) }}
>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
{ width: 20, height: 2 },
)
app.renderer.start()
try {
await wait(() => theme?.ready === true)
expect(theme?.selected).toBe("opencode")
} finally {
app.renderer.destroy()
}
+90 -4
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,
normalizeTheme,
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 invalid envelopes and unknown versions", () => {
const name = `plugin-theme-invalid-${Date.now()}`
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
expect(addTheme(name, { version: 3, light: {} })).toBe(false)
expect(allThemes()[name]).toBeUndefined()
})
test("normalizes unversioned and explicit V1 themes lazily once", () => {
const unversioned = structuredClone(DEFAULT_THEMES.opencode)
const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 }
const first = normalizeTheme(unversioned, "unversioned")
const second = normalizeTheme(explicit, "explicit")
expect(first.version).toBe(2)
expect(second.version).toBe(2)
expect(normalizeTheme(unversioned, "unversioned")).toBe(first)
expect(normalizeTheme(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 file = normalizeTheme(allThemes()[name]!, name)
expect(file.light?.categorical).toEqual(["red"])
expect(normalizeTheme(allThemes()[name]!, name)).toBe(file)
})
test("defers invalid V2 errors until normalization", () => {
const name = `plugin-theme-invalid-v2-${Date.now()}`
expect(addTheme(name, { version: 2, light: { categorical: [] } })).toBe(true)
expect(() => normalizeTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`)
})
test("defers invalid V1 errors until normalization", () => {
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(() => normalizeTheme(allThemes()[name]!, name)).toThrow("Circular color reference")
})
test("replacement sources receive independent normalization 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 = normalizeTheme(allThemes()[name]!, name)
expect(upsertTheme(name, second)).toBe(true)
const next = normalizeTheme(allThemes()[name]!, name)
expect(next).not.toBe(previous)
expect(normalizeTheme(allThemes()[name]!, name)).toBe(next)
})
test("upsert invalidates a mutated source object's normalization cache", () => {
const name = `plugin-theme-mutate-${Date.now()}`
const source = structuredClone(DEFAULT_THEMES.opencode)
expect(addTheme(name, source)).toBe(true)
const previous = normalizeTheme(allThemes()[name]!, name)
source.theme.primary = "#123456"
expect(upsertTheme(name, source)).toBe(true)
expect(normalizeTheme(allThemes()[name]!, name)).not.toBe(previous)
})
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)