summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSebastian <[email protected]>2026-02-25 23:53:09 +0100
committerGitHub <[email protected]>2026-02-25 23:53:09 +0100
commit9d29d692c6d93322f5894cca4232d80106e7c81a (patch)
tree65ac7313b158679e73722a90f7c813291a0ba03c
parent1172fa418e9aa5e0fcfccea326c6c9d35e1d57fd (diff)
downloadopencode-9d29d692c6d93322f5894cca4232d80106e7c81a.tar.gz
opencode-9d29d692c6d93322f5894cca4232d80106e7c81a.zip
split tui/server config (#13968)
-rw-r--r--packages/console/app/package.json2
-rwxr-xr-xpackages/opencode/script/schema.ts90
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx63
-rw-r--r--packages/opencode/src/cli/cmd/tui/attach.ts8
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx5
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/tips.tsx8
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/keybind.tsx16
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme.tsx8
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/tui-config.tsx9
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx10
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx5
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts8
-rw-r--r--packages/opencode/src/config/config.ts182
-rw-r--r--packages/opencode/src/config/migrate-tui-config.ts155
-rw-r--r--packages/opencode/src/config/paths.ts174
-rw-r--r--packages/opencode/src/config/tui-schema.ts34
-rw-r--r--packages/opencode/src/config/tui.ts118
-rw-r--r--packages/opencode/src/flag/flag.ts12
-rw-r--r--packages/opencode/test/config/config.test.ts54
-rw-r--r--packages/opencode/test/config/tui.test.ts510
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts409
-rw-r--r--packages/web/astro.config.mjs2
-rw-r--r--packages/web/src/content/docs/cli.mdx1
-rw-r--r--packages/web/src/content/docs/config.mdx57
-rw-r--r--packages/web/src/content/docs/keybinds.mdx12
-rw-r--r--packages/web/src/content/docs/themes.mdx6
-rw-r--r--packages/web/src/content/docs/tui.mdx32
27 files changed, 1284 insertions, 706 deletions
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index adf2d2d28..05d2309a4 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -7,7 +7,7 @@
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
- "build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json",
+ "build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
"start": "vite start"
},
"dependencies": {
diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts
index 585701c95..61d11ea7c 100755
--- a/packages/opencode/script/schema.ts
+++ b/packages/opencode/script/schema.ts
@@ -2,46 +2,62 @@
import { z } from "zod"
import { Config } from "../src/config/config"
+import { TuiConfig } from "../src/config/tui"
+
+function generate(schema: z.ZodType) {
+ const result = z.toJSONSchema(schema, {
+ io: "input", // Generate input shape (treats optional().default() as not required)
+ /**
+ * We'll use the `default` values of the field as the only value in `examples`.
+ * This will ensure no docs are needed to be read, as the configuration is
+ * self-documenting.
+ *
+ * See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
+ */
+ override(ctx) {
+ const schema = ctx.jsonSchema
+
+ // Preserve strictness: set additionalProperties: false for objects
+ if (
+ schema &&
+ typeof schema === "object" &&
+ schema.type === "object" &&
+ schema.additionalProperties === undefined
+ ) {
+ schema.additionalProperties = false
+ }
+
+ // Add examples and default descriptions for string fields with defaults
+ if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
+ if (!schema.examples) {
+ schema.examples = [schema.default]
+ }
-const file = process.argv[2]
-console.log(file)
-
-const result = z.toJSONSchema(Config.Info, {
- io: "input", // Generate input shape (treats optional().default() as not required)
- /**
- * We'll use the `default` values of the field as the only value in `examples`.
- * This will ensure no docs are needed to be read, as the configuration is
- * self-documenting.
- *
- * See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
- */
- override(ctx) {
- const schema = ctx.jsonSchema
-
- // Preserve strictness: set additionalProperties: false for objects
- if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
- schema.additionalProperties = false
- }
-
- // Add examples and default descriptions for string fields with defaults
- if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
- if (!schema.examples) {
- schema.examples = [schema.default]
+ schema.description = [schema.description || "", `default: \`${schema.default}\``]
+ .filter(Boolean)
+ .join("\n\n")
+ .trim()
}
+ },
+ }) as Record<string, unknown> & {
+ allowComments?: boolean
+ allowTrailingCommas?: boolean
+ }
+
+ // used for json lsps since config supports jsonc
+ result.allowComments = true
+ result.allowTrailingCommas = true
- schema.description = [schema.description || "", `default: \`${schema.default}\``]
- .filter(Boolean)
- .join("\n\n")
- .trim()
- }
- },
-}) as Record<string, unknown> & {
- allowComments?: boolean
- allowTrailingCommas?: boolean
+ return result
}
-// used for json lsps since config supports jsonc
-result.allowComments = true
-result.allowTrailingCommas = true
+const configFile = process.argv[2]
+const tuiFile = process.argv[3]
-await Bun.write(file, JSON.stringify(result, null, 2))
+console.log(configFile)
+await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
+
+if (tuiFile) {
+ console.log(tuiFile)
+ await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
+}
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index ab3d09689..97c910a47 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
+import { TuiConfigProvider } from "./context/tui-config"
+import { TuiConfig } from "@/config/tui"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -104,6 +106,7 @@ import type { EventSource } from "./context/sdk"
export function tui(input: {
url: string
args: Args
+ config: TuiConfig.Info
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@@ -138,35 +141,37 @@ export function tui(input: {
<KVProvider>
<ToastProvider>
<RouteProvider>
- <SDKProvider
- url={input.url}
- directory={input.directory}
- fetch={input.fetch}
- headers={input.headers}
- events={input.events}
- >
- <SyncProvider>
- <ThemeProvider mode={mode}>
- <LocalProvider>
- <KeybindProvider>
- <PromptStashProvider>
- <DialogProvider>
- <CommandProvider>
- <FrecencyProvider>
- <PromptHistoryProvider>
- <PromptRefProvider>
- <App />
- </PromptRefProvider>
- </PromptHistoryProvider>
- </FrecencyProvider>
- </CommandProvider>
- </DialogProvider>
- </PromptStashProvider>
- </KeybindProvider>
- </LocalProvider>
- </ThemeProvider>
- </SyncProvider>
- </SDKProvider>
+ <TuiConfigProvider config={input.config}>
+ <SDKProvider
+ url={input.url}
+ directory={input.directory}
+ fetch={input.fetch}
+ headers={input.headers}
+ events={input.events}
+ >
+ <SyncProvider>
+ <ThemeProvider mode={mode}>
+ <LocalProvider>
+ <KeybindProvider>
+ <PromptStashProvider>
+ <DialogProvider>
+ <CommandProvider>
+ <FrecencyProvider>
+ <PromptHistoryProvider>
+ <PromptRefProvider>
+ <App />
+ </PromptRefProvider>
+ </PromptHistoryProvider>
+ </FrecencyProvider>
+ </CommandProvider>
+ </DialogProvider>
+ </PromptStashProvider>
+ </KeybindProvider>
+ </LocalProvider>
+ </ThemeProvider>
+ </SyncProvider>
+ </SDKProvider>
+ </TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts
index a2559cfce..e892f9922 100644
--- a/packages/opencode/src/cli/cmd/tui/attach.ts
+++ b/packages/opencode/src/cli/cmd/tui/attach.ts
@@ -2,6 +2,9 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
+import { TuiConfig } from "@/config/tui"
+import { Instance } from "@/project/instance"
+import { existsSync } from "fs"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -63,8 +66,13 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
+ const config = await Instance.provide({
+ directory: directory && existsSync(directory) ? directory : process.cwd(),
+ fn: () => TuiConfig.get(),
+ })
await tui({
url: args.url,
+ config,
args: {
continue: args.continue,
sessionID: args.session,
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
index 38dc40275..be031296e 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
@@ -10,8 +10,7 @@ import {
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
-import { useKeybind } from "@tui/context/keybind"
-import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
+import { type KeybindKey, useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
@@ -22,7 +21,7 @@ export type Slash = {
}
export type CommandOption = DialogSelectOption<string> & {
- keybind?: keyof KeybindsConfig
+ keybind?: KeybindKey
suggested?: boolean
slash?: Slash
hidden?: boolean
diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx
index d0a7e5b44..73d82248a 100644
--- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx
@@ -80,11 +80,11 @@ const TIPS = [
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
- "Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
- "Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
+ "Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
+ "Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
- "Override any keybind in config via the {highlight}keybinds{/highlight} section",
+ "Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
@@ -140,7 +140,7 @@ const TIPS = [
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
- "Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
+ "Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
index 0dbbbc6f9..566d66ade 100644
--- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
@@ -1,20 +1,22 @@
import { createMemo } from "solid-js"
-import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
-import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
+import type { TuiConfig } from "@/config/tui"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
+import { useTuiConfig } from "./tui-config"
+
+export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
- const sync = useSync()
- const keybinds = createMemo(() => {
+ const config = useTuiConfig()
+ const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
return pipe(
- sync.data.config.keybinds ?? {},
+ (config.keybinds ?? {}) as Record<string, string>,
mapValues((value) => Keybind.parse(value)),
)
})
@@ -78,7 +80,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
return Keybind.fromParsedKey(evt, store.leader)
},
- match(key: keyof KeybindsConfig, evt: ParsedKey) {
+ match(key: KeybindKey, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt)
@@ -88,7 +90,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
}
},
- print(key: keyof KeybindsConfig) {
+ print(key: KeybindKey) {
const first = keybinds()[key]?.at(0)
if (!first) return ""
const result = Keybind.toString(first)
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 465ed805e..2320c08cc 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -1,7 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
-import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
@@ -42,6 +41,7 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
+import { useTuiConfig } from "./tui-config"
type ThemeColors = {
primary: RGBA
@@ -280,17 +280,17 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
- const sync = useSync()
+ const config = useTuiConfig()
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
- active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
+ active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
createEffect(() => {
- const theme = sync.data.config.theme
+ const theme = config.theme
if (theme) setStore("active", theme)
})
diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
new file mode 100644
index 000000000..62dbf1ebd
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
@@ -0,0 +1,9 @@
+import { TuiConfig } from "@/config/tui"
+import { createSimpleContext } from "./helper"
+
+export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
+ name: "TuiConfig",
+ init: (props: { config: TuiConfig.Info }) => {
+ return props.config
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 365eb3314..f20267e08 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
+import { useTuiConfig } from "../../context/tui-config"
addDefaultParsers(parsers.parsers)
@@ -101,6 +102,7 @@ const context = createContext<{
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
+ tui: ReturnType<typeof useTuiConfig>
}>()
function use() {
@@ -113,6 +115,7 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
+ const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -166,7 +169,7 @@ export function Session() {
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
- const tui = sync.data.config.tui
+ const tui = tuiConfig
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
@@ -988,6 +991,7 @@ export function Session() {
showGenericToolOutput,
diffWrapMode,
sync,
+ tui: tuiConfig,
}}
>
<box flexDirection="row">
@@ -1949,7 +1953,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
const { theme, syntax } = useTheme()
const view = createMemo(() => {
- const diffStyle = ctx.sync.data.config.tui?.diff_style
+ const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
@@ -2003,7 +2007,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => {
- const diffStyle = ctx.sync.data.config.tui?.diff_style
+ const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index 389fc2418..a50cd96fc 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
+import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject"
@@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
- const sync = useSync()
+ const config = useTuiConfig()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
- const diffStyle = sync.data.config.tui?.diff_style
+ const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 50f63c3df..750347d9d 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -12,6 +12,8 @@ import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
+import { TuiConfig } from "@/config/tui"
+import { Instance } from "@/project/instance"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -135,6 +137,10 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
+ const config = await Instance.provide({
+ directory: cwd,
+ fn: () => TuiConfig.get(),
+ })
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
@@ -163,6 +169,8 @@ export const TuiThreadCommand = cmd({
const tuiPromise = tui({
url,
+ config,
+ directory: cwd,
fetch: customFetch,
events,
args: {
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 761ce23f3..28aea4d67 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -4,7 +4,6 @@ import { pathToFileURL, fileURLToPath } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
-import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
@@ -34,6 +33,8 @@ import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Control } from "@/control"
+import { ConfigPaths } from "./paths"
+import { Filesystem } from "@/util/filesystem"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -42,7 +43,7 @@ export namespace Config {
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
- function getManagedConfigDir(): string {
+ function systemManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
@@ -53,10 +54,14 @@ export namespace Config {
}
}
- const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
+ export function managedConfigDir() {
+ return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
+ }
+
+ const managedDir = managedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
- function merge(target: Info, source: Info): Info {
+ function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -91,7 +96,7 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
- result = merge(
+ result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${key}/.well-known/opencode`),
@@ -107,21 +112,18 @@ export namespace Config {
}
// Global user config overrides remote config.
- result = merge(result, await global())
+ result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
- result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
+ result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- for (const file of ["opencode.jsonc", "opencode.json"]) {
- const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
- for (const resolved of found.toReversed()) {
- result = merge(result, await loadFile(resolved))
- }
+ for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
+ result = mergeConfigConcatArrays(result, await loadFile(file))
}
}
@@ -129,31 +131,10 @@ export namespace Config {
result.mode = result.mode || {}
result.plugin = result.plugin || []
- const directories = [
- Global.Path.config,
- // Only scan project .opencode/ directories when project discovery is enabled
- ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
- ? await Array.fromAsync(
- Filesystem.up({
- targets: [".opencode"],
- start: Instance.directory,
- stop: Instance.worktree,
- }),
- )
- : []),
- // Always scan ~/.opencode/ (user home directory)
- ...(await Array.fromAsync(
- Filesystem.up({
- targets: [".opencode"],
- start: Global.Path.home,
- stop: Global.Path.home,
- }),
- )),
- ]
+ const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) {
- directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
@@ -163,7 +144,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
- result = merge(result, await loadFile(path.join(dir, file)))
+ result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -186,7 +167,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (process.env.OPENCODE_CONFIG_CONTENT) {
- result = merge(
+ result = mergeConfigConcatArrays(
result,
await load(process.env.OPENCODE_CONFIG_CONTENT, {
dir: Instance.directory,
@@ -200,9 +181,9 @@ export namespace Config {
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
// This way it only loads config file and not skills/plugins/commands
- if (existsSync(managedConfigDir)) {
+ if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
- result = merge(result, await loadFile(path.join(managedConfigDir, file)))
+ result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
}
}
@@ -241,8 +222,6 @@ export namespace Config {
result.share = "auto"
}
- if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
-
// Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
@@ -306,7 +285,7 @@ export namespace Config {
}
}
- async function needsInstall(dir: string) {
+ export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
@@ -930,20 +909,6 @@ export namespace Config {
ref: "KeybindsConfig",
})
- export const TUI = z.object({
- scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
- scroll_acceleration: z
- .object({
- enabled: z.boolean().describe("Enable scroll acceleration"),
- })
- .optional()
- .describe("Scroll acceleration settings"),
- diff_style: z
- .enum(["auto", "stacked"])
- .optional()
- .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
- })
-
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -1018,10 +983,7 @@ export namespace Config {
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
- theme: z.string().optional().describe("Theme name to use for the interface"),
- keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
- tui: TUI.optional().describe("TUI specific settings"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), Command)
@@ -1241,86 +1203,37 @@ export namespace Config {
return result
})
+ export const { readFile } = ConfigPaths
+
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
- let text = await Filesystem.readText(filepath).catch((err: any) => {
- if (err.code === "ENOENT") return
- throw new JsonError({ path: filepath }, { cause: err })
- })
+ const text = await readFile(filepath)
if (!text) return {}
return load(text, { path: filepath })
}
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
const original = text
- const configDir = "path" in options ? path.dirname(options.path) : options.dir
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
+ const data = await ConfigPaths.parseText(
+ text,
+ "path" in options ? options.path : { source: options.source, dir: options.dir },
+ )
- text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
- return process.env[varName] || ""
- })
-
- const fileMatches = text.match(/\{file:[^}]+\}/g)
- if (fileMatches) {
- 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: source,
- message: errMsg + ` ${resolvedPath} does not exist`,
- },
- { cause: error },
- )
- }
- throw new InvalidError({ path: source, message: errMsg }, { cause: error })
- })
- ).trim()
- text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
- }
- }
-
- 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: source,
- message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
- })
- }
+ const normalized = (() => {
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data
+ const copy = { ...(data as Record<string, unknown>) }
+ const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
+ if (!hadLegacy) return copy
+ delete copy.theme
+ delete copy.keybinds
+ delete copy.tui
+ log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
+ return copy
+ })()
- const parsed = Info.safeParse(data)
+ const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1353,13 +1266,7 @@ export namespace Config {
issues: parsed.error.issues,
})
}
- export const JsonError = NamedError.create(
- "ConfigJsonError",
- z.object({
- path: z.string(),
- message: z.string().optional(),
- }),
- )
+ export const { JsonError, InvalidError } = ConfigPaths
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
@@ -1370,15 +1277,6 @@ export namespace Config {
}),
)
- export const InvalidError = NamedError.create(
- "ConfigInvalidError",
- z.object({
- path: z.string(),
- issues: z.custom<z.core.$ZodIssue[]>().optional(),
- message: z.string().optional(),
- }),
- )
-
export async function get() {
return state().then((x) => x.config)
}
diff --git a/packages/opencode/src/config/migrate-tui-config.ts b/packages/opencode/src/config/migrate-tui-config.ts
new file mode 100644
index 000000000..b426e4fbd
--- /dev/null
+++ b/packages/opencode/src/config/migrate-tui-config.ts
@@ -0,0 +1,155 @@
+import path from "path"
+import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
+import { unique } from "remeda"
+import z from "zod"
+import { ConfigPaths } from "./paths"
+import { TuiInfo, TuiOptions } from "./tui-schema"
+import { Instance } from "@/project/instance"
+import { Flag } from "@/flag/flag"
+import { Log } from "@/util/log"
+import { Filesystem } from "@/util/filesystem"
+import { Global } from "@/global"
+
+const log = Log.create({ service: "tui.migrate" })
+
+const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
+
+const LegacyTheme = TuiInfo.shape.theme.optional()
+const LegacyRecord = z.record(z.string(), z.unknown()).optional()
+
+const TuiLegacy = z
+ .object({
+ scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
+ scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
+ diff_style: TuiOptions.shape.diff_style.catch(undefined),
+ })
+ .strip()
+
+interface MigrateInput {
+ directories: string[]
+ custom?: string
+ managed: string
+}
+
+/**
+ * Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
+ * into dedicated tui.json files. Migration is performed per-directory and
+ * skips only locations where a tui.json already exists.
+ */
+export async function migrateTuiConfig(input: MigrateInput) {
+ const opencode = await opencodeFiles(input)
+ for (const file of opencode) {
+ const source = await Filesystem.readText(file).catch((error) => {
+ log.warn("failed to read config for tui migration", { path: file, error })
+ return undefined
+ })
+ if (!source) continue
+ const errors: JsoncParseError[] = []
+ const data = parseJsonc(source, errors, { allowTrailingComma: true })
+ if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
+
+ const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
+ const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
+ const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
+ const extracted = {
+ theme: theme.success ? theme.data : undefined,
+ keybinds: keybinds.success ? keybinds.data : undefined,
+ tui: legacyTui.success ? legacyTui.data : undefined,
+ }
+ const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
+ if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
+
+ const target = path.join(path.dirname(file), "tui.json")
+ const targetExists = await Filesystem.exists(target)
+ if (targetExists) continue
+
+ const payload: Record<string, unknown> = {
+ $schema: TUI_SCHEMA_URL,
+ }
+ if (extracted.theme !== undefined) payload.theme = extracted.theme
+ if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
+ if (tui) Object.assign(payload, tui)
+
+ const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
+ .then(() => true)
+ .catch((error) => {
+ log.warn("failed to write tui migration target", { from: file, to: target, error })
+ return false
+ })
+ if (!wrote) continue
+
+ const stripped = await backupAndStripLegacy(file, source)
+ if (!stripped) {
+ log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
+ continue
+ }
+ log.info("migrated tui config", { from: file, to: target })
+ }
+}
+
+function normalizeTui(data: Record<string, unknown>) {
+ const parsed = TuiLegacy.parse(data)
+ if (
+ parsed.scroll_speed === undefined &&
+ parsed.diff_style === undefined &&
+ parsed.scroll_acceleration === undefined
+ ) {
+ return
+ }
+ return parsed
+}
+
+async function backupAndStripLegacy(file: string, source: string) {
+ const backup = file + ".tui-migration.bak"
+ const hasBackup = await Filesystem.exists(backup)
+ const backed = hasBackup
+ ? true
+ : await Bun.write(backup, source)
+ .then(() => true)
+ .catch((error) => {
+ log.warn("failed to backup source config during tui migration", { path: file, backup, error })
+ return false
+ })
+ if (!backed) return false
+
+ const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
+ const edits = modify(acc, [key], undefined, {
+ formattingOptions: {
+ insertSpaces: true,
+ tabSize: 2,
+ },
+ })
+ if (!edits.length) return acc
+ return applyEdits(acc, edits)
+ }, source)
+
+ return Bun.write(file, text)
+ .then(() => {
+ log.info("stripped tui keys from server config", { path: file, backup })
+ return true
+ })
+ .catch((error) => {
+ log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
+ return false
+ })
+}
+
+async function opencodeFiles(input: { directories: string[]; managed: string }) {
+ const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? []
+ : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
+ const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
+ for (const dir of unique(input.directories)) {
+ files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
+ }
+ if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
+ files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
+
+ const existing = await Promise.all(
+ unique(files).map(async (file) => {
+ const ok = await Filesystem.exists(file)
+ return ok ? file : undefined
+ }),
+ )
+ return existing.filter((file): file is string => !!file)
+}
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
new file mode 100644
index 000000000..396417e9a
--- /dev/null
+++ b/packages/opencode/src/config/paths.ts
@@ -0,0 +1,174 @@
+import path from "path"
+import os from "os"
+import z from "zod"
+import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
+import { NamedError } from "@opencode-ai/util/error"
+import { Filesystem } from "@/util/filesystem"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+
+export namespace ConfigPaths {
+ export async function projectFiles(name: string, directory: string, worktree: string) {
+ const files: string[] = []
+ for (const file of [`${name}.jsonc`, `${name}.json`]) {
+ const found = await Filesystem.findUp(file, directory, worktree)
+ for (const resolved of found.toReversed()) {
+ files.push(resolved)
+ }
+ }
+ return files
+ }
+
+ export async function directories(directory: string, worktree: string) {
+ return [
+ Global.Path.config,
+ ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? await Array.fromAsync(
+ Filesystem.up({
+ targets: [".opencode"],
+ start: directory,
+ stop: worktree,
+ }),
+ )
+ : []),
+ ...(await Array.fromAsync(
+ Filesystem.up({
+ targets: [".opencode"],
+ start: Global.Path.home,
+ stop: Global.Path.home,
+ }),
+ )),
+ ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
+ ]
+ }
+
+ export function fileInDirectory(dir: string, name: string) {
+ return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
+ }
+
+ export const JsonError = NamedError.create(
+ "ConfigJsonError",
+ z.object({
+ path: z.string(),
+ message: z.string().optional(),
+ }),
+ )
+
+ export const InvalidError = NamedError.create(
+ "ConfigInvalidError",
+ z.object({
+ path: z.string(),
+ issues: z.custom<z.core.$ZodIssue[]>().optional(),
+ message: z.string().optional(),
+ }),
+ )
+
+ /** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
+ export async function readFile(filepath: string) {
+ return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
+ if (err.code === "ENOENT") return
+ throw new JsonError({ path: filepath }, { cause: err })
+ })
+ }
+
+ type ParseSource = string | { source: string; dir: string }
+
+ function source(input: ParseSource) {
+ return typeof input === "string" ? input : input.source
+ }
+
+ function dir(input: ParseSource) {
+ return typeof input === "string" ? path.dirname(input) : input.dir
+ }
+
+ /** Apply {env:VAR} and {file:path} substitutions to config text. */
+ async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
+ text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
+ return process.env[varName] || ""
+ })
+
+ const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
+ if (!fileMatches.length) return text
+
+ const configDir = dir(input)
+ const configSource = source(input)
+ let out = ""
+ let cursor = 0
+
+ for (const match of fileMatches) {
+ const token = match[0]
+ const index = match.index!
+ out += text.slice(cursor, index)
+
+ const lineStart = text.lastIndexOf("\n", index - 1) + 1
+ const prefix = text.slice(lineStart, index).trimStart()
+ if (prefix.startsWith("//")) {
+ out += token
+ cursor = index + token.length
+ continue
+ }
+
+ let filePath = token.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 Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
+ if (missing === "empty") return ""
+
+ const errMsg = `bad file reference: "${token}"`
+ if (error.code === "ENOENT") {
+ throw new InvalidError(
+ {
+ path: configSource,
+ message: errMsg + ` ${resolvedPath} does not exist`,
+ },
+ { cause: error },
+ )
+ }
+ throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
+ })
+ ).trim()
+
+ out += JSON.stringify(fileContent).slice(1, -1)
+ cursor = index + token.length
+ }
+
+ out += text.slice(cursor)
+ return out
+ }
+
+ /** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
+ export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
+ const configSource = source(input)
+ text = await substitute(text, input, missing)
+
+ 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: configSource,
+ message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
+ })
+ }
+
+ return data
+ }
+}
diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts
new file mode 100644
index 000000000..f9068e3f0
--- /dev/null
+++ b/packages/opencode/src/config/tui-schema.ts
@@ -0,0 +1,34 @@
+import z from "zod"
+import { Config } from "./config"
+
+const KeybindOverride = z
+ .object(
+ Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
+ string,
+ z.ZodOptional<z.ZodString>
+ >,
+ )
+ .strict()
+
+export const TuiOptions = z.object({
+ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
+ scroll_acceleration: z
+ .object({
+ enabled: z.boolean().describe("Enable scroll acceleration"),
+ })
+ .optional()
+ .describe("Scroll acceleration settings"),
+ diff_style: z
+ .enum(["auto", "stacked"])
+ .optional()
+ .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
+})
+
+export const TuiInfo = z
+ .object({
+ $schema: z.string().optional(),
+ theme: z.string().optional(),
+ keybinds: KeybindOverride.optional(),
+ })
+ .extend(TuiOptions.shape)
+ .strict()
diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts
new file mode 100644
index 000000000..f0964f63b
--- /dev/null
+++ b/packages/opencode/src/config/tui.ts
@@ -0,0 +1,118 @@
+import { existsSync } from "fs"
+import z from "zod"
+import { mergeDeep, unique } from "remeda"
+import { Config } from "./config"
+import { ConfigPaths } from "./paths"
+import { migrateTuiConfig } from "./migrate-tui-config"
+import { TuiInfo } from "./tui-schema"
+import { Instance } from "@/project/instance"
+import { Flag } from "@/flag/flag"
+import { Log } from "@/util/log"
+import { Global } from "@/global"
+
+export namespace TuiConfig {
+ const log = Log.create({ service: "tui.config" })
+
+ export const Info = TuiInfo
+
+ export type Info = z.output<typeof Info>
+
+ function mergeInfo(target: Info, source: Info): Info {
+ return mergeDeep(target, source)
+ }
+
+ function customPath() {
+ return Flag.OPENCODE_TUI_CONFIG
+ }
+
+ const state = Instance.state(async () => {
+ let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? []
+ : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
+ const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
+ const custom = customPath()
+ const managed = Config.managedConfigDir()
+ await migrateTuiConfig({ directories, custom, managed })
+ // Re-compute after migration since migrateTuiConfig may have created new tui.json files
+ projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+ ? []
+ : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
+
+ let result: Info = {}
+
+ for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+ result = mergeInfo(result, await loadFile(file))
+ }
+
+ if (custom) {
+ result = mergeInfo(result, await loadFile(custom))
+ log.debug("loaded custom tui config", { path: custom })
+ }
+
+ for (const file of projectFiles) {
+ result = mergeInfo(result, await loadFile(file))
+ }
+
+ for (const dir of unique(directories)) {
+ if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+ for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+ result = mergeInfo(result, await loadFile(file))
+ }
+ }
+
+ if (existsSync(managed)) {
+ for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
+ result = mergeInfo(result, await loadFile(file))
+ }
+ }
+
+ result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
+
+ return {
+ config: result,
+ }
+ })
+
+ export async function get() {
+ return state().then((x) => x.config)
+ }
+
+ async function loadFile(filepath: string): Promise<Info> {
+ const text = await ConfigPaths.readFile(filepath)
+ if (!text) return {}
+ return load(text, filepath).catch((error) => {
+ log.warn("failed to load tui config", { path: filepath, error })
+ return {}
+ })
+ }
+
+ async function load(text: string, configFilepath: string): Promise<Info> {
+ const data = await ConfigPaths.parseText(text, configFilepath, "empty")
+ if (!data || typeof data !== "object" || Array.isArray(data)) return {}
+
+ // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
+ // (mirroring the old opencode.json shape) still get their settings applied.
+ const normalized = (() => {
+ const copy = { ...(data as Record<string, unknown>) }
+ if (!("tui" in copy)) return copy
+ if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
+ delete copy.tui
+ return copy
+ }
+ const tui = copy.tui as Record<string, unknown>
+ delete copy.tui
+ return {
+ ...tui,
+ ...copy,
+ }
+ })()
+
+ const parsed = Info.safeParse(normalized)
+ if (!parsed.success) {
+ log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
+ return {}
+ }
+
+ return parsed.data
+ }
+}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 0049d716d..e02f191c7 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -7,6 +7,7 @@ export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
+ export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
@@ -74,6 +75,17 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
configurable: false,
})
+// Dynamic getter for OPENCODE_TUI_CONFIG
+// This must be evaluated at access time, not module load time,
+// because tests and external tooling may set this env var at runtime
+Object.defineProperty(Flag, "OPENCODE_TUI_CONFIG", {
+ get() {
+ return process.env["OPENCODE_TUI_CONFIG"]
+ },
+ enumerable: true,
+ configurable: false,
+})
+
// Dynamic getter for OPENCODE_CONFIG_DIR
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 2b1ba816e..f245dc349 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -56,6 +56,28 @@ test("loads JSON config file", async () => {
})
})
+test("ignores legacy tui keys in opencode config", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(dir, {
+ $schema: "https://opencode.ai/config.json",
+ model: "test/model",
+ theme: "legacy",
+ tui: { scroll_speed: 4 },
+ })
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ expect(config.model).toBe("test/model")
+ expect((config as Record<string, unknown>).theme).toBeUndefined()
+ expect((config as Record<string, unknown>).tui).toBeUndefined()
+ },
+ })
+})
+
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -110,14 +132,14 @@ test("merges multiple config files with correct precedence", async () => {
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
- process.env["TEST_VAR"] = "test_theme"
+ process.env["TEST_VAR"] = "test-user"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
- theme: "{env:TEST_VAR}",
+ username: "{env:TEST_VAR}",
})
},
})
@@ -125,7 +147,7 @@ test("handles environment variable substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.theme).toBe("test_theme")
+ expect(config.username).toBe("test-user")
},
})
} finally {
@@ -148,7 +170,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
- theme: "{env:PRESERVE_VAR}",
+ username: "{env:PRESERVE_VAR}",
}),
)
},
@@ -157,7 +179,7 @@ test("preserves env variables when adding $schema to config", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.theme).toBe("secret_value")
+ expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
@@ -178,10 +200,10 @@ test("preserves env variables when adding $schema to config", async () => {
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
- await Filesystem.write(path.join(dir, "included.txt"), "test_theme")
+ await Filesystem.write(path.join(dir, "included.txt"), "test-user")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
- theme: "{file:included.txt}",
+ username: "{file:included.txt}",
})
},
})
@@ -189,7 +211,7 @@ test("handles file inclusion substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.theme).toBe("test_theme")
+ expect(config.username).toBe("test-user")
},
})
})
@@ -200,7 +222,7 @@ test("handles file inclusion with replacement tokens", async () => {
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
- theme: "{file:included.md}",
+ username: "{file:included.md}",
})
},
})
@@ -208,7 +230,7 @@ test("handles file inclusion with replacement tokens", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.theme).toBe("const out = await Bun.$`echo hi`")
+ expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
})
@@ -1043,7 +1065,6 @@ test("managed settings override project settings", async () => {
$schema: "https://opencode.ai/config.json",
autoupdate: true,
disabled_providers: [],
- theme: "dark",
})
},
})
@@ -1060,7 +1081,6 @@ test("managed settings override project settings", async () => {
const config = await Config.get()
expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"])
- expect(config.theme).toBe("dark")
},
})
})
@@ -1809,7 +1829,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
- theme: "{env:TEST_CONFIG_VAR}",
+ username: "{env:TEST_CONFIG_VAR}",
})
try {
@@ -1818,7 +1838,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.theme).toBe("test_api_key_12345")
+ expect(config.username).toBe("test_api_key_12345")
},
})
} finally {
@@ -1841,10 +1861,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
try {
await using tmp = await tmpdir({
init: async (dir) => {
- await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
+ await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
- theme: "{file:./api_key.txt}",
+ username: "{file:./api_key.txt}",
})
},
})
@@ -1852,7 +1872,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- expect(config.theme).toBe("secret_key_from_file")
+ expect(config.username).toBe("secret_key_from_file")
},
})
} finally {
diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts
new file mode 100644
index 000000000..f9de5b041
--- /dev/null
+++ b/packages/opencode/test/config/tui.test.ts
@@ -0,0 +1,510 @@
+import { afterEach, expect, test } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+import { TuiConfig } from "../../src/config/tui"
+import { Global } from "../../src/global"
+import { Filesystem } from "../../src/util/filesystem"
+
+const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
+
+afterEach(async () => {
+ delete process.env.OPENCODE_CONFIG
+ delete process.env.OPENCODE_TUI_CONFIG
+ await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
+ await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
+ await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
+})
+
+test("loads tui config with the same precedence order as server config paths", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
+ await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
+ await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+ await Bun.write(
+ path.join(dir, ".opencode", "tui.json"),
+ JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("local")
+ expect(config.diff_style).toBe("stacked")
+ },
+ })
+})
+
+test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify(
+ {
+ theme: "migrated-theme",
+ tui: { scroll_speed: 5 },
+ keybinds: { app_exit: "ctrl+q" },
+ },
+ null,
+ 2,
+ ),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("migrated-theme")
+ expect(config.scroll_speed).toBe(5)
+ expect(config.keybinds?.app_exit).toBe("ctrl+q")
+ const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
+ expect(JSON.parse(text)).toMatchObject({
+ theme: "migrated-theme",
+ scroll_speed: 5,
+ })
+ const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+ expect(server.theme).toBeUndefined()
+ expect(server.keybinds).toBeUndefined()
+ expect(server.tui).toBeUndefined()
+ expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
+ expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+ },
+ })
+})
+
+test("migrates project legacy tui keys even when global tui.json already exists", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify(
+ {
+ theme: "project-migrated",
+ tui: { scroll_speed: 2 },
+ },
+ null,
+ 2,
+ ),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("project-migrated")
+ expect(config.scroll_speed).toBe(2)
+ expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+
+ const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+ expect(server.theme).toBeUndefined()
+ expect(server.tui).toBeUndefined()
+ },
+ })
+})
+
+test("drops unknown legacy tui keys during migration", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify(
+ {
+ theme: "migrated-theme",
+ tui: { scroll_speed: 2, foo: 1 },
+ },
+ null,
+ 2,
+ ),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("migrated-theme")
+ expect(config.scroll_speed).toBe(2)
+
+ const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
+ const migrated = JSON.parse(text)
+ expect(migrated.scroll_speed).toBe(2)
+ expect(migrated.foo).toBeUndefined()
+ },
+ })
+})
+
+test("skips migration when opencode.jsonc is syntactically invalid", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.jsonc"),
+ `{
+ "theme": "broken-theme",
+ "tui": { "scroll_speed": 2 }
+ "username": "still-broken"
+}`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBeUndefined()
+ expect(config.scroll_speed).toBeUndefined()
+ expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
+ expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
+ const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
+ expect(source).toContain('"theme": "broken-theme"')
+ expect(source).toContain('"tui": { "scroll_speed": 2 }')
+ },
+ })
+})
+
+test("skips migration when tui.json already exists", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
+ await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.diff_style).toBe("stacked")
+ expect(config.theme).toBeUndefined()
+
+ const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+ expect(server.theme).toBe("legacy")
+ expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
+ },
+ })
+})
+
+test("continues loading tui config when legacy source cannot be stripped", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
+ },
+ })
+
+ const source = path.join(tmp.path, "opencode.json")
+ await fs.chmod(source, 0o444)
+
+ try {
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("readonly-theme")
+ expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+
+ const server = JSON.parse(await Filesystem.readText(source))
+ expect(server.theme).toBe("readonly-theme")
+ },
+ })
+ } finally {
+ await fs.chmod(source, 0o644)
+ }
+})
+
+test("migration backup preserves JSONC comments", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.jsonc"),
+ `{
+ // top-level comment
+ "theme": "jsonc-theme",
+ "tui": {
+ // nested comment
+ "scroll_speed": 1.5
+ }
+}`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await TuiConfig.get()
+ const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
+ expect(backup).toContain("// top-level comment")
+ expect(backup).toContain("// nested comment")
+ expect(backup).toContain('"theme": "jsonc-theme"')
+ expect(backup).toContain('"scroll_speed": 1.5')
+ },
+ })
+})
+
+test("migrates legacy tui keys across multiple opencode.json levels", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const nested = path.join(dir, "apps", "client")
+ await fs.mkdir(nested, { recursive: true })
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2))
+ await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
+ },
+ })
+
+ await Instance.provide({
+ directory: path.join(tmp.path, "apps", "client"),
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("nested-theme")
+ expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+ expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
+ },
+ })
+})
+
+test("flattens nested tui key inside tui.json", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({
+ theme: "outer",
+ tui: { scroll_speed: 3, diff_style: "stacked" },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.scroll_speed).toBe(3)
+ expect(config.diff_style).toBe("stacked")
+ // top-level keys take precedence over nested tui keys
+ expect(config.theme).toBe("outer")
+ },
+ })
+})
+
+test("top-level keys in tui.json take precedence over nested tui key", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({
+ diff_style: "auto",
+ tui: { diff_style: "stacked", scroll_speed: 2 },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.diff_style).toBe("auto")
+ expect(config.scroll_speed).toBe(2)
+ },
+ })
+})
+
+test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
+ const custom = path.join(dir, "custom-tui.json")
+ await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" }))
+ process.env.OPENCODE_TUI_CONFIG = custom
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ // project tui.json overrides the custom path, same as server config precedence
+ expect(config.theme).toBe("project")
+ // project also set diff_style, so that wins
+ expect(config.diff_style).toBe("auto")
+ },
+ })
+})
+
+test("merges keybind overrides across precedence layers", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }))
+ await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.keybinds?.app_exit).toBe("ctrl+q")
+ expect(config.keybinds?.theme_list).toBe("ctrl+k")
+ },
+ })
+})
+
+test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const custom = path.join(dir, "custom-tui.json")
+ await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
+ process.env.OPENCODE_TUI_CONFIG = custom
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("from-env")
+ expect(config.diff_style).toBe("stacked")
+ },
+ })
+})
+
+test("does not derive tui path from OPENCODE_CONFIG", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const customDir = path.join(dir, "custom")
+ await fs.mkdir(customDir, { recursive: true })
+ await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" }))
+ await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" }))
+ process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBeUndefined()
+ },
+ })
+})
+
+test("applies env and file substitutions in tui.json", async () => {
+ const original = process.env.TUI_THEME_TEST
+ process.env.TUI_THEME_TEST = "env-theme"
+ try {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
+ await Bun.write(
+ path.join(dir, "tui.json"),
+ JSON.stringify({
+ theme: "{env:TUI_THEME_TEST}",
+ keybinds: { app_exit: "{file:keybind.txt}" },
+ }),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("env-theme")
+ expect(config.keybinds?.app_exit).toBe("ctrl+q")
+ },
+ })
+ } finally {
+ if (original === undefined) delete process.env.TUI_THEME_TEST
+ else process.env.TUI_THEME_TEST = original
+ }
+})
+
+test("applies file substitutions when first identical token is in a commented line", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "theme.txt"), "resolved-theme")
+ await Bun.write(
+ path.join(dir, "tui.jsonc"),
+ `{
+ // "theme": "{file:theme.txt}",
+ "theme": "{file:theme.txt}"
+}`,
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("resolved-theme")
+ },
+ })
+})
+
+test("loads managed tui config and gives it highest precedence", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
+ await fs.mkdir(managedConfigDir, { recursive: true })
+ await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("managed-theme")
+ },
+ })
+})
+
+test("loads .opencode/tui.json", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+ await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.diff_style).toBe("stacked")
+ },
+ })
+})
+
+test("gracefully falls back when tui.json has invalid JSON", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
+ await fs.mkdir(managedConfigDir, { recursive: true })
+ await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await TuiConfig.get()
+ expect(config.theme).toBe("managed-fallback")
+ expect(config.keybinds).toBeDefined()
+ },
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 28d5caa02..be6c00cf4 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -992,388 +992,6 @@ export type GlobalEvent = {
}
/**
- * Custom keybind configurations
- */
-export type KeybindsConfig = {
- /**
- * Leader key for keybind combinations
- */
- leader?: string
- /**
- * Exit the application
- */
- app_exit?: string
- /**
- * Open external editor
- */
- editor_open?: string
- /**
- * List available themes
- */
- theme_list?: string
- /**
- * Toggle sidebar
- */
- sidebar_toggle?: string
- /**
- * Toggle session scrollbar
- */
- scrollbar_toggle?: string
- /**
- * Toggle username visibility
- */
- username_toggle?: string
- /**
- * View status
- */
- status_view?: string
- /**
- * Export session to editor
- */
- session_export?: string
- /**
- * Create a new session
- */
- session_new?: string
- /**
- * List all sessions
- */
- session_list?: string
- /**
- * Show session timeline
- */
- session_timeline?: string
- /**
- * Fork session from message
- */
- session_fork?: string
- /**
- * Rename session
- */
- session_rename?: string
- /**
- * Delete session
- */
- session_delete?: string
- /**
- * Delete stash entry
- */
- stash_delete?: string
- /**
- * Open provider list from model dialog
- */
- model_provider_list?: string
- /**
- * Toggle model favorite status
- */
- model_favorite_toggle?: string
- /**
- * Share current session
- */
- session_share?: string
- /**
- * Unshare current session
- */
- session_unshare?: string
- /**
- * Interrupt current session
- */
- session_interrupt?: string
- /**
- * Compact the session
- */
- session_compact?: string
- /**
- * Scroll messages up by one page
- */
- messages_page_up?: string
- /**
- * Scroll messages down by one page
- */
- messages_page_down?: string
- /**
- * Scroll messages up by one line
- */
- messages_line_up?: string
- /**
- * Scroll messages down by one line
- */
- messages_line_down?: string
- /**
- * Scroll messages up by half page
- */
- messages_half_page_up?: string
- /**
- * Scroll messages down by half page
- */
- messages_half_page_down?: string
- /**
- * Navigate to first message
- */
- messages_first?: string
- /**
- * Navigate to last message
- */
- messages_last?: string
- /**
- * Navigate to next message
- */
- messages_next?: string
- /**
- * Navigate to previous message
- */
- messages_previous?: string
- /**
- * Navigate to last user message
- */
- messages_last_user?: string
- /**
- * Copy message
- */
- messages_copy?: string
- /**
- * Undo message
- */
- messages_undo?: string
- /**
- * Redo message
- */
- messages_redo?: string
- /**
- * Toggle code block concealment in messages
- */
- messages_toggle_conceal?: string
- /**
- * Toggle tool details visibility
- */
- tool_details?: string
- /**
- * List available models
- */
- model_list?: string
- /**
- * Next recently used model
- */
- model_cycle_recent?: string
- /**
- * Previous recently used model
- */
- model_cycle_recent_reverse?: string
- /**
- * Next favorite model
- */
- model_cycle_favorite?: string
- /**
- * Previous favorite model
- */
- model_cycle_favorite_reverse?: string
- /**
- * List available commands
- */
- command_list?: string
- /**
- * List agents
- */
- agent_list?: string
- /**
- * Next agent
- */
- agent_cycle?: string
- /**
- * Previous agent
- */
- agent_cycle_reverse?: string
- /**
- * Cycle model variants
- */
- variant_cycle?: string
- /**
- * Clear input field
- */
- input_clear?: string
- /**
- * Paste from clipboard
- */
- input_paste?: string
- /**
- * Submit input
- */
- input_submit?: string
- /**
- * Insert newline in input
- */
- input_newline?: string
- /**
- * Move cursor left in input
- */
- input_move_left?: string
- /**
- * Move cursor right in input
- */
- input_move_right?: string
- /**
- * Move cursor up in input
- */
- input_move_up?: string
- /**
- * Move cursor down in input
- */
- input_move_down?: string
- /**
- * Select left in input
- */
- input_select_left?: string
- /**
- * Select right in input
- */
- input_select_right?: string
- /**
- * Select up in input
- */
- input_select_up?: string
- /**
- * Select down in input
- */
- input_select_down?: string
- /**
- * Move to start of line in input
- */
- input_line_home?: string
- /**
- * Move to end of line in input
- */
- input_line_end?: string
- /**
- * Select to start of line in input
- */
- input_select_line_home?: string
- /**
- * Select to end of line in input
- */
- input_select_line_end?: string
- /**
- * Move to start of visual line in input
- */
- input_visual_line_home?: string
- /**
- * Move to end of visual line in input
- */
- input_visual_line_end?: string
- /**
- * Select to start of visual line in input
- */
- input_select_visual_line_home?: string
- /**
- * Select to end of visual line in input
- */
- input_select_visual_line_end?: string
- /**
- * Move to start of buffer in input
- */
- input_buffer_home?: string
- /**
- * Move to end of buffer in input
- */
- input_buffer_end?: string
- /**
- * Select to start of buffer in input
- */
- input_select_buffer_home?: string
- /**
- * Select to end of buffer in input
- */
- input_select_buffer_end?: string
- /**
- * Delete line in input
- */
- input_delete_line?: string
- /**
- * Delete to end of line in input
- */
- input_delete_to_line_end?: string
- /**
- * Delete to start of line in input
- */
- input_delete_to_line_start?: string
- /**
- * Backspace in input
- */
- input_backspace?: string
- /**
- * Delete character in input
- */
- input_delete?: string
- /**
- * Undo in input
- */
- input_undo?: string
- /**
- * Redo in input
- */
- input_redo?: string
- /**
- * Move word forward in input
- */
- input_word_forward?: string
- /**
- * Move word backward in input
- */
- input_word_backward?: string
- /**
- * Select word forward in input
- */
- input_select_word_forward?: string
- /**
- * Select word backward in input
- */
- input_select_word_backward?: string
- /**
- * Delete word forward in input
- */
- input_delete_word_forward?: string
- /**
- * Delete word backward in input
- */
- input_delete_word_backward?: string
- /**
- * Previous history item
- */
- history_previous?: string
- /**
- * Next history item
- */
- history_next?: string
- /**
- * Next child session
- */
- session_child_cycle?: string
- /**
- * Previous child session
- */
- session_child_cycle_reverse?: string
- /**
- * Go to parent session
- */
- session_parent?: string
- /**
- * Suspend terminal
- */
- terminal_suspend?: string
- /**
- * Toggle terminal title
- */
- terminal_title_toggle?: string
- /**
- * Toggle tips on home screen
- */
- tips_toggle?: string
- /**
- * Toggle thinking blocks visibility
- */
- display_thinking?: string
-}
-
-/**
* Log level
*/
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"
@@ -1672,34 +1290,7 @@ export type Config = {
* JSON schema reference for configuration validation
*/
$schema?: string
- /**
- * Theme name to use for the interface
- */
- theme?: string
- keybinds?: KeybindsConfig
logLevel?: LogLevel
- /**
- * TUI specific settings
- */
- tui?: {
- /**
- * TUI scroll speed
- */
- scroll_speed?: number
- /**
- * Scroll acceleration settings
- */
- scroll_acceleration?: {
- /**
- * Enable scroll acceleration
- */
- enabled: boolean
- }
- /**
- * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column
- */
- diff_style?: "auto" | "stacked"
- }
server?: ServerConfig
/**
* Command configuration, see https://opencode.ai/docs/commands
diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs
index b14a7ccb8..612d4fb8c 100644
--- a/packages/web/astro.config.mjs
+++ b/packages/web/astro.config.mjs
@@ -314,7 +314,7 @@ function configSchema() {
hooks: {
"astro:build:done": async () => {
console.log("generating config schema")
- spawnSync("../opencode/script/schema.ts", ["./dist/config.json"])
+ spawnSync("../opencode/script/schema.ts", ["./dist/config.json", "./dist/tui.json"])
},
},
}
diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx
index c504f734f..6b1c3dee5 100644
--- a/packages/web/src/content/docs/cli.mdx
+++ b/packages/web/src/content/docs/cli.mdx
@@ -558,6 +558,7 @@ OpenCode can be configured using environment variables.
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
+| `OPENCODE_TUI_CONFIG` | string | Path to TUI config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx
index eeccde2f7..038f25327 100644
--- a/packages/web/src/content/docs/config.mdx
+++ b/packages/web/src/content/docs/config.mdx
@@ -14,10 +14,11 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
- // Theme configuration
- "theme": "opencode",
"model": "anthropic/claude-sonnet-4-5",
"autoupdate": true,
+ "server": {
+ "port": 4096,
+ },
}
```
@@ -34,7 +35,7 @@ Configuration files are **merged together**, not replaced.
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
-For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
+For example, if your global config sets `autoupdate: true` and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include both settings.
---
@@ -95,7 +96,9 @@ You can enable specific servers in your local config:
### Global
-Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
+Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide server/runtime preferences like providers, models, and permissions.
+
+For TUI-specific settings, use `~/.config/opencode/tui.json`.
Global config overrides remote organizational defaults.
@@ -105,6 +108,8 @@ Global config overrides remote organizational defaults.
Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
+For project-specific TUI settings, add `tui.json` alongside it.
+
:::tip
Place project specific config in the root of your project.
:::
@@ -146,7 +151,9 @@ The custom directory is loaded after the global config and `.opencode` directori
## Schema
-The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
+The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
+
+TUI config uses [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json).
Your editor should be able to validate and autocomplete based on the schema.
@@ -154,28 +161,24 @@ Your editor should be able to validate and autocomplete based on the schema.
### TUI
-You can configure TUI-specific settings through the `tui` option.
+Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
-```json title="opencode.json"
+```json title="tui.json"
{
- "$schema": "https://opencode.ai/config.json",
- "tui": {
- "scroll_speed": 3,
- "scroll_acceleration": {
- "enabled": true
- },
- "diff_style": "auto"
- }
+ "$schema": "https://opencode.ai/tui.json",
+ "scroll_speed": 3,
+ "scroll_acceleration": {
+ "enabled": true
+ },
+ "diff_style": "auto"
}
```
-Available options:
+Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
-- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
-- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
-- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
+Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
-[Learn more about using the TUI here](/docs/tui).
+[Learn more about TUI configuration here](/docs/tui#configure).
---
@@ -301,12 +304,12 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr
### Themes
-You can configure the theme you want to use in your OpenCode config through the `theme` option.
+Set your UI theme in `tui.json`.
-```json title="opencode.json"
+```json title="tui.json"
{
- "$schema": "https://opencode.ai/config.json",
- "theme": ""
+ "$schema": "https://opencode.ai/tui.json",
+ "theme": "tokyonight"
}
```
@@ -406,11 +409,11 @@ You can also define commands using markdown files in `~/.config/opencode/command
### Keybinds
-You can customize your keybinds through the `keybinds` option.
+Customize keybinds in `tui.json`.
-```json title="opencode.json"
+```json title="tui.json"
{
- "$schema": "https://opencode.ai/config.json",
+ "$schema": "https://opencode.ai/tui.json",
"keybinds": {}
}
```
diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx
index 25fe2a1d9..95b3d4963 100644
--- a/packages/web/src/content/docs/keybinds.mdx
+++ b/packages/web/src/content/docs/keybinds.mdx
@@ -3,11 +3,11 @@ title: Keybinds
description: Customize your keybinds.
---
-OpenCode has a list of keybinds that you can customize through the OpenCode config.
+OpenCode has a list of keybinds that you can customize through `tui.json`.
-```json title="opencode.json"
+```json title="tui.json"
{
- "$schema": "https://opencode.ai/config.json",
+ "$schema": "https://opencode.ai/tui.json",
"keybinds": {
"leader": "ctrl+x",
"app_exit": "ctrl+c,ctrl+d,<leader>q",
@@ -117,11 +117,11 @@ You don't need to use a leader key for your keybinds but we recommend doing so.
## Disable keybind
-You can disable a keybind by adding the key to your config with a value of "none".
+You can disable a keybind by adding the key to `tui.json` with a value of "none".
-```json title="opencode.json"
+```json title="tui.json"
{
- "$schema": "https://opencode.ai/config.json",
+ "$schema": "https://opencode.ai/tui.json",
"keybinds": {
"session_compact": "none"
}
diff --git a/packages/web/src/content/docs/themes.mdx b/packages/web/src/content/docs/themes.mdx
index d37ce3135..8a7c6a46a 100644
--- a/packages/web/src/content/docs/themes.mdx
+++ b/packages/web/src/content/docs/themes.mdx
@@ -61,11 +61,11 @@ The system theme is for users who:
## Using a theme
-You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in your [config](/docs/config).
+You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in `tui.json`.
-```json title="opencode.json" {3}
+```json title="tui.json" {3}
{
- "$schema": "https://opencode.ai/config.json",
+ "$schema": "https://opencode.ai/tui.json",
"theme": "tokyonight"
}
```
diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx
index 1e48d42cc..010e8328f 100644
--- a/packages/web/src/content/docs/tui.mdx
+++ b/packages/web/src/content/docs/tui.mdx
@@ -355,24 +355,34 @@ Some editors need command-line arguments to run in blocking mode. The `--wait` f
## Configure
-You can customize TUI behavior through your OpenCode config file.
+You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
-```json title="opencode.json"
+```json title="tui.json"
{
- "$schema": "https://opencode.ai/config.json",
- "tui": {
- "scroll_speed": 3,
- "scroll_acceleration": {
- "enabled": true
- }
- }
+ "$schema": "https://opencode.ai/tui.json",
+ "theme": "opencode",
+ "keybinds": {
+ "leader": "ctrl+x"
+ },
+ "scroll_speed": 3,
+ "scroll_acceleration": {
+ "enabled": true
+ },
+ "diff_style": "auto"
}
```
+This is separate from `opencode.json`, which configures server/runtime behavior.
+
### Options
-- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
-- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
+- `theme` - Sets your UI theme. [Learn more](/docs/themes).
+- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
+- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
+- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
+- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
+
+Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
---