From 19ee264e4ae6a43b6c58ba9f3e331dedf20d0367 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 19 Nov 2025 18:02:39 +0100 Subject: [PATCH 1/4] fix(cli): enhance error handling for theme-related issues --- packages/opencode/src/cli/cmd/tui/app.tsx | 58 +++++++++++++------ .../src/cli/cmd/tui/context/theme.tsx | 22 ++++++- packages/opencode/src/cli/error.ts | 9 +++ 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4f990e76e9f..0c94397acda 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -29,6 +29,7 @@ import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" +import { FormatError } from "@/cli/error" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -468,12 +469,13 @@ function App() { function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise }) { const term = useTerminalDimensions() + const [copied, setCopied] = createSignal(false) + useKeyboard((evt) => { - if (evt.ctrl && evt.name === "c") { + if ((evt.ctrl && evt.name === "c") || evt.name === "return") { props.onExit() } }) - const [copied, setCopied] = createSignal(false) const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml") @@ -496,28 +498,50 @@ function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => }) } + const formattedError = FormatError(props.error) || props.error.message + const errorText = formattedError || props.error.stack || "An unknown error occurred" + return ( - - - Please report an issue. - - Copy issue URL (exception info pre-filled) + + + Fatal Error + + A fatal error occurred. Please report this issue or try resetting the TUI: + + + + {copied() ? "Successfully copied" : "Copy issue URL (exception info pre-filled)"} + - {copied() && Successfully copied} - - - A fatal error occurred! - + Reset TUI - + + + Exit + + or press Ctrl+C / Enter + + + + + {errorText} + - - {props.error.stack} - - {props.error.message} ) } diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 4b7c4de0ae4..0763cb4b3a6 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -32,6 +32,8 @@ import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" +import { NamedError } from "@/util/error" +import z from "zod" type Theme = { primary: RGBA @@ -99,6 +101,22 @@ type ThemeJson = { theme: Record } +export namespace Theme { + export const ColorReferenceError = NamedError.create( + "ThemeColorReferenceError", + z.object({ + color: z.string(), + }), + ) + + export const NotFoundError = NamedError.create( + "ThemeNotFoundError", + z.object({ + theme: z.string(), + }), + ) +} + export const DEFAULT_THEMES: Record = { aura, ayu, @@ -140,7 +158,9 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { } else if (theme.theme[c as keyof Theme]) { return resolveColor(theme.theme[c as keyof Theme]) } else { - throw new Error(`Color reference "${c}" not found in defs or theme`) + throw new Theme.ColorReferenceError({ + color: c, + }) } } return resolveColor(c[mode]) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index b3ff5670e35..531249821ef 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -2,6 +2,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { Config } from "../config/config" import { MCP } from "../mcp" import { UI } from "./ui" +import { Theme } from "./cmd/tui/context/theme" export function FormatError(input: unknown) { if (MCP.Failed.isInstance(input)) @@ -23,5 +24,13 @@ export function FormatError(input: unknown) { ...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []), ].join("\n") + if (Theme.ColorReferenceError.isInstance(input)) { + return `Theme has an invalid color reference: "${input.data.color}"` + } + + if (Theme.NotFoundError.isInstance(input)) { + return `Theme "${input.data.theme}" not found. Please check your theme configuration.` + } + if (UI.CancelledError.isInstance(input)) return "" } From 9f6a364e7152bf9b2b5837c8b3d4ef78020b0ff1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 17:03:59 +0000 Subject: [PATCH 2/4] chore: format code --- packages/opencode/src/cli/cmd/tui/app.tsx | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0c94397acda..a8307ff63b1 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -508,29 +508,17 @@ function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => A fatal error occurred. Please report this issue or try resetting the TUI: - + {copied() ? "Successfully copied" : "Copy issue URL (exception info pre-filled)"} - + Reset TUI - + Exit From 8978e6680f69f5f24f8c40a76fdd4a00ea1fe1d4 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Thu, 20 Nov 2025 00:58:44 +0100 Subject: [PATCH 3/4] feat(cli): suuport for ANSI color references --- .../src/cli/cmd/tui/context/theme.tsx | 113 +++++++++++++++++- packages/opencode/src/cli/error.ts | 4 + 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 0763cb4b3a6..056dcc72b26 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -34,6 +34,8 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { NamedError } from "@/util/error" import z from "zod" +import { useExit } from "./exit" +import { FormatError } from "@/cli/error" type Theme = { primary: RGBA @@ -94,10 +96,11 @@ type Variant = { dark: HexColor | RefName light: HexColor | RefName } -type ColorValue = HexColor | RefName | Variant | RGBA +type ColorValue = HexColor | RefName | Variant | RGBA | number +type AnsiColor = number type ThemeJson = { $schema?: string - defs?: Record + defs?: Record theme: Record } @@ -115,6 +118,13 @@ export namespace Theme { theme: z.string(), }), ) + + export const ThemeANSIReferenceError = NamedError.create( + "ThemeANSIReferenceError", + z.object({ + color: z.string(), + }), + ) } export const DEFAULT_THEMES: Record = { @@ -144,6 +154,64 @@ 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 + } + + 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[]): ThemeJson { + const normalizeValue = (v: any): any => { + if (typeof v === "number") { + if (!palette[v]) { + throw new Theme.ThemeANSIReferenceError({ + color: v.toString(), + }) + } + return RGBA.fromHex(palette[v].toString()) + } + if (typeof v === "object" && v !== null && "dark" in v && "light" in v) { + return { + dark: typeof v.dark === "number" ? (palette[v.dark] ? RGBA.fromHex(palette[v.dark].toString()) : v.dark) : v.dark, + light: typeof v.light === "number" ? (palette[v.light] ? RGBA.fromHex(palette[v.light].toString()) : v.light) : v.light, + } + } + return v + } + + const normalizedDefs = theme.defs + ? Object.fromEntries( + Object.entries(theme.defs).map(([key, value]) => [key, normalizeValue(value)]), + ) + : undefined + + const normalizedTheme = Object.fromEntries( + Object.entries(theme.theme).map(([key, value]) => [key, normalizeValue(value)]), + ) as Record + + return { + ...theme, + defs: normalizedDefs, + theme: normalizedTheme, + } +} + function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const defs = theme.defs ?? {} function resolveColor(c: ColorValue): RGBA { @@ -163,7 +231,15 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { }) } } - return resolveColor(c[mode]) + + // Handle variant objects + if (typeof c === "object" && "dark" in c && "light" in c) { + return resolveColor(c[mode]) + } + + throw new Theme.ColorReferenceError({ + color: String(c), + }) } return Object.fromEntries( Object.entries(theme.theme).map(([key, value]) => { @@ -184,17 +260,42 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ ready: false, }) + const renderer = useRenderer() + + const exit = useExit() + createEffect(async () => { + try { const custom = await getCustomThemes() + const normalizedCustom: Record = {} + + for (const [name, theme] of Object.entries(custom)) { + if (usesAnsiColors(theme)) { + const colors = await renderer.getPalette({ size: 256 }) + if (colors.palette[0]) { + const palette = colors.palette.filter((x) => x !== null) + normalizedCustom[name] = normalizeTheme(theme, palette) + } else { + normalizedCustom[name] = theme + } + } else { + normalizedCustom[name] = theme + } + } + setStore( produce((draft) => { - Object.assign(draft.themes, custom) + Object.assign(draft.themes, normalizedCustom) draft.ready = true }), ) - }) + } catch (error) { + const formatted = FormatError(error) + // Prefered quick exit as main error component isnt rendered yet (init error) + exit(formatted) + } + }) - const renderer = useRenderer() renderer .getPalette({ size: 16, diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 531249821ef..ecfe3e5bbeb 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -32,5 +32,9 @@ export function FormatError(input: unknown) { return `Theme "${input.data.theme}" not found. Please check your theme configuration.` } + if (Theme.ThemeANSIReferenceError.isInstance(input)) { + return `Theme has an invalid ANSI color reference: "${input.data.color}". Please check your theme configuration.` + } + if (UI.CancelledError.isInstance(input)) return "" } From 4bbf848c7a4b12dafe744ee33b3b0cee55f6e804 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 19 Nov 2025 23:59:26 +0000 Subject: [PATCH 4/4] chore: format code --- .../src/cli/cmd/tui/context/theme.tsx | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 056dcc72b26..4633cf9654e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -162,17 +162,17 @@ function usesAnsiColors(theme: ThemeJson): boolean { } 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 } @@ -188,17 +188,21 @@ function normalizeTheme(theme: ThemeJson, palette: string[]): ThemeJson { } if (typeof v === "object" && v !== null && "dark" in v && "light" in v) { return { - dark: typeof v.dark === "number" ? (palette[v.dark] ? RGBA.fromHex(palette[v.dark].toString()) : v.dark) : v.dark, - light: typeof v.light === "number" ? (palette[v.light] ? RGBA.fromHex(palette[v.light].toString()) : v.light) : v.light, + dark: + typeof v.dark === "number" ? (palette[v.dark] ? RGBA.fromHex(palette[v.dark].toString()) : v.dark) : v.dark, + light: + typeof v.light === "number" + ? palette[v.light] + ? RGBA.fromHex(palette[v.light].toString()) + : v.light + : v.light, } } return v } const normalizedDefs = theme.defs - ? Object.fromEntries( - Object.entries(theme.defs).map(([key, value]) => [key, normalizeValue(value)]), - ) + ? Object.fromEntries(Object.entries(theme.defs).map(([key, value]) => [key, normalizeValue(value)])) : undefined const normalizedTheme = Object.fromEntries( @@ -266,35 +270,35 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ createEffect(async () => { try { - const custom = await getCustomThemes() - const normalizedCustom: Record = {} - - for (const [name, theme] of Object.entries(custom)) { - if (usesAnsiColors(theme)) { - const colors = await renderer.getPalette({ size: 256 }) - if (colors.palette[0]) { - const palette = colors.palette.filter((x) => x !== null) - normalizedCustom[name] = normalizeTheme(theme, palette) + const custom = await getCustomThemes() + const normalizedCustom: Record = {} + + for (const [name, theme] of Object.entries(custom)) { + if (usesAnsiColors(theme)) { + const colors = await renderer.getPalette({ size: 256 }) + if (colors.palette[0]) { + const palette = colors.palette.filter((x) => x !== null) + normalizedCustom[name] = normalizeTheme(theme, palette) + } else { + normalizedCustom[name] = theme + } } else { normalizedCustom[name] = theme } - } else { - normalizedCustom[name] = theme } + + setStore( + produce((draft) => { + Object.assign(draft.themes, normalizedCustom) + draft.ready = true + }), + ) + } catch (error) { + const formatted = FormatError(error) + // Prefered quick exit as main error component isnt rendered yet (init error) + exit(formatted) } - - setStore( - produce((draft) => { - Object.assign(draft.themes, normalizedCustom) - draft.ready = true - }), - ) - } catch (error) { - const formatted = FormatError(error) - // Prefered quick exit as main error component isnt rendered yet (init error) - exit(formatted) - } - }) + }) renderer .getPalette({