Parcourir la source

split tui/server config (#13968)

Sebastian il y a 5 mois
Parent
commit
9d29d692c6
27 fichiers modifiés avec 1284 ajouts et 706 suppressions
  1. 1 1
      packages/console/app/package.json
  2. 53 37
      packages/opencode/script/schema.ts
  3. 34 29
      packages/opencode/src/cli/cmd/tui/app.tsx
  4. 8 0
      packages/opencode/src/cli/cmd/tui/attach.ts
  5. 2 3
      packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
  6. 4 4
      packages/opencode/src/cli/cmd/tui/component/tips.tsx
  7. 9 7
      packages/opencode/src/cli/cmd/tui/context/keybind.tsx
  8. 4 4
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  9. 9 0
      packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
  10. 7 3
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  11. 3 2
      packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
  12. 8 0
      packages/opencode/src/cli/cmd/tui/thread.ts
  13. 40 142
      packages/opencode/src/config/config.ts
  14. 155 0
      packages/opencode/src/config/migrate-tui-config.ts
  15. 174 0
      packages/opencode/src/config/paths.ts
  16. 34 0
      packages/opencode/src/config/tui-schema.ts
  17. 118 0
      packages/opencode/src/config/tui.ts
  18. 12 0
      packages/opencode/src/flag/flag.ts
  19. 37 17
      packages/opencode/test/config/config.test.ts
  20. 510 0
      packages/opencode/test/config/tui.test.ts
  21. 0 409
      packages/sdk/js/src/v2/gen/types.gen.ts
  22. 1 1
      packages/web/astro.config.mjs
  23. 1 0
      packages/web/src/content/docs/cli.mdx
  24. 30 27
      packages/web/src/content/docs/config.mdx
  25. 6 6
      packages/web/src/content/docs/keybinds.mdx
  26. 3 3
      packages/web/src/content/docs/themes.mdx
  27. 21 11
      packages/web/src/content/docs/tui.mdx

+ 1 - 1
packages/console/app/package.json

@@ -7,7 +7,7 @@
     "typecheck": "tsgo --noEmit",
     "dev": "vite dev --host 0.0.0.0",
     "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
-    "build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json",
+    "build": "bun ./script/generate-sitemap.ts && vite build && bun ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
     "start": "vite start"
   },
   "dependencies": {

+ 53 - 37
packages/opencode/script/schema.ts

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

+ 34 - 29
packages/opencode/src/cli/cmd/tui/app.tsx

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

+ 8 - 0
packages/opencode/src/cli/cmd/tui/attach.ts

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

+ 2 - 3
packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx

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

+ 4 - 4
packages/opencode/src/cli/cmd/tui/component/tips.tsx

@@ -80,11 +80,11 @@ const TIPS = [
   "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
   "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
   "Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
-  "Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
-  "Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
+  "Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
+  "Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
   "Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
   "Configure {highlight}model{/highlight} in config to set your default model",
-  "Override any keybind in config via the {highlight}keybinds{/highlight} section",
+  "Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
   "Set any keybind to {highlight}none{/highlight} to disable it completely",
   "Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
   "OpenCode auto-handles OAuth for remote MCP servers requiring auth",
@@ -140,7 +140,7 @@ const TIPS = [
   "Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
   "Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
   "Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
-  "Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
+  "Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
   "Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
   "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
   "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",

+ 9 - 7
packages/opencode/src/cli/cmd/tui/context/keybind.tsx

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

+ 4 - 4
packages/opencode/src/cli/cmd/tui/context/theme.tsx

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

+ 9 - 0
packages/opencode/src/cli/cmd/tui/context/tui-config.tsx

@@ -0,0 +1,9 @@
+import { TuiConfig } from "@/config/tui"
+import { createSimpleContext } from "./helper"
+
+export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
+  name: "TuiConfig",
+  init: (props: { config: TuiConfig.Info }) => {
+    return props.config
+  },
+})

+ 7 - 3
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

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

+ 3 - 2
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

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

+ 8 - 0
packages/opencode/src/cli/cmd/tui/thread.ts

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

+ 40 - 142
packages/opencode/src/config/config.ts

@@ -4,7 +4,6 @@ import { pathToFileURL, fileURLToPath } from "url"
 import { createRequire } from "module"
 import os from "os"
 import z from "zod"
-import { Filesystem } from "../util/filesystem"
 import { ModelsDev } from "../provider/models"
 import { mergeDeep, pipe, unique } from "remeda"
 import { Global } from "../global"
@@ -34,6 +33,8 @@ import { PackageRegistry } from "@/bun/registry"
 import { proxied } from "@/util/proxied"
 import { iife } from "@/util/iife"
 import { Control } from "@/control"
+import { ConfigPaths } from "./paths"
+import { Filesystem } from "@/util/filesystem"
 
 export namespace Config {
   const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -42,7 +43,7 @@ export namespace Config {
 
   // Managed settings directory for enterprise deployments (highest priority, admin-controlled)
   // These settings override all user and project settings
-  function getManagedConfigDir(): string {
+  function systemManagedConfigDir(): string {
     switch (process.platform) {
       case "darwin":
         return "/Library/Application Support/opencode"
@@ -53,10 +54,14 @@ export namespace Config {
     }
   }
 
-  const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
+  export function managedConfigDir() {
+    return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
+  }
+
+  const managedDir = managedConfigDir()
 
   // Custom merge function that concatenates array fields instead of replacing them
-  function merge(target: Info, source: Info): Info {
+  function mergeConfigConcatArrays(target: Info, source: Info): Info {
     const merged = mergeDeep(target, source)
     if (target.plugin && source.plugin) {
       merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -91,7 +96,7 @@ export namespace Config {
         const remoteConfig = wellknown.config ?? {}
         // Add $schema to prevent load() from trying to write back to a non-existent file
         if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
-        result = merge(
+        result = mergeConfigConcatArrays(
           result,
           await load(JSON.stringify(remoteConfig), {
             dir: path.dirname(`${key}/.well-known/opencode`),
@@ -107,21 +112,18 @@ export namespace Config {
     }
 
     // Global user config overrides remote config.
-    result = merge(result, await global())
+    result = mergeConfigConcatArrays(result, await global())
 
     // Custom config path overrides global config.
     if (Flag.OPENCODE_CONFIG) {
-      result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
+      result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
       log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
     }
 
     // Project config overrides global and remote config.
     if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
-      for (const file of ["opencode.jsonc", "opencode.json"]) {
-        const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
-        for (const resolved of found.toReversed()) {
-          result = merge(result, await loadFile(resolved))
-        }
+      for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
+        result = mergeConfigConcatArrays(result, await loadFile(file))
       }
     }
 
@@ -129,31 +131,10 @@ export namespace Config {
     result.mode = result.mode || {}
     result.plugin = result.plugin || []
 
-    const directories = [
-      Global.Path.config,
-      // Only scan project .opencode/ directories when project discovery is enabled
-      ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
-        ? await Array.fromAsync(
-            Filesystem.up({
-              targets: [".opencode"],
-              start: Instance.directory,
-              stop: Instance.worktree,
-            }),
-          )
-        : []),
-      // Always scan ~/.opencode/ (user home directory)
-      ...(await Array.fromAsync(
-        Filesystem.up({
-          targets: [".opencode"],
-          start: Global.Path.home,
-          stop: Global.Path.home,
-        }),
-      )),
-    ]
+    const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
 
     // .opencode directory config overrides (project and global) config sources.
     if (Flag.OPENCODE_CONFIG_DIR) {
-      directories.push(Flag.OPENCODE_CONFIG_DIR)
       log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
     }
 
@@ -163,7 +144,7 @@ export namespace Config {
       if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
         for (const file of ["opencode.jsonc", "opencode.json"]) {
           log.debug(`loading config from ${path.join(dir, file)}`)
-          result = merge(result, await loadFile(path.join(dir, file)))
+          result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
           // to satisfy the type checker
           result.agent ??= {}
           result.mode ??= {}
@@ -186,7 +167,7 @@ export namespace Config {
 
     // Inline config content overrides all non-managed config sources.
     if (process.env.OPENCODE_CONFIG_CONTENT) {
-      result = merge(
+      result = mergeConfigConcatArrays(
         result,
         await load(process.env.OPENCODE_CONFIG_CONTENT, {
           dir: Instance.directory,
@@ -200,9 +181,9 @@ export namespace Config {
     // Kept separate from directories array to avoid write operations when installing plugins
     // which would fail on system directories requiring elevated permissions
     // This way it only loads config file and not skills/plugins/commands
-    if (existsSync(managedConfigDir)) {
+    if (existsSync(managedDir)) {
       for (const file of ["opencode.jsonc", "opencode.json"]) {
-        result = merge(result, await loadFile(path.join(managedConfigDir, file)))
+        result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
       }
     }
 
@@ -241,8 +222,6 @@ export namespace Config {
       result.share = "auto"
     }
 
-    if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
-
     // Apply flag overrides for compaction settings
     if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
       result.compaction = { ...result.compaction, auto: false }
@@ -306,7 +285,7 @@ export namespace Config {
     }
   }
 
-  async function needsInstall(dir: string) {
+  export async function needsInstall(dir: string) {
     // Some config dirs may be read-only.
     // Installing deps there will fail; skip installation in that case.
     const writable = await isWritable(dir)
@@ -930,20 +909,6 @@ export namespace Config {
       ref: "KeybindsConfig",
     })
 
-  export const TUI = z.object({
-    scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
-    scroll_acceleration: z
-      .object({
-        enabled: z.boolean().describe("Enable scroll acceleration"),
-      })
-      .optional()
-      .describe("Scroll acceleration settings"),
-    diff_style: z
-      .enum(["auto", "stacked"])
-      .optional()
-      .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
-  })
-
   export const Server = z
     .object({
       port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -1018,10 +983,7 @@ export namespace Config {
   export const Info = z
     .object({
       $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
-      theme: z.string().optional().describe("Theme name to use for the interface"),
-      keybinds: Keybinds.optional().describe("Custom keybind configurations"),
       logLevel: Log.Level.optional().describe("Log level"),
-      tui: TUI.optional().describe("TUI specific settings"),
       server: Server.optional().describe("Server configuration for opencode serve and web commands"),
       command: z
         .record(z.string(), Command)
@@ -1241,86 +1203,37 @@ export namespace Config {
     return result
   })
 
+  export const { readFile } = ConfigPaths
+
   async function loadFile(filepath: string): Promise<Info> {
     log.info("loading", { path: filepath })
-    let text = await Filesystem.readText(filepath).catch((err: any) => {
-      if (err.code === "ENOENT") return
-      throw new JsonError({ path: filepath }, { cause: err })
-    })
+    const text = await readFile(filepath)
     if (!text) return {}
     return load(text, { path: filepath })
   }
 
   async function load(text: string, options: { path: string } | { dir: string; source: string }) {
     const original = text
-    const configDir = "path" in options ? path.dirname(options.path) : options.dir
     const source = "path" in options ? options.path : options.source
     const isFile = "path" in options
+    const data = await ConfigPaths.parseText(
+      text,
+      "path" in options ? options.path : { source: options.source, dir: options.dir },
+    )
 
-    text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
-      return process.env[varName] || ""
-    })
-
-    const fileMatches = text.match(/\{file:[^}]+\}/g)
-    if (fileMatches) {
-      const lines = text.split("\n")
-
-      for (const match of fileMatches) {
-        const lineIndex = lines.findIndex((line) => line.includes(match))
-        if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
-          continue
-        }
-        let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
-        if (filePath.startsWith("~/")) {
-          filePath = path.join(os.homedir(), filePath.slice(2))
-        }
-        const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
-        const fileContent = (
-          await Bun.file(resolvedPath)
-            .text()
-            .catch((error) => {
-              const errMsg = `bad file reference: "${match}"`
-              if (error.code === "ENOENT") {
-                throw new InvalidError(
-                  {
-                    path: source,
-                    message: errMsg + ` ${resolvedPath} does not exist`,
-                  },
-                  { cause: error },
-                )
-              }
-              throw new InvalidError({ path: source, message: errMsg }, { cause: error })
-            })
-        ).trim()
-        text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
-      }
-    }
-
-    const errors: JsoncParseError[] = []
-    const data = parseJsonc(text, errors, { allowTrailingComma: true })
-    if (errors.length) {
-      const lines = text.split("\n")
-      const errorDetails = errors
-        .map((e) => {
-          const beforeOffset = text.substring(0, e.offset).split("\n")
-          const line = beforeOffset.length
-          const column = beforeOffset[beforeOffset.length - 1].length + 1
-          const problemLine = lines[line - 1]
-
-          const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
-          if (!problemLine) return error
-
-          return `${error}\n   Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
-        })
-        .join("\n")
-
-      throw new JsonError({
-        path: source,
-        message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
-      })
-    }
+    const normalized = (() => {
+      if (!data || typeof data !== "object" || Array.isArray(data)) return data
+      const copy = { ...(data as Record<string, unknown>) }
+      const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
+      if (!hadLegacy) return copy
+      delete copy.theme
+      delete copy.keybinds
+      delete copy.tui
+      log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
+      return copy
+    })()
 
-    const parsed = Info.safeParse(data)
+    const parsed = Info.safeParse(normalized)
     if (parsed.success) {
       if (!parsed.data.$schema && isFile) {
         parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1353,13 +1266,7 @@ export namespace Config {
       issues: parsed.error.issues,
     })
   }
-  export const JsonError = NamedError.create(
-    "ConfigJsonError",
-    z.object({
-      path: z.string(),
-      message: z.string().optional(),
-    }),
-  )
+  export const { JsonError, InvalidError } = ConfigPaths
 
   export const ConfigDirectoryTypoError = NamedError.create(
     "ConfigDirectoryTypoError",
@@ -1370,15 +1277,6 @@ export namespace Config {
     }),
   )
 
-  export const InvalidError = NamedError.create(
-    "ConfigInvalidError",
-    z.object({
-      path: z.string(),
-      issues: z.custom<z.core.$ZodIssue[]>().optional(),
-      message: z.string().optional(),
-    }),
-  )
-
   export async function get() {
     return state().then((x) => x.config)
   }

+ 155 - 0
packages/opencode/src/config/migrate-tui-config.ts

@@ -0,0 +1,155 @@
+import path from "path"
+import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
+import { unique } from "remeda"
+import z from "zod"
+import { ConfigPaths } from "./paths"
+import { TuiInfo, TuiOptions } from "./tui-schema"
+import { Instance } from "@/project/instance"
+import { Flag } from "@/flag/flag"
+import { Log } from "@/util/log"
+import { Filesystem } from "@/util/filesystem"
+import { Global } from "@/global"
+
+const log = Log.create({ service: "tui.migrate" })
+
+const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
+
+const LegacyTheme = TuiInfo.shape.theme.optional()
+const LegacyRecord = z.record(z.string(), z.unknown()).optional()
+
+const TuiLegacy = z
+  .object({
+    scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
+    scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
+    diff_style: TuiOptions.shape.diff_style.catch(undefined),
+  })
+  .strip()
+
+interface MigrateInput {
+  directories: string[]
+  custom?: string
+  managed: string
+}
+
+/**
+ * Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
+ * into dedicated tui.json files. Migration is performed per-directory and
+ * skips only locations where a tui.json already exists.
+ */
+export async function migrateTuiConfig(input: MigrateInput) {
+  const opencode = await opencodeFiles(input)
+  for (const file of opencode) {
+    const source = await Filesystem.readText(file).catch((error) => {
+      log.warn("failed to read config for tui migration", { path: file, error })
+      return undefined
+    })
+    if (!source) continue
+    const errors: JsoncParseError[] = []
+    const data = parseJsonc(source, errors, { allowTrailingComma: true })
+    if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
+
+    const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
+    const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
+    const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
+    const extracted = {
+      theme: theme.success ? theme.data : undefined,
+      keybinds: keybinds.success ? keybinds.data : undefined,
+      tui: legacyTui.success ? legacyTui.data : undefined,
+    }
+    const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
+    if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
+
+    const target = path.join(path.dirname(file), "tui.json")
+    const targetExists = await Filesystem.exists(target)
+    if (targetExists) continue
+
+    const payload: Record<string, unknown> = {
+      $schema: TUI_SCHEMA_URL,
+    }
+    if (extracted.theme !== undefined) payload.theme = extracted.theme
+    if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
+    if (tui) Object.assign(payload, tui)
+
+    const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
+      .then(() => true)
+      .catch((error) => {
+        log.warn("failed to write tui migration target", { from: file, to: target, error })
+        return false
+      })
+    if (!wrote) continue
+
+    const stripped = await backupAndStripLegacy(file, source)
+    if (!stripped) {
+      log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
+      continue
+    }
+    log.info("migrated tui config", { from: file, to: target })
+  }
+}
+
+function normalizeTui(data: Record<string, unknown>) {
+  const parsed = TuiLegacy.parse(data)
+  if (
+    parsed.scroll_speed === undefined &&
+    parsed.diff_style === undefined &&
+    parsed.scroll_acceleration === undefined
+  ) {
+    return
+  }
+  return parsed
+}
+
+async function backupAndStripLegacy(file: string, source: string) {
+  const backup = file + ".tui-migration.bak"
+  const hasBackup = await Filesystem.exists(backup)
+  const backed = hasBackup
+    ? true
+    : await Bun.write(backup, source)
+        .then(() => true)
+        .catch((error) => {
+          log.warn("failed to backup source config during tui migration", { path: file, backup, error })
+          return false
+        })
+  if (!backed) return false
+
+  const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
+    const edits = modify(acc, [key], undefined, {
+      formattingOptions: {
+        insertSpaces: true,
+        tabSize: 2,
+      },
+    })
+    if (!edits.length) return acc
+    return applyEdits(acc, edits)
+  }, source)
+
+  return Bun.write(file, text)
+    .then(() => {
+      log.info("stripped tui keys from server config", { path: file, backup })
+      return true
+    })
+    .catch((error) => {
+      log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
+      return false
+    })
+}
+
+async function opencodeFiles(input: { directories: string[]; managed: string }) {
+  const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+    ? []
+    : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
+  const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
+  for (const dir of unique(input.directories)) {
+    files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
+  }
+  if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
+  files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
+
+  const existing = await Promise.all(
+    unique(files).map(async (file) => {
+      const ok = await Filesystem.exists(file)
+      return ok ? file : undefined
+    }),
+  )
+  return existing.filter((file): file is string => !!file)
+}

+ 174 - 0
packages/opencode/src/config/paths.ts

@@ -0,0 +1,174 @@
+import path from "path"
+import os from "os"
+import z from "zod"
+import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
+import { NamedError } from "@opencode-ai/util/error"
+import { Filesystem } from "@/util/filesystem"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+
+export namespace ConfigPaths {
+  export async function projectFiles(name: string, directory: string, worktree: string) {
+    const files: string[] = []
+    for (const file of [`${name}.jsonc`, `${name}.json`]) {
+      const found = await Filesystem.findUp(file, directory, worktree)
+      for (const resolved of found.toReversed()) {
+        files.push(resolved)
+      }
+    }
+    return files
+  }
+
+  export async function directories(directory: string, worktree: string) {
+    return [
+      Global.Path.config,
+      ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+        ? await Array.fromAsync(
+            Filesystem.up({
+              targets: [".opencode"],
+              start: directory,
+              stop: worktree,
+            }),
+          )
+        : []),
+      ...(await Array.fromAsync(
+        Filesystem.up({
+          targets: [".opencode"],
+          start: Global.Path.home,
+          stop: Global.Path.home,
+        }),
+      )),
+      ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
+    ]
+  }
+
+  export function fileInDirectory(dir: string, name: string) {
+    return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
+  }
+
+  export const JsonError = NamedError.create(
+    "ConfigJsonError",
+    z.object({
+      path: z.string(),
+      message: z.string().optional(),
+    }),
+  )
+
+  export const InvalidError = NamedError.create(
+    "ConfigInvalidError",
+    z.object({
+      path: z.string(),
+      issues: z.custom<z.core.$ZodIssue[]>().optional(),
+      message: z.string().optional(),
+    }),
+  )
+
+  /** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
+  export async function readFile(filepath: string) {
+    return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
+      if (err.code === "ENOENT") return
+      throw new JsonError({ path: filepath }, { cause: err })
+    })
+  }
+
+  type ParseSource = string | { source: string; dir: string }
+
+  function source(input: ParseSource) {
+    return typeof input === "string" ? input : input.source
+  }
+
+  function dir(input: ParseSource) {
+    return typeof input === "string" ? path.dirname(input) : input.dir
+  }
+
+  /** Apply {env:VAR} and {file:path} substitutions to config text. */
+  async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
+    text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
+      return process.env[varName] || ""
+    })
+
+    const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
+    if (!fileMatches.length) return text
+
+    const configDir = dir(input)
+    const configSource = source(input)
+    let out = ""
+    let cursor = 0
+
+    for (const match of fileMatches) {
+      const token = match[0]
+      const index = match.index!
+      out += text.slice(cursor, index)
+
+      const lineStart = text.lastIndexOf("\n", index - 1) + 1
+      const prefix = text.slice(lineStart, index).trimStart()
+      if (prefix.startsWith("//")) {
+        out += token
+        cursor = index + token.length
+        continue
+      }
+
+      let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
+      if (filePath.startsWith("~/")) {
+        filePath = path.join(os.homedir(), filePath.slice(2))
+      }
+
+      const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
+      const fileContent = (
+        await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
+          if (missing === "empty") return ""
+
+          const errMsg = `bad file reference: "${token}"`
+          if (error.code === "ENOENT") {
+            throw new InvalidError(
+              {
+                path: configSource,
+                message: errMsg + ` ${resolvedPath} does not exist`,
+              },
+              { cause: error },
+            )
+          }
+          throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
+        })
+      ).trim()
+
+      out += JSON.stringify(fileContent).slice(1, -1)
+      cursor = index + token.length
+    }
+
+    out += text.slice(cursor)
+    return out
+  }
+
+  /** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
+  export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
+    const configSource = source(input)
+    text = await substitute(text, input, missing)
+
+    const errors: JsoncParseError[] = []
+    const data = parseJsonc(text, errors, { allowTrailingComma: true })
+    if (errors.length) {
+      const lines = text.split("\n")
+      const errorDetails = errors
+        .map((e) => {
+          const beforeOffset = text.substring(0, e.offset).split("\n")
+          const line = beforeOffset.length
+          const column = beforeOffset[beforeOffset.length - 1].length + 1
+          const problemLine = lines[line - 1]
+
+          const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
+          if (!problemLine) return error
+
+          return `${error}\n   Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
+        })
+        .join("\n")
+
+      throw new JsonError({
+        path: configSource,
+        message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
+      })
+    }
+
+    return data
+  }
+}

+ 34 - 0
packages/opencode/src/config/tui-schema.ts

@@ -0,0 +1,34 @@
+import z from "zod"
+import { Config } from "./config"
+
+const KeybindOverride = z
+  .object(
+    Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
+      string,
+      z.ZodOptional<z.ZodString>
+    >,
+  )
+  .strict()
+
+export const TuiOptions = z.object({
+  scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
+  scroll_acceleration: z
+    .object({
+      enabled: z.boolean().describe("Enable scroll acceleration"),
+    })
+    .optional()
+    .describe("Scroll acceleration settings"),
+  diff_style: z
+    .enum(["auto", "stacked"])
+    .optional()
+    .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
+})
+
+export const TuiInfo = z
+  .object({
+    $schema: z.string().optional(),
+    theme: z.string().optional(),
+    keybinds: KeybindOverride.optional(),
+  })
+  .extend(TuiOptions.shape)
+  .strict()

+ 118 - 0
packages/opencode/src/config/tui.ts

@@ -0,0 +1,118 @@
+import { existsSync } from "fs"
+import z from "zod"
+import { mergeDeep, unique } from "remeda"
+import { Config } from "./config"
+import { ConfigPaths } from "./paths"
+import { migrateTuiConfig } from "./migrate-tui-config"
+import { TuiInfo } from "./tui-schema"
+import { Instance } from "@/project/instance"
+import { Flag } from "@/flag/flag"
+import { Log } from "@/util/log"
+import { Global } from "@/global"
+
+export namespace TuiConfig {
+  const log = Log.create({ service: "tui.config" })
+
+  export const Info = TuiInfo
+
+  export type Info = z.output<typeof Info>
+
+  function mergeInfo(target: Info, source: Info): Info {
+    return mergeDeep(target, source)
+  }
+
+  function customPath() {
+    return Flag.OPENCODE_TUI_CONFIG
+  }
+
+  const state = Instance.state(async () => {
+    let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+      ? []
+      : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
+    const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
+    const custom = customPath()
+    const managed = Config.managedConfigDir()
+    await migrateTuiConfig({ directories, custom, managed })
+    // Re-compute after migration since migrateTuiConfig may have created new tui.json files
+    projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+      ? []
+      : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
+
+    let result: Info = {}
+
+    for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+      result = mergeInfo(result, await loadFile(file))
+    }
+
+    if (custom) {
+      result = mergeInfo(result, await loadFile(custom))
+      log.debug("loaded custom tui config", { path: custom })
+    }
+
+    for (const file of projectFiles) {
+      result = mergeInfo(result, await loadFile(file))
+    }
+
+    for (const dir of unique(directories)) {
+      if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+      for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+        result = mergeInfo(result, await loadFile(file))
+      }
+    }
+
+    if (existsSync(managed)) {
+      for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
+        result = mergeInfo(result, await loadFile(file))
+      }
+    }
+
+    result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
+
+    return {
+      config: result,
+    }
+  })
+
+  export async function get() {
+    return state().then((x) => x.config)
+  }
+
+  async function loadFile(filepath: string): Promise<Info> {
+    const text = await ConfigPaths.readFile(filepath)
+    if (!text) return {}
+    return load(text, filepath).catch((error) => {
+      log.warn("failed to load tui config", { path: filepath, error })
+      return {}
+    })
+  }
+
+  async function load(text: string, configFilepath: string): Promise<Info> {
+    const data = await ConfigPaths.parseText(text, configFilepath, "empty")
+    if (!data || typeof data !== "object" || Array.isArray(data)) return {}
+
+    // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
+    // (mirroring the old opencode.json shape) still get their settings applied.
+    const normalized = (() => {
+      const copy = { ...(data as Record<string, unknown>) }
+      if (!("tui" in copy)) return copy
+      if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
+        delete copy.tui
+        return copy
+      }
+      const tui = copy.tui as Record<string, unknown>
+      delete copy.tui
+      return {
+        ...tui,
+        ...copy,
+      }
+    })()
+
+    const parsed = Info.safeParse(normalized)
+    if (!parsed.success) {
+      log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
+      return {}
+    }
+
+    return parsed.data
+  }
+}

+ 12 - 0
packages/opencode/src/flag/flag.ts

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

+ 37 - 17
packages/opencode/test/config/config.test.ts

@@ -56,6 +56,28 @@ test("loads JSON config file", async () => {
   })
 })
 
+test("ignores legacy tui keys in opencode config", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await writeConfig(dir, {
+        $schema: "https://opencode.ai/config.json",
+        model: "test/model",
+        theme: "legacy",
+        tui: { scroll_speed: 4 },
+      })
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.model).toBe("test/model")
+      expect((config as Record<string, unknown>).theme).toBeUndefined()
+      expect((config as Record<string, unknown>).tui).toBeUndefined()
+    },
+  })
+})
+
 test("loads JSONC config file", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
@@ -110,14 +132,14 @@ test("merges multiple config files with correct precedence", async () => {
 
 test("handles environment variable substitution", async () => {
   const originalEnv = process.env["TEST_VAR"]
-  process.env["TEST_VAR"] = "test_theme"
+  process.env["TEST_VAR"] = "test-user"
 
   try {
     await using tmp = await tmpdir({
       init: async (dir) => {
         await writeConfig(dir, {
           $schema: "https://opencode.ai/config.json",
-          theme: "{env:TEST_VAR}",
+          username: "{env:TEST_VAR}",
         })
       },
     })
@@ -125,7 +147,7 @@ test("handles environment variable substitution", async () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        expect(config.theme).toBe("test_theme")
+        expect(config.username).toBe("test-user")
       },
     })
   } finally {
@@ -148,7 +170,7 @@ test("preserves env variables when adding $schema to config", async () => {
         await Filesystem.write(
           path.join(dir, "opencode.json"),
           JSON.stringify({
-            theme: "{env:PRESERVE_VAR}",
+            username: "{env:PRESERVE_VAR}",
           }),
         )
       },
@@ -157,7 +179,7 @@ test("preserves env variables when adding $schema to config", async () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        expect(config.theme).toBe("secret_value")
+        expect(config.username).toBe("secret_value")
 
         // Read the file to verify the env variable was preserved
         const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
@@ -178,10 +200,10 @@ test("preserves env variables when adding $schema to config", async () => {
 test("handles file inclusion substitution", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
-      await Filesystem.write(path.join(dir, "included.txt"), "test_theme")
+      await Filesystem.write(path.join(dir, "included.txt"), "test-user")
       await writeConfig(dir, {
         $schema: "https://opencode.ai/config.json",
-        theme: "{file:included.txt}",
+        username: "{file:included.txt}",
       })
     },
   })
@@ -189,7 +211,7 @@ test("handles file inclusion substitution", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.theme).toBe("test_theme")
+      expect(config.username).toBe("test-user")
     },
   })
 })
@@ -200,7 +222,7 @@ test("handles file inclusion with replacement tokens", async () => {
       await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
       await writeConfig(dir, {
         $schema: "https://opencode.ai/config.json",
-        theme: "{file:included.md}",
+        username: "{file:included.md}",
       })
     },
   })
@@ -208,7 +230,7 @@ test("handles file inclusion with replacement tokens", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.theme).toBe("const out = await Bun.$`echo hi`")
+      expect(config.username).toBe("const out = await Bun.$`echo hi`")
     },
   })
 })
@@ -1043,7 +1065,6 @@ test("managed settings override project settings", async () => {
         $schema: "https://opencode.ai/config.json",
         autoupdate: true,
         disabled_providers: [],
-        theme: "dark",
       })
     },
   })
@@ -1060,7 +1081,6 @@ test("managed settings override project settings", async () => {
       const config = await Config.get()
       expect(config.autoupdate).toBe(false)
       expect(config.disabled_providers).toEqual(["openai"])
-      expect(config.theme).toBe("dark")
     },
   })
 })
