refactor(tui): simplify theme palette lifecycle

This commit is contained in:
Dax Raad
2026-09-12 17:50:32 -04:00
parent 2816d1c849
commit dbc63955b0
2 changed files with 87 additions and 95 deletions
+7 -7
View File
@@ -482,7 +482,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const toast = useToast()
const updater = useUpdateNotification()
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock, prepareSystem } = useThemes()
const { mode, supports, setMode, locked, lock, unlock, afterPaint } = useThemes()
const data = useData()
const location = useLocation()
const exit = useExit()
@@ -490,15 +490,15 @@ function App(props: { pair?: DialogPairCredentials }) {
const plugins = usePlugin()
const clipboard = useClipboard()
const terminalEnvironment = useTuiTerminalEnvironment()
let systemThemeTimeout: ReturnType<typeof setTimeout> | undefined
const prepareSystemTheme = () => {
let paletteTimer: ReturnType<typeof setTimeout> | undefined
const afterFrame = () => {
// The native writer can still be flushing the frame when FRAME fires. Keep OSC probes behind visible app output.
systemThemeTimeout = setTimeout(prepareSystem, 50)
paletteTimer = setTimeout(afterPaint, 50)
}
onMount(() => renderer.once(CliRenderEvents.FRAME, prepareSystemTheme))
onMount(() => renderer.once(CliRenderEvents.FRAME, afterFrame))
onCleanup(() => {
renderer.off(CliRenderEvents.FRAME, prepareSystemTheme)
if (systemThemeTimeout) clearTimeout(systemThemeTimeout)
renderer.off(CliRenderEvents.FRAME, afterFrame)
if (paletteTimer) clearTimeout(paletteTimer)
})
createEffect(() => {
if (client.connection.status() !== "connected") return
+80 -88
View File
@@ -24,7 +24,7 @@ import {
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes } from "../theme/discovery"
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
@@ -95,7 +95,6 @@ export {
const THEME_REFRESH_DELAYS = [250, 1000] as const
type State = {
themes: Record<string, ThemeDocumentSource>
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
@@ -117,7 +116,7 @@ type Themes = {
unlock(): void
setMode(mode?: "dark" | "light", persist?: boolean): boolean
set(theme: string): boolean
prepareSystem(): void
afterPaint(): void
onError(handler: ThemeErrorHandler): () => void
readonly ready: boolean
}
@@ -129,14 +128,14 @@ type ThemeContextValue = {
}
const [store, setStore] = createStore<State>({
themes: allThemes(),
mode: "dark",
lock: undefined,
active: "opencode",
ready: false,
})
const [themeSources, setThemeSources] = createSignal(allThemes())
subscribeThemes((themes) => setStore("themes", themes))
subscribeThemes(setThemeSources)
const themeContext = createSimpleContext({
name: "Theme",
@@ -145,20 +144,15 @@ const themeContext = createSimpleContext({
const configState = useConfig()
const config = configState.data
const themes = props.source
const systemPalette = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} })
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
}
const cache = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} })
setStore(
produce((draft) => {
const lock = pick(config.theme?.mode)
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
const lock = config.theme?.mode === "dark" || config.theme?.mode === "light" ? config.theme.mode : undefined
const mode = lock ?? renderer.themeMode ?? props.mode
draft.mode = mode
draft.lock = lock
const active = config.theme?.name ?? "opencode"
draft.active = typeof active === "string" ? active : "opencode"
draft.active = config.theme?.name ?? "opencode"
draft.ready = false
}),
)
@@ -187,78 +181,68 @@ const themeContext = createSimpleContext({
}
onMount(() => {
// Terminal palette queries serialize with frame output. Use the mode fallback for the first paint;
// App asks for the system palette after that frame and updates the theme in place.
// Terminal palette queries serialize with frame output. First paint uses the cached palette or built-in fallback.
void syncCustomThemes().finally(() => {
valuesV2()
tokens()
setStore("ready", true)
})
})
const cachedSystemPalette = systemPalette?.[0].colors
let systemThemeSignature = cachedSystemPalette ? JSON.stringify(cachedSystemPalette) : undefined
let systemThemeMode = cachedSystemPalette
? (store.lock ?? terminalMode(cachedSystemPalette) ?? store.mode)
: undefined
let hasResolvedSystemTheme = cachedSystemPalette !== undefined
if (cachedSystemPalette && systemThemeMode) {
if (store.mode !== systemThemeMode) setStore("mode", systemThemeMode)
setSystemTheme(generateSystem(cachedSystemPalette, systemThemeMode))
const cachedPalette = cache?.[0].colors
let palette = usablePalette(cachedPalette) ? cachedPalette : undefined
const applyPalette = () => {
if (palette) setSystemTheme(generateSystem(palette, store.mode))
}
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
return renderer
if (palette) {
const mode = store.lock ?? terminalMode(palette) ?? store.mode
if (store.mode !== mode) setStore("mode", mode)
applyPalette()
} else setSystemTheme(undefined)
let canProbe = false
let probing = false
let queued = false
let disposed = false
function refreshPalette() {
queued = true
if (!canProbe || probing || disposed) return
queued = false
probing = true
// Clearing does not cancel a query already owned by the renderer. Share it, then run one fresh query.
const retry = renderer.paletteDetectionStatus === "detecting"
renderer.clearPaletteCache()
void renderer
.getPalette({ size: 16 })
.then((colors: TerminalColors) => {
if (!colors.palette[0]) {
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
return
}
const next = store.lock ?? terminalMode(colors) ?? mode
if (store.mode !== next) setStore("mode", next)
const signature = JSON.stringify(colors)
hasResolvedSystemTheme = true
if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return
systemThemeSignature = signature
systemThemeMode = next
setSystemTheme(generateSystem(colors, next))
void systemPalette?.[1]((draft) => {
.then((colors) => {
if (disposed || !usablePalette(colors)) return
palette = colors
const mode = store.lock ?? terminalMode(colors) ?? store.mode
if (store.mode !== mode) setStore("mode", mode)
applyPalette()
void cache?.[1]((draft) => {
draft.colors = colors
}).catch(() => {})
})
.catch(() => {
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
.catch(() => {})
.finally(() => {
probing = false
if (disposed || (!retry && !queued)) return
refreshPalette()
})
}
let systemRefreshRunning = false
let systemRefreshQueued = false
let systemRefreshMode = store.mode
function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
systemRefreshMode = mode
if (systemRefreshRunning) {
systemRefreshQueued = true
return
}
systemRefreshRunning = true
const retry = renderer.paletteDetectionStatus === "detecting"
renderer.clearPaletteCache()
void resolveSystemTheme(mode).finally(() => {
systemRefreshRunning = false
if (!retry && !systemRefreshQueued) return
systemRefreshQueued = false
refreshSystemTheme(systemRefreshMode)
})
function afterPaint() {
canProbe = true
refreshPalette()
}
function apply(mode: "dark" | "light") {
if (store.mode === mode) return
setStore("mode", mode)
refreshSystemTheme(mode)
if (store.mode !== mode) {
setStore("mode", mode)
applyPalette()
}
refreshPalette()
}
function pin(mode: "dark" | "light" = store.mode, persist = true) {
@@ -274,7 +258,7 @@ const themeContext = createSimpleContext({
function free(persist = true) {
setStore("lock", undefined)
refreshSystemTheme(renderer.themeMode ?? store.mode)
apply(renderer.themeMode ?? store.mode)
if (!persist) return
void configState
.update((draft) => {
@@ -283,33 +267,34 @@ const themeContext = createSimpleContext({
.catch(() => {})
}
const handle = (mode: "dark" | "light") => {
const handleMode = (mode: "dark" | "light") => {
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
renderer.on(CliRenderEvents.THEME_MODE, handleMode)
const handleThemeNotification = (sequence: string) => {
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
queueMicrotask(() => refreshSystemTheme())
queueMicrotask(refreshPalette)
return false
}
renderer.prependInputHandler(handleThemeNotification)
let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
const refresh = () => {
const refreshThemes = () => {
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
setTimeout(() => {
refreshSystemTheme()
refreshPalette()
if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
}, delay),
)
}
const unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
const unsubscribeRefresh = themes.subscribeRefresh?.(refreshThemes)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
disposed = true
renderer.off(CliRenderEvents.THEME_MODE, handleMode)
renderer.removeInputHandler(handleThemeNotification)
unsubscribeRefresh?.()
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
@@ -318,29 +303,30 @@ const themeContext = createSimpleContext({
const initStarted = performance.now()
const selected = createMemo(() => {
const name = store.themes[store.active] ? store.active : "opencode"
const sources = themeSources()
const name = sources[store.active] ? store.active : "opencode"
try {
return loadTheme(store.themes[name], name, store.mode)
return loadTheme(sources[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)
return loadTheme(sources.opencode, "opencode", store.mode)
}
})
const modes = () => selected().modes
const mode = () => selected().mode
const valuesV2 = () => selected().theme
valuesV2()
const tokens = () => selected().theme
tokens()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
const current = createComponentTheme(valuesV2, mode)
const current = createComponentTheme(tokens, mode)
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
createEffect(() => renderer.setBackgroundColor(tokens().background.default))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(tokens(), mode()))
const service: Themes = {
current,
currentTokens: valuesV2,
currentTokens: tokens,
currentSyntax,
get selected() {
return store.active
@@ -368,7 +354,7 @@ const themeContext = createSimpleContext({
.catch(() => {})
return true
},
prepareSystem: refreshSystemTheme,
afterPaint,
onError: themeErrors.onError,
get ready() {
return store.ready
@@ -395,6 +381,12 @@ export function useTheme(context?: ContextName) {
}
export const ThemeProvider = themeContext.provider
function usablePalette(colors: TerminalColors | undefined): colors is TerminalColors {
return Boolean(
colors && (colors.defaultBackground ?? colors.palette[0]) && (colors.defaultForeground ?? colors.palette[7]),
)
}
/** Switches context without remounting children; undefined inherits the enclosing view. */
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
const value = themeContext.use()