feat(tui): migrate core surfaces to V2 themes (#37145)

This commit is contained in:
James Long 2026-07-16 10:22:29 -04:00 committed by GitHub
commit 5fcef6773c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1389 additions and 815 deletions

View file

@ -54,7 +54,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme().theme
const { theme, themeV2 } = useTheme()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@ -84,7 +84,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current: undefined as string | undefined,
})
const colors = createMemo(() => [
theme.secondary,
themeV2.hue.accent(500),
theme.accent,
theme.success,
theme.warning,

View file

@ -13,10 +13,14 @@ import {
setSystemTheme,
subscribeThemes,
upsertTheme,
type Theme,
type ThemeJson,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeFile } from "../theme/v2/resolve"
import { migrateV1 } from "../theme/v2/v1-migrate"
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
@ -24,6 +28,9 @@ import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { DevTools } from "../devtools"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
export type ThemeSource = Readonly<{
discover(): Promise<Record<string, unknown>>
@ -77,6 +84,24 @@ type State = {
ready: boolean
}
type ContextName = "elevated" | "overlay"
type ThemeService = {
theme: Theme
themeV2: ComponentTheme
contextual(context: ContextName): ThemeService
readonly selected: string
all: typeof allThemes
has: typeof hasTheme
syntax: Accessor<SyntaxStyle>
mode: Accessor<"dark" | "light">
locked: Accessor<boolean>
lock(): void
unlock(): void
setMode(mode?: "dark" | "light", persist?: boolean): void
set(theme: string): boolean
readonly ready: boolean
}
const [store, setStore] = createStore<State>({
themes: allThemes(),
mode: "dark",
@ -141,6 +166,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
onMount(() => {
void Promise.allSettled([resolveSystemTheme(store.mode), syncCustomThemes()]).finally(() => {
valuesV2()
setStore("ready", true)
})
})
@ -149,6 +175,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
let systemThemeMode: "dark" | "light" | undefined
let hasResolvedSystemTheme = false
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
const started = performance.now()
return renderer
.getPalette({ size: 16 })
.then((colors: TerminalColors) => {
@ -172,6 +199,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
})
.finally(() => themePerformance.set("Resolve system palette", duration(performance.now() - started)))
}
let systemRefreshRunning = false
@ -257,23 +285,49 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
themeRefreshTimeouts.length = 0
})
const values = createMemo(() => {
const active = store.themes[store.active]
if (active) return resolveTheme(active, store.mode)
return resolveTheme(store.themes.opencode, store.mode)
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
const values = createMemo(() => resolveTheme(source(), store.mode))
const valuesV2 = createMemo(() => {
const started = performance.now()
const file = migrateV1(source())
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
const resolveStarted = performance.now()
const result = resolveThemeFile(file, store.mode, sourceName())
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
return result
})
const themeV2 = createComponentTheme(valuesV2)
const contextsV2 = {
elevated: createComponentTheme(() => {
const theme = valuesV2().contexts["@context:elevated"]
if (!theme) throw new Error("Theme context is not defined: elevated")
return theme
}),
overlay: createComponentTheme(() => {
const theme = valuesV2().contexts["@context:overlay"]
if (!theme) throw new Error("Theme context is not defined: overlay")
return theme
}),
}
createEffect(() => renderer.setBackgroundColor(values().background))
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
return {
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error Properties are forwarded to the current reactive value.
return values()[prop]
},
}),
const theme = new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error Properties are forwarded to the current reactive value.
return values()[prop]
},
})
function contextual(context: ContextName) {
return contextualServices[context]
}
const service: ThemeService = {
theme,
themeV2,
contextual,
get selected() {
return store.active
},
@ -299,9 +353,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.ready
},
}
const contextualServices = {
elevated: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.elevated }),
overlay: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.overlay }),
}
return service
},
})
function duration(milliseconds: number) {
return `${milliseconds.toFixed(2)} ms`
}
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
const renderer = useRenderer()
const retained = new Set<SyntaxStyle>()