@@ -1809,7 +1829,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
     process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
     process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
       $schema: "https://opencode.ai/config.json",
-      theme: "{env:TEST_CONFIG_VAR}",
+      username: "{env:TEST_CONFIG_VAR}",
     })
 
     try {
@@ -1818,7 +1838,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
         directory: tmp.path,
         fn: async () => {
           const config = await Config.get()
-          expect(config.theme).toBe("test_api_key_12345")
+          expect(config.username).toBe("test_api_key_12345")
         },
       })
     } finally {
@@ -1841,10 +1861,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
     try {
       await using tmp = await tmpdir({
         init: async (dir) => {
-          await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
+          await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
           process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
             $schema: "https://opencode.ai/config.json",
-            theme: "{file:./api_key.txt}",
+            username: "{file:./api_key.txt}",
           })
         },
       })
@@ -1852,7 +1872,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
         directory: tmp.path,
         fn: async () => {
           const config = await Config.get()
-          expect(config.theme).toBe("secret_key_from_file")
+          expect(config.username).toBe("secret_key_from_file")
         },
       })
     } finally {

+ 510 - 0
packages/opencode/test/config/tui.test.ts

@@ -0,0 +1,510 @@
+import { afterEach, expect, test } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+import { TuiConfig } from "../../src/config/tui"
+import { Global } from "../../src/global"
+import { Filesystem } from "../../src/util/filesystem"
+
+const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
+
+afterEach(async () => {
+  delete process.env.OPENCODE_CONFIG
+  delete process.env.OPENCODE_TUI_CONFIG
+  await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
+  await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
+  await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
+})
+
+test("loads tui config with the same precedence order as server config paths", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
+      await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+      await Bun.write(
+        path.join(dir, ".opencode", "tui.json"),
+        JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("local")
+      expect(config.diff_style).toBe("stacked")
+    },
+  })
+})
+
+test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify(
+          {
+            theme: "migrated-theme",
+            tui: { scroll_speed: 5 },
+            keybinds: { app_exit: "ctrl+q" },
+          },
+          null,
+          2,
+        ),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("migrated-theme")
+      expect(config.scroll_speed).toBe(5)
+      expect(config.keybinds?.app_exit).toBe("ctrl+q")
+      const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
+      expect(JSON.parse(text)).toMatchObject({
+        theme: "migrated-theme",
+        scroll_speed: 5,
+      })
+      const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+      expect(server.theme).toBeUndefined()
+      expect(server.keybinds).toBeUndefined()
+      expect(server.tui).toBeUndefined()
+      expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
+      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+    },
+  })
+})
+
+test("migrates project legacy tui keys even when global tui.json already exists", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify(
+          {
+            theme: "project-migrated",
+            tui: { scroll_speed: 2 },
+          },
+          null,
+          2,
+        ),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("project-migrated")
+      expect(config.scroll_speed).toBe(2)
+      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+
+      const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+      expect(server.theme).toBeUndefined()
+      expect(server.tui).toBeUndefined()
+    },
+  })
+})
+
+test("drops unknown legacy tui keys during migration", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify(
+          {
+            theme: "migrated-theme",
+            tui: { scroll_speed: 2, foo: 1 },
+          },
+          null,
+          2,
+        ),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("migrated-theme")
+      expect(config.scroll_speed).toBe(2)
+
+      const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
+      const migrated = JSON.parse(text)
+      expect(migrated.scroll_speed).toBe(2)
+      expect(migrated.foo).toBeUndefined()
+    },
+  })
+})
+
+test("skips migration when opencode.jsonc is syntactically invalid", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.jsonc"),
+        `{
+  "theme": "broken-theme",
+  "tui": { "scroll_speed": 2 }
+  "username": "still-broken"
+}`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBeUndefined()
+      expect(config.scroll_speed).toBeUndefined()
+      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
+      expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
+      const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
+      expect(source).toContain('"theme": "broken-theme"')
+      expect(source).toContain('"tui": { "scroll_speed": 2 }')
+    },
+  })
+})
+
+test("skips migration when tui.json already exists", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.diff_style).toBe("stacked")
+      expect(config.theme).toBeUndefined()
+
+      const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
+      expect(server.theme).toBe("legacy")
+      expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
+    },
+  })
+})
+
+test("continues loading tui config when legacy source cannot be stripped", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
+    },
+  })
+
+  const source = path.join(tmp.path, "opencode.json")
+  await fs.chmod(source, 0o444)
+
+  try {
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await TuiConfig.get()
+        expect(config.theme).toBe("readonly-theme")
+        expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+
+        const server = JSON.parse(await Filesystem.readText(source))
+        expect(server.theme).toBe("readonly-theme")
+      },
+    })
+  } finally {
+    await fs.chmod(source, 0o644)
+  }
+})
+
+test("migration backup preserves JSONC comments", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.jsonc"),
+        `{
+  // top-level comment
+  "theme": "jsonc-theme",
+  "tui": {
+    // nested comment
+    "scroll_speed": 1.5
+  }
+}`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await TuiConfig.get()
+      const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
+      expect(backup).toContain("// top-level comment")
+      expect(backup).toContain("// nested comment")
+      expect(backup).toContain('"theme": "jsonc-theme"')
+      expect(backup).toContain('"scroll_speed": 1.5')
+    },
+  })
+})
+
+test("migrates legacy tui keys across multiple opencode.json levels", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const nested = path.join(dir, "apps", "client")
+      await fs.mkdir(nested, { recursive: true })
+      await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2))
+      await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
+    },
+  })
+
+  await Instance.provide({
+    directory: path.join(tmp.path, "apps", "client"),
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("nested-theme")
+      expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
+      expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
+    },
+  })
+})
+
+test("flattens nested tui key inside tui.json", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          theme: "outer",
+          tui: { scroll_speed: 3, diff_style: "stacked" },
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.scroll_speed).toBe(3)
+      expect(config.diff_style).toBe("stacked")
+      // top-level keys take precedence over nested tui keys
+      expect(config.theme).toBe("outer")
+    },
+  })
+})
+
+test("top-level keys in tui.json take precedence over nested tui key", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          diff_style: "auto",
+          tui: { diff_style: "stacked", scroll_speed: 2 },
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.diff_style).toBe("auto")
+      expect(config.scroll_speed).toBe(2)
+    },
+  })
+})
+
+test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
+      const custom = path.join(dir, "custom-tui.json")
+      await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" }))
+      process.env.OPENCODE_TUI_CONFIG = custom
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      // project tui.json overrides the custom path, same as server config precedence
+      expect(config.theme).toBe("project")
+      // project also set diff_style, so that wins
+      expect(config.diff_style).toBe("auto")
+    },
+  })
+})
+
+test("merges keybind overrides across precedence layers", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }))
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.keybinds?.app_exit).toBe("ctrl+q")
+      expect(config.keybinds?.theme_list).toBe("ctrl+k")
+    },
+  })
+})
+
+test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const custom = path.join(dir, "custom-tui.json")
+      await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
+      process.env.OPENCODE_TUI_CONFIG = custom
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("from-env")
+      expect(config.diff_style).toBe("stacked")
+    },
+  })
+})
+
+test("does not derive tui path from OPENCODE_CONFIG", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const customDir = path.join(dir, "custom")
+      await fs.mkdir(customDir, { recursive: true })
+      await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" }))
+      await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" }))
+      process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBeUndefined()
+    },
+  })
+})
+
+test("applies env and file substitutions in tui.json", async () => {
+  const original = process.env.TUI_THEME_TEST
+  process.env.TUI_THEME_TEST = "env-theme"
+  try {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
+        await Bun.write(
+          path.join(dir, "tui.json"),
+          JSON.stringify({
+            theme: "{env:TUI_THEME_TEST}",
+            keybinds: { app_exit: "{file:keybind.txt}" },
+          }),
+        )
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await TuiConfig.get()
+        expect(config.theme).toBe("env-theme")
+        expect(config.keybinds?.app_exit).toBe("ctrl+q")
+      },
+    })
+  } finally {
+    if (original === undefined) delete process.env.TUI_THEME_TEST
+    else process.env.TUI_THEME_TEST = original
+  }
+})
+
+test("applies file substitutions when first identical token is in a commented line", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "theme.txt"), "resolved-theme")
+      await Bun.write(
+        path.join(dir, "tui.jsonc"),
+        `{
+  // "theme": "{file:theme.txt}",
+  "theme": "{file:theme.txt}"
+}`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("resolved-theme")
+    },
+  })
+})
+
+test("loads managed tui config and gives it highest precedence", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
+      await fs.mkdir(managedConfigDir, { recursive: true })
+      await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("managed-theme")
+    },
+  })
+})
+
+test("loads .opencode/tui.json", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+      await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.diff_style).toBe("stacked")
+    },
+  })
+})
+
+test("gracefully falls back when tui.json has invalid JSON", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
+      await fs.mkdir(managedConfigDir, { recursive: true })
+      await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("managed-fallback")
+      expect(config.keybinds).toBeDefined()
+    },
+  })
+})

