Răsfoiți Sursa

feat(tui): add managed config interface

Dax Raad 4 săptămâni în urmă
părinte
comite
3568dd1b99
52 a modificat fișierele cu 848 adăugiri și 266 ștergeri
  1. 1 0
      bun.lock
  2. 1 0
      packages/cli/package.json
  3. 6 3
      packages/cli/src/commands/handlers/default.ts
  4. 109 0
      packages/cli/src/config/config.ts
  5. 1 0
      packages/cli/src/config/index.ts
  6. 142 0
      packages/cli/src/config/migrate.ts
  7. 5 0
      packages/cli/src/config/schema.ts
  8. 12 3
      packages/cli/src/framework/runtime.ts
  9. 2 0
      packages/cli/src/index.ts
  10. 0 24
      packages/cli/src/tui-config.ts
  11. 125 0
      packages/cli/test/config.test.ts
  12. 0 25
      packages/cli/test/tui-config.test.ts
  13. 28 7
      packages/plugin/src/tui.ts
  14. 2 2
      packages/tui/package.json
  15. 27 33
      packages/tui/src/app.tsx
  16. 1 1
      packages/tui/src/attention.ts
  17. 1 1
      packages/tui/src/component/command-palette.tsx
  18. 1 1
      packages/tui/src/component/dialog-mcp.tsx
  19. 1 1
      packages/tui/src/component/dialog-project-copy-name.tsx
  20. 1 1
      packages/tui/src/component/dialog-workspace-file-changes.tsx
  21. 1 1
      packages/tui/src/component/prompt/autocomplete.tsx
  22. 2 2
      packages/tui/src/component/prompt/index.tsx
  23. 216 0
      packages/tui/src/config/index.tsx
  24. 2 0
      packages/tui/src/config/keybind.ts
  25. 1 0
      packages/tui/src/config/v1/index.tsx
  26. 0 2
      packages/tui/src/config/v1/keybind.ts
  27. 0 97
      packages/tui/src/config/v2/index.ts
  28. 0 6
      packages/tui/src/config/v2/keybind.ts
  29. 44 3
      packages/tui/src/context/kv.tsx
  30. 3 3
      packages/tui/src/context/theme.tsx
  31. 1 1
      packages/tui/src/feature-plugins/home/tips-view.tsx
  32. 2 1
      packages/tui/src/feature-plugins/system/diff-viewer.tsx
  33. 4 4
      packages/tui/src/keymap.tsx
  34. 1 1
      packages/tui/src/plugin/adapters.tsx
  35. 1 1
      packages/tui/src/plugin/command-shim.ts
  36. 1 1
      packages/tui/src/plugin/runtime.tsx
  37. 1 10
      packages/tui/src/routes/home.tsx
  38. 1 1
      packages/tui/src/routes/session/form.tsx
  39. 6 4
      packages/tui/src/routes/session/index.tsx
  40. 4 3
      packages/tui/src/routes/session/permission.tsx
  41. 1 1
      packages/tui/src/routes/session/sidebar.tsx
  42. 1 1
      packages/tui/src/ui/dialog-prompt.tsx
  43. 1 1
      packages/tui/src/ui/dialog-select.tsx
  44. 7 5
      packages/tui/src/util/scroll.ts
  45. 2 3
      packages/tui/test/app-lifecycle.test.tsx
  46. 3 3
      packages/tui/test/cli/tui/dialog-prompt.test.tsx
  47. 1 1
      packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx
  48. 2 2
      packages/tui/test/cli/tui/diff-viewer.test.tsx
  49. 1 1
      packages/tui/test/cli/tui/form.test.tsx
  50. 67 0
      packages/tui/test/config-v2.test.tsx
  51. 4 4
      packages/tui/test/fixture/tui-runtime.ts
  52. 1 1
      packages/tui/test/keymap.test.tsx

+ 1 - 0
bun.lock

@@ -115,6 +115,7 @@
         "@parcel/watcher": "2.5.1",
         "effect": "catalog:",
         "fuzzysort": "catalog:",
+        "immer": "11.1.4",
         "jsonc-parser": "3.3.1",
         "open": "10.1.2",
         "opentui-spinner": "catalog:",

+ 1 - 0
packages/cli/package.json

@@ -45,6 +45,7 @@
     "@parcel/watcher": "2.5.1",
     "effect": "catalog:",
     "fuzzysort": "catalog:",
+    "immer": "11.1.4",
     "jsonc-parser": "3.3.1",
     "open": "10.1.2",
     "opentui-spinner": "catalog:",

+ 6 - 3
packages/cli/src/commands/handlers/default.ts

@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
 import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
 import { Commands } from "../commands"
 import { Runtime } from "../../framework/runtime"
-import { TuiConfig } from "../../tui-config"
+import { Config } from "../../config"
 import { Effect, Option } from "effect"
 import { Server } from "../../services/server"
 import { Updater } from "../../services/updater"
@@ -35,13 +35,16 @@ export default Runtime.handler(Commands, (input) =>
       ),
     )
     preflight.loading()
-    const config = yield* TuiConfig.load()
+    const configService = yield* Config.Service
     let disposeSlots: (() => void) | undefined
     const runFork = Effect.runForkWith(yield* Effect.context())
     yield* run({
       server,
       args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
-      config,
+      config: {
+        get: () => Effect.runPromise(configService.get()),
+        update: (update) => Effect.runPromise(configService.update(update)),
+      },
       terminalHandoff: () => preflight.finish(),
       log: (level, message, tags) => {
         const effect =

+ 109 - 0
packages/cli/src/config/config.ts

@@ -0,0 +1,109 @@
+export * as Config from "./config"
+
+import { Global } from "@opencode-ai/core/global"
+import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
+import { produce, type Draft } from "immer"
+import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
+import path from "path"
+import { ConfigMigration } from "./migrate"
+import { Info } from "./schema"
+
+export * from "./schema"
+
+export interface Interface {
+  readonly path: string
+  readonly get: () => Effect.Effect<Info>
+  readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
+
+const decode = Schema.decodeUnknownOption(Info)
+const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
+const empty: Info = {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* FileSystem.FileSystem
+    const global = yield* Global.Service
+    const file = path.join(global.config, "cli.json")
+    const lock = yield* Semaphore.make(1)
+
+    const readJson = Effect.fnUntraced(function* () {
+      const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
+      if (text === undefined) return undefined
+      const errors: ParseError[] = []
+      const value: any = parse(text, errors, { allowTrailingComma: true })
+      if (errors.length) return undefined
+      return Option.getOrUndefined(decodeRecord(value))
+    })
+
+    const write = Effect.fnUntraced(function* (text: string) {
+      const temp = file + ".tmp"
+      yield* fs.makeDirectory(path.dirname(file), { recursive: true })
+      yield* fs.writeFileString(temp, text, { mode: 0o600 })
+      yield* fs.rename(temp, file)
+    })
+
+    const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
+      Effect.provideService(FileSystem.FileSystem, fs),
+    )
+
+    const get = Effect.fn("cli.config.get")(function* () {
+      yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
+      return Option.getOrElse(decode(yield* readJson()), () => empty)
+    })
+
+    const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
+      lock
+        .withPermits(1)(
+          Effect.gen(function* () {
+            yield* migrate
+            const current = Option.getOrElse(decode(yield* readJson()), () => empty)
+            const next = produce(current, update)
+            const edits = changes(current, next)
+            if (!edits.length) return current
+            const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
+            const updated = edits.reduce(
+              (text, edit) =>
+                applyEdits(
+                  text,
+                  modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
+                ),
+              text,
+            )
+            const errors: ParseError[] = []
+            const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
+            if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
+            yield* write(updated.endsWith("\n") ? updated : updated + "\n")
+            return config
+          }),
+        )
+        .pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
+    )
+
+    return Service.of({ path: file, get, update })
+  }),
+)
+
+type Edit = { readonly path: (string | number)[]; readonly value: any }
+
+function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
+  if (Object.is(before, after)) return []
+  if (
+    before !== null &&
+    after !== null &&
+    typeof before === "object" &&
+    typeof after === "object" &&
+    !Array.isArray(before) &&
+    !Array.isArray(after)
+  ) {
+    return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
+      if (!(key in after)) return [{ path: [...path, key], value: undefined }]
+      if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
+      return changes(before[key], after[key], [...path, key])
+    })
+  }
+  return [{ path, value: after }]
+}

