From fc029ec2b1c2154873337f5b36f519c4c4b23473 Mon Sep 17 00:00:00 2001 From: CasualDeveloper <10153929+CasualDeveloper@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:08:24 +0800 Subject: [PATCH 1/2] feat(tui): add XML/HTML syntax theme tokens --- .../src/cli/cmd/tui/context/theme-resolver.ts | 277 ++++++++++++++++++ .../src/cli/cmd/tui/context/theme.tsx | 261 +---------------- .../cli/cmd/tui/context/theme/opencode.json | 12 + packages/web/public/theme.json | 5 +- 4 files changed, 302 insertions(+), 253 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/theme-resolver.ts diff --git a/packages/opencode/src/cli/cmd/tui/context/theme-resolver.ts b/packages/opencode/src/cli/cmd/tui/context/theme-resolver.ts new file mode 100644 index 00000000000..1dc78d49dfb --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/theme-resolver.ts @@ -0,0 +1,277 @@ +import { RGBA } from "@opentui/core" +import aura from "./theme/aura.json" with { type: "json" } +import ayu from "./theme/ayu.json" with { type: "json" } +import catppuccin from "./theme/catppuccin.json" with { type: "json" } +import catppuccinFrappe from "./theme/catppuccin-frappe.json" with { type: "json" } +import catppuccinMacchiato from "./theme/catppuccin-macchiato.json" with { type: "json" } +import cobalt2 from "./theme/cobalt2.json" with { type: "json" } +import cursor from "./theme/cursor.json" with { type: "json" } +import dracula from "./theme/dracula.json" with { type: "json" } +import everforest from "./theme/everforest.json" with { type: "json" } +import flexoki from "./theme/flexoki.json" with { type: "json" } +import github from "./theme/github.json" with { type: "json" } +import gruvbox from "./theme/gruvbox.json" with { type: "json" } +import kanagawa from "./theme/kanagawa.json" with { type: "json" } +import material from "./theme/material.json" with { type: "json" } +import matrix from "./theme/matrix.json" with { type: "json" } +import mercury from "./theme/mercury.json" with { type: "json" } +import monokai from "./theme/monokai.json" with { type: "json" } +import nightowl from "./theme/nightowl.json" with { type: "json" } +import nord from "./theme/nord.json" with { type: "json" } +import osakaJade from "./theme/osaka-jade.json" with { type: "json" } +import onedark from "./theme/one-dark.json" with { type: "json" } +import opencode from "./theme/opencode.json" with { type: "json" } +import orng from "./theme/orng.json" with { type: "json" } +import lucentOrng from "./theme/lucent-orng.json" with { type: "json" } +import palenight from "./theme/palenight.json" with { type: "json" } +import rosepine from "./theme/rosepine.json" with { type: "json" } +import solarized from "./theme/solarized.json" with { type: "json" } +import synthwave84 from "./theme/synthwave84.json" with { type: "json" } +import tokyonight from "./theme/tokyonight.json" with { type: "json" } +import vercel from "./theme/vercel.json" with { type: "json" } +import vesper from "./theme/vesper.json" with { type: "json" } +import zenburn from "./theme/zenburn.json" with { type: "json" } +import carbonfox from "./theme/carbonfox.json" with { type: "json" } + +export type ThemeColors = { + primary: RGBA + secondary: RGBA + accent: RGBA + error: RGBA + warning: RGBA + success: RGBA + info: RGBA + text: RGBA + textMuted: RGBA + selectedListItemText: RGBA + background: RGBA + backgroundPanel: RGBA + backgroundElement: RGBA + backgroundMenu: RGBA + border: RGBA + borderActive: RGBA + borderSubtle: RGBA + diffAdded: RGBA + diffRemoved: RGBA + diffContext: RGBA + diffHunkHeader: RGBA + diffHighlightAdded: RGBA + diffHighlightRemoved: RGBA + diffAddedBg: RGBA + diffRemovedBg: RGBA + diffContextBg: RGBA + diffLineNumber: RGBA + diffAddedLineNumberBg: RGBA + diffRemovedLineNumberBg: RGBA + markdownText: RGBA + markdownHeading: RGBA + markdownLink: RGBA + markdownLinkText: RGBA + markdownCode: RGBA + markdownBlockQuote: RGBA + markdownEmph: RGBA + markdownStrong: RGBA + markdownHorizontalRule: RGBA + markdownListItem: RGBA + markdownListEnumeration: RGBA + markdownImage: RGBA + markdownImageText: RGBA + markdownCodeBlock: RGBA + syntaxComment: RGBA + syntaxKeyword: RGBA + syntaxFunction: RGBA + syntaxVariable: RGBA + syntaxString: RGBA + syntaxNumber: RGBA + syntaxType: RGBA + syntaxOperator: RGBA + syntaxPunctuation: RGBA + syntaxTag: RGBA + syntaxAttribute: RGBA + syntaxTagDelimiter: RGBA +} + +export type Theme = ThemeColors & { + _hasSelectedListItemText: boolean + thinkingOpacity: number +} + +type HexColor = `#${string}` +type RefName = string +type Variant = { + dark: HexColor | RefName + light: HexColor | RefName +} +type ColorValue = HexColor | RefName | Variant | RGBA + +export type ThemeJson = { + $schema?: string + defs?: Record + theme: Omit< + Record, + "selectedListItemText" | "backgroundMenu" | "syntaxTag" | "syntaxAttribute" | "syntaxTagDelimiter" + > & { + selectedListItemText?: ColorValue + backgroundMenu?: ColorValue + syntaxTag?: ColorValue + syntaxAttribute?: ColorValue + syntaxTagDelimiter?: ColorValue + thinkingOpacity?: number + } +} + +export const DEFAULT_THEMES: Record = { + aura, + ayu, + catppuccin, + ["catppuccin-frappe"]: catppuccinFrappe, + ["catppuccin-macchiato"]: catppuccinMacchiato, + cobalt2, + cursor, + dracula, + everforest, + flexoki, + github, + gruvbox, + kanagawa, + material, + matrix, + mercury, + monokai, + nightowl, + nord, + ["one-dark"]: onedark, + ["osaka-jade"]: osakaJade, + opencode, + orng, + ["lucent-orng"]: lucentOrng, + palenight, + rosepine, + solarized, + synthwave84, + tokyonight, + vesper, + vercel, + zenburn, + carbonfox, +} + +export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { + const defs = theme.defs ?? {} + function resolveColor(c: ColorValue): 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 (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`) + } + } + if (typeof c === "number") { + return ansiToRgba(c) + } + return resolveColor(c[mode]) + } + + const resolved = Object.fromEntries( + Object.entries(theme.theme) + .filter( + ([key]) => + key !== "selectedListItemText" && + key !== "backgroundMenu" && + key !== "thinkingOpacity" && + key !== "syntaxTag" && + key !== "syntaxAttribute" && + key !== "syntaxTagDelimiter", + ) + .map(([key, value]) => { + return [key, resolveColor(value as ColorValue)] + }), + ) as Partial + + const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined + if (hasSelectedListItemText) { + resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!) + } else { + resolved.selectedListItemText = resolved.background + } + + if (theme.theme.backgroundMenu !== undefined) { + resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu) + } else { + resolved.backgroundMenu = resolved.backgroundElement + } + + if (theme.theme.syntaxTag !== undefined) { + resolved.syntaxTag = resolveColor(theme.theme.syntaxTag) + } else { + resolved.syntaxTag = resolved.error + } + + if (theme.theme.syntaxAttribute !== undefined) { + resolved.syntaxAttribute = resolveColor(theme.theme.syntaxAttribute) + } else { + resolved.syntaxAttribute = resolved.syntaxKeyword + } + + if (theme.theme.syntaxTagDelimiter !== undefined) { + resolved.syntaxTagDelimiter = resolveColor(theme.theme.syntaxTagDelimiter) + } else { + resolved.syntaxTagDelimiter = resolved.syntaxOperator + } + + const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 + + return { + ...resolved, + _hasSelectedListItemText: hasSelectedListItemText, + thinkingOpacity, + } as Theme +} + +export function ansiToRgba(code: number): RGBA { + if (code < 16) { + const ansiColors = [ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + ] + return RGBA.fromHex(ansiColors[code] ?? "#000000") + } + + if (code < 232) { + const index = code - 16 + const b = index % 6 + const g = Math.floor(index / 6) % 6 + const r = Math.floor(index / 36) + + const val = (x: number) => (x === 0 ? 0 : x * 40 + 55) + return RGBA.fromInts(val(r), val(g), val(b)) + } + + if (code < 256) { + const gray = (code - 232) * 10 + 8 + return RGBA.fromInts(gray, gray, gray) + } + + return RGBA.fromInts(0, 0, 0) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7cde1b9648e..3562b2b02c4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -3,39 +3,9 @@ import path from "path" import { createEffect, createMemo, onMount } from "solid-js" import { useSync } from "@tui/context/sync" import { createSimpleContext } from "./helper" -import aura from "./theme/aura.json" with { type: "json" } -import ayu from "./theme/ayu.json" with { type: "json" } -import catppuccin from "./theme/catppuccin.json" with { type: "json" } -import catppuccinFrappe from "./theme/catppuccin-frappe.json" with { type: "json" } -import catppuccinMacchiato from "./theme/catppuccin-macchiato.json" with { type: "json" } -import cobalt2 from "./theme/cobalt2.json" with { type: "json" } -import cursor from "./theme/cursor.json" with { type: "json" } -import dracula from "./theme/dracula.json" with { type: "json" } -import everforest from "./theme/everforest.json" with { type: "json" } -import flexoki from "./theme/flexoki.json" with { type: "json" } -import github from "./theme/github.json" with { type: "json" } -import gruvbox from "./theme/gruvbox.json" with { type: "json" } -import kanagawa from "./theme/kanagawa.json" with { type: "json" } -import material from "./theme/material.json" with { type: "json" } -import matrix from "./theme/matrix.json" with { type: "json" } -import mercury from "./theme/mercury.json" with { type: "json" } -import monokai from "./theme/monokai.json" with { type: "json" } -import nightowl from "./theme/nightowl.json" with { type: "json" } -import nord from "./theme/nord.json" with { type: "json" } -import osakaJade from "./theme/osaka-jade.json" with { type: "json" } -import onedark from "./theme/one-dark.json" with { type: "json" } -import opencode from "./theme/opencode.json" with { type: "json" } -import orng from "./theme/orng.json" with { type: "json" } -import lucentOrng from "./theme/lucent-orng.json" with { type: "json" } -import palenight from "./theme/palenight.json" with { type: "json" } -import rosepine from "./theme/rosepine.json" with { type: "json" } -import solarized from "./theme/solarized.json" with { type: "json" } -import synthwave84 from "./theme/synthwave84.json" with { type: "json" } -import tokyonight from "./theme/tokyonight.json" with { type: "json" } -import vercel from "./theme/vercel.json" with { type: "json" } -import vesper from "./theme/vesper.json" with { type: "json" } -import zenburn from "./theme/zenburn.json" with { type: "json" } -import carbonfox from "./theme/carbonfox.json" with { type: "json" } +import { DEFAULT_THEMES, ansiToRgba, resolveTheme, type Theme, type ThemeJson } from "./theme-resolver" + +export { DEFAULT_THEMES } import { useKV } from "./kv" import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" @@ -43,66 +13,6 @@ import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { useSDK } from "./sdk" -type ThemeColors = { - primary: RGBA - secondary: RGBA - accent: RGBA - error: RGBA - warning: RGBA - success: RGBA - info: RGBA - text: RGBA - textMuted: RGBA - selectedListItemText: RGBA - background: RGBA - backgroundPanel: RGBA - backgroundElement: RGBA - backgroundMenu: RGBA - border: RGBA - borderActive: RGBA - borderSubtle: RGBA - diffAdded: RGBA - diffRemoved: RGBA - diffContext: RGBA - diffHunkHeader: RGBA - diffHighlightAdded: RGBA - diffHighlightRemoved: RGBA - diffAddedBg: RGBA - diffRemovedBg: RGBA - diffContextBg: RGBA - diffLineNumber: RGBA - diffAddedLineNumberBg: RGBA - diffRemovedLineNumberBg: RGBA - markdownText: RGBA - markdownHeading: RGBA - markdownLink: RGBA - markdownLinkText: RGBA - markdownCode: RGBA - markdownBlockQuote: RGBA - markdownEmph: RGBA - markdownStrong: RGBA - markdownHorizontalRule: RGBA - markdownListItem: RGBA - markdownListEnumeration: RGBA - markdownImage: RGBA - markdownImageText: RGBA - markdownCodeBlock: RGBA - syntaxComment: RGBA - syntaxKeyword: RGBA - syntaxFunction: RGBA - syntaxVariable: RGBA - syntaxString: RGBA - syntaxNumber: RGBA - syntaxType: RGBA - syntaxOperator: RGBA - syntaxPunctuation: RGBA -} - -type Theme = ThemeColors & { - _hasSelectedListItemText: boolean - thinkingOpacity: number -} - export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { // If theme explicitly defines selectedListItemText, use it if (theme._hasSelectedListItemText) { @@ -121,162 +31,6 @@ export function selectedForeground(theme: Theme, bg?: RGBA): RGBA { return theme.background } -type HexColor = `#${string}` -type RefName = string -type Variant = { - dark: HexColor | RefName - light: HexColor | RefName -} -type ColorValue = HexColor | RefName | Variant | RGBA -type ThemeJson = { - $schema?: string - defs?: Record - theme: Omit, "selectedListItemText" | "backgroundMenu"> & { - selectedListItemText?: ColorValue - backgroundMenu?: ColorValue - thinkingOpacity?: number - } -} - -export const DEFAULT_THEMES: Record = { - aura, - ayu, - catppuccin, - ["catppuccin-frappe"]: catppuccinFrappe, - ["catppuccin-macchiato"]: catppuccinMacchiato, - cobalt2, - cursor, - dracula, - everforest, - flexoki, - github, - gruvbox, - kanagawa, - material, - matrix, - mercury, - monokai, - nightowl, - nord, - ["one-dark"]: onedark, - ["osaka-jade"]: osakaJade, - opencode, - orng, - ["lucent-orng"]: lucentOrng, - palenight, - rosepine, - solarized, - synthwave84, - tokyonight, - vesper, - vercel, - zenburn, - carbonfox, -} - -function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { - const defs = theme.defs ?? {} - function resolveColor(c: ColorValue): 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 (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`) - } - } - if (typeof c === "number") { - return ansiToRgba(c) - } - return resolveColor(c[mode]) - } - - 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 - - // 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 - - return { - ...resolved, - _hasSelectedListItemText: hasSelectedListItemText, - thinkingOpacity, - } as Theme -} - -function ansiToRgba(code: number): RGBA { - // Standard ANSI colors (0-15) - if (code < 16) { - const ansiColors = [ - "#000000", // Black - "#800000", // Red - "#008000", // Green - "#808000", // Yellow - "#000080", // Blue - "#800080", // Magenta - "#008080", // Cyan - "#c0c0c0", // White - "#808080", // Bright Black - "#ff0000", // Bright Red - "#00ff00", // Bright Green - "#ffff00", // Bright Yellow - "#0000ff", // Bright Blue - "#ff00ff", // Bright Magenta - "#00ffff", // Bright Cyan - "#ffffff", // Bright White - ] - return RGBA.fromHex(ansiColors[code] ?? "#000000") - } - - // 6x6x6 Color Cube (16-231) - if (code < 232) { - const index = code - 16 - const b = index % 6 - const g = Math.floor(index / 6) % 6 - const r = Math.floor(index / 36) - - const val = (x: number) => (x === 0 ? 0 : x * 40 + 55) - return RGBA.fromInts(val(r), val(g), val(b)) - } - - // Grayscale Ramp (232-255) - if (code < 256) { - const gray = (code - 232) * 10 + 8 - return RGBA.fromInts(gray, gray, gray) - } - - // Fallback for invalid codes - return RGBA.fromInts(0, 0, 0) -} - export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light" }) => { @@ -530,6 +284,9 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs syntaxType: ansiColors.cyan, syntaxOperator: ansiColors.cyan, syntaxPunctuation: fg, + syntaxTag: ansiColors.red, + syntaxAttribute: ansiColors.magenta, + syntaxTagDelimiter: ansiColors.cyan, }, } } @@ -1061,19 +818,19 @@ function getSyntaxRules(theme: Theme) { { scope: ["tag"], style: { - foreground: theme.error, + foreground: theme.syntaxTag, }, }, { scope: ["tag.attribute"], style: { - foreground: theme.syntaxKeyword, + foreground: theme.syntaxAttribute, }, }, { scope: ["tag.delimiter"], style: { - foreground: theme.syntaxOperator, + foreground: theme.syntaxTagDelimiter, }, }, { diff --git a/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json b/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json index 8f585a45091..f4203066d39 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json +++ b/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json @@ -240,6 +240,18 @@ "syntaxPunctuation": { "dark": "darkStep12", "light": "lightStep12" + }, + "syntaxTag": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxAttribute": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "syntaxTagDelimiter": { + "dark": "darkCyan", + "light": "lightCyan" } } } diff --git a/packages/web/public/theme.json b/packages/web/public/theme.json index 7c80776344f..c1103f28de3 100644 --- a/packages/web/public/theme.json +++ b/packages/web/public/theme.json @@ -87,7 +87,10 @@ "syntaxNumber": { "$ref": "#/definitions/colorValue" }, "syntaxType": { "$ref": "#/definitions/colorValue" }, "syntaxOperator": { "$ref": "#/definitions/colorValue" }, - "syntaxPunctuation": { "$ref": "#/definitions/colorValue" } + "syntaxPunctuation": { "$ref": "#/definitions/colorValue" }, + "syntaxTag": { "$ref": "#/definitions/colorValue" }, + "syntaxAttribute": { "$ref": "#/definitions/colorValue" }, + "syntaxTagDelimiter": { "$ref": "#/definitions/colorValue" } }, "required": ["primary", "secondary", "accent", "text", "textMuted", "background"], "additionalProperties": false From c954561669574697d9f5583b1f1f60673c071c4a Mon Sep 17 00:00:00 2001 From: CasualDeveloper <10153929+CasualDeveloper@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:11:44 +0800 Subject: [PATCH 2/2] test(tui): cover XML/HTML syntax token fallbacks --- packages/opencode/test/cli/tui/theme.test.ts | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/opencode/test/cli/tui/theme.test.ts diff --git a/packages/opencode/test/cli/tui/theme.test.ts b/packages/opencode/test/cli/tui/theme.test.ts new file mode 100644 index 00000000000..71503905ed7 --- /dev/null +++ b/packages/opencode/test/cli/tui/theme.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { DEFAULT_THEMES, resolveTheme } from "../../../src/cli/cmd/tui/context/theme-resolver" + +test("resolveTheme falls back to legacy XML/HTML syntax colors", () => { + const theme = { + ...DEFAULT_THEMES.opencode, + theme: { + ...DEFAULT_THEMES.opencode.theme, + }, + } + + delete theme.theme.syntaxTag + delete theme.theme.syntaxAttribute + delete theme.theme.syntaxTagDelimiter + + const resolved = resolveTheme(theme, "dark") + + expect(resolved.syntaxTag).toBe(resolved.error) + expect(resolved.syntaxAttribute).toBe(resolved.syntaxKeyword) + expect(resolved.syntaxTagDelimiter).toBe(resolved.syntaxOperator) +}) + +test("resolveTheme honors explicit XML/HTML syntax tokens", () => { + const syntaxTag = RGBA.fromInts(10, 20, 30) + const syntaxAttribute = RGBA.fromInts(40, 50, 60) + const syntaxTagDelimiter = RGBA.fromInts(70, 80, 90) + + const theme = { + ...DEFAULT_THEMES.opencode, + theme: { + ...DEFAULT_THEMES.opencode.theme, + syntaxTag, + syntaxAttribute, + syntaxTagDelimiter, + }, + } + + const resolved = resolveTheme(theme, "dark") + + expect(resolved.syntaxTag).toBe(syntaxTag) + expect(resolved.syntaxAttribute).toBe(syntaxAttribute) + expect(resolved.syntaxTagDelimiter).toBe(syntaxTagDelimiter) +})