refactor(tui): extract shared frontend helpers (#37868)

This commit is contained in:
Simon Klee 2026-07-20 10:43:02 +02:00 committed by GitHub
commit 0eb71d0fc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1655 additions and 1614 deletions

View file

@ -1,6 +1,5 @@
import { RGBA } from "@opentui/core"
import { ansiToRgba } from "./color"
import { DEFAULT_THEMES, type ColorValue, type Theme, type ThemeColor, type ThemeJson } from "./v1"
import { resolveThemeColors } from "./resolve"
import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1"
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1"
@ -79,62 +78,11 @@ export function upsertTheme(name: string, theme: unknown) {
return true
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
if (c instanceof RGBA) return c
if (typeof c === "string") {
if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
if (c.startsWith("#")) return RGBA.fromHex(c)
if (chain.includes(c)) {
throw new Error(`Circular color reference: ${[...chain, c].join(" -> ")}`)
}
const next = defs[c] ?? theme.theme[c as ThemeColor]
if (next === undefined) {
throw new Error(`Color reference "${c}" not found in defs or theme`)
}
return resolveColor(next, [...chain, c])
}
if (typeof c === "number") {
return ansiToRgba(c)
}
return resolveColor(c[mode], chain)
}
const resolved = Object.fromEntries(
Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
.map(([key, value]) => {
return [key, resolveColor(value as ColorValue)]
}),
) as Partial<Record<ThemeColor, RGBA>>
// Handle selectedListItemText separately since it's optional
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
if (hasSelectedListItemText) {
resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
} else {
// Backward compatibility: if selectedListItemText is not defined, use background color
// This preserves the current behavior for all existing themes
resolved.selectedListItemText = resolved.background
}
// Handle backgroundMenu - optional with fallback to backgroundElement
if (theme.theme.backgroundMenu !== undefined) {
resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
} else {
resolved.backgroundMenu = resolved.backgroundElement
}
// Handle thinkingOpacity - optional with default of 0.6
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
const resolved = resolveThemeColors(theme, mode)
return {
...resolved,
_hasSelectedListItemText: hasSelectedListItemText,
thinkingOpacity,
} as Theme
...resolved.theme,
_hasSelectedListItemText: resolved.hasSelectedListItemText,
thinkingOpacity: resolved.thinkingOpacity,
}
}

View file

@ -0,0 +1,51 @@
import { RGBA } from "@opentui/core"
import { ansiToRgba } from "./color"
import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1"
export function resolveThemeColors(
theme: ThemeJson,
mode: "dark" | "light",
resolveAnsi: (code: number) => RGBA = ansiToRgba,
) {
const defs = theme.defs ?? {}
function resolveColor(color: ColorValue, chain: string[] = []): RGBA {
if (color instanceof RGBA) return color
if (typeof color === "string") {
if (color === "transparent" || color === "none") return RGBA.fromInts(0, 0, 0, 0)
if (color.startsWith("#")) return RGBA.fromHex(color)
if (chain.includes(color)) {
throw new Error(`Circular color reference: ${[...chain, color].join(" -> ")}`)
}
const next = defs[color] ?? theme.theme[color as ThemeColor]
if (next === undefined) {
throw new Error(`Color reference "${color}" not found in defs or theme`)
}
return resolveColor(next, [...chain, color])
}
if (typeof color === "number") return resolveAnsi(color)
return resolveColor(color[mode], chain)
}
const resolved = Object.fromEntries(
Object.entries(theme.theme)
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
.map(([key, value]) => [key, resolveColor(value as ColorValue)]),
) as Partial<Record<ThemeColor, RGBA>>
const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
return {
theme: {
...(resolved as Record<ThemeColor, RGBA>),
selectedListItemText: hasSelectedListItemText
? resolveColor(theme.theme.selectedListItemText!)
: resolved.background!,
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

@ -97,7 +97,7 @@ export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>