From 3568dd1b992814499bd361b2679c25e51fbcd65c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 12 Jul 2026 16:54:08 -0400 Subject: [PATCH] feat(tui): add managed config interface --- bun.lock | 1 + packages/cli/package.json | 1 + packages/cli/src/commands/handlers/default.ts | 9 +- packages/cli/src/config/config.ts | 109 +++++++++ packages/cli/src/config/index.ts | 1 + packages/cli/src/config/migrate.ts | 142 ++++++++++++ packages/cli/src/config/schema.ts | 5 + packages/cli/src/framework/runtime.ts | 15 +- packages/cli/src/index.ts | 2 + packages/cli/src/tui-config.ts | 24 -- packages/cli/test/config.test.ts | 125 ++++++++++ packages/cli/test/tui-config.test.ts | 25 -- packages/plugin/src/tui.ts | 35 ++- packages/tui/package.json | 4 +- packages/tui/src/app.tsx | 60 +++-- packages/tui/src/attention.ts | 2 +- .../tui/src/component/command-palette.tsx | 2 +- packages/tui/src/component/dialog-mcp.tsx | 2 +- .../component/dialog-project-copy-name.tsx | 2 +- .../dialog-workspace-file-changes.tsx | 2 +- .../tui/src/component/prompt/autocomplete.tsx | 2 +- packages/tui/src/component/prompt/index.tsx | 4 +- packages/tui/src/config/index.tsx | 216 ++++++++++++++++++ packages/tui/src/config/keybind.ts | 2 + packages/tui/src/config/v1/index.tsx | 1 + packages/tui/src/config/v1/keybind.ts | 2 - packages/tui/src/config/v2/index.ts | 97 -------- packages/tui/src/config/v2/keybind.ts | 6 - packages/tui/src/context/kv.tsx | 47 +++- packages/tui/src/context/theme.tsx | 6 +- .../src/feature-plugins/home/tips-view.tsx | 2 +- .../feature-plugins/system/diff-viewer.tsx | 3 +- packages/tui/src/keymap.tsx | 8 +- packages/tui/src/plugin/adapters.tsx | 2 +- packages/tui/src/plugin/command-shim.ts | 2 +- packages/tui/src/plugin/runtime.tsx | 2 +- packages/tui/src/routes/home.tsx | 11 +- packages/tui/src/routes/session/form.tsx | 2 +- packages/tui/src/routes/session/index.tsx | 10 +- .../tui/src/routes/session/permission.tsx | 7 +- packages/tui/src/routes/session/sidebar.tsx | 2 +- packages/tui/src/ui/dialog-prompt.tsx | 2 +- packages/tui/src/ui/dialog-select.tsx | 2 +- packages/tui/src/util/scroll.ts | 12 +- packages/tui/test/app-lifecycle.test.tsx | 5 +- .../tui/test/cli/tui/dialog-prompt.test.tsx | 6 +- .../cli/tui/diff-viewer-file-tree.test.tsx | 2 +- .../tui/test/cli/tui/diff-viewer.test.tsx | 4 +- packages/tui/test/cli/tui/form.test.tsx | 2 +- packages/tui/test/config-v2.test.tsx | 67 ++++++ packages/tui/test/fixture/tui-runtime.ts | 8 +- packages/tui/test/keymap.test.tsx | 2 +- 52 files changed, 848 insertions(+), 266 deletions(-) create mode 100644 packages/cli/src/config/config.ts create mode 100644 packages/cli/src/config/index.ts create mode 100644 packages/cli/src/config/migrate.ts create mode 100644 packages/cli/src/config/schema.ts delete mode 100644 packages/cli/src/tui-config.ts create mode 100644 packages/cli/test/config.test.ts delete mode 100644 packages/cli/test/tui-config.test.ts create mode 100644 packages/tui/src/config/index.tsx create mode 100644 packages/tui/src/config/keybind.ts delete mode 100644 packages/tui/src/config/v2/index.ts delete mode 100644 packages/tui/src/config/v2/keybind.ts create mode 100644 packages/tui/test/config-v2.test.tsx diff --git a/bun.lock b/bun.lock index 6627771aca..4eb9d477ed 100644 --- a/bun.lock +++ b/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:", diff --git a/packages/cli/package.json b/packages/cli/package.json index fbd12647e6..e5172df3c4 100644 --- a/packages/cli/package.json +++ b/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:", diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index d7b5921628..63b4fb122c 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/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 = diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts new file mode 100644 index 0000000000..d9f01b3fce --- /dev/null +++ b/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 + readonly update: (update: (draft: Draft) => void) => Effect.Effect +} + +export class Service extends Context.Service()("@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) => 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 }] +} diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts new file mode 100644 index 0000000000..60e39c3163 --- /dev/null +++ b/packages/cli/src/config/index.ts @@ -0,0 +1 @@ +export * as Config from "./config" diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts new file mode 100644 index 0000000000..bf57fdb317 --- /dev/null +++ b/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): 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)) +}) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts new file mode 100644 index 0000000000..3b83db86d7 --- /dev/null +++ b/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 diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 2108da415d..cffd80e772 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/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 extends Spec.Node @@ -13,18 +14,26 @@ export type Input = type RuntimeHandler = ( input: unknown, -) => Effect.Effect +) => Effect.Effect< + void, + unknown, + FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope +> type Loader = () => Promise<{ default: ( input: Input, - ) => Effect.Effect + ) => 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 = keyof Node["commands"] extends never diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 75f798b0eb..2128495526 100755 --- a/packages/cli/src/index.ts +++ b/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), diff --git a/packages/cli/src/tui-config.ts b/packages/cli/src/tui-config.ts deleted file mode 100644 index 1b838917bb..0000000000 --- a/packages/cli/src/tui-config.ts +++ /dev/null @@ -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" }, - ) -}) diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts new file mode 100644 index 0000000000..3c3dd45721 --- /dev/null +++ b/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(directory: string, effect: Effect.Effect) { + 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}` + } +}) diff --git a/packages/cli/test/tui-config.test.ts b/packages/cli/test/tui-config.test.ts deleted file mode 100644 index a9656c4fae..0000000000 --- a/packages/cli/test/tui-config.test.ts +++ /dev/null @@ -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("n") - } finally { - await Bun.$`rm -rf ${directory}` - } -}) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 101e12a4f1..1608deb5ba 100644 --- a/packages/plugin/src/tui.ts +++ b/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> } -type TuiConfigView = Pick & - NonNullable & { - leader_timeout: number - attention: TuiAttentionConfigView - plugin_enabled?: Record - keybinds: TuiBindingLookupView +type TuiConfigView = { + theme?: { + name?: string + mode?: "system" | "dark" | "light" } + plugins?: ReadonlyArray }> + 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 diff --git a/packages/tui/package.json b/packages/tui/package.json index 9c16e0a731..8b445f6bd0 100644 --- a/packages/tui/package.json +++ b/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", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 61144859a9..5ce5a7b57d 100644 --- a/packages/tui/src/app.tsx +++ b/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 } args: Args - config: TuiConfig.Resolved + config: TuiConfig.Interface onSnapshot?: () => Promise 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) { - - - - + + + + - - - - + + + + @@ -1004,17 +1009,6 @@ function App(props: { onSnapshot?: () => Promise; 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: diff --git a/packages/tui/src/attention.ts b/packages/tui/src/attention.ts index 3fea75e5ff..b309edcd9d 100644 --- a/packages/tui/src/attention.ts +++ b/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" diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index a113419653..3dd6829c54 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/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[number] diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 34afc6fa48..0ef74bd551 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/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" diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx index 59dcc4a481..7ac357e1c8 100644 --- a/packages/tui/src/component/dialog-project-copy-name.tsx +++ b/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" diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx index 231f6c4648..bca389c368 100644 --- a/packages/tui/src/component/dialog-workspace-file-changes.tsx +++ b/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" diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 0a58892aef..91b4bbdb42 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/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" diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index d235d8cf42..d5f5a9196f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/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 ( diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx new file mode 100644 index 0000000000..2cfcd41359 --- /dev/null +++ b/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 + readonly update: (update: (draft: any) => void) => Promise +} + +export const AttentionSoundName = Schema.Literals([ + "default", + "question", + "permission", + "error", + "done", + "subagent_done", +]) +export type AttentionSoundName = Schema.Schema.Type +export type AttentionSoundPaths = Partial> + +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 + +export type Resolved = Omit & { + 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 {props.children} +} + +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 +} diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts new file mode 100644 index 0000000000..475f5120f8 --- /dev/null +++ b/packages/tui/src/config/keybind.ts @@ -0,0 +1,2 @@ +export * from "./v1/keybind" +export * as TuiKeybind from "./v1/keybind" diff --git a/packages/tui/src/config/v1/index.tsx b/packages/tui/src/config/v1/index.tsx index df9239763a..dfa388c3bf 100644 --- a/packages/tui/src/config/v1/index.tsx +++ b/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" diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 4b108f5303..57b414e65c 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/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", diff --git a/packages/tui/src/config/v2/index.ts b/packages/tui/src/config/v2/index.ts deleted file mode 100644 index 23ebc01e56..0000000000 --- a/packages/tui/src/config/v2/index.ts +++ /dev/null @@ -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 diff --git a/packages/tui/src/config/v2/keybind.ts b/packages/tui/src/config/v2/keybind.ts deleted file mode 100644 index 5dcd6d4f67..0000000000 --- a/packages/tui/src/config/v2/keybind.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * as TuiKeybind from "./keybind" - -import { Schema } from "effect" - -export const KeybindOverrides = Schema.Struct({}) -export type KeybindOverrides = Schema.Schema.Type diff --git a/packages/tui/src/context/kv.tsx b/packages/tui/src/context/kv.tsx index 7b90c95f59..ae7face0cd 100644 --- a/packages/tui/src/context/kv.tsx +++ b/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>(file)) .then((x) => { - setStore(x) + const values: Record = { ...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 = {} + 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 +} diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 79456b8498..4a9008b2b5 100644 --- a/packages/tui/src/context/theme.tsx +++ b/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) }) diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 867d292350..a8d78beccf 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/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())})` diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index d5e1e56e7b..68be440dcb 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -144,7 +144,8 @@ function DiffViewer(props: { api: TuiPluginApi }) { const patchLeftBorder = createMemo(() => (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(storedView(props.api.kv.get(KV_VIEW))) diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx index e4dc694935..20376c5e6e 100644 --- a/packages/tui/src/keymap.tsx +++ b/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[] } type FormatConfig = { keybinds: BindingLookup } -type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number } +type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { leader_timeout: number }) const modeStacks = new WeakMap() @@ -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) diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index 088557e931..b97f7b9b73 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/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" diff --git a/packages/tui/src/plugin/command-shim.ts b/packages/tui/src/plugin/command-shim.ts index d909f328cb..61eb833fe7 100644 --- a/packages/tui/src/plugin/command-shim.ts +++ b/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" diff --git a/packages/tui/src/plugin/runtime.tsx b/packages/tui/src/plugin/runtime.tsx index ea2d88d84b..4130ac9be7 100644 --- a/packages/tui/src/plugin/runtime.tsx +++ b/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" diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index e6fc75dfb3..623772fcb6 100644 --- a/packages/tui/src/routes/home.tsx +++ b/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() { - + { - 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" }) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 51553a97d9..de592ecccf 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/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" }) diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index b1f7744b9a..d2ebcd2851 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/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" diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index 0898b0ae42..f518fb2950 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/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 = { diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index ef13bc1d50..28235531e2 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/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 { diff --git a/packages/tui/src/util/scroll.ts b/packages/tui/src/util/scroll.ts index 44645932df..758e18ae88 100644 --- a/packages/tui/src/util/scroll.ts +++ b/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) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 48d4a48aba..3827333856 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/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: { diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index 160f660e38..4b185fdef1 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/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) diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index 3c6fc8a84a..2a5a172f9c 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/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 { diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 3b77f7ed21..c882e67f14 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/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" diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index e73ef9973d..b447e3ab43 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/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" diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx new file mode 100644 index 0000000000..c2d4417e8e --- /dev/null +++ b/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 = { ...current } + update(draft) + current = draft + return draft + }, + } + let configService: Interface | undefined + + function Consumer() { + const config = useTuiConfig() + configService = useTuiConfigService() + return {`${config.mouse ? "mouse" : "none"} ${config.keybinds.get("leader")?.[0]?.key}`} + } + + const app = await testRender(() => ( + + + + )) + 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() + } +}) diff --git a/packages/tui/test/fixture/tui-runtime.ts b/packages/tui/test/fixture/tui-runtime.ts index 028396c57d..26a5fc8e4f 100644 --- a/packages/tui/test/fixture/tui-runtime.ts +++ b/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 & { +type ResolvedInput = Omit & { attention?: Partial keybinds?: Partial - leader_timeout?: number + leader?: { timeout?: number } } export function createTuiResolvedConfig(input: ResolvedInput = {}) { diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index 70fbd6e6b2..fb3b685dbd 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/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,