diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 6489fc0e1ef..9898f196f0b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -40,6 +40,7 @@ import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" +import { Config } from "../../../../config/config" import { useSDK } from "./sdk" type ThemeColors = { @@ -127,7 +128,7 @@ type Variant = { light: HexColor | RefName } type ColorValue = HexColor | RefName | Variant | RGBA -type ThemeJson = { +export type ThemeJson = { $schema?: string defs?: Record theme: Omit, "selectedListItemText" | "backgroundMenu"> & { @@ -390,7 +391,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }) -const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json") +const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.{json,jsonc}") async function getCustomThemes() { const directories = [ Global.Path.config, @@ -410,8 +411,11 @@ async function getCustomThemes() { dot: true, cwd: dir, })) { - const name = path.basename(item, ".json") - result[name] = await Bun.file(item).json() + const ext = path.extname(item) + const name = path.basename(item, ext) + + // Use JSONC parser for all theme files regardless of extension + result[name] = await Config.loadThemeFile(item) } } return result diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ead3a0149b4..8427ad4394c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -13,6 +13,7 @@ import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" +import type { ThemeJson } from "../cli/cmd/tui/context/theme" import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" @@ -1094,45 +1095,48 @@ export namespace Config { return load(text, filepath) } - async function load(text: string, configFilepath: string) { - text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - return process.env[varName] || "" - }) - - const fileMatches = text.match(/\{file:[^}]+\}/g) - if (fileMatches) { - const configDir = path.dirname(configFilepath) - const lines = text.split("\n") + async function load(text: string, configFilepath: string, enableConfigSubstitutions: boolean = true) { + if (enableConfigSubstitutions) { + text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { + return process.env[varName] || "" + }) + } - for (const match of fileMatches) { - const lineIndex = lines.findIndex((line) => line.includes(match)) - if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) { - continue // Skip if line is commented - } - let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "") - if (filePath.startsWith("~/")) { - filePath = path.join(os.homedir(), filePath.slice(2)) + if (enableConfigSubstitutions) { + const fileMatches = text.match(/\{file:[^}]+\}/g) + if (fileMatches) { + const configDir = path.dirname(configFilepath) + const lines = text.split("\n") + + for (const match of fileMatches) { + const lineIndex = lines.findIndex((line) => line.includes(match)) + if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) { + continue + } + let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "") + if (filePath.startsWith("~/")) { + filePath = path.join(os.homedir(), filePath.slice(2)) + } + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) + const fileContent = ( + await Bun.file(resolvedPath) + .text() + .catch((error) => { + const errMsg = `bad file reference: "${match}"` + if (error.code === "ENOENT") { + throw new InvalidError( + { + path: configFilepath, + message: errMsg + ` ${resolvedPath} does not exist`, + }, + { cause: error }, + ) + } + throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error }) + }) + ).trim() + text = text.replace(match, JSON.stringify(fileContent).slice(1, -1)) } - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) - const fileContent = ( - await Bun.file(resolvedPath) - .text() - .catch((error) => { - const errMsg = `bad file reference: "${match}"` - if (error.code === "ENOENT") { - throw new InvalidError( - { - path: configFilepath, - message: errMsg + ` ${resolvedPath} does not exist`, - }, - { cause: error }, - ) - } - throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error }) - }) - ).trim() - // escape newlines/quotes, strip outer quotes - text = text.replace(match, JSON.stringify(fileContent).slice(1, -1)) } } @@ -1213,6 +1217,48 @@ export namespace Config { return state().then((x) => x.config) } + export async function loadThemeFile(filepath: string): Promise { + log.info("loading theme", { path: filepath }) + let text = await Bun.file(filepath) + .text() + .catch((err) => { + if (err.code === "ENOENT") return + throw new JsonError({ path: filepath }, { cause: err }) + }) + if (!text) { + throw new Error("Empty theme file") + } + + // Parse JSONC directly without special features for themes + const errors: JsoncParseError[] = [] + const data = parseJsonc(text, errors, { allowTrailingComma: true }) + + if (errors.length) { + const lines = text.split("\n") + const errorDetails = errors + .map((e) => { + const beforeOffset = text.substring(0, e.offset).split("\n") + const line = beforeOffset.length + const column = beforeOffset[beforeOffset.length - 1].length + 1 + const problemLine = lines[line - 1] + + const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` + if (!problemLine) return error + + return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` + }) + .join("\n") + + throw new JsonError({ + path: filepath, + message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, + }) + } + + // Return data as ThemeJson (basic validation) + return data as ThemeJson + } + export async function update(config: Info) { const filepath = path.join(Instance.directory, "config.json") const existing = await loadFile(filepath) diff --git a/packages/opencode/test/config/theme.test.ts b/packages/opencode/test/config/theme.test.ts new file mode 100644 index 00000000000..09d16fc7758 --- /dev/null +++ b/packages/opencode/test/config/theme.test.ts @@ -0,0 +1,115 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { tmpdir } from "os" +import { join } from "path" +import { Config } from "../../src/config/config" +import { writeFileSync, mkdirSync, rmSync } from "fs" + +describe("Theme Loading", () => { + let tempDir: string + + beforeAll(() => { + tempDir = join(tmpdir(), "opencode-theme-test") + mkdirSync(tempDir, { recursive: true }) + }) + + afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + test("should load JSONC theme file with comments", async () => { + const themeContent = `{ + // This is a comment + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#ff0000", + "secondary": "#00ff00" + } +}` + + const themeFile = join(tempDir, "test-theme.jsonc") + writeFileSync(themeFile, themeContent) + + const theme = await Config.loadThemeFile(themeFile) + + expect(theme.theme.primary).toBe("#ff0000") + expect(theme.theme.secondary).toBe("#00ff00") + }) + + test("should NOT process environment variables in themes", async () => { + process.env.TEST_COLOR = "#00ff00" + const themeContent = `{ + "theme": { + "primary": "{env:TEST_COLOR}", + "secondary": "#ff0000" + } +}` + + const themeFile = join(tempDir, "test-theme.jsonc") + writeFileSync(themeFile, themeContent) + + const theme = await Config.loadThemeFile(themeFile) + + // Environment variable should NOT be processed in themes + expect(theme.theme.primary).toBe("{env:TEST_COLOR}") + expect(theme.theme.secondary).toBe("#ff0000") + }) + + test("should NOT process file inclusion in themes", async () => { + const colorFile = join(tempDir, "color.txt") + writeFileSync(colorFile, "#00ff00") + + const themeContent = `{ + "theme": { + "primary": "{file:color.txt}", + "secondary": "#ff0000" + } +}` + + const themeFile = join(tempDir, "test-theme.jsonc") + writeFileSync(themeFile, themeContent) + + const theme = await Config.loadThemeFile(themeFile) + + // File inclusion should NOT be processed in themes + expect(theme.theme.primary).toBe("{file:color.txt}") + expect(theme.theme.secondary).toBe("#ff0000") + }) + + test("should handle trailing commas in JSONC themes", async () => { + const themeContent = `{ + "theme": { + "primary": "#ff0000", + "secondary": "#00ff00", // Trailing comma + } +}` + + const themeFile = join(tempDir, "test-theme.jsonc") + writeFileSync(themeFile, themeContent) + + const theme = await Config.loadThemeFile(themeFile) + + expect(theme.theme.primary).toBe("#ff0000") + expect(theme.theme.secondary).toBe("#00ff00") + }) + + test("should throw error for invalid JSONC theme", async () => { + const themeContent = `{ + "theme": { + "primary": "#ff0000", + // Missing closing brace + "secondary": "#00ff00", + }` + + const themeFile = join(tempDir, "test-theme.jsonc") + writeFileSync(themeFile, themeContent) + + expect(Config.loadThemeFile(themeFile)).rejects.toThrow() + }) + + test("should throw error for empty theme file", async () => { + const themeFile = join(tempDir, "empty-theme.jsonc") + writeFileSync(themeFile, "") + + expect(Config.loadThemeFile(themeFile)).rejects.toThrow("Empty theme file") + }) +})