From f64ded7b17da335fa960adc267df95b8514c4497 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 1 Dec 2025 11:33:43 +0000 Subject: [PATCH 01/10] chore: format code --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 21ef2a74407..7248787e88e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 9d3de8bb100..651f061ae07 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From ad111a727c12b07dad60ab83c15d1dfacdc8e815 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 1 Dec 2025 15:20:59 -0500 Subject: [PATCH 02/10] wip: option 2 for JSONC user themes. --- .../src/cli/cmd/tui/context/theme.tsx | 16 ++- packages/opencode/src/config/config.ts | 121 ++++++++++++------ 2 files changed, 96 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 88b9616b06a..1bd83db1571 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -32,6 +32,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" type ThemeColors = { primary: RGBA @@ -116,7 +117,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"> & { @@ -275,7 +276,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, @@ -295,8 +296,15 @@ 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 appropriate parser based on file extension + if (ext === ".jsonc") { + result[name] = await Config.loadThemeFile(item) + } else { + result[name] = await Bun.file(item).json() + } } } return result diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a4961209055..3bc91966f5f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,6 +12,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" @@ -715,45 +716,49 @@ 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, enableSpecialFeatures: boolean = true) { + if (enableSpecialFeatures) { + 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 (enableSpecialFeatures) { + 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 // Skip if line is commented + } + 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() + // escape newlines/quotes, strip outer quotes + 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)) } } @@ -834,6 +839,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) From 6dc434dff5e7e4d8bbd7348cb4e62d1f88afba0c Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 1 Dec 2025 15:21:38 -0500 Subject: [PATCH 03/10] wip: option 2 for JSONC user themes. --- packages/opencode/test/config/theme.test.ts | 115 ++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/opencode/test/config/theme.test.ts 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") + }) +}) From f5264491e154dc1a27f25246ca10d494fe7f13e8 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 1 Dec 2025 16:13:16 -0500 Subject: [PATCH 04/10] fix: always use JSONC parser for user themes for consistency with how opencode.jsonc is handled. --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 8 ++------ 1 file changed, 2 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 1bd83db1571..f369e5212ff 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -299,12 +299,8 @@ async function getCustomThemes() { const ext = path.extname(item) const name = path.basename(item, ext) - // Use appropriate parser based on file extension - if (ext === ".jsonc") { - result[name] = await Config.loadThemeFile(item) - } else { - result[name] = await Bun.file(item).json() - } + // Use JSONC parser for all theme files regardless of extension + result[name] = await Config.loadThemeFile(item) } } return result From cddc65638b92f9a5ac213c78cbbd42750dc267e2 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 1 Dec 2025 16:58:04 -0500 Subject: [PATCH 05/10] refactor: more expressive parameter name (enableSpecialFeatures->enableSpecialFeatures), add a JSDoc comment. --- packages/opencode/src/config/config.ts | 33 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3bc91966f5f..edcec9c4b54 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -716,14 +716,41 @@ export namespace Config { return load(text, filepath) } - async function load(text: string, configFilepath: string, enableSpecialFeatures: boolean = true) { - if (enableSpecialFeatures) { + /** + * Loads and parses a JSONC configuration file with optional config substitutions. + * + * Config substitutions are template-like features that allow dynamic content: + * - Environment variable substitution: `{env:VAR_NAME}` → process.env.VAR_NAME + * - File inclusion: `{file:path}` → content of external file + * + * @param text - Raw JSONC content to parse + * @param configFilepath - Path to config file (for error reporting and resolving relative paths) + * @param enableConfigSubstitutions - Whether to process config substitutions (default: true) + * - Set to `true` for config files (allows env vars and file inclusion) + * - Set to `false` for theme files (security: no env vars or file inclusion) + * + * @returns Parsed configuration object + * + * @example + * // For config files (enable substitutions) + * const config = await load(jsonContent, "/path/to/config.json", true) + * + * @example + * // For theme files (disable substitutions for security) + * const theme = await load(jsonContent, "/path/to/theme.json", false) + */ + async function load(text: string, configFilepath: string, enableConfigSubstitutions: boolean = true) { + // Process environment variable substitutions if enabled + // Security: Only enabled for config files, not themes + if (enableConfigSubstitutions) { text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) } - if (enableSpecialFeatures) { + // Process file inclusion substitutions if enabled + // Security: Only enabled for config files, not themes + if (enableConfigSubstitutions) { const fileMatches = text.match(/\{file:[^}]+\}/g) if (fileMatches) { const configDir = path.dirname(configFilepath) From d924de463e792f6d6e62e4958fc0619eada83ed1 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 8 Dec 2025 08:27:32 -0500 Subject: [PATCH 06/10] Fix TypeScript error: remove cacheKey from FileContents interface usage --- packages/desktop/src/pages/session.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 81f4dc1cbc4..1e86868fdcb 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -31,7 +31,7 @@ import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" -import { checksum } from "@opencode-ai/util/encode" + export default function Page() { const layout = useLayout() @@ -493,7 +493,6 @@ export default function Page() { file={{ name: f().path, contents: f().content?.content ?? "", - cacheKey: checksum(f().content?.content ?? ""), }} overflow="scroll" class="pb-40" From 59cf724080d137cc38027433294e3de5d00190d1 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 8 Dec 2025 13:34:21 -0500 Subject: [PATCH 07/10] fix: revert damaged file --- packages/desktop/src/pages/session.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 1e86868fdcb..81f4dc1cbc4 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -31,7 +31,7 @@ import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Terminal } from "@/components/terminal" - +import { checksum } from "@opencode-ai/util/encode" export default function Page() { const layout = useLayout() @@ -493,6 +493,7 @@ export default function Page() { file={{ name: f().path, contents: f().content?.content ?? "", + cacheKey: checksum(f().content?.content ?? ""), }} overflow="scroll" class="pb-40" From 151673a77b42d91b23d5710e949cbf318667a20a Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 10 Dec 2025 15:08:46 -0500 Subject: [PATCH 08/10] Fix type error: useKittyKeyboard should be boolean --- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1107ddd6a55..1ca7e126985 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -144,7 +144,7 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise Date: Wed, 10 Dec 2025 19:51:58 -0500 Subject: [PATCH 09/10] fix: uncorrupt --- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1ca7e126985..1107ddd6a55 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -144,7 +144,7 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise Date: Sun, 14 Dec 2025 13:05:15 -0500 Subject: [PATCH 10/10] style: remove some comments to match existing style --- packages/opencode/src/config/config.ts | 30 +------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 93e09c83b5f..b67532c3dad 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -832,40 +832,13 @@ export namespace Config { return load(text, filepath) } - /** - * Loads and parses a JSONC configuration file with optional config substitutions. - * - * Config substitutions are template-like features that allow dynamic content: - * - Environment variable substitution: `{env:VAR_NAME}` → process.env.VAR_NAME - * - File inclusion: `{file:path}` → content of external file - * - * @param text - Raw JSONC content to parse - * @param configFilepath - Path to config file (for error reporting and resolving relative paths) - * @param enableConfigSubstitutions - Whether to process config substitutions (default: true) - * - Set to `true` for config files (allows env vars and file inclusion) - * - Set to `false` for theme files (security: no env vars or file inclusion) - * - * @returns Parsed configuration object - * - * @example - * // For config files (enable substitutions) - * const config = await load(jsonContent, "/path/to/config.json", true) - * - * @example - * // For theme files (disable substitutions for security) - * const theme = await load(jsonContent, "/path/to/theme.json", false) - */ async function load(text: string, configFilepath: string, enableConfigSubstitutions: boolean = true) { - // Process environment variable substitutions if enabled - // Security: Only enabled for config files, not themes if (enableConfigSubstitutions) { text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) } - // Process file inclusion substitutions if enabled - // Security: Only enabled for config files, not themes if (enableConfigSubstitutions) { const fileMatches = text.match(/\{file:[^}]+\}/g) if (fileMatches) { @@ -875,7 +848,7 @@ export namespace Config { 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 + continue } let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "") if (filePath.startsWith("~/")) { @@ -899,7 +872,6 @@ export namespace Config { 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)) } }