+ 0 - 409
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -991,388 +991,6 @@ export type GlobalEvent = {
   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
  */
@@ -1672,34 +1290,7 @@ export type Config = {
    * JSON schema reference for configuration validation
    */
   $schema?: string
-  /**
-   * Theme name to use for the interface
-   */
-  theme?: string
-  keybinds?: KeybindsConfig
   logLevel?: LogLevel
-  /**
-   * TUI specific settings
-   */
-  tui?: {
-    /**
-     * TUI scroll speed
-     */
-    scroll_speed?: number
-    /**
-     * Scroll acceleration settings
-     */
-    scroll_acceleration?: {
-      /**
-       * Enable scroll acceleration
-       */
-      enabled: boolean
-    }
-    /**
-     * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column
-     */
-    diff_style?: "auto" | "stacked"
-  }
   server?: ServerConfig
   /**
    * Command configuration, see https://opencode.ai/docs/commands

+ 1 - 1
packages/web/astro.config.mjs

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

+ 1 - 0
packages/web/src/content/docs/cli.mdx

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

+ 30 - 27
packages/web/src/content/docs/config.mdx

@@ -14,10 +14,11 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
 ```jsonc title="opencode.jsonc"
 {
   "$schema": "https://opencode.ai/config.json",
-  // Theme configuration
-  "theme": "opencode",
   "model": "anthropic/claude-sonnet-4-5",
   "autoupdate": true,
+  "server": {
+    "port": 4096,
+  },
 }
 ```
 
@@ -34,7 +35,7 @@ Configuration files are **merged together**, not replaced.
 
 Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
 
-For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
+For example, if your global config sets `autoupdate: true` and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include both settings.
 
 ---
 
@@ -95,7 +96,9 @@ You can enable specific servers in your local config:
 
 ### Global
 
-Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
+Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide server/runtime preferences like providers, models, and permissions.
+
+For TUI-specific settings, use `~/.config/opencode/tui.json`.
 
 Global config overrides remote organizational defaults.
 
@@ -105,6 +108,8 @@ Global config overrides remote organizational defaults.
 
 Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
 
+For project-specific TUI settings, add `tui.json` alongside it.
+
 :::tip
 Place project specific config in the root of your project.
 :::
@@ -146,7 +151,9 @@ The custom directory is loaded after the global config and `.opencode` directori
 
 ## Schema
 
-The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
+The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
+
+TUI config uses [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json).
 
 Your editor should be able to validate and autocomplete based on the schema.
 
@@ -154,28 +161,24 @@ Your editor should be able to validate and autocomplete based on the schema.
 
 ### TUI
 
-You can configure TUI-specific settings through the `tui` option.
+Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
 
-```json title="opencode.json"
+```json title="tui.json"
 {
-  "$schema": "https://opencode.ai/config.json",
-  "tui": {
-    "scroll_speed": 3,
-    "scroll_acceleration": {
-      "enabled": true
-    },
-    "diff_style": "auto"
-  }
+  "$schema": "https://opencode.ai/tui.json",
+  "scroll_speed": 3,
+  "scroll_acceleration": {
+    "enabled": true
+  },
+  "diff_style": "auto"
 }
 ```
 
-Available options:
+Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
 
-- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
-- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
-- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
+Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
 
-[Learn more about using the TUI here](/docs/tui).
+[Learn more about TUI configuration here](/docs/tui#configure).
 
 ---
 
@@ -301,12 +304,12 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr
 
 ### Themes
 
-You can configure the theme you want to use in your OpenCode config through the `theme` option.
+Set your UI theme in `tui.json`.
 
-```json title="opencode.json"
+```json title="tui.json"
 {
-  "$schema": "https://opencode.ai/config.json",
-  "theme": ""
+  "$schema": "https://opencode.ai/tui.json",
+  "theme": "tokyonight"
 }
 ```
 
@@ -406,11 +409,11 @@ You can also define commands using markdown files in `~/.config/opencode/command
 
 ### Keybinds
 
-You can customize your keybinds through the `keybinds` option.
+Customize keybinds in `tui.json`.
 
-```json title="opencode.json"
+```json title="tui.json"
 {
-  "$schema": "https://opencode.ai/config.json",
+  "$schema": "https://opencode.ai/tui.json",
   "keybinds": {}
 }
 ```

+ 6 - 6
packages/web/src/content/docs/keybinds.mdx

@@ -3,11 +3,11 @@ title: Keybinds
 description: Customize your keybinds.
 ---
 
-OpenCode has a list of keybinds that you can customize through the OpenCode config.
+OpenCode has a list of keybinds that you can customize through `tui.json`.
 
-```json title="opencode.json"
+```json title="tui.json"
 {
-  "$schema": "https://opencode.ai/config.json",
+  "$schema": "https://opencode.ai/tui.json",
   "keybinds": {
     "leader": "ctrl+x",
     "app_exit": "ctrl+c,ctrl+d,<leader>q",
@@ -117,11 +117,11 @@ You don't need to use a leader key for your keybinds but we recommend doing so.
 
 ## Disable keybind
 
-You can disable a keybind by adding the key to your config with a value of "none".
+You can disable a keybind by adding the key to `tui.json` with a value of "none".
 
-```json title="opencode.json"
+```json title="tui.json"
 {
-  "$schema": "https://opencode.ai/config.json",
+  "$schema": "https://opencode.ai/tui.json",
   "keybinds": {
     "session_compact": "none"
   }

+ 3 - 3
packages/web/src/content/docs/themes.mdx

@@ -61,11 +61,11 @@ The system theme is for users who:
 
 ## Using a theme
 
-You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in your [config](/docs/config).
+You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in `tui.json`.
 
-```json title="opencode.json" {3}
+```json title="tui.json" {3}
 {
-  "$schema": "https://opencode.ai/config.json",
+  "$schema": "https://opencode.ai/tui.json",
   "theme": "tokyonight"
 }
 ```

+ 21 - 11
packages/web/src/content/docs/tui.mdx

@@ -355,24 +355,34 @@ Some editors need command-line arguments to run in blocking mode. The `--wait` f
 
 ## Configure
 
-You can customize TUI behavior through your OpenCode config file.
+You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
 
-```json title="opencode.json"
+```json title="tui.json"
 {
-  "$schema": "https://opencode.ai/config.json",
-  "tui": {
-    "scroll_speed": 3,
-    "scroll_acceleration": {
-      "enabled": true
-    }
-  }
+  "$schema": "https://opencode.ai/tui.json",
+  "theme": "opencode",
+  "keybinds": {
+    "leader": "ctrl+x"
+  },
+  "scroll_speed": 3,
+  "scroll_acceleration": {
+    "enabled": true
+  },
+  "diff_style": "auto"
 }
 ```
 
+This is separate from `opencode.json`, which configures server/runtime behavior.
+
 ### Options
 
-- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
-- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
+- `theme` - Sets your UI theme. [Learn more](/docs/themes).
+- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
+- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
+- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
+- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
+
+Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
 
 ---