diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5b7f522534d..58f62838019 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -30,6 +30,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" import open from "open" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { @@ -488,12 +489,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") @@ -516,28 +518,38 @@ 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 ed77e04b4fc..33b7a9999ff 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -32,6 +32,10 @@ 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" +import { useExit } from "./exit" +import { FormatError } from "@/cli/error" type ThemeColors = { primary: RGBA @@ -115,7 +119,8 @@ 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 @@ -125,6 +130,29 @@ type ThemeJson = { } } +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 ThemeANSIReferenceError = NamedError.create( + "ThemeANSIReferenceError", + z.object({ + color: z.string(), + }), + ) +} + export const DEFAULT_THEMES: Record = { aura, ayu, @@ -152,6 +180,68 @@ 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 { @@ -166,9 +256,17 @@ 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`) + throw new Theme.ColorReferenceError({ + color: c, + }) } } + + // Handle variant objects + if (typeof c === "object" && "dark" in c && "light" in c) { + return resolveColor(c[mode]) + } + if (typeof c === "number") { return ansiToRgba(c) } @@ -263,17 +361,42 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ ready: false, }) + const renderer = useRenderer() + + const exit = useExit() + createEffect(async () => { - const custom = await getCustomThemes() - setStore( - produce((draft) => { - Object.assign(draft.themes, custom) - draft.ready = true - }), - ) + 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, 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 07a53d29316..8c02867a9e5 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -3,6 +3,7 @@ import { Config } from "../config/config" import { MCP } from "../mcp" import { Provider } from "../provider/provider" import { UI } from "./ui" +import { Theme } from "./cmd/tui/context/theme" export function FormatError(input: unknown) { if (MCP.Failed.isInstance(input)) @@ -36,6 +37,18 @@ 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 (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 "" }