diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 306c03825e39..9f3e9a308b60 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 { useKV } from "./kv" import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@opencode-ai/core/global" +import { loadThemeFile } from "@/config/config" import { Filesystem } from "@/util/filesystem" import { useTuiConfig } from "./tui-config" import { isRecord } from "@/util/record" @@ -484,6 +485,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, }) +const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.{json,jsonc}") + async function getCustomThemes() { const directories = [ Global.Path.config, @@ -497,15 +500,15 @@ async function getCustomThemes() { const result: Record = {} for (const dir of directories) { - for (const item of await Glob.scan("themes/*.json", { + for (const item of await Glob.scan("themes/*.{json,jsonc}", { cwd: dir, absolute: true, dot: true, symlink: true, })) { - const name = path.basename(item, ".json") - const theme = await Filesystem.readJson(item) - if (isTheme(theme)) result[name] = theme + const ext = path.extname(item) + const name = path.basename(item, ext) + result[name] = await loadThemeFile(item) } } return result diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3a933f81e967..b37824ae4d55 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -10,7 +10,14 @@ import { NamedError } from "@opencode-ai/core/util/error" import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" -import { applyEdits, modify } from "jsonc-parser" +import { + type ParseError as JsoncParseError, + applyEdits, + modify, + parse as parseJsonc, + printParseErrorCode, +} from "jsonc-parser" +import type { ThemeJson } from "../cli/cmd/tui/context/theme" import { type InstanceContext } from "../project/instance" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" @@ -26,6 +33,7 @@ import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" +import { JsonError } from "./error" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" import { ConfigLSP } from "./lsp" @@ -311,6 +319,46 @@ function globalConfigFile() { return candidates[0] } +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") + } + + 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 +} + function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { const edits = modify(input, path, patch, { diff --git a/packages/opencode/test/config/theme.test.ts b/packages/opencode/test/config/theme.test.ts new file mode 100644 index 000000000000..16ebffc56abd --- /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 { loadThemeFile } 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 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 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 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 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(loadThemeFile(themeFile)).rejects.toThrow() + }) + + test("should throw error for empty theme file", async () => { + const themeFile = join(tempDir, "empty-theme.jsonc") + writeFileSync(themeFile, "") + + expect(loadThemeFile(themeFile)).rejects.toThrow("Empty theme file") + }) +})