+ 1 - 0
packages/cli/src/config/index.ts

@@ -0,0 +1 @@
+export * as Config from "./config"

+ 142 - 0
packages/cli/src/config/migrate.ts

@@ -0,0 +1,142 @@
+export * as ConfigMigration from "./migrate"
+
+import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
+import { Effect, FileSystem, Option, Schema } from "effect"
+import { parse, type ParseError } from "jsonc-parser"
+import path from "path"
+import type { Info } from "./schema"
+
+const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
+const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
+
+export const run = Effect.fn("cli.config.migrate")(function* (input: {
+  readonly file: string
+  readonly config: string
+  readonly state: string
+}) {
+  const fs = yield* FileSystem.FileSystem
+  if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
+
+  const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
+  const legacy = Option.getOrUndefined(decodeV1(legacyValue))
+  const kv = yield* readJson(path.join(input.state, "kv.json"))
+  const migrated = migrateV1(legacy, kv ?? {})
+  if (!Object.keys(migrated).length) return
+
+  const temp = input.file + ".tmp"
+  yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
+  yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
+  yield* fs.rename(temp, input.file)
+  yield* Effect.logInfo("migrated cli config", {
+    from: [
+      legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
+      kv === undefined ? undefined : path.join(input.state, "kv.json"),
+    ].filter(Boolean),
+    to: input.file,
+  })
+})
+
+export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
+  const plugins = [
+    ...(legacy?.plugin?.map((plugin) =>
+      typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
+    ) ?? []),
+    ...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
+  ]
+  const themeName = legacy?.theme ?? kv.theme
+  const themeMode = kv.theme_mode_lock
+  const attentionSoundPack = kv.attention_sound_pack
+  const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
+  const thinking =
+    kv.thinking_mode ??
+    (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
+
+  return {
+    ...(themeName !== undefined || themeMode !== undefined
+      ? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
+      : {}),
+    ...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
+    ...(plugins.length ? { plugins } : {}),
+    ...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
+    ...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
+      ? {}
+      : {
+          scroll: {
+            ...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
+            ...(legacy.scroll_acceleration?.enabled === undefined
+              ? {}
+              : { acceleration: legacy.scroll_acceleration.enabled }),
+          },
+        }),
+    ...(legacy?.attention === undefined && attentionSoundPack === undefined
+      ? {}
+      : {
+          attention: {
+            ...legacy?.attention,
+            ...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
+          },
+        }),
+    ...(legacy?.diff_style === undefined &&
+    kv.diff_wrap_mode === undefined &&
+    kv.diff_viewer_show_file_tree === undefined &&
+    kv.diff_viewer_single_patch === undefined &&
+    diffView === undefined
+      ? {}
+      : {
+          diffs: {
+            ...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
+            ...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
+            ...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
+            ...(diffView === undefined ? {} : { view: diffView }),
+          },
+        }),
+    ...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
+    ...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
+      ? {}
+      : {
+          prompt: {
+            ...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
+            ...(kv.paste_summary_enabled === undefined
+              ? {}
+              : { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
+          },
+        }),
+    ...(kv.sidebar === undefined &&
+    kv.scrollbar_visible === undefined &&
+    thinking === undefined &&
+    kv.exploration_grouping === undefined
+      ? {}
+      : {
+          session: {
+            ...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
+            ...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
+            ...(thinking === undefined ? {} : { thinking }),
+            ...(kv.exploration_grouping === undefined
+              ? {}
+              : { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
+          },
+        }),
+    ...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
+      ? {}
+      : {
+          hints: {
+            ...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
+            ...(kv.dismissed_getting_started === undefined
+              ? {}
+              : { onboarding: !kv.dismissed_getting_started }),
+          },
+        }),
+    ...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
+    ...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
+  }
+}
+
+const readJson = Effect.fnUntraced(function* (target: string) {
+  const fs = yield* FileSystem.FileSystem
+  const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
+  if (text === undefined) return undefined
+  const errors: ParseError[] = []
+  const value: any = parse(text, errors, { allowTrailingComma: true })
+  if (errors.length) return undefined
+  return Option.getOrUndefined(decodeRecord(value))
+})

+ 5 - 0
packages/cli/src/config/schema.ts

@@ -0,0 +1,5 @@
+import { TuiConfig } from "@opencode-ai/tui/config"
+import { Schema } from "effect"
+
+export const Info = Schema.Struct({ ...TuiConfig.Info.fields })
+export type Info = Schema.Schema.Type<typeof Info>

+ 12 - 3
packages/cli/src/framework/runtime.ts

@@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
 import { Spec } from "./spec"
 import { Global } from "@opencode-ai/core/global"
 import { Updater } from "../services/updater"
+import { Config } from "../config"
 
 export type Input<Value> =
   Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -13,18 +14,26 @@ export type Input<Value> =
 
 type RuntimeHandler = (
   input: unknown,
-) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
+) => Effect.Effect<
+  void,
+  unknown,
+  FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
+>
 type Loader<Node extends Spec.Any> = () => Promise<{
   default: (
     input: Input<Node>,
-  ) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
+  ) => Effect.Effect<
+    void,
+    any,
+    FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
+  >
 }>
 type ProvidedCommand = Command.Command<
   string,
   unknown,
   unknown,
   unknown,
-  FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
+  FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
 >
 
 export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never

+ 2 - 0
packages/cli/src/index.ts

@@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Global } from "@opencode-ai/core/global"
 import { AppProcess } from "@opencode-ai/core/process"
+import { Config } from "./config"
 
 const Handlers = Runtime.handlers(Commands, {
   $: () => import("./commands/handlers/default"),
@@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
 }).pipe(
   Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
   Effect.annotateLogs({ role: "cli" }),
+  Effect.provide(Config.layer),
   Effect.provide(Updater.layer),
   Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
   Effect.provide(Observability.layer),

+ 0 - 24
packages/cli/src/tui-config.ts

@@ -1,24 +0,0 @@
-export * as TuiConfig from "./tui-config"
-
-import { Global } from "@opencode-ai/core/global"
-import { TuiConfig } from "@opencode-ai/tui/config/v1"
-import { Effect, FileSystem, Option, Schema } from "effect"
-import { parse, type ParseError } from "jsonc-parser"
-import path from "path"
-
-export const load = Effect.fn("TuiConfig.load")(function* () {
-  const fs = yield* FileSystem.FileSystem
-  const global = yield* Global.Service
-  const filepath = path.join(global.config, "tui.json")
-  const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
-  if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
-
-  const errors: ParseError[] = []
-  const input: unknown = parse(text, errors, { allowTrailingComma: true })
-  if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
-
-  return TuiConfig.resolve(
-    Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
-    { terminalSuspend: process.platform !== "win32" },
-  )
-})

+ 125 - 0
packages/cli/test/config.test.ts

@@ -0,0 +1,125 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { Global } from "@opencode-ai/core/global"
+import { Effect } from "effect"
+import { expect, test } from "bun:test"
+import path from "path"
+import { Config } from "../src/config"
+
+function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
+  return Effect.runPromise(
+    effect.pipe(
+      Effect.provide(Config.layer),
+      Effect.provide(Global.layerWith({ config: directory, state: directory })),
+      Effect.provide(NodeFileSystem.layer),
+    ),
+  )
+}
+
+test("migrates tui and kv config into cli.json", async () => {
+  const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+  await Bun.write(
+    path.join(directory, "tui.json"),
+    JSON.stringify({
+      theme: "legacy",
+      keybinds: { leader: "ctrl+o" },
+      plugin: [["example", { mode: "safe" }]],
+      plugin_enabled: { disabled: false },
+      leader_timeout: 500,
+      scroll_speed: 2,
+      scroll_acceleration: { enabled: true },
+      diff_style: "stacked",
+      mouse: false,
+    }),
+  )
+  await Bun.write(
+    path.join(directory, "kv.json"),
+    JSON.stringify({
+      theme_mode_lock: "light",
+      paste_summary_enabled: false,
+      exploration_grouping: false,
+      tips_hidden: true,
+    }),
+  )
+
+  try {
+    const config = await run(
+      directory,
+      Effect.gen(function* () {
+        const service = yield* Config.Service
+        return yield* service.get()
+      }),
+    )
+
+    expect(config).toMatchObject({
+      theme: { name: "legacy", mode: "light" },
+      keybinds: { leader: "ctrl+o" },
+      plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
+      leader: { timeout: 500 },
+      scroll: { speed: 2, acceleration: true },
+      diffs: { view: "unified" },
+      prompt: { paste: "full" },
+      session: { grouping: "none" },
+      hints: { tips: false },
+      mouse: false,
+    })
+    expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
+    expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
+    expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
+    expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
+  } finally {
+    await Bun.$`rm -rf ${directory}`
+  }
+})
+
+test("migrates before the first update and does not remigrate afterward", async () => {
+  const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+  await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
+
+  try {
+    const config = await run(
+      directory,
+      Effect.gen(function* () {
+        const service = yield* Config.Service
+        yield* service.update((draft) => {
+          draft.animations = false
+          draft.mouse = false
+        })
+        yield* Effect.promise(() =>
+          Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
+        )
+        return yield* service.get()
+      }),
+    )
+
+    expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
+    expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
+      theme: { name: "legacy" },
+      animations: false,
+      mouse: false,
+    })
+  } finally {
+    await Bun.$`rm -rf ${directory}`
+  }
+})
+
+test("updates a config draft while preserving JSONC comments", async () => {
+  const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
+  await Bun.write(path.join(directory, "cli.json"), "{\n  // Keep this comment\n  \"animations\": true\n}\n")
+
+  try {
+    const config = await run(
+      directory,
+      Effect.gen(function* () {
+        const service = yield* Config.Service
+        return yield* service.update((draft) => {
+          draft.prompt = { paste: "compact" }
+        })
+      }),
+    )
+
+    expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
+    expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
+  } finally {
+    await Bun.$`rm -rf ${directory}`
+  }
+})

+ 0 - 25
packages/cli/test/tui-config.test.ts

@@ -1,25 +0,0 @@
-import { NodeFileSystem } from "@effect/platform-node"
-import { Global } from "@opencode-ai/core/global"
-import { Effect } from "effect"
-import { expect, test } from "bun:test"
-import path from "path"
-import { TuiConfig } from "../src/tui-config"
-
-test("loads the global tui config", async () => {
-  const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
-  await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
-
-  try {
-    const config = await Effect.runPromise(
-      TuiConfig.load().pipe(
-        Effect.provide(Global.layerWith({ config: directory })),
-        Effect.provide(NodeFileSystem.layer),
-      ),
-    )
-
-    expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
-    expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
-  } finally {
-    await Bun.$`rm -rf ${directory}`
-  }
-})

+ 28 - 7
packages/plugin/src/tui.ts

@@ -24,7 +24,7 @@ import {
   type SequenceBindingLike,
 } from "@opentui/keymap/extras"
 import type { JSX, SolidPlugin } from "@opentui/solid"
-import type { Config as PluginConfig, PluginOptions } from "./index.js"
+import type { PluginOptions } from "./index.js"
 
 export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core"
 export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
@@ -407,13 +407,34 @@ type TuiAttentionConfigView = {
   sounds: Partial<Record<TuiAttentionSoundName, string>>
 }
 
-type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
-  NonNullable<PluginConfig["tui"]> & {
-    leader_timeout: number
-    attention: TuiAttentionConfigView
-    plugin_enabled?: Record<string, boolean>
-    keybinds: TuiBindingLookupView
+type TuiConfigView = {
+  theme?: {
+    name?: string
+    mode?: "system" | "dark" | "light"
   }
+  plugins?: ReadonlyArray<string | { package: string; options?: Record<string, any> }>
+  leader: { timeout: number }
+  scroll?: { speed?: number; acceleration?: boolean }
+  attention: TuiAttentionConfigView
+  diffs?: {
+    wrap?: "word" | "none"
+    tree?: boolean
+    single?: boolean
+    view?: "auto" | "split" | "unified"
+  }
+  terminal?: { title?: boolean }
+  prompt?: { editor?: boolean; paste?: "compact" | "full" }
+  session?: {
+    sidebar?: "auto" | "hide"
+    scrollbar?: boolean
+    thinking?: "show" | "hide"
+    grouping?: "auto" | "none"
+  }
+  hints?: { tips?: boolean; onboarding?: boolean }
+  animations?: boolean
+  mouse: boolean
+  keybinds: TuiBindingLookupView
+}
 
 export type TuiApp = {
   readonly version: string

+ 2 - 2
packages/tui/package.json

@@ -12,10 +12,10 @@
   "exports": {
     ".": "./src/index.tsx",
     "./builtins": "./src/feature-plugins/builtins.ts",
+    "./config": "./src/config/index.tsx",
+    "./config/keybind": "./src/config/keybind.ts",
     "./config/v1": "./src/config/v1/index.tsx",
     "./config/v1/keybind": "./src/config/v1/keybind.ts",
-    "./config/v2": "./src/config/v2/index.ts",
-    "./config/v2/keybind": "./src/config/v2/keybind.ts",
     "./context/args": "./src/context/args.tsx",
     "./context/epilogue": "./src/context/epilogue.tsx",
     "./context/exit": "./src/context/exit.tsx",

+ 27 - 33
packages/tui/src/app.tsx

@@ -75,7 +75,7 @@ import * as Model from "./util/model"
 import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
-import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1"
+import { TuiConfig, TuiConfigProvider, useTuiConfig } from "./config"
 import { createTuiApiAdapters } from "./plugin/adapters"
 import { createTuiApi } from "./plugin/api"
 import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
@@ -144,7 +144,6 @@ const appBindingCommands = [
   "app.toggle.file_context",
   "app.toggle.diffwrap",
   "app.toggle.paste_summary",
-  "app.toggle.session_directory_filter",
 ] as const
 
 export type TuiInput = {
@@ -154,7 +153,7 @@ export type TuiInput = {
     reload?: () => Promise<void>
   }
   args: Args
-  config: TuiConfig.Resolved
+  config: TuiConfig.Interface
   onSnapshot?: () => Promise<string[]>
   pluginHost: TuiPluginHost
   terminalHandoff?: () => Promise<
@@ -203,6 +202,8 @@ function isVersionGreater(left: string, right: string) {
 export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
   const log = input.log ?? (() => {})
   const global = yield* Global.Service
+  const configInfo = yield* Effect.tryPromise(() => input.config.get())
+  const config = TuiConfig.resolve(configInfo, { terminalSuspend: process.platform !== "win32" })
   const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
   const api = OpenCode.make(options)
   const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
@@ -235,7 +236,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
               useKittyKeyboard: {},
               autoFocus: false,
               openConsoleOnError: false,
-              useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
+              useMouse: !Flag.OPENCODE_DISABLE_MOUSE && config.mouse,
               consoleOptions: {
                 keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
               },
@@ -263,7 +264,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
       win32DisableProcessedInput()
       const keymap = createDefaultOpenTuiKeymap(renderer)
       yield* Effect.acquireRelease(
-        Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
+        Effect.sync(() => registerOpencodeKeymap(keymap, renderer, config)),
         (unregister) => Effect.sync(unregister),
       )
       yield* Effect.addFinalizer(() =>
@@ -337,19 +338,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                           <ClipboardProvider>
                             <OpencodeKeymapProvider keymap={keymap}>
                               <ArgsProvider {...input.args}>
-                                <KVProvider>
-                                  <ToastProvider>
-                                    <RouteProvider
-                                      initialRoute={
-                                        input.args.continue
-                                          ? {
-                                              type: "session",
-                                              sessionID: "dummy",
-                                            }
-                                          : undefined
-                                      }
-                                    >
-                                      <TuiConfigProvider config={input.config}>
+                                <TuiConfigProvider
+                                  config={config}
+                                  service={input.config}
+                                  options={{ terminalSuspend: process.platform !== "win32" }}
+                                >
+                                  <KVProvider>
+                                    <ToastProvider>
+                                      <RouteProvider
+                                        initialRoute={
+                                          input.args.continue
+                                            ? {
+                                                type: "session",
+                                                sessionID: "dummy",
+                                              }
+                                            : undefined
+                                        }
+                                      >
                                         <PluginRuntimeProvider value={pluginRuntime}>
                                           <SDKProvider
                                             client={createOpencodeClient({ ...options, directory })}
@@ -397,10 +402,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                                             </PermissionProvider>
                                           </SDKProvider>
                                         </PluginRuntimeProvider>
-                                      </TuiConfigProvider>
-                                    </RouteProvider>
-                                  </ToastProvider>
-                                </KVProvider>
+                                      </RouteProvider>
+                                    </ToastProvider>
+                                  </KVProvider>
+                                </TuiConfigProvider>
                               </ArgsProvider>
                             </OpencodeKeymapProvider>
                           </ClipboardProvider>
@@ -1004,17 +1009,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
           dialog.clear()
         },
       },
-      {
-        name: "app.toggle.session_directory_filter",
-        title: kv.get("session_directory_filter_enabled", true)
-          ? "Disable session directory filtering"
-          : "Enable session directory filtering",
-        category: "System",
-        run: async () => {
-          kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
-          dialog.clear()
-        },
-      },
       {
         name: "permission.mode",
         title:

+ 1 - 1
packages/tui/src/attention.ts

@@ -10,7 +10,7 @@ import type {
   TuiAttentionSoundPack,
   TuiAttentionSoundPackInfo,
 } from "@opencode-ai/plugin/tui"
-import { AttentionSoundName, type TuiConfig } from "./config/v1"
+import { AttentionSoundName, type TuiConfig } from "./config"
 import { Schema } from "effect"
 import stripAnsi from "strip-ansi"
 import * as TuiAudio from "./audio"

+ 1 - 1
packages/tui/src/component/command-palette.tsx

@@ -8,7 +8,7 @@ import {
   useKeymapSelector,
   useOpencodeKeymap,
 } from "../keymap"
-import { useTuiConfig } from "../config/v1"
+import { useTuiConfig } from "../config"
 
 type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
 

+ 1 - 1
packages/tui/src/component/dialog-mcp.tsx

@@ -9,7 +9,7 @@ import type { McpServer } from "@opencode-ai/client"
 import { useClipboard } from "../context/clipboard"
 import { useToast } from "../ui/toast"
 import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
-import { useTuiConfig } from "../config/v1"
+import { useTuiConfig } from "../config"
 import { getScrollAcceleration } from "../util/scroll"
 import { useBindings } from "../keymap"
 

+ 1 - 1
packages/tui/src/component/dialog-project-copy-name.tsx

@@ -1,7 +1,7 @@
 import { InputRenderable, TextAttributes } from "@opentui/core"
 import { Slug } from "@opencode-ai/core/util/slug"
 import { createSignal, onMount } from "solid-js"
-import { useTuiConfig } from "../config/v1"
+import { useTuiConfig } from "../config"
 import { useTheme } from "../context/theme"
 import { useBindings, useCommandShortcut } from "../keymap"
 import { useDialog, type DialogContext } from "../ui/dialog"

+ 1 - 1
packages/tui/src/component/dialog-workspace-file-changes.tsx

@@ -5,7 +5,7 @@ import { createMemo, For } from "solid-js"
 import { createStore } from "solid-js/store"
 import { FilePath } from "../ui/file-path"
 import { useTheme } from "../context/theme"
-import { useTuiConfig } from "../config/v1"
+import { useTuiConfig } from "../config"
 import { useDialog, type DialogContext } from "../ui/dialog"
 import { getScrollAcceleration } from "../util/scroll"
 

+ 1 - 1
packages/tui/src/component/prompt/autocomplete.tsx

@@ -11,7 +11,7 @@ import { useSDK } from "../../context/sdk"
 import { useData } from "../../context/data"
 import { getScrollAcceleration } from "../../util/scroll"
 import { useTuiPaths } from "../../context/runtime"
-import { useTuiConfig } from "../../config/v1"
+import { useTuiConfig } from "../../config"
 import { useLocation } from "../../context/location"
 import { useTheme, selectedForeground } from "../../context/theme"
 import { SplitBorder } from "../../ui/border"

+ 2 - 2
packages/tui/src/component/prompt/index.tsx

@@ -48,7 +48,7 @@ import { createFadeIn } from "../../util/signal"
 import { DialogSkill } from "../dialog-skill"
 import { useArgs } from "../../context/args"
 import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
-import { useTuiConfig } from "../../config/v1"
+import { useTuiConfig } from "../../config"
 import { usePromptMove } from "./move"
 import { readLocalAttachment } from "./local-attachment"
 import { useData } from "../../context/data"
@@ -1321,7 +1321,7 @@ export function Prompt(props: PromptProps) {
       }),
     }
   })
-  const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
+  const maxHeight = createMemo(() => Math.max(6, Math.floor(dimensions().height / 3)))
   const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
 
   return (

+ 216 - 0
packages/tui/src/config/index.tsx

@@ -0,0 +1,216 @@
+export * as TuiConfig from "."
+
+import { createBindingLookup } from "@opentui/keymap/extras"
+import { Schema } from "effect"
+import { createContext, type JSX, useContext } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { TuiKeybind } from "./keybind"
+
+export interface Interface {
+  readonly get: () => Promise<Info>
+  readonly update: (update: (draft: any) => void) => Promise<Info>
+}
+
+export const AttentionSoundName = Schema.Literals([
+  "default",
+  "question",
+  "permission",
+  "error",
+  "done",
+  "subagent_done",
+])
+export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
+export type AttentionSoundPaths = Partial<Record<AttentionSoundName, string>>
+
+export const Plugin = Schema.Union([
+  Schema.String,
+  Schema.Struct({
+    package: Schema.String.annotate({ description: "Plugin package name or path" }),
+    options: Schema.optional(Schema.Record(Schema.String, Schema.Any)).annotate({
+      description: "Options passed to the plugin",
+    }),
+  }),
+])
+
+export const Info = Schema.Struct({
+  theme: Schema.optional(
+    Schema.Struct({
+      name: Schema.optional(Schema.String).annotate({ description: "Theme name" }),
+      mode: Schema.optional(Schema.Literals(["system", "dark", "light"])).annotate({
+        description: "Color mode; 'system' follows the terminal",
+      }),
+    }),
+  ).annotate({ description: "Color theme settings" }),
+  keybinds: Schema.optional(TuiKeybind.KeybindOverrides).annotate({ description: "Custom key bindings" }),
+  plugins: Schema.optional(Schema.Array(Plugin)).annotate({
+    description: "Ordered plugin enablement directives and external package declarations",
+  }),
+  leader: Schema.optional(
+    Schema.Struct({
+      timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
+        description: "Time in milliseconds to wait for a key after the leader key",
+      }),
+    }),
+  ).annotate({ description: "Leader key behavior" }),
+  scroll: Schema.optional(
+    Schema.Struct({
+      speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({
+        description: "Distance scrolled per input tick",
+      }),
+      acceleration: Schema.optional(Schema.Boolean).annotate({
+        description: "Accelerate scrolling from repeated input",
+      }),
+    }),
+  ).annotate({ description: "Scrolling behavior" }),
+  attention: Schema.optional(
+    Schema.Struct({
+      enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable attention alerts" }),
+      notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
+      sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
+      volume: Schema.optional(
+        Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)),
+      ).annotate({ description: "Attention sound volume from 0 to 1" }),
+      sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }),
+      sounds: Schema.optional(
+        Schema.Record(
+          AttentionSoundName,
+          Schema.optionalKey(Schema.String),
+        ),
+      ).annotate({ description: "Sound file overrides by attention event" }),
+    }),
+  ).annotate({ description: "System notification and sound settings" }),
+  diffs: Schema.optional(
+    Schema.Struct({
+      wrap: Schema.optional(Schema.Literals(["word", "none"])).annotate({
+        description: "Line wrapping behavior in diff output",
+      }),
+      tree: Schema.optional(Schema.Boolean).annotate({ description: "Show the diff file tree" }),
+      single: Schema.optional(Schema.Boolean).annotate({ description: "Show only the selected file patch" }),
+      view: Schema.optional(Schema.Literals(["auto", "split", "unified"])).annotate({
+        description: "Diff layout; 'auto' selects a layout from the available width",
+      }),
+    }),
+  ).annotate({ description: "Diff presentation settings" }),
+  terminal: Schema.optional(
+    Schema.Struct({
+      title: Schema.optional(Schema.Boolean).annotate({ description: "Update the terminal window title" }),
+    }),
+  ).annotate({ description: "Terminal integration settings" }),
+  prompt: Schema.optional(
+    Schema.Struct({
+      editor: Schema.optional(Schema.Boolean).annotate({
+        description: "Include the active editor file or selection as prompt context",
+      }),
+      paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
+        description: "Display large pastes as compact placeholders or full text",
+      }),
+    }),
+  ).annotate({ description: "Prompt input behavior" }),
+  session: Schema.optional(
+    Schema.Struct({
+      sidebar: Schema.optional(Schema.Literals(["auto", "hide"])).annotate({
+        description: "Session sidebar visibility; 'auto' shows it when space permits",
+      }),
+      scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
+      thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
+        description: "Show or hide model reasoning by default",
+      }),
+      grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
+        description: "Group related transcript items automatically or render each item separately",
+      }),
+    }),
+  ).annotate({ description: "Session transcript presentation settings" }),
+  hints: Schema.optional(
+    Schema.Struct({
+      tips: Schema.optional(Schema.Boolean).annotate({ description: "Show usage tips on the home screen" }),
+      onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
+    }),
+  ).annotate({ description: "In-product guidance settings" }),
+  animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
+  mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
+})
+export type Info = Schema.Schema.Type<typeof Info>
+
+export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"> & {
+  attention: {
+    enabled: boolean
+    notifications: boolean
+    sound: boolean
+    volume: number
+    sound_pack: string
+    sounds: AttentionSoundPaths
+  }
+  keybinds: TuiKeybind.BindingLookupView
+  leader: { timeout: number }
+  mouse: boolean
+}
+
+export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
+  const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
+  if (!options.terminalSuspend) {
+    keybinds.terminal_suspend = "none"
+    if (keybinds.input_undo === undefined) {
+      const inputUndo = TuiKeybind.defaultValue("input_undo")
+      keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
+        .filter((value, index, values) => values.indexOf(value) === index)
+        .join(",")
+    }
+  }
+
+  return {
+    ...input,
+    attention: {
+      enabled: input.attention?.enabled ?? false,
+      notifications: input.attention?.notifications ?? true,
+      sound: input.attention?.sound ?? true,
+      volume: input.attention?.volume ?? 0.4,
+      sound_pack: input.attention?.sound_pack ?? "opencode.default",
+      sounds: input.attention?.sounds ?? {},
+    },
+    keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
+      commandMap: TuiKeybind.CommandMap,
+      bindingDefaults: TuiKeybind.bindingDefaults(),
+    }),
+    leader: { timeout: input.leader?.timeout ?? 2000 },
+    mouse: input.mouse ?? true,
+  }
+}
+
+const ConfigContext = createContext<{ config: Resolved; service?: Interface }>()
+
+export function TuiConfigProvider(props: {
+  config: Resolved
+  service?: Interface
+  options?: { terminalSuspend: boolean }
+  children: JSX.Element
+}) {
+  const [config, setConfig] = createStore(props.config)
+  const host = props.service
+  const service = host
+    ? {
+        get: host.get,
+        update: async (update: (draft: any) => void) => {
+          const info = await host.update(update)
+          setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
+          return info
+        },
+      }
+    : undefined
+  return <ConfigContext.Provider value={{ config, service }}>{props.children}</ConfigContext.Provider>
+}
+
+export function useTuiConfig() {
+  const value = useContext(ConfigContext)
+  if (!value) throw new Error("TuiConfigProvider is missing")
+  return value.config
+}
+
+export function useTuiConfigOptional() {
+  return useContext(ConfigContext)?.config
+}
+
+export function useTuiConfigService() {
+  const value = useContext(ConfigContext)
+  if (!value?.service) throw new Error("TuiConfig service is missing")
+  return value.service
+}

+ 2 - 0
packages/tui/src/config/keybind.ts

@@ -0,0 +1,2 @@
+export * from "./v1/keybind"
+export * as TuiKeybind from "./v1/keybind"

+ 1 - 0
packages/tui/src/config/v1/index.tsx

@@ -1,4 +1,5 @@
 export * as TuiConfig from "."
+export * as TuiConfigV1 from "."
 
 import { createBindingLookup } from "@opentui/keymap/extras"
 import { Schema } from "effect"

+ 0 - 2
packages/tui/src/config/v1/keybind.ts

@@ -53,7 +53,6 @@ export const Definitions = {
   app_toggle_file_context: keybind("none", "Toggle file context"),
   app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"),
   app_toggle_paste_summary: keybind("none", "Toggle paste summary"),
-  app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"),
   command_list: keybind("ctrl+p", "List available commands"),
   help_show: keybind("none", "Open help dialog"),
   docs_open: keybind("none", "Open documentation"),
@@ -257,7 +256,6 @@ export const CommandMap = {
   app_toggle_file_context: "app.toggle.file_context",
   app_toggle_diffwrap: "app.toggle.diffwrap",
   app_toggle_paste_summary: "app.toggle.paste_summary",
-  app_toggle_session_directory_filter: "app.toggle.session_directory_filter",
   command_list: "command.palette.show",
   help_show: "help.show",
   docs_open: "docs.open",

+ 0 - 97
packages/tui/src/config/v2/index.ts

@@ -1,97 +0,0 @@
-export * as TuiConfigV2 from "."
-
-import { Schema } from "effect"
-import { TuiKeybind } from "./keybind"
-
-export const Plugin = Schema.Union([
-  Schema.String,
-  Schema.Struct({
-    package: Schema.String,
-    options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
-  }),
-])
-
-export const Info = Schema.Struct({
-  theme: Schema.optional(
-    Schema.Struct({
-      name: Schema.optional(Schema.String),
-      mode: Schema.optional(Schema.Literals(["system", "dark", "light"])),
-    }),
-  ),
-  keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
-  plugins: Schema.optional(Schema.Array(Plugin)),
-  leader: Schema.optional(
-    Schema.Struct({
-      timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
-    }),
-  ),
-  scroll: Schema.optional(
-    Schema.Struct({
-      speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))),
-      acceleration: Schema.optional(Schema.Boolean),
-    }),
-  ),
-  attention: Schema.optional(
-    Schema.Struct({
-      enabled: Schema.optional(Schema.Boolean),
-      notifications: Schema.optional(Schema.Boolean),
-      sound: Schema.optional(Schema.Boolean),
-      volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
-      sound_pack: Schema.optional(Schema.String),
-      sounds: Schema.optional(
-        Schema.Record(
-          Schema.Literals(["default", "question", "permission", "error", "done", "subagent_done"]),
-          Schema.optionalKey(Schema.String),
-        ),
-      ),
-    }),
-  ),
-  diffs: Schema.optional(
-    Schema.Struct({
-      wrap: Schema.optional(Schema.Literals(["word", "none"])),
-      tree: Schema.optional(Schema.Boolean),
-      single: Schema.optional(Schema.Boolean),
-      view: Schema.optional(Schema.Literals(["auto", "split", "unified"])),
-    }),
-  ),
-  terminal: Schema.optional(
-    Schema.Struct({
-      title: Schema.optional(Schema.Boolean),
-    }),
-  ),
-  composer: Schema.optional(
-    Schema.Struct({
-      file_context: Schema.optional(Schema.Boolean),
-      paste_summary: Schema.optional(Schema.Boolean),
-    }),
-  ),
-  session: Schema.optional(
-    Schema.Struct({
-      sidebar: Schema.optional(Schema.Literals(["auto", "hide"])),
-      scrollbar: Schema.optional(Schema.Boolean),
-      thinking: Schema.optional(Schema.Literals(["show", "hide"])),
-      group_exploration: Schema.optional(Schema.Boolean),
-      directory_filter: Schema.optional(Schema.Boolean),
-    }),
-  ),
-  which_key: Schema.optional(
-    Schema.Struct({
-      layout: Schema.optional(Schema.Literals(["dock", "overlay"])),
-      pending_preview: Schema.optional(Schema.Boolean),
-    }),
-  ),
-  hints: Schema.optional(
-    Schema.Struct({
-      tips: Schema.optional(Schema.Boolean),
-      getting_started: Schema.optional(Schema.Boolean),
-    }),
-  ),
-  updates: Schema.optional(
-    Schema.Struct({
-      skipped: Schema.optional(Schema.String),
-    }),
-  ),
-  animations: Schema.optional(Schema.Boolean),
-  mouse: Schema.optional(Schema.Boolean),
-})
-export type Info = Schema.Schema.Type<typeof Info>

+ 0 - 6
packages/tui/src/config/v2/keybind.ts

@@ -1,6 +0,0 @@
-export * as TuiKeybind from "./keybind"
-
-import { Schema } from "effect"
-
-export const KeybindOverrides = Schema.Struct({})
-export type KeybindOverrides = Schema.Schema.Type<typeof KeybindOverrides>

+ 44 - 3
packages/tui/src/context/kv.tsx

@@ -1,4 +1,4 @@
-import { createSignal, type Setter } from "solid-js"
+import { createEffect, createSignal, type Setter } from "solid-js"
 import { createStore, unwrap } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { Flock } from "@opencode-ai/core/util/flock"
@@ -6,10 +6,12 @@ import { Global } from "@opencode-ai/core/global"
 import { readJson, writeJsonAtomic } from "../util/persistence"
 import { useTuiPaths } from "./runtime"
 import path from "path"
+import { useTuiConfigOptional, type TuiConfig } from "../config"
 
 export const { use: useKV, provider: KVProvider } = createSimpleContext({
   name: "KV",
-  init: () => {
+  init: (props: { config?: TuiConfig.Info }) => {
+    const config = props.config ?? useTuiConfigOptional()
     const paths = useTuiPaths()
     void Global.Path.state
     const file = path.join(paths.state, "kv.json")
@@ -21,7 +23,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
 
     Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
       .then((x) => {
-        setStore(x)
+        const values: Record<string, any> = { ...x }
+        Object.entries(configValues(config ?? {})).forEach(([key, value]) => {
+          if (value === undefined) delete values[key]
+          else values[key] = value
+        })
+        setStore(values)
       })
       .catch((error) => {
         console.error("Failed to read KV state", { error })
@@ -30,6 +37,14 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
         setReady(true)
       })
 
+    createEffect(() => {
+      if (!ready() || !config) return
+      Object.entries(configValues(config)).forEach(([key, value]) => {
+        if (value === undefined) setStore(key, undefined)
+        else setStore(key, value)
+      })
+    })
+
     const result = {
       get ready() {
         return ready()
@@ -64,3 +79,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
     return result
   },
 })
+
+function configValues(config: TuiConfig.Info) {
+  const values: Record<string, any> = {}
+  if (config.theme?.name !== undefined) values.theme = config.theme.name
+  if (config.theme?.mode !== undefined) {
+    values.theme_mode_lock = config.theme.mode === "system" ? undefined : config.theme.mode
+    values.theme_mode = undefined
+  }
+  if (config.attention?.sound_pack !== undefined) values.attention_sound_pack = config.attention.sound_pack
+  if (config.diffs?.wrap !== undefined) values.diff_wrap_mode = config.diffs.wrap
+  if (config.diffs?.tree !== undefined) values.diff_viewer_show_file_tree = config.diffs.tree
+  if (config.diffs?.single !== undefined) values.diff_viewer_single_patch = config.diffs.single
+  if (config.diffs?.view !== undefined)
+    values.diff_viewer_view = config.diffs.view === "auto" ? undefined : config.diffs.view
+  if (config.terminal?.title !== undefined) values.terminal_title_enabled = config.terminal.title
+  if (config.prompt?.editor !== undefined) values.file_context_enabled = config.prompt.editor
+  if (config.prompt?.paste !== undefined) values.paste_summary_enabled = config.prompt.paste === "compact"
+  if (config.session?.sidebar !== undefined) values.sidebar = config.session.sidebar
+  if (config.session?.scrollbar !== undefined) values.scrollbar_visible = config.session.scrollbar
+  if (config.session?.thinking !== undefined) values.thinking_mode = config.session.thinking
+  if (config.session?.grouping !== undefined) values.exploration_grouping = config.session.grouping === "auto"
+  if (config.hints?.tips !== undefined) values.tips_hidden = !config.hints.tips
+  if (config.hints?.onboarding !== undefined) values.dismissed_getting_started = !config.hints.onboarding
+  if (config.animations !== undefined) values.animations_enabled = config.animations
+  return values
+}

+ 3 - 3
packages/tui/src/context/theme.tsx

@@ -23,7 +23,7 @@ import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { useKV } from "./kv"
-import { useTuiConfig } from "../config/v1"
+import { useTuiConfig } from "../config"
 import { Global } from "@opencode-ai/core/global"
 import { Glob } from "@opencode-ai/core/util/glob"
 import { readFile } from "node:fs/promises"
@@ -118,14 +118,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
         draft.mode = mode
         draft.lock = lock
-        const active = config.theme ?? kv.get("theme", "opencode")
+        const active = config.theme?.name ?? kv.get("theme", "opencode")
         draft.active = typeof active === "string" ? active : "opencode"
         draft.ready = false
       }),
     )
 
     createEffect(() => {
-      const theme = config.theme
+      const theme = config.theme?.name
       if (theme) setStore("active", theme)
     })
 

+ 1 - 1
packages/tui/src/feature-plugins/home/tips-view.tsx

@@ -269,7 +269,7 @@ const TIPS: Tip[] = [
   (shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
   (shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
   (shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
-  "Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth scrolling",
+  "Enable {highlight}scroll.acceleration{/highlight} in {highlight}cli.json{/highlight} for smooth scrolling",
   (shortcuts) =>
     shortcuts.commandList()
       ? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`

+ 2 - 1
packages/tui/src/feature-plugins/system/diff-viewer.tsx

@@ -144,7 +144,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
   const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
   const defaultView = createMemo(() => {
-    if (props.api.tuiConfig.diff_style === "stacked") return "unified"
+    if (props.api.tuiConfig.diffs?.view === "unified") return "unified"
+    if (props.api.tuiConfig.diffs?.view === "split") return "split"
     return splitAvailable() ? "split" : "unified"
   })
   const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))

+ 4 - 4
packages/tui/src/keymap.tsx

@@ -14,8 +14,8 @@ import {
 } from "@opentui/keymap/extras"
 import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
 import { createMemo, type Accessor } from "solid-js"
-import { useTuiConfig } from "./config/v1"
-import { TuiKeybind } from "./config/v1/keybind"
+import { useTuiConfig } from "./config"
+import { TuiKeybind } from "./config/keybind"
 
 export const LEADER_TOKEN = "leader"
 export const OPENCODE_BASE_MODE = "base"
@@ -42,7 +42,7 @@ type BindingLookup = {
   gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
 }
 type FormatConfig = { keybinds: BindingLookup }
-type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number }
+type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number })
 
 const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
 
@@ -225,7 +225,7 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
     ? registerTimedLeader(keymap, {
         trigger: leader,
         name: LEADER_TOKEN,
-        timeoutMs: config.leader_timeout,
+        timeoutMs: "leader" in config ? config.leader.timeout : config.leader_timeout,
       })
     : () => {}
   const offEscape = registerEscapeClearsPendingSequence(keymap)

+ 1 - 1
packages/tui/src/plugin/adapters.tsx

@@ -1,5 +1,5 @@
 import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
-import type { TuiConfig } from "../config/v1"
+import type { TuiConfig } from "../config"
 import type { useEvent } from "../context/event"
 import type { useRoute } from "../context/route"
 import type { useSDK } from "../context/sdk"

+ 1 - 1
packages/tui/src/plugin/command-shim.ts

@@ -1,6 +1,6 @@
 // Legacy `api.command` bridge for v1 plugins; remove in v2.
 import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
-import { TuiKeybind } from "../config/v1/keybind"
+import { TuiKeybind } from "../config/keybind"
 import type { DialogContext } from "../ui/dialog"
 
 const COMMAND_PALETTE_SHOW = "command.palette.show"

+ 1 - 1
packages/tui/src/plugin/runtime.tsx

@@ -4,7 +4,7 @@ import type {
   TuiPluginInstallResult,
   TuiPluginStatus,
 } from "@opencode-ai/plugin/tui"
-import type { TuiConfig } from "../config/v1"
+import type { TuiConfig } from "../config"
 import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
 import { createPluginRoutes } from "./api"
 import { createSlots, type HostSlots } from "./slots"

+ 1 - 10
packages/tui/src/routes/home.tsx

@@ -8,8 +8,6 @@ import { usePromptRef } from "../context/prompt"
 import { useLocal } from "../context/local"
 import { usePluginRuntime } from "../plugin/runtime"
 import { useEditorContext } from "../context/editor"
-import { useTerminalDimensions } from "@opentui/solid"
-import { useTuiConfig } from "../config/v1"
 import { HomeSessionDestinationProvider } from "./home/session-destination"
 import { useData } from "../context/data"
 import { LocationProvider } from "../context/location"
@@ -29,16 +27,9 @@ export function Home() {
   const args = useArgs()
   const local = useLocal()
   const editor = useEditorContext()
-  const dimensions = useTerminalDimensions()
-  const tuiConfig = useTuiConfig()
   const data = useData()
   // Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
   const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
-  const promptMaxWidth = createMemo(() => {
-    const configured = tuiConfig.prompt?.max_width
-    if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
-    return configured ?? 75
-  })
   let sent = false
 
   onMount(() => {
@@ -83,7 +74,7 @@ export function Home() {
             </pluginRuntime.Slot>
           </box>
           <box height={1} minHeight={0} flexShrink={1} />
-          <box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
+          <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
             <pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
               <Prompt
                 ref={bind}

+ 1 - 1
packages/tui/src/routes/session/form.tsx

@@ -10,7 +10,7 @@ import { useSDK } from "../../context/sdk"
 import { useClipboard } from "../../context/clipboard"
 import { SplitBorder } from "../../ui/border"
 import { useToast } from "../../ui/toast"
-import { useTuiConfig } from "../../config/v1"
+import { useTuiConfig } from "../../config"
 import { useBindings, useOpencodeModeStack } from "../../keymap"
 
 const FORM_MODE = "form"

+ 6 - 4
packages/tui/src/routes/session/index.tsx

@@ -64,7 +64,7 @@ import { FormPrompt } from "./form"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { DialogExportResult } from "../../ui/dialog-export-result"
 import { sessionEpilogue } from "../../util/presentation"
-import { useTuiConfig } from "../../config/v1"
+import { useTuiConfig } from "../../config"
 import { useClipboard } from "../../context/clipboard"
 import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
 import { getScrollAcceleration } from "../../util/scroll"
@@ -2465,8 +2465,9 @@ function Edit(props: ToolProps) {
   const pathFormatter = usePathFormatter()
 
   const view = createMemo(() => {
-    const diffStyle = ctx.tui.diff_style
-    if (diffStyle === "stacked") return "unified"
+    const diffView = ctx.tui.diffs?.view
+    if (diffView === "unified") return "unified"
+    if (diffView === "split") return "split"
     // Default to "auto" behavior
     return ctx.width > 120 ? "split" : "unified"
   })
@@ -2541,7 +2542,8 @@ function ApplyPatch(props: ToolProps) {
     })
   })
   const view = createMemo(() => {
-    if (ctx.tui.diff_style === "stacked") return "unified"
+    if (ctx.tui.diffs?.view === "unified") return "unified"
+    if (ctx.tui.diffs?.view === "split") return "split"
     return ctx.width > 120 ? "split" : "unified"
   })
 

+ 4 - 3
packages/tui/src/routes/session/permission.tsx

@@ -12,7 +12,7 @@ import { filetype } from "../../util/filetype"
 import { Locale } from "../../util/locale"
 import { webSearchProviderLabel } from "../../util/tool-display"
 import { getScrollAcceleration } from "../../util/scroll"
-import { useTuiConfig } from "../../config/v1"
+import { useTuiConfig } from "../../config"
 import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
 import { usePathFormatter } from "../../context/path-format"
 
@@ -34,8 +34,9 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
   })
 
   const view = createMemo(() => {
-    const diffStyle = config.diff_style
-    if (diffStyle === "stacked") return "unified"
+    const diffView = config.diffs?.view
+    if (diffView === "unified") return "unified"
+    if (diffView === "split") return "split"
     return dimensions().width > 120 ? "split" : "unified"
   })
 

+ 1 - 1
packages/tui/src/routes/session/sidebar.tsx

@@ -1,7 +1,7 @@
 import { useData } from "../../context/data"
 import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
-import { useTuiConfig } from "../../config/v1"
+import { useTuiConfig } from "../../config"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { usePluginRuntime } from "../../plugin/runtime"
 

+ 1 - 1
packages/tui/src/ui/dialog-prompt.tsx

@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
 import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
 import { Spinner } from "../component/spinner"
-import { useTuiConfig } from "../config/v1"
+import { useTuiConfig } from "../config"
 import { useBindings, useCommandShortcut } from "../keymap"
 
 export type DialogPromptProps = {

+ 1 - 1
packages/tui/src/ui/dialog-select.tsx

@@ -17,7 +17,7 @@ import { isDeepEqual } from "remeda"
 import { useDialog, type DialogContext } from "./dialog"
 import { Locale } from "../util/locale"
 import { getScrollAcceleration } from "../util/scroll"
-import { useTuiConfig } from "../config/v1"
+import { useTuiConfig } from "../config"
 import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
 
 export interface DialogSelectProps<T> {

+ 7 - 5
packages/tui/src/util/scroll.ts

@@ -1,8 +1,10 @@
 import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
 
 export type ScrollConfig = {
-  scroll_acceleration?: { enabled?: boolean }
-  scroll_speed?: number
+  scroll?: {
+    acceleration?: boolean
+    speed?: number
+  }
 }
 
 export class CustomSpeedScroll implements ScrollAcceleration {
@@ -16,11 +18,11 @@ export class CustomSpeedScroll implements ScrollAcceleration {
 }
 
 export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
-  if (tuiConfig?.scroll_acceleration?.enabled) {
+  if (tuiConfig?.scroll?.acceleration) {
     return new MacOSScrollAccel()
   }
-  if (tuiConfig?.scroll_speed !== undefined) {
-    return new CustomSpeedScroll(tuiConfig.scroll_speed)
+  if (tuiConfig?.scroll?.speed !== undefined) {
+    return new CustomSpeedScroll(tuiConfig.scroll.speed)
   }
 
   return new CustomSpeedScroll(3)

+ 2 - 3
packages/tui/test/app-lifecycle.test.tsx

@@ -4,7 +4,6 @@ import { createTestRenderer } from "@opentui/core/testing"
 import { Effect } from "effect"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Global } from "@opencode-ai/core/global"
-import { createTuiResolvedConfig } from "./fixture/tui-runtime"
 import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
 
 test("SIGHUP clears title and disposes scoped resources once", async () => {
@@ -32,7 +31,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
     const task = Effect.runPromise(
       run({
         server: { endpoint: { url: server.url.toString() } },
-        config: createTuiResolvedConfig({ plugin_enabled: {} }),
+        config: { get: async () => ({}), update: async () => ({}) },
         args: {},
         log: () => {},
         pluginHost: {
@@ -118,7 +117,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
     const task = Effect.runPromise(
       run({
         server: { endpoint: { url: server.url.toString() } },
-        config: createTuiResolvedConfig({ plugin_enabled: {} }),
+        config: { get: async () => ({}), update: async () => ({}) },
         args: { sessionID: "dummy" },
         log: () => {},
         pluginHost: {

+ 3 - 3
packages/tui/test/cli/tui/dialog-prompt.test.tsx

@@ -8,7 +8,7 @@ import path from "node:path"
 import { onCleanup } from "solid-js"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import type { TuiKeybind } from "../../../src/config/v1/keybind"
+import type { TuiKeybind } from "../../../src/config/keybind"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 
 async function wait(fn: () => boolean, timeout = 2000) {
@@ -41,7 +41,7 @@ async function mountPrompt(input: {
     import("../../../src/ui/dialog-prompt"),
     import("../../../src/context/kv"),
     import("../../../src/context/theme"),
-    import("../../../src/config/v1"),
+    import("../../../src/config"),
     import("../../../src/ui/toast"),
     import("../../../src/keymap"),
   ])
@@ -51,7 +51,7 @@ async function mountPrompt(input: {
     const keymap = createDefaultOpenTuiKeymap(renderer)
     const resolvedConfig = createTuiResolvedConfig({
       keybinds: input.keybinds,
-      leader_timeout: 1000,
+      leader: { timeout: 1000 },
     })
     const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
     onCleanup(off)

+ 1 - 1
packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx

@@ -6,7 +6,7 @@ import type { JSX } from "solid-js"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { KVProvider } from "../../../src/context/kv"
 import { ThemeProvider } from "../../../src/context/theme"
-import { TuiConfigProvider } from "../../../src/config/v1"
+import { TuiConfigProvider } from "../../../src/config"
 import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
 import { TestTuiContexts } from "../../fixture/tui-environment"
 import {

+ 2 - 2
packages/tui/test/cli/tui/diff-viewer.test.tsx

@@ -7,9 +7,9 @@ import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition }
 import type { Session } from "@opencode-ai/sdk/v2"
 import { KVProvider } from "../../../src/context/kv"
 import { ThemeProvider } from "../../../src/context/theme"
-import { TuiConfigProvider } from "../../../src/config/v1"
+import { TuiConfigProvider } from "../../../src/config"
 import { SDKProvider } from "../../../src/context/sdk"
-import { TuiKeybind } from "../../../src/config/v1/keybind"
+import { TuiKeybind } from "../../../src/config/keybind"
 import { OpencodeKeymapProvider } from "../../../src/keymap"
 import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"

+ 1 - 1
packages/tui/test/cli/tui/form.test.tsx

@@ -10,7 +10,7 @@ import type { FormWithLocation } from "../../../src/context/data"
 import { KVProvider } from "../../../src/context/kv"
 import { SDKProvider } from "../../../src/context/sdk"
 import { ThemeProvider } from "../../../src/context/theme"
-import { TuiConfigProvider } from "../../../src/config/v1"
+import { TuiConfigProvider } from "../../../src/config"
 import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
 import { ToastProvider } from "../../../src/ui/toast"
 import { tmpdir } from "../../fixture/fixture"

+ 67 - 0
packages/tui/test/config-v2.test.tsx

@@ -0,0 +1,67 @@
+/** @jsxImportSource @opentui/solid */
+import { testRender } from "@opentui/solid"
+import { expect, test } from "bun:test"
+import {
+  resolve,
+  TuiConfigProvider,
+  useTuiConfig,
+  useTuiConfigService,
+  type Interface,
+} from "../src/config"
+
+test("resolves nested config and keybind defaults", () => {
+  const config = resolve(
+    {
+      keybinds: { leader: "ctrl+o" },
+      leader: { timeout: 500 },
+      scroll: { speed: 2, acceleration: true },
+      diffs: { view: "split" },
+    },
+    { terminalSuspend: true },
+  )
+
+  expect(config.leader.timeout).toBe(500)
+  expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
+  expect(config.scroll).toEqual({ speed: 2, acceleration: true })
+  expect(config.diffs).toEqual({ view: "split" })
+})
+
+test("provides config and its host interface", async () => {
+  const config = resolve({}, { terminalSuspend: true })
+  let current = {}
+  const service: Interface = {
+    get: async () => current,
+    update: async (update) => {
+      const draft: Record<string, any> = { ...current }
+      update(draft)
+      current = draft
+      return draft
+    },
+  }
+  let configService: Interface | undefined
+
+  function Consumer() {
+    const config = useTuiConfig()
+    configService = useTuiConfigService()
+    return <text>{`${config.mouse ? "mouse" : "none"} ${config.keybinds.get("leader")?.[0]?.key}`}</text>
+  }
+
+  const app = await testRender(() => (
+    <TuiConfigProvider config={config} service={service}>
+      <Consumer />
+    </TuiConfigProvider>
+  ))
+  try {
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("mouse ctrl+x")
+    if (!configService) throw new Error("Config service was not provided")
+    await configService.update((draft) => {
+      draft.mouse = false
+      draft.keybinds = { leader: "ctrl+o" }
+    })
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("none ctrl+o")
+  } finally {
+    app.renderer.destroy()
+  }
+})

+ 4 - 4
packages/tui/test/fixture/tui-runtime.ts

@@ -1,10 +1,10 @@
-import { resolve, type Info, type Resolved } from "../../src/config/v1"
-import { TuiKeybind } from "../../src/config/v1/keybind"
+import { resolve, type Info, type Resolved } from "../../src/config"
+import { TuiKeybind } from "../../src/config/keybind"
 
-type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
+type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader"> & {
   attention?: Partial<Resolved["attention"]>
   keybinds?: Partial<TuiKeybind.Keybinds>
-  leader_timeout?: number
+  leader?: { timeout?: number }
 }
 
 export function createTuiResolvedConfig(input: ResolvedInput = {}) {

+ 1 - 1
packages/tui/test/keymap.test.tsx

@@ -4,7 +4,7 @@ import { createBindingLookup } from "@opentui/keymap/extras"
 import { testRender, useRenderer } from "@opentui/solid"
 import { expect, test } from "bun:test"
 import { onCleanup } from "solid-js"
-import { TuiKeybind } from "../src/config/v1/keybind"
+import { TuiKeybind } from "../src/config/keybind"
 import {
   formatKeySequence,
   getOpencodeModeStack,