split tui/server config (#13968)

This commit is contained in:
Sebastian
2026-02-25 23:53:09 +01:00
committed by GitHub
parent 1172fa418e
commit 9d29d692c6
27 changed files with 1283 additions and 705 deletions

View File

@@ -7,7 +7,7 @@
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0", "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", "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" "start": "vite start"
}, },
"dependencies": { "dependencies": {

View File

@@ -2,46 +2,62 @@
import { z } from "zod" import { z } from "zod"
import { Config } from "../src/config/config" import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
const file = process.argv[2] function generate(schema: z.ZodType) {
console.log(file) 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
const result = z.toJSONSchema(Config.Info, { // Preserve strictness: set additionalProperties: false for objects
io: "input", // Generate input shape (treats optional().default() as not required) if (
/** schema &&
* We'll use the `default` values of the field as the only value in `examples`. typeof schema === "object" &&
* This will ensure no docs are needed to be read, as the configuration is schema.type === "object" &&
* self-documenting. schema.additionalProperties === undefined
* ) {
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5 schema.additionalProperties = false
*/
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}\``] // Add examples and default descriptions for string fields with defaults
.filter(Boolean) if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
.join("\n\n") if (!schema.examples) {
.trim() schema.examples = [schema.default]
} }
},
}) as Record<string, unknown> & { schema.description = [schema.description || "", `default: \`${schema.default}\``]
allowComments?: boolean .filter(Boolean)
allowTrailingCommas?: 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
return result
} }
// used for json lsps since config supports jsonc const configFile = process.argv[2]
result.allowComments = true const tuiFile = process.argv[3]
result.allowTrailingCommas = true
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))
}

View File

