Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/docs/script/generate-theme-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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. */}

Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -337,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<DataProvider>
<LocationProvider>
<ThemeProvider mode={mode}>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
Expand Down
20 changes: 20 additions & 0 deletions packages/tui/src/component/theme-error-toast.tsx
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 61 additions & 31 deletions packages/tui/src/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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>>
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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"))
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -331,6 +352,7 @@ const themeContext = createSimpleContext({
.catch(() => {})
return true
},
onError: themeErrors.onError,
get ready() {
return store.ready
},
Expand All @@ -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>()
Expand Down
6 changes: 3 additions & 3 deletions packages/tui/src/mini/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
74 changes: 56 additions & 18 deletions packages/tui/src/theme/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, ThemeJson>) => void) {
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()
}
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -78,11 +104,23 @@ 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,
_hasSelectedListItemText: resolved.hasSelectedListItemText,
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)}`)
}
8 changes: 5 additions & 3 deletions packages/tui/src/theme/resolve.ts
Original file line number Diff line number Diff line change
@@ -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,
) {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading