From 52ac92cd9e43b95570ae1bf15632808bc3787059 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 3 Dec 2025 00:31:26 +0100 Subject: [PATCH 1/6] feat: enhance theme handling with custom theme normalization and error reporting --- packages/opencode/src/cli/cmd/tui/app.tsx | 12 +- .../src/cli/cmd/tui/context/theme.tsx | 150 ++++++++++++++++-- 2 files changed, 144 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8f3b47a39ab9..b023c29b6117 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -157,7 +157,7 @@ function App() { const command = useCommandDialog() const { event } = useSDK() const toast = useToast() - const { theme, mode, setMode } = useTheme() + const { theme, mode, setMode, customThemeError } = useTheme() const sync = useSync() const exit = useExit() @@ -392,6 +392,16 @@ function App() { } }) + createEffect(() => { + if (customThemeError) { + toast.show({ + variant: "error", + message: customThemeError, + duration: 5000, + }) + } + }) + event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index ed77e04b4fc5..67446aa41ad0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -152,9 +152,84 @@ export const DEFAULT_THEMES: Record = { zenburn, } -function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { +function usesAnsiColors(theme: ThemeJson): boolean { + const checkValue = (v: any): boolean => { + if (typeof v === "number") return true + if (typeof v === "object" && v !== null && "dark" in v && "light" in v) { + return typeof v.dark === "number" || typeof v.light === "number" + } + return false + } + + if (theme.defs) { + for (const value of Object.values(theme.defs)) { + if (checkValue(value)) return true + } + } + + for (const value of Object.values(theme.theme)) { + if (checkValue(value)) return true + } + + return false +} + +function normalizeTheme(theme: ThemeJson, palette: string[], themeName: string): ThemeJson | { error: string } { + const normalizeValue = (v: any): any => { + if (typeof v === "string") { + if (v.startsWith("#") || v === "transparent" || v === "none") return v + if (theme.defs && v in theme.defs) return v + if (v in theme.theme) return v + return { error: `Theme "${themeName}.json" has an invalid color reference: "${v}"` } + } + if (typeof v === "number") { + if (!palette[v]) { + return { error: `Theme "${themeName}.json" has an invalid ANSI color reference: "${v.toString()}"` } + } + return RGBA.fromHex(palette[v].toString()) + } + if (typeof v === "object" && v !== null && "dark" in v && "light" in v) { + // Recursively check nested values + const dark = normalizeValue(v.dark) + if (dark && typeof dark === "object" && "error" in dark) return dark + + const light = normalizeValue(v.light) + if (light && typeof light === "object" && "error" in light) return light + + return { + dark, + light, + } + } + return v + } + + const normalizedDefs: Record = {} + if (theme.defs) { + for (const [key, value] of Object.entries(theme.defs)) { + const result = normalizeValue(value) + if (result && typeof result === "object" && "error" in result) return result + normalizedDefs[key] = result + } + } + + const normalizedTheme: Record = {} as any + for (const [key, value] of Object.entries(theme.theme)) { + const result = normalizeValue(value) + if (result && typeof result === "object" && "error" in result) return result + normalizedTheme[key as keyof ThemeColors] = result + } + + return { + ...theme, + defs: theme.defs ? normalizedDefs : undefined, + theme: normalizedTheme as any, + } +} + +function resolveTheme(theme: ThemeJson, mode: "dark" | "light", themeName: string) { const defs = theme.defs ?? {} - function resolveColor(c: ColorValue): RGBA { + function resolveColor(c: ColorValue): RGBA | {error: string} { if (c instanceof RGBA) return c if (typeof c === "string") { if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0) @@ -166,9 +241,15 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { } else if (theme.theme[c as keyof ThemeColors] !== undefined) { return resolveColor(theme.theme[c as keyof ThemeColors]!) } else { - throw new Error(`Color reference "${c}" not found in defs or theme`) + return {error: `Theme "${themeName}.json" has an invalid color reference: "${c}`} } } + + if (typeof c === "object" && "dark" in c && "light" in c) { + return resolveColor(c[mode]) + } + + // Is not really needed if (typeof c === "number") { return ansiToRgba(c) } @@ -178,15 +259,19 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const resolved = Object.fromEntries( Object.entries(theme.theme) .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu") - .map(([key, value]) => { - return [key, resolveColor(value)] + .flatMap(([key, value]) => { + const result = resolveColor(value) + return result instanceof RGBA ? [[key, result]] : [] // if error, return empty array }), ) as Partial // Handle selectedListItemText separately since it's optional const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined if (hasSelectedListItemText) { - resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!) + const result = resolveColor(theme.theme.selectedListItemText!) + if(result instanceof RGBA) { + resolved.selectedListItemText = result + } } else { // Backward compatibility: if selectedListItemText is not defined, use background color // This preserves the current behavior for all existing themes @@ -195,7 +280,10 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { // Handle backgroundMenu - optional with fallback to backgroundElement if (theme.theme.backgroundMenu !== undefined) { - resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu) + const result = resolveColor(theme.theme.backgroundMenu) + if(result instanceof RGBA) { + resolved.backgroundMenu = result + } } else { resolved.backgroundMenu = resolved.backgroundElement } @@ -261,19 +349,44 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ mode: kv.get("theme_mode", props.mode), active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string, ready: false, + customThemeError: undefined as string | undefined, }) + const renderer = useRenderer() + createEffect(async () => { - const custom = await getCustomThemes() - setStore( - produce((draft) => { - Object.assign(draft.themes, custom) - draft.ready = true - }), - ) - }) + const custom = await getCustomThemes() + const normalizedCustom: Record = {} - const renderer = useRenderer() + for (const [name, theme] of Object.entries(custom)) { + let palette: string[] = [] + if (usesAnsiColors(theme)) { + const colors = await renderer.getPalette({ size: 256 }) + if (colors.palette[0]) { + palette = colors.palette.filter((x) => x !== null) + } + } + + const normalized = normalizeTheme(theme, palette, name) + if ("error" in normalized) { + setStore("customThemeError", normalized.error as string) + setStore("active", "opencode") + kv.set("theme", "opencode") + setStore("ready", true) + return + } + normalizedCustom[name] = normalized + } + + setStore( + produce((draft) => { + Object.assign(draft.themes, normalizedCustom) + draft.ready = true + }), + ) + setStore("customThemeError", undefined) + }) + renderer .getPalette({ size: 16, @@ -284,7 +397,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) const values = createMemo(() => { - return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode) + return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode, store.active) }) const syntax = createMemo(() => generateSyntax(values())) @@ -319,6 +432,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ get ready() { return store.ready }, + get customThemeError() { + return store.customThemeError + }, } }, }) From 36a22f344251bdbb55ea57dff690ded83a05528e Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 3 Dec 2025 01:34:25 +0100 Subject: [PATCH 2/6] feat: implement Zod schemas for theme validation --- .../src/cli/cmd/tui/context/theme.tsx | 397 ++++++++++++------ 1 file changed, 264 insertions(+), 133 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 67446aa41ad0..6fd08201b624 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -32,6 +32,7 @@ import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" +import { z } from "zod" type ThemeColors = { primary: RGBA @@ -109,20 +110,118 @@ export function selectedForeground(theme: Theme): RGBA { return theme.background } -type HexColor = `#${string}` -type RefName = string -type Variant = { - dark: HexColor | RefName - light: HexColor | RefName -} -type ColorValue = HexColor | RefName | Variant | RGBA +// Zod schemas for color values +const HexColorSchema = z.string().regex(/^#[0-9a-fA-F]{3,8}$/, "Invalid hex color") +const SpecialColorSchema = z.enum(["transparent", "none"]) +const AnsiColorSchema = z.number().int().min(0).max(255) +const RefNameSchema = z.string() + +// Base color value (without variant) - order matters for union parsing +const BaseColorValueSchema = z.union([HexColorSchema, SpecialColorSchema, AnsiColorSchema, RefNameSchema]) + +// Variant with dark/light modes +const VariantSchema = z.object({ + dark: BaseColorValueSchema, + light: BaseColorValueSchema, +}) + +// Full color value - check variant first since it's an object +const ColorValueSchema = z.union([VariantSchema, BaseColorValueSchema]) + +// Defs schema - map of color definitions +const DefsSchema = z.record(z.string(), ColorValueSchema) + +// All theme color keys - missing keys will default to "none" (transparent) +const themeColorKeys = [ + "primary", + "secondary", + "accent", + "error", + "warning", + "success", + "info", + "text", + "textMuted", + "selectedListItemText", + "background", + "backgroundPanel", + "backgroundElement", + "backgroundMenu", + "border", + "borderActive", + "borderSubtle", + "diffAdded", + "diffRemoved", + "diffContext", + "diffHunkHeader", + "diffHighlightAdded", + "diffHighlightRemoved", + "diffAddedBg", + "diffRemovedBg", + "diffContextBg", + "diffLineNumber", + "diffAddedLineNumberBg", + "diffRemovedLineNumberBg", + "markdownText", + "markdownHeading", + "markdownLink", + "markdownLinkText", + "markdownCode", + "markdownBlockQuote", + "markdownEmph", + "markdownStrong", + "markdownHorizontalRule", + "markdownListItem", + "markdownListEnumeration", + "markdownImage", + "markdownImageText", + "markdownCodeBlock", + "syntaxComment", + "syntaxKeyword", + "syntaxFunction", + "syntaxVariable", + "syntaxString", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxPunctuation", +] as const + +// All valid theme keys +const validThemeKeys: Set = new Set(themeColorKeys) + +// Theme colors schema - all keys optional, but no extra keys allowed +const ThemeColorsSchema = z + .record(z.string(), ColorValueSchema) + .superRefine((obj, ctx) => { + for (const key of Object.keys(obj)) { + if (!validThemeKeys.has(key)) { + ctx.addIssue({ + code: "invalid_key", + origin: "record", + issues: [], + message: `Unknown color key "${key}"`, + path: [key], + }) + } + } + }) + +// Full theme JSON schema +const ThemeJsonSchema = z.object({ + $schema: z.string().optional(), + defs: DefsSchema.optional(), + theme: ThemeColorsSchema, +}) + +// Type aliases - use manual types for better compatibility with JSON imports +// ColorValue can include RGBA after normalization (when ANSI codes are resolved) +type Variant = { dark: string | number | RGBA; light: string | number | RGBA } +type ColorValue = string | number | Variant | RGBA type ThemeJson = { $schema?: string - defs?: Record - theme: Omit, "selectedListItemText" | "backgroundMenu"> & { - selectedListItemText?: ColorValue - backgroundMenu?: ColorValue - } + defs?: Record + theme: Record } export const DEFAULT_THEMES: Record = { @@ -152,138 +251,151 @@ export const DEFAULT_THEMES: Record = { zenburn, } -function usesAnsiColors(theme: ThemeJson): boolean { - const checkValue = (v: any): boolean => { - if (typeof v === "number") return true - if (typeof v === "object" && v !== null && "dark" in v && "light" in v) { - return typeof v.dark === "number" || typeof v.light === "number" - } - return false +function isAnsiColor(value: ColorValue): boolean { + if (typeof value === "number") return true + if (typeof value === "object" && "dark" in value && "light" in value) { + return typeof value.dark === "number" || typeof value.light === "number" } + return false +} +function usesAnsiColors(theme: ThemeJson): boolean { if (theme.defs) { for (const value of Object.values(theme.defs)) { - if (checkValue(value)) return true + if (isAnsiColor(value)) return true } } - for (const value of Object.values(theme.theme)) { - if (checkValue(value)) return true + if (value && isAnsiColor(value)) return true } - return false } -function normalizeTheme(theme: ThemeJson, palette: string[], themeName: string): ThemeJson | { error: string } { - const normalizeValue = (v: any): any => { - if (typeof v === "string") { - if (v.startsWith("#") || v === "transparent" || v === "none") return v - if (theme.defs && v in theme.defs) return v - if (v in theme.theme) return v - return { error: `Theme "${themeName}.json" has an invalid color reference: "${v}"` } - } - if (typeof v === "number") { - if (!palette[v]) { - return { error: `Theme "${themeName}.json" has an invalid ANSI color reference: "${v.toString()}"` } - } - return RGBA.fromHex(palette[v].toString()) - } - if (typeof v === "object" && v !== null && "dark" in v && "light" in v) { - // Recursively check nested values - const dark = normalizeValue(v.dark) - if (dark && typeof dark === "object" && "error" in dark) return dark - - const light = normalizeValue(v.light) - if (light && typeof light === "object" && "error" in light) return light - - return { - dark, - light, - } - } - return v +function parseThemeJson(json: unknown, themeName: string): ThemeJson | { error: string } { + const result = ThemeJsonSchema.safeParse(json) + if (!result.success) { + const issue = result.error.issues[0] + const path = issue?.path.join(".") || "unknown" + return { error: `Theme "${themeName}.json" is invalid at "${path}": ${issue?.message}` } } + return result.data +} - const normalizedDefs: Record = {} - if (theme.defs) { - for (const [key, value] of Object.entries(theme.defs)) { - const result = normalizeValue(value) - if (result && typeof result === "object" && "error" in result) return result - normalizedDefs[key] = result +function normalizeColorValue( + value: ColorValue, + palette: string[], + defs: Record, + themeColors: Record, + themeName: string, +): ColorValue | { error: string } { + if (value instanceof RGBA) return value + if (typeof value === "string") { + // Hex or special colors pass through + if (value.startsWith("#") || value === "transparent" || value === "none") return value + // Check if it's a valid reference + if (value in defs || value in themeColors) return value + return { error: `Theme "${themeName}.json" has an invalid color reference: "${value}"` } + } + if (typeof value === "number") { + const ansiColor = palette[value] ? RGBA.fromHex(palette[value]) : ansiToRgba(value) + if (!ansiColor || ansiColor.a === 0) { + return { error: `Theme "${themeName}.json" has an invalid ANSI color reference: "${value}"` } } + return ansiColor + } + if (typeof value === "object" && "dark" in value && "light" in value) { + const dark = normalizeColorValue(value.dark, palette, defs, themeColors, themeName) + if (typeof dark === "object" && "error" in dark) return dark + const light = normalizeColorValue(value.light, palette, defs, themeColors, themeName) + if (typeof light === "object" && "error" in light) return light + return { dark: dark as string | RGBA, light: light as string | RGBA } + } + return value +} + +function normalizeTheme(theme: ThemeJson, palette: string[], themeName: string): ThemeJson | { error: string } { + const defs = theme.defs ?? {} + const themeColors = theme.theme as Record + + const normalizedDefs: Record = {} + for (const [key, value] of Object.entries(defs)) { + const result = normalizeColorValue(value, palette, defs, themeColors, themeName) + if (typeof result === "object" && "error" in result) return result + normalizedDefs[key] = result } - const normalizedTheme: Record = {} as any - for (const [key, value] of Object.entries(theme.theme)) { - const result = normalizeValue(value) - if (result && typeof result === "object" && "error" in result) return result - normalizedTheme[key as keyof ThemeColors] = result + const normalizedTheme: Record = {} + for (const [key, value] of Object.entries(themeColors)) { + if (value === undefined) continue + const result = normalizeColorValue(value, palette, normalizedDefs, themeColors, themeName) + if (typeof result === "object" && "error" in result) return result + normalizedTheme[key] = result } return { ...theme, - defs: theme.defs ? normalizedDefs : undefined, - theme: normalizedTheme as any, + defs: Object.keys(normalizedDefs).length > 0 ? normalizedDefs : undefined, + theme: normalizedTheme as ThemeJson["theme"], + } +} + +function resolveColor( + value: ColorValue, + mode: "dark" | "light", + defs: Record, + themeColors: Record, + themeName: string, +): RGBA | { error: string } { + if (value instanceof RGBA) return value + if (typeof value === "string") { + if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0) + if (value.startsWith("#")) return RGBA.fromHex(value) + // Reference resolution + if (defs[value]) return resolveColor(defs[value], mode, defs, themeColors, themeName) + if (themeColors[value] !== undefined) return resolveColor(themeColors[value]!, mode, defs, themeColors, themeName) + return { error: `Theme "${themeName}.json" has an invalid color reference: "${value}"` } + } + if (typeof value === "number") { + // Shouldn't happen after normalization, but handle it anyway + return ansiToRgba(value) + } + if (typeof value === "object" && "dark" in value && "light" in value) { + return resolveColor(value[mode], mode, defs, themeColors, themeName) } + return { error: `Theme "${themeName}.json" has an invalid color value "${value}"` } } function resolveTheme(theme: ThemeJson, mode: "dark" | "light", themeName: string) { const defs = theme.defs ?? {} - function resolveColor(c: ColorValue): RGBA | {error: string} { - 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 (defs[c]) { - return resolveColor(defs[c]) - } else if (theme.theme[c as keyof ThemeColors] !== undefined) { - return resolveColor(theme.theme[c as keyof ThemeColors]!) - } else { - return {error: `Theme "${themeName}.json" has an invalid color reference: "${c}`} - } - } - - if (typeof c === "object" && "dark" in c && "light" in c) { - return resolveColor(c[mode]) - } - - // Is not really needed - if (typeof c === "number") { - return ansiToRgba(c) + const themeColors = theme.theme as Record + const transparent = RGBA.fromInts(0, 0, 0, 0) + + // Resolve all standard color keys, defaulting to transparent if missing + const resolved: Partial = {} + for (const key of themeColorKeys) { + const value = themeColors[key] + if (value !== undefined) { + const result = resolveColor(value, mode, defs, themeColors, themeName) + resolved[key] = result instanceof RGBA ? result : transparent + } else { + resolved[key] = transparent } - return resolveColor(c[mode]) } - const resolved = Object.fromEntries( - Object.entries(theme.theme) - .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu") - .flatMap(([key, value]) => { - const result = resolveColor(value) - return result instanceof RGBA ? [[key, result]] : [] // if error, return empty array - }), - ) as Partial - - // Handle selectedListItemText separately since it's optional - const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined + // Handle selectedListItemText separately since it has special fallback behavior + const hasSelectedListItemText = themeColors.selectedListItemText !== undefined if (hasSelectedListItemText) { - const result = resolveColor(theme.theme.selectedListItemText!) - if(result instanceof RGBA) { - resolved.selectedListItemText = result - } + const result = resolveColor(themeColors.selectedListItemText!, mode, defs, themeColors, themeName) + resolved.selectedListItemText = result instanceof RGBA ? result : transparent } 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) { - const result = resolveColor(theme.theme.backgroundMenu) - if(result instanceof RGBA) { - resolved.backgroundMenu = result - } + if (themeColors.backgroundMenu !== undefined) { + const result = resolveColor(themeColors.backgroundMenu, mode, defs, themeColors, themeName) + resolved.backgroundMenu = result instanceof RGBA ? result : transparent } else { resolved.backgroundMenu = resolved.backgroundElement } @@ -355,10 +467,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const renderer = useRenderer() createEffect(async () => { - const custom = await getCustomThemes() - const normalizedCustom: Record = {} + const { themes: customThemes, errors } = await getCustomThemes() - for (const [name, theme] of Object.entries(custom)) { + const normalizedCustom: Record = {} + for (const [name, theme] of Object.entries(customThemes)) { let palette: string[] = [] if (usesAnsiColors(theme)) { const colors = await renderer.getPalette({ size: 256 }) @@ -369,22 +481,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const normalized = normalizeTheme(theme, palette, name) if ("error" in normalized) { - setStore("customThemeError", normalized.error as string) - setStore("active", "opencode") - kv.set("theme", "opencode") - setStore("ready", true) - return + errors.push(normalized.error) + continue } normalizedCustom[name] = normalized } + // If active theme failed to load, fall back to opencode + const activeTheme = store.active + if (activeTheme !== "opencode" && !(activeTheme in DEFAULT_THEMES) && !(activeTheme in normalizedCustom)) { + setStore("active", "opencode") + kv.set("theme", "opencode") + } + setStore( produce((draft) => { Object.assign(draft.themes, normalizedCustom) draft.ready = true + draft.customThemeError = errors[0] // Only show the first error }), ) - setStore("customThemeError", undefined) }) renderer @@ -439,8 +555,13 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }) +type CustomThemesResult = { + themes: Record + errors: string[] +} + const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json") -async function getCustomThemes() { +async function getCustomThemes(): Promise { const directories = [ Global.Path.config, ...(await Array.fromAsync( @@ -451,7 +572,9 @@ async function getCustomThemes() { )), ] - const result: Record = {} + const themes: Record = {} + const errors: string[] = [] + for (const dir of directories) { for await (const item of CUSTOM_THEME_GLOB.scan({ absolute: true, @@ -460,32 +583,39 @@ async function getCustomThemes() { cwd: dir, })) { const name = path.basename(item, ".json") - result[name] = await Bun.file(item).json() + const json = await Bun.file(item).json() + const parsed = parseThemeJson(json, name) + if ("error" in parsed) { + errors.push(parsed.error) + continue + } + themes[name] = parsed } } - return result + return { themes, errors } } + function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) - const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) - const palette = colors.palette.filter((x) => x !== null).map((x) => RGBA.fromHex(x)) + const fg = colors.defaultForeground ?? colors.palette[7]! + const palette = colors.palette.filter((x) => x !== null) const isDark = mode == "dark" // Generate gray scale based on terminal background const grays = generateGrayScale(bg, isDark) const textMuted = generateMutedTextColor(bg, isDark) - // ANSI color references + // ANSI color references (use raw hex from palette) const ansiColors = { - black: palette[0], - red: palette[1], - green: palette[2], - yellow: palette[3], - blue: palette[4], - magenta: palette[5], - cyan: palette[6], - white: palette[7], + black: palette[0]!, + red: palette[1]!, + green: palette[2]!, + yellow: palette[3]!, + blue: palette[4]!, + magenta: palette[5]!, + cyan: palette[6]!, + white: palette[7]!, } return { @@ -1171,3 +1301,4 @@ function getSyntaxRules(theme: Theme) { }, ] } + From 66156d1d8e7e6c5ca15c8287631d6a0c7f59e09d Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 3 Dec 2025 23:10:25 +0100 Subject: [PATCH 3/6] refactor: improve theme color handling and normalization logic --- .../src/cli/cmd/tui/context/theme.tsx | 151 +++++++++++------- 1 file changed, 94 insertions(+), 57 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 6fd08201b624..80333eed682b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -221,7 +221,7 @@ type ColorValue = string | number | Variant | RGBA type ThemeJson = { $schema?: string defs?: Record - theme: Record + theme: Record } export const DEFAULT_THEMES: Record = { @@ -253,22 +253,22 @@ export const DEFAULT_THEMES: Record = { function isAnsiColor(value: ColorValue): boolean { if (typeof value === "number") return true + if (typeof value === "object" && "dark" in value && "light" in value) { return typeof value.dark === "number" || typeof value.light === "number" } + return false } function usesAnsiColors(theme: ThemeJson): boolean { - if (theme.defs) { - for (const value of Object.values(theme.defs)) { - if (isAnsiColor(value)) return true - } - } - for (const value of Object.values(theme.theme)) { - if (value && isAnsiColor(value)) return true + // Check defs + if (theme.defs && Object.values(theme.defs).some(isAnsiColor)) { + return true } - return false + + // Check theme colors + return Object.values(theme.theme).some(value => value && isAnsiColor(value)) } function parseThemeJson(json: unknown, themeName: string): ThemeJson | { error: string } { @@ -288,14 +288,38 @@ function normalizeColorValue( themeColors: Record, themeName: string, ): ColorValue | { error: string } { - if (value instanceof RGBA) return value + // Already normalized + if (value instanceof RGBA) { + return value + } + + // Handle variant objects by recursing on each mode + if (typeof value === "object" && "dark" in value && "light" in value) { + const dark = normalizeColorValue(value.dark, palette, defs, themeColors, themeName) + if (typeof dark === "object" && "error" in dark) return dark + + const light = normalizeColorValue(value.light, palette, defs, themeColors, themeName) + if (typeof light === "object" && "error" in light) return light + + return { dark: dark as string | RGBA, light: light as string | RGBA } + } + + // Handle string values (hex, special colors, or references) if (typeof value === "string") { - // Hex or special colors pass through - if (value.startsWith("#") || value === "transparent" || value === "none") return value - // Check if it's a valid reference - if (value in defs || value in themeColors) return value + // Pass through hex and special colors + if (value.startsWith("#") || value === "transparent" || value === "none") { + return value + } + + // Validate references exist + if (value in defs || value in themeColors) { + return value + } + return { error: `Theme "${themeName}.json" has an invalid color reference: "${value}"` } } + + // Handle ANSI color codes if (typeof value === "number") { const ansiColor = palette[value] ? RGBA.fromHex(palette[value]) : ansiToRgba(value) if (!ansiColor || ansiColor.a === 0) { @@ -303,28 +327,26 @@ function normalizeColorValue( } return ansiColor } - if (typeof value === "object" && "dark" in value && "light" in value) { - const dark = normalizeColorValue(value.dark, palette, defs, themeColors, themeName) - if (typeof dark === "object" && "error" in dark) return dark - const light = normalizeColorValue(value.light, palette, defs, themeColors, themeName) - if (typeof light === "object" && "error" in light) return light - return { dark: dark as string | RGBA, light: light as string | RGBA } - } + + // Shouldn't reach here return value } function normalizeTheme(theme: ThemeJson, palette: string[], themeName: string): ThemeJson | { error: string } { const defs = theme.defs ?? {} - const themeColors = theme.theme as Record + const themeColors = theme.theme const normalizedDefs: Record = {} + const normalizedTheme: Record = {} + + // Normalize defs for (const [key, value] of Object.entries(defs)) { const result = normalizeColorValue(value, palette, defs, themeColors, themeName) if (typeof result === "object" && "error" in result) return result normalizedDefs[key] = result } - const normalizedTheme: Record = {} + // Normalize theme colors for (const [key, value] of Object.entries(themeColors)) { if (value === undefined) continue const result = normalizeColorValue(value, palette, normalizedDefs, themeColors, themeName) @@ -335,7 +357,7 @@ function normalizeTheme(theme: ThemeJson, palette: string[], themeName: string): return { ...theme, defs: Object.keys(normalizedDefs).length > 0 ? normalizedDefs : undefined, - theme: normalizedTheme as ThemeJson["theme"], + theme: normalizedTheme, } } @@ -346,22 +368,43 @@ function resolveColor( themeColors: Record, themeName: string, ): RGBA | { error: string } { - if (value instanceof RGBA) return value + // Already resolved + if (value instanceof RGBA) { + return value + } + + // Handle variant objects by recursing with the mode-specific value + if (typeof value === "object" && "dark" in value && "light" in value) { + return resolveColor(value[mode], mode, defs, themeColors, themeName) + } + + // Handle string values if (typeof value === "string") { - if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0) - if (value.startsWith("#")) return RGBA.fromHex(value) - // Reference resolution - if (defs[value]) return resolveColor(defs[value], mode, defs, themeColors, themeName) - if (themeColors[value] !== undefined) return resolveColor(themeColors[value]!, mode, defs, themeColors, themeName) + // Special color keywords + if (value === "transparent" || value === "none") { + return RGBA.fromInts(0, 0, 0, 0) + } + + // Hex colors + if (value.startsWith("#")) { + return RGBA.fromHex(value) + } + + // Reference resolution - check defs first, then themeColors + const referenced = defs[value] ?? themeColors[value] + if (referenced !== undefined) { + return resolveColor(referenced, mode, defs, themeColors, themeName) + } + return { error: `Theme "${themeName}.json" has an invalid color reference: "${value}"` } } + + // Handle number values (ANSI codes - shouldn't happen after normalization) if (typeof value === "number") { - // Shouldn't happen after normalization, but handle it anyway return ansiToRgba(value) } - if (typeof value === "object" && "dark" in value && "light" in value) { - return resolveColor(value[mode], mode, defs, themeColors, themeName) - } + + // Fallback for unexpected types return { error: `Theme "${themeName}.json" has an invalid color value "${value}"` } } @@ -370,35 +413,29 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light", themeName: strin const themeColors = theme.theme as Record const transparent = RGBA.fromInts(0, 0, 0, 0) + // Helper to resolve a color value or return transparent + const resolveOrTransparent = (value: ColorValue | undefined): RGBA => { + if (value === undefined) return transparent + const result = resolveColor(value, mode, defs, themeColors, themeName) + return result instanceof RGBA ? result : transparent + } + // Resolve all standard color keys, defaulting to transparent if missing const resolved: Partial = {} for (const key of themeColorKeys) { - const value = themeColors[key] - if (value !== undefined) { - const result = resolveColor(value, mode, defs, themeColors, themeName) - resolved[key] = result instanceof RGBA ? result : transparent - } else { - resolved[key] = transparent - } + resolved[key] = resolveOrTransparent(themeColors[key]) } - // Handle selectedListItemText separately since it has special fallback behavior + // Handle selectedListItemText with backward compatibility const hasSelectedListItemText = themeColors.selectedListItemText !== undefined - if (hasSelectedListItemText) { - const result = resolveColor(themeColors.selectedListItemText!, mode, defs, themeColors, themeName) - resolved.selectedListItemText = result instanceof RGBA ? result : transparent - } else { - // Backward compatibility: if selectedListItemText is not defined, use background color - resolved.selectedListItemText = resolved.background - } - - // Handle backgroundMenu - optional with fallback to backgroundElement - if (themeColors.backgroundMenu !== undefined) { - const result = resolveColor(themeColors.backgroundMenu, mode, defs, themeColors, themeName) - resolved.backgroundMenu = result instanceof RGBA ? result : transparent - } else { - resolved.backgroundMenu = resolved.backgroundElement - } + resolved.selectedListItemText = hasSelectedListItemText + ? resolveOrTransparent(themeColors.selectedListItemText) + : resolved.background! + + // Handle backgroundMenu with fallback to backgroundElement + resolved.backgroundMenu = themeColors.backgroundMenu !== undefined + ? resolveOrTransparent(themeColors.backgroundMenu) + : resolved.backgroundElement! return { ...resolved, From 9bfe0e01fa5aaa080c43507efde7e8041c4a51cd Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 3 Dec 2025 23:16:32 +0100 Subject: [PATCH 4/6] fix: fixed conflict and remove unreachable code in color resolution logic --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index be00f73276e9..80333eed682b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -383,13 +383,6 @@ function resolveColor( // Special color keywords if (value === "transparent" || value === "none") { return RGBA.fromInts(0, 0, 0, 0) - if (defs[c] != null) { - return resolveColor(defs[c]) - } else if (theme.theme[c as keyof ThemeColors] !== undefined) { - return resolveColor(theme.theme[c as keyof ThemeColors]!) - } else { - throw new Error(`Color reference "${c}" not found in defs or theme`) - } } // Hex colors From 26e6504b0ad8445dcdd33c36d4a7a5d5ea1e47c7 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 3 Dec 2025 23:18:37 +0100 Subject: [PATCH 5/6] fix: ensure palette filtering handles null values correctly --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 80333eed682b..8a3bbf9157c4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -636,7 +636,7 @@ async function getCustomThemes(): Promise { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg = colors.defaultForeground ?? colors.palette[7]! - const palette = colors.palette.filter((x) => x !== null) + const palette = colors.palette.filter((x: string | null) => x !== null) const isDark = mode == "dark" // Generate gray scale based on terminal background From 7a0ff6f9c230f6ab6cd8daa12a4e016670ce818c Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Fri, 5 Dec 2025 16:44:45 +0100 Subject: [PATCH 6/6] chore(theme): Following suggested code Style Guide --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 8a3bbf9157c4..ba25b24bc5a1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -508,13 +508,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const normalizedCustom: Record = {} for (const [name, theme] of Object.entries(customThemes)) { - let palette: string[] = [] - if (usesAnsiColors(theme)) { - const colors = await renderer.getPalette({ size: 256 }) - if (colors.palette[0]) { - palette = colors.palette.filter((x) => x !== null) - } - } + + // Get palette from renderer if theme uses ANSI colors + const palette: string[] = usesAnsiColors(theme) ? + await renderer.getPalette({ size: 256 }).then((colors) => colors.palette.filter((x) => x !== null)) + : [] // If theme doesn't use ANSI colors, use empty palette const normalized = normalizeTheme(theme, palette, name) if ("error" in normalized) {