@@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY // can't set raw mode if not a TTY
@@ -104,6 +106,7 @@ import type { EventSource } from "./context/sdk"
export function tui(input: { export function tui(input: {
url: string url: string
args: Args args: Args
config: TuiConfig.Info
directory?: string directory?: string
fetch?: typeof fetch fetch?: typeof fetch
headers?: RequestInit["headers"] headers?: RequestInit["headers"]
@@ -138,35 +141,37 @@ export function tui(input: {
<KVProvider> <KVProvider>
<ToastProvider> <ToastProvider>
<RouteProvider> <RouteProvider>
<SDKProvider <TuiConfigProvider config={input.config}>
url={input.url} <SDKProvider
directory={input.directory} url={input.url}
fetch={input.fetch} directory={input.directory}
headers={input.headers} fetch={input.fetch}
events={input.events} headers={input.headers}
> events={input.events}
<SyncProvider> >
<ThemeProvider mode={mode}> <SyncProvider>
<LocalProvider> <ThemeProvider mode={mode}>
<KeybindProvider> <LocalProvider>
<PromptStashProvider> <KeybindProvider>
<DialogProvider> <PromptStashProvider>
<CommandProvider> <DialogProvider>
<FrecencyProvider> <CommandProvider>
<PromptHistoryProvider> <FrecencyProvider>
<PromptRefProvider> <PromptHistoryProvider>
<App /> <PromptRefProvider>
</PromptRefProvider> <App />
</PromptHistoryProvider> </PromptRefProvider>
</FrecencyProvider> </PromptHistoryProvider>
</CommandProvider> </FrecencyProvider>
</DialogProvider> </CommandProvider>
</PromptStashProvider> </DialogProvider>
</KeybindProvider> </PromptStashProvider>
</LocalProvider> </KeybindProvider>
</ThemeProvider> </LocalProvider>
</SyncProvider> </ThemeProvider>
</SDKProvider> </SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider> </RouteProvider>
</ToastProvider> </ToastProvider>
</KVProvider> </KVProvider>

View File

@@ -2,6 +2,9 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { tui } from "./app" import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
export const AttachCommand = cmd({ export const AttachCommand = cmd({
command: "attach <url>", command: "attach <url>",
@@ -63,8 +66,13 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth } return { Authorization: auth }
})() })()
const config = await Instance.provide({
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
})
await tui({ await tui({
url: args.url, url: args.url,
config,
args: { args: {
continue: args.continue, continue: args.continue,
sessionID: args.session, sessionID: args.session,

View File

@@ -10,8 +10,7 @@ import {
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind" import { type KeybindKey, useKeybind } from "@tui/context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
type Context = ReturnType<typeof init> type Context = ReturnType<typeof init>
const ctx = createContext<Context>() const ctx = createContext<Context>()
@@ -22,7 +21,7 @@ export type Slash = {
} }
export type CommandOption = DialogSelectOption<string> & { export type CommandOption = DialogSelectOption<string> & {
keybind?: keyof KeybindsConfig keybind?: KeybindKey
suggested?: boolean suggested?: boolean
slash?: Slash slash?: Slash
hidden?: boolean hidden?: boolean

View File

@@ -80,11 +80,11 @@ const TIPS = [
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes", "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents", "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", "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", "Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config", "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", "Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model", "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", "Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section", "Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth", "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 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 H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info", "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})", "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", "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", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",

View File

@@ -1,20 +1,22 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind" import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda" 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 type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid" import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper" 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({ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind", name: "Keybind",
init: () => { init: () => {
const sync = useSync() const config = useTuiConfig()
const keybinds = createMemo(() => { const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
return pipe( return pipe(
sync.data.config.keybinds ?? {}, (config.keybinds ?? {}) as Record<string, string>,
mapValues((value) => Keybind.parse(value)), mapValues((value) => Keybind.parse(value)),
) )
}) })
@@ -78,7 +80,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
} }
return Keybind.fromParsedKey(evt, store.leader) return Keybind.fromParsedKey(evt, store.leader)
}, },
match(key: keyof KeybindsConfig, evt: ParsedKey) { match(key: KeybindKey, evt: ParsedKey) {
const keybind = keybinds()[key] const keybind = keybinds()[key]
if (!keybind) return false if (!keybind) return false
const parsed: Keybind.Info = result.parse(evt) 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) const first = keybinds()[key]?.at(0)
if (!first) return "" if (!first) return ""
const result = Keybind.toString(first) const result = Keybind.toString(first)

View File

@@ -1,7 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path" import path from "path"
import { createEffect, createMemo, onMount } from "solid-js" import { createEffect, createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob" import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" } 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 { createStore, produce } from "solid-js/store"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
type ThemeColors = { type ThemeColors = {
primary: RGBA primary: RGBA
@@ -280,17 +280,17 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme", name: "Theme",
init: (props: { mode: "dark" | "light" }) => { init: (props: { mode: "dark" | "light" }) => {
const sync = useSync() const config = useTuiConfig()
const kv = useKV() const kv = useKV()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
themes: DEFAULT_THEMES, themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode), 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, ready: false,
}) })
createEffect(() => { createEffect(() => {
const theme = sync.data.config.theme const theme = config.theme
if (theme) setStore("active", theme) if (theme) setStore("active", theme)
}) })

View File

@@ -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
},
})

View File

@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript" import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts" import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -101,6 +102,7 @@ const context = createContext<{
showGenericToolOutput: () => boolean showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none" diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>() }>()
function use() { function use() {
@@ -113,6 +115,7 @@ export function Session() {
const route = useRouteData("session") const route = useRouteData("session")
const { navigate } = useRoute() const { navigate } = useRoute()
const sync = useSync() const sync = useSync()
const tuiConfig = useTuiConfig()
const kv = useKV() const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
const promptRef = usePromptRef() const promptRef = usePromptRef()
@@ -166,7 +169,7 @@ export function Session() {
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => { const scrollAcceleration = createMemo(() => {
const tui = sync.data.config.tui const tui = tuiConfig
if (tui?.scroll_acceleration?.enabled) { if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel() return new MacOSScrollAccel()
} }
@@ -988,6 +991,7 @@ export function Session() {
showGenericToolOutput, showGenericToolOutput,
diffWrapMode, diffWrapMode,
sync, sync,
tui: tuiConfig,
}} }}
> >
<box flexDirection="row"> <box flexDirection="row">
@@ -1949,7 +1953,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const view = createMemo(() => { const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified" if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior // Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified" return ctx.width > 120 ? "split" : "unified"
@@ -2003,7 +2007,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const files = createMemo(() => props.metadata.files ?? []) const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => { const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified" if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified" return ctx.width > 120 ? "split" : "unified"
}) })

View File

@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Global } from "@/global" import { Global } from "@/global"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject" type PermissionStage = "permission" | "always" | "reject"
@@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme() const themeState = useTheme()
const theme = themeState.theme const theme = themeState.theme
const syntax = themeState.syntax const syntax = themeState.syntax
const sync = useSync() const config = useTuiConfig()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "") const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "") const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => { const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified" if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified" return dimensions().width > 120 ? "split" : "unified"
}) })

View File

@@ -12,6 +12,8 @@ import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2" import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@@ -135,6 +137,10 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt 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) // Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args) const networkOpts = await resolveNetworkOptions(args)
@@ -163,6 +169,8 @@ export const TuiThreadCommand = cmd({
const tuiPromise = tui({ const tuiPromise = tui({
url, url,
config,
directory: cwd,
fetch: customFetch, fetch: customFetch,
events, events,
args: { args: {

View File

@@ -4,7 +4,6 @@ import { pathToFileURL, fileURLToPath } from "url"
import { createRequire } from "module" import { createRequire } from "module"
import os from "os" import os from "os"
import z from "zod" import z from "zod"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models" import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda" import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global" import { Global } from "../global"
@@ -34,6 +33,8 @@ import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied" import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Control } from "@/control" import { Control } from "@/control"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) 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) // Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings // These settings override all user and project settings
function getManagedConfigDir(): string { function systemManagedConfigDir(): string {
switch (process.platform) { switch (process.platform) {
case "darwin": case "darwin":
return "/Library/Application Support/opencode" 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 // 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) const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) { if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...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 ?? {} const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file // 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" if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = merge( result = mergeConfigConcatArrays(
result, result,
await load(JSON.stringify(remoteConfig), { await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${key}/.well-known/opencode`), dir: path.dirname(`${key}/.well-known/opencode`),
@@ -107,21 +112,18 @@ export namespace Config {
} }
// Global user config overrides remote config. // Global user config overrides remote config.
result = merge(result, await global()) result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global config. // Custom config path overrides global config.
if (Flag.OPENCODE_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 }) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
// Project config overrides global and remote config. // Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) result = mergeConfigConcatArrays(result, await loadFile(file))
for (const resolved of found.toReversed()) {
result = merge(result, await loadFile(resolved))
}
} }
} }
@@ -129,31 +131,10 @@ export namespace Config {
result.mode = result.mode || {} result.mode = result.mode || {}
result.plugin = result.plugin || [] result.plugin = result.plugin || []
const directories = [ const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
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,
}),
)),
]
// .opencode directory config overrides (project and global) config sources. // .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) { if (Flag.OPENCODE_CONFIG_DIR) {
directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: 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) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`) 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 // to satisfy the type checker
result.agent ??= {} result.agent ??= {}
result.mode ??= {} result.mode ??= {}
@@ -186,7 +167,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources. // Inline config content overrides all non-managed config sources.
if (process.env.OPENCODE_CONFIG_CONTENT) { if (process.env.OPENCODE_CONFIG_CONTENT) {
result = merge( result = mergeConfigConcatArrays(
result, result,
await load(process.env.OPENCODE_CONFIG_CONTENT, { await load(process.env.OPENCODE_CONFIG_CONTENT, {
dir: Instance.directory, dir: Instance.directory,
@@ -200,9 +181,9 @@ export namespace Config {
// Kept separate from directories array to avoid write operations when installing plugins // Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions // which would fail on system directories requiring elevated permissions
// This way it only loads config file and not skills/plugins/commands // 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"]) { 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" result.share = "auto"
} }
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
// Apply flag overrides for compaction settings // Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false } 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. // Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case. // Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir) const writable = await isWritable(dir)
@@ -930,20 +909,6 @@ export namespace Config {
ref: "KeybindsConfig", 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 export const Server = z
.object({ .object({
port: z.number().int().positive().optional().describe("Port to listen on"), port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -1018,10 +983,7 @@ export namespace Config {
export const Info = z export const Info = z
.object({ .object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"), $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"), 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"), server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z command: z
.record(z.string(), Command) .record(z.string(), Command)
@@ -1241,86 +1203,37 @@ export namespace Config {
return result return result
}) })
export const { readFile } = ConfigPaths
async function loadFile(filepath: string): Promise<Info> { async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath }) log.info("loading", { path: filepath })
let text = await Filesystem.readText(filepath).catch((err: any) => { const text = await readFile(filepath)
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {} if (!text) return {}
return load(text, { path: filepath }) return load(text, { path: filepath })
} }
async function load(text: string, options: { path: string } | { dir: string; source: string }) { async function load(text: string, options: { path: string } | { dir: string; source: string }) {
const original = text const original = text
const configDir = "path" in options ? path.dirname(options.path) : options.dir
const source = "path" in options ? options.path : options.source const source = "path" in options ? options.path : options.source
const isFile = "path" in options 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) => { const normalized = (() => {
return process.env[varName] || "" 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 fileMatches = text.match(/\{file:[^}]+\}/g) const parsed = Info.safeParse(normalized)
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 parsed = Info.safeParse(data)
if (parsed.success) { if (parsed.success) {
if (!parsed.data.$schema && isFile) { if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json" parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1353,13 +1266,7 @@ export namespace Config {
issues: parsed.error.issues, issues: parsed.error.issues,
}) })
} }
export const JsonError = NamedError.create( export const { JsonError, InvalidError } = ConfigPaths
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const ConfigDirectoryTypoError = NamedError.create( export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError", "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() { export async function get() {
return state().then((x) => x.config) return state().then((x) => x.config)
} }

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -7,6 +7,7 @@ export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] 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 declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
@@ -74,6 +75,17 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
configurable: false, 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 // Dynamic getter for OPENCODE_CONFIG_DIR
// This must be evaluated at access time, not module load time, // This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime // because external tooling may set this env var at runtime

View File

@@ -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 () => { test("loads JSONC config file", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
@@ -110,14 +132,14 @@ test("merges multiple config files with correct precedence", async () => {
test("handles environment variable substitution", async () => { test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"] const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test_theme" process.env["TEST_VAR"] = "test-user"
try { try {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await writeConfig(dir, { await writeConfig(dir, {
$schema: "https://opencode.ai/config.json", $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, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
expect(config.theme).toBe("test_theme") expect(config.username).toBe("test-user")
}, },
}) })
} finally { } finally {
@@ -148,7 +170,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Filesystem.write( await Filesystem.write(
path.join(dir, "opencode.json"), path.join(dir, "opencode.json"),
JSON.stringify({ 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, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() 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 // Read the file to verify the env variable was preserved
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json")) 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 () => { test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { 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, { await writeConfig(dir, {
$schema: "https://opencode.ai/config.json", $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, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() 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 Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, { await writeConfig(dir, {
$schema: "https://opencode.ai/config.json", $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, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() 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", $schema: "https://opencode.ai/config.json",
autoupdate: true, autoupdate: true,
disabled_providers: [], disabled_providers: [],
theme: "dark",
}) })
}, },
}) })
@@ -1060,7 +1081,6 @@ test("managed settings override project settings", async () => {
const config = await Config.get() const config = await Config.get()
expect(config.autoupdate).toBe(false) expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"]) 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["TEST_CONFIG_VAR"] = "test_api_key_12345"
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
theme: "{env:TEST_CONFIG_VAR}", username: "{env:TEST_CONFIG_VAR}",
}) })
try { try {
@@ -1818,7 +1838,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
expect(config.theme).toBe("test_api_key_12345") expect(config.username).toBe("test_api_key_12345")
}, },
}) })
} finally { } finally {
@@ -1841,10 +1861,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
try { try {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { 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({ process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json", $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, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
expect(config.theme).toBe("secret_key_from_file") expect(config.username).toBe("secret_key_from_file")
}, },
}) })
} finally { } finally {

View File

@@ -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()
},
})
})

View File

@@ -991,388 +991,6 @@ export type GlobalEvent = {
payload: Event payload: Event
} }
/**
* 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 * Log level
*/ */
@@ -1672,34 +1290,7 @@ export type Config = {
* JSON schema reference for configuration validation * JSON schema reference for configuration validation
*/ */
$schema?: string $schema?: string
/**
* Theme name to use for the interface
*/
theme?: string
keybinds?: KeybindsConfig
logLevel?: LogLevel 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 server?: ServerConfig
/** /**
* Command configuration, see https://opencode.ai/docs/commands * Command configuration, see https://opencode.ai/docs/commands

View File

@@ -314,7 +314,7 @@ function configSchema() {
hooks: { hooks: {
"astro:build:done": async () => { "astro:build:done": async () => {
console.log("generating config schema") console.log("generating config schema")
spawnSync("../opencode/script/schema.ts", ["./dist/config.json"]) spawnSync("../opencode/script/schema.ts", ["./dist/config.json", "./dist/tui.json"])
}, },
}, },
} }

View File

@@ -558,6 +558,7 @@ OpenCode can be configured using environment variables.
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions | | `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows | | `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file | | `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_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content | | `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks | | `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |

View File

@@ -14,10 +14,11 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
```jsonc title="opencode.jsonc" ```jsonc title="opencode.jsonc"
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
// Theme configuration
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
"autoupdate": true, "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. 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 ### 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. 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. 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 :::tip
Place project specific config in the root of your project. 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 ## 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. 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 ### 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", "$schema": "https://opencode.ai/tui.json",
"tui": { "scroll_speed": 3,
"scroll_speed": 3, "scroll_acceleration": {
"scroll_acceleration": { "enabled": true
"enabled": true },
}, "diff_style": "auto"
"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`.** Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
- `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.
[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 ### 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", "$schema": "https://opencode.ai/tui.json",
"theme": "" "theme": "tokyonight"
} }
``` ```
@@ -406,11 +409,11 @@ You can also define commands using markdown files in `~/.config/opencode/command
### Keybinds ### 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": {} "keybinds": {}
} }
``` ```

View File

@@ -3,11 +3,11 @@ title: Keybinds
description: Customize your 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": { "keybinds": {
"leader": "ctrl+x", "leader": "ctrl+x",
"app_exit": "ctrl+c,ctrl+d,<leader>q", "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 ## 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": { "keybinds": {
"session_compact": "none" "session_compact": "none"
} }

View File

@@ -61,11 +61,11 @@ The system theme is for users who:
## Using a theme ## 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" "theme": "tokyonight"
} }
``` ```

View File

@@ -355,24 +355,34 @@ Some editors need command-line arguments to run in blocking mode. The `--wait` f
## Configure ## 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", "$schema": "https://opencode.ai/tui.json",
"tui": { "theme": "opencode",
"scroll_speed": 3, "keybinds": {
"scroll_acceleration": { "leader": "ctrl+x"
"enabled": true },
} "scroll_speed": 3,
} "scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
} }
``` ```
This is separate from `opencode.json`, which configures server/runtime behavior.
### Options ### 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.** - `theme` - Sets your UI theme. [Learn more](/docs/themes).
- `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`.** - `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.
--- ---