From 79c4c92f6331edda240f56987427dddd51f1deab Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 15 Jul 2026 14:25:10 +0200 Subject: [PATCH 01/16] refactor(tui): use command IDs for v2 keybinds --- packages/cli/src/config/migrate.ts | 12 +- packages/cli/test/config.test.ts | 24 +- packages/tui/src/config/index.tsx | 9 +- packages/tui/src/config/keybind.ts | 294 +++++++++++++++++- packages/tui/src/plugin/command-shim.ts | 2 +- .../tui/test/cli/tui/dialog-prompt.test.tsx | 6 +- .../tui/test/cli/tui/diff-viewer.test.tsx | 4 +- packages/tui/test/config-v2.test.tsx | 13 + packages/tui/test/keymap.test.tsx | 4 +- 9 files changed, 349 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index ca8070e25c5e..798488f6df82 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -1,6 +1,7 @@ export * as ConfigMigration from "./migrate" import { TuiConfigV1 } from "@opencode-ai/tui/config/v1" +import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind" import { Effect, FileSystem, Option, Schema } from "effect" import { parse, type ParseError } from "jsonc-parser" import path from "path" @@ -50,12 +51,21 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record [ + TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name, + value, + ]), + ) return { ...(themeName !== undefined || themeMode !== undefined ? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } } : {}), - ...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }), + ...(keybinds === undefined ? {} : { keybinds }), ...(plugins.length ? { plugins } : {}), ...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }), ...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index a0e32c4aa899..1f70b730a5f1 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -21,7 +21,13 @@ test("migrates tui and kv config into cli.json", async () => { path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy", - keybinds: { leader: "ctrl+o" }, + keybinds: { + leader: "ctrl+o", + app_exit: "ctrl+q", + input_paste: { key: "ctrl+v", preventDefault: false }, + session_delete: false, + "dialog.select.next": "ctrl+n", + }, plugin: [["example", { mode: "safe" }]], plugin_enabled: { disabled: false }, leader_timeout: 500, @@ -65,7 +71,13 @@ test("migrates tui and kv config into cli.json", async () => { expect(config).toMatchObject({ theme: { name: "legacy", mode: "light" }, - keybinds: { leader: "ctrl+o" }, + keybinds: { + leader: "ctrl+o", + "app.exit": "ctrl+q", + "prompt.paste": { key: "ctrl+v", preventDefault: false }, + "session.delete": false, + "dialog.select.next": "ctrl+n", + }, plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"], leader: { timeout: 500 }, scroll: { speed: 2, acceleration: true }, @@ -80,7 +92,13 @@ test("migrates tui and kv config into cli.json", async () => { }) expect(config).not.toHaveProperty("skipped_version") expect(config).not.toHaveProperty("which_key") - expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" }) + expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ + leader: "ctrl+o", + "app.exit": "ctrl+q", + "prompt.paste": { key: "ctrl+v", preventDefault: false }, + "session.delete": false, + "dialog.select.next": "ctrl+n", + }) 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) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 424e8dbc9a8f..8b177ab43a0a 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -184,10 +184,10 @@ export type Resolved = Omit 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(",") : [])] + 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(",") } @@ -204,7 +204,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res sounds: input.attention?.sounds ?? {}, }, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), { - commandMap: TuiKeybind.CommandMap, bindingDefaults: TuiKeybind.bindingDefaults(), }), leader: { timeout: input.leader?.timeout ?? 2000 }, diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 475f5120f883..711e7e769a02 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -1,2 +1,292 @@ -export * from "./v1/keybind" -export * as TuiKeybind from "./v1/keybind" +export * as TuiKeybind from "./keybind" + +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { BindingConfig, BindingDefaults } from "@opentui/keymap/extras" +import { Schema } from "effect" + +const KeyStroke = Schema.Struct({ + name: Schema.String, + ctrl: Schema.optional(Schema.Boolean), + shift: Schema.optional(Schema.Boolean), + meta: Schema.optional(Schema.Boolean), + super: Schema.optional(Schema.Boolean), + hyper: Schema.optional(Schema.Boolean), +}) + +const BindingObject = Schema.StructWithRest( + Schema.Struct({ + key: Schema.Union([Schema.String, KeyStroke]), + event: Schema.optional(Schema.Literals(["press", "release"])), + preventDefault: Schema.optional(Schema.Boolean), + fallthrough: Schema.optional(Schema.Boolean), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) + +const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject]) +export const BindingValueSchema = Schema.Union([ + Schema.Literal(false), + Schema.Literal("none"), + BindingItem, + Schema.Array(BindingItem), +]) +export type BindingValueSchema = Schema.Schema.Type + +type Definition = { + default: BindingValueSchema + description: string +} + +export const LeaderDefault = "ctrl+x" + +const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description }) + +export const Definitions = { + leader: keybind(LeaderDefault, "Leader key for keybind combinations"), + + "app.exit": keybind("ctrl+c,ctrl+d,q", "Exit the application"), + "app.debug": keybind("none", "Toggle debug panel"), + "app.console": keybind("none", "Toggle console"), + "app.heap_snapshot": keybind("none", "Write heap snapshot"), + "app.toggle.animations": keybind("none", "Toggle animations"), + "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"), + "command.palette.show": keybind("ctrl+p", "List available commands"), + "help.show": keybind("none", "Open help dialog"), + "docs.open": keybind("none", "Open documentation"), + "diff.open": keybind("none", "Open diff viewer"), + "diff.close": keybind("escape,q", "Close diff viewer"), + "diff.toggle": keybind("enter,space", "Toggle diff viewer item"), + "diff.expand": keybind("right", "Expand diff viewer item"), + "diff.expand_all": keybind("E", "Expand all diff viewer folders"), + "diff.collapse": keybind("left", "Collapse diff viewer item"), + "diff.switch_focus": keybind("tab", "Switch diff viewer focus"), + "diff.next_hunk": keybind("]", "Jump to next diff hunk"), + "diff.previous_hunk": keybind("[", "Jump to previous diff hunk"), + "diff.next_file": keybind("n", "Jump to next diff file"), + "diff.previous_file": keybind("p", "Jump to previous diff file"), + "diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"), + "diff.single_patch": keybind("s", "Toggle single patch view"), + "diff.switch_source": keybind("d", "Switch diff viewer source"), + "diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"), + "diff.help": keybind("?", "Show more diff viewer shortcuts"), + + "prompt.editor": keybind("e", "Open external editor"), + "theme.switch": keybind("t", "List available themes"), + "theme.switch_mode": keybind("none", "Switch between light and dark theme mode"), + "theme.mode.lock": keybind("none", "Lock or unlock theme mode"), + "session.sidebar.toggle": keybind("b", "Toggle sidebar"), + "session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"), + "opencode.status": keybind("s", "View status"), + "opencode.debug": keybind("none", "View debug info"), + + "session.export": keybind("x", "Export session to editor"), + "session.copy": keybind("none", "Copy session transcript"), + "session.move": keybind("none", "Move session"), + "session.new": keybind("n", "Create a new session"), + "session.list": keybind("l", "List all sessions"), + "session.timeline": keybind("g", "Show session timeline"), + "session.fork": keybind("none", "Fork session from message"), + "session.rename": keybind("ctrl+r", "Rename session"), + "session.delete": keybind("ctrl+d", "Delete session"), + "session.share": keybind("none", "Share current session"), + "session.unshare": keybind("none", "Unshare current session"), + "session.interrupt": keybind("escape", "Interrupt current session"), + "session.background": keybind("ctrl+b", "Background blocking session tools"), + "session.compact": keybind("c", "Compact the session"), + "session.queued_prompts": keybind("q", "Manage queued prompts"), + "session.child.first": keybind("down,down", "Toggle subagent picker"), + "session.child.next": keybind("right", "Go to next child session"), + "session.child.previous": keybind("left", "Go to previous child session"), + "session.parent": keybind("up", "Go to parent session"), + "session.pin.toggle": keybind("ctrl+f", "Pin or unpin session in the session list"), + "session.quick_switch.1": keybind("1", "Switch to session in quick slot 1"), + "session.quick_switch.2": keybind("2", "Switch to session in quick slot 2"), + "session.quick_switch.3": keybind("3", "Switch to session in quick slot 3"), + "session.quick_switch.4": keybind("4", "Switch to session in quick slot 4"), + "session.quick_switch.5": keybind("5", "Switch to session in quick slot 5"), + "session.quick_switch.6": keybind("6", "Switch to session in quick slot 6"), + "session.quick_switch.7": keybind("7", "Switch to session in quick slot 7"), + "session.quick_switch.8": keybind("8", "Switch to session in quick slot 8"), + "session.quick_switch.9": keybind("9", "Switch to session in quick slot 9"), + + "stash.delete": keybind("ctrl+d", "Delete stash entry"), + "model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"), + "model.dialog.favorite": keybind("ctrl+f", "Toggle model favorite status"), + "model.list": keybind("m", "List available models"), + "model.cycle_recent": keybind("f2", "Next recently used model"), + "model.cycle_recent_reverse": keybind("shift+f2", "Previous recently used model"), + "model.cycle_favorite": keybind("none", "Next favorite model"), + "model.cycle_favorite_reverse": keybind("none", "Previous favorite model"), + "mcp.list": keybind("none", "List MCP servers"), + "provider.connect": keybind("none", "Connect integration"), + "agent.list": keybind("a", "List agents"), + "agent.cycle": keybind("tab", "Next agent"), + "agent.cycle.reverse": keybind("shift+tab", "Previous agent"), + "variant.cycle": keybind("ctrl+t", "Cycle model variants"), + "variant.list": keybind("none", "List model variants"), + + "session.page.up": keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), + "session.page.down": keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), + "session.line.up": keybind("ctrl+alt+y", "Scroll messages up by one line"), + "session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"), + "session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"), + "session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"), + "session.first": keybind("ctrl+g,home", "Navigate to first message"), + "session.last": keybind("ctrl+alt+g,end", "Navigate to last message"), + "session.message.next": keybind("none", "Navigate to next message"), + "session.message.previous": keybind("none", "Navigate to previous message"), + "session.messages_last_user": keybind("none", "Navigate to last user message"), + "messages.copy": keybind("y", "Copy message"), + "session.undo": keybind("u", "Undo message"), + "session.redo": keybind("r", "Redo message"), + "session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"), + + "prompt.submit": keybind("none", "Submit prompt"), + "prompt.editor_context.clear": keybind("none", "Clear editor context"), + "prompt.skills": keybind("none", "Open skill selector"), + "prompt.stash": keybind("none", "Stash prompt"), + "prompt.stash.pop": keybind("none", "Pop stashed prompt"), + "prompt.stash.list": keybind("none", "List stashed prompts"), + + "prompt.clear": keybind("ctrl+c", "Clear input field"), + "prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), + "input.submit": keybind("return", "Submit input"), + "input.newline": keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), + "input.move.left": keybind("left,ctrl+b", "Move cursor left in input"), + "input.move.right": keybind("right,ctrl+f", "Move cursor right in input"), + "input.move.up": keybind("up", "Move cursor up in input"), + "input.move.down": keybind("down", "Move cursor down in input"), + "input.select.left": keybind("shift+left", "Select left in input"), + "input.select.right": keybind("shift+right", "Select right in input"), + "input.select.up": keybind("shift+up", "Select up in input"), + "input.select.down": keybind("shift+down", "Select down in input"), + "input.line.home": keybind("ctrl+a", "Move to start of line in input"), + "input.line.end": keybind("ctrl+e", "Move to end of line in input"), + "input.select.line.home": keybind("ctrl+shift+a", "Select to start of line in input"), + "input.select.line.end": keybind("ctrl+shift+e", "Select to end of line in input"), + "input.visual.line.home": keybind("alt+a", "Move to start of visual line in input"), + "input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"), + "input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"), + "input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"), + "input.buffer.home": keybind("home", "Move to start of buffer in input"), + "input.buffer.end": keybind("end", "Move to end of buffer in input"), + "input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"), + "input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"), + "input.delete.line": keybind("ctrl+shift+d", "Delete line in input"), + "input.delete.to.line.end": keybind("ctrl+k", "Delete to end of line in input"), + "input.delete.to.line.start": keybind("ctrl+u", "Delete to start of line in input"), + "input.backspace": keybind("backspace,shift+backspace", "Backspace in input"), + "input.delete": keybind("ctrl+d,delete,shift+delete", "Delete character in input"), + "input.undo": keybind("ctrl+-,super+z", "Undo in input"), + "input.redo": keybind("ctrl+.,super+shift+z", "Redo in input"), + "input.word.forward": keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), + "input.word.backward": keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), + "input.select.word.forward": keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), + "input.select.word.backward": keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), + "input.delete.word.forward": keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), + "input.delete.word.backward": keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), + "input.select.all": keybind("super+a", "Select all in input"), + "prompt.history.previous": keybind("up", "Previous history item"), + "prompt.history.next": keybind("down", "Next history item"), + + "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), + "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), + "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), + "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), + "dialog.select.home": keybind("home", "Move to first dialog item"), + "dialog.select.end": keybind("end", "Move to last dialog item"), + "dialog.select.submit": keybind("return", "Submit selected dialog item"), + "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), + "dialog.project_copy.generate": keybind("tab", "Generate project copy name"), + "dialog.move_session.new": keybind("ctrl+m", "New project copy"), + "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), + "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"), + "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), + "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), + "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), + "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), + "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), + "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), + "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), + + "terminal.suspend": keybind("ctrl+z", "Suspend terminal"), + "terminal.title.toggle": keybind("none", "Toggle terminal title"), + "plugins.list": keybind("none", "Open plugin manager dialog"), + "plugins.install": keybind("none", "Install plugin"), + + "which-key.toggle": keybind("ctrl+alt+k", "Toggle which-key panel"), + "which-key.layout.toggle": keybind("ctrl+alt+shift+k", "Switch which-key layout"), + "which-key.pending.toggle": keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"), + "which-key.group.previous": keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"), + "which-key.group.next": keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"), + "which-key.scroll.up": keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"), + "which-key.scroll.down": keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"), + "which-key.page.up": keybind("ctrl+alt+pageup", "Page which-key up"), + "which-key.page.down": keybind("ctrl+alt+pagedown", "Page which-key down"), + "which-key.home": keybind("ctrl+alt+home", "Jump to first which-key binding"), + "which-key.end": keybind("ctrl+alt+end", "Jump to last which-key binding"), +} satisfies Record + +type KeybindName = keyof typeof Definitions +const KeybindNames = new Set(Object.keys(Definitions)) + +export const KeybindOverrides = Schema.Struct( + Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + Schema.optional(BindingValueSchema).annotate({ description: item.description }), + ]), + ), +).annotate({ description: "TUI keybinding overrides" }) +export const Descriptions = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [name, item.description]), +) as Record + +export type Keybinds = { [K in KeybindName]: BindingValueSchema } +export type KeybindOverrides = Partial +export type BindingLookupView = { + readonly bindings: readonly Binding[] + get(command: string): readonly Binding[] + has(command: string): boolean + gather(name: string, commands: readonly string[]): readonly Binding[] + pick(name: string, commands: readonly string[]): Binding[] + omit(name: string, commands: readonly string[]): Binding[] +} + +export function toBindingConfig(keybinds: Keybinds): BindingConfig { + return Object.fromEntries(Object.entries(keybinds)) as BindingConfig +} + +const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema) + +export function defaultValue(name: KeybindName) { + return Definitions[name].default +} + +export function parse(keybinds: KeybindOverrides): Keybinds { + const invalid = unknownKeys(keybinds) + if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`) + return Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + decodeBindingValue(keybinds[name as KeybindName] ?? item.default), + ]), + ) as Keybinds +} + +export const Keybinds = { parse } + +export function unknownKeys(input: object) { + return Object.keys(input).filter((key) => !KeybindNames.has(key)) +} + +export function bindingDefaults(): BindingDefaults { + return ({ command, binding }) => { + if (binding.desc !== undefined) return + return { desc: Descriptions[command as KeybindName] } + } +} diff --git a/packages/tui/src/plugin/command-shim.ts b/packages/tui/src/plugin/command-shim.ts index 36e7548fcfac..9a4355d3e71e 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/keybind" +import { TuiKeybind } from "../config/v1/keybind" import type { DialogContext } from "../ui/dialog" const COMMAND_PALETTE_SHOW = "command.palette.show" diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index b2716d05598b..5a3b9c05a310 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -87,8 +87,8 @@ test("dialog prompt submit wins when return is also input newline", async () => const prompt = await mountPrompt({ root: tmp.path, keybinds: { - input_submit: "super+return", - input_newline: "return,shift+return,alt+return,ctrl+j", + "input.submit": "super+return", + "input.newline": "return,shift+return,alt+return,ctrl+j", }, onConfirm: (value) => confirmed.push(value), }) @@ -113,7 +113,7 @@ test("dialog prompt submit can be rebound separately from input submit", async ( const prompt = await mountPrompt({ root: tmp.path, keybinds: { - input_submit: "return", + "input.submit": "return", "dialog.prompt.submit": "ctrl+y", }, onConfirm: (value) => confirmed.push(value), diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index f09e49aa0a9c..8e3614635436 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -106,8 +106,8 @@ test("brackets navigate diff hunks", async () => { const scroll = findScrollBox(viewer.app.renderer.root)! const initial = scroll.scrollTop - expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]") - expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[") + expect(TuiKeybind.defaultValue("diff.next_hunk")).toBe("]") + expect(TuiKeybind.defaultValue("diff.previous_hunk")).toBe("[") viewer.commands.get("diff.next_hunk")!.run() await viewer.app.renderOnce() diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index c349ea522430..832f4299e1a1 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -9,6 +9,7 @@ import { useConfig, type Interface, } from "../src/config" +import { TuiKeybind } from "../src/config/keybind" test("validates mini replay settings", () => { const decode = Schema.decodeUnknownSync(Info) @@ -39,6 +40,18 @@ test("resolves nested config and keybind defaults", () => { expect(config.debug).toEqual({ devtools: true }) }) +test("uses command IDs as keybind keys", () => { + const config = resolve({ keybinds: { "session.list": "ctrl+l" } }, { terminalSuspend: true }) + + expect(config.keybinds.get("session.list")).toMatchObject([{ key: "ctrl+l" }]) + expect(TuiKeybind.unknownKeys({ session_list: "ctrl+l" })).toEqual(["session_list"]) + expect( + Object.keys(TuiKeybind.Definitions) + .filter((key) => key !== "leader") + .every((key) => key.includes(".")), + ).toBe(true) +}) + test("provides config and its host interface", async () => { const config = resolve({}, { terminalSuspend: true }) let current = {} diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index 3885914ebc1c..3021445b76c5 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -27,8 +27,8 @@ test("legacy page key aliases compile as page keys", async () => { From 528423f21b8269db42e578df3f7f1dc991128c1f Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 15 Jul 2026 17:37:56 +0200 Subject: [PATCH 02/16] fix(tui): complete v2 keybind alignment --- packages/cli/src/commands/handlers/mini.ts | 2 +- packages/cli/src/config/migrate.ts | 82 +++++++++++++++++-- packages/cli/test/config.test.ts | 82 +++++++++++++++++++ packages/tui/src/app.tsx | 2 + packages/tui/src/config/keybind.ts | 21 ++++- .../feature-plugins/system/diff-viewer.tsx | 5 -- packages/tui/src/mini/footer.view.tsx | 5 +- .../src/routes/session/composer/shell-tab.tsx | 3 - .../routes/session/composer/subagents-tab.tsx | 4 - packages/tui/src/ui/dialog-prompt.tsx | 1 - .../tui/test/cli/tui/dialog-prompt.test.tsx | 26 ++++++ packages/tui/test/config-v2.test.tsx | 71 ++++++++++++++-- packages/tui/test/mini/footer-keymap.test.tsx | 18 +++- 13 files changed, 291 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 972c3bf962aa..1d56fa2eb178 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -1,8 +1,8 @@ import { Context, Effect, FileSystem, Option } from "effect" import { Commands } from "../commands" +import { Config } from "../../config" import { Runtime } from "../../framework/runtime" import { ServerConnection } from "../../services/server-connection" -import { Config } from "../../config" import { resolve } from "@opencode-ai/tui/config" export default Runtime.handler(Commands.commands.mini, (input) => diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index 798488f6df82..d2397f284500 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -2,13 +2,22 @@ export * as ConfigMigration from "./migrate" import { TuiConfigV1 } from "@opencode-ai/tui/config/v1" import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind" +import { Definitions } from "@opencode-ai/tui/config/keybind" import { Effect, FileSystem, Option, Schema } from "effect" -import { parse, type ParseError } from "jsonc-parser" +import { + createScanner, + findNodeAtLocation, + parse, + parseTree, + type Node, + 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)) +const LegacyKeybindTargets = new Set(Object.values(TuiKeybind.CommandMap)) export const run = Effect.fn("cli.config.migrate")(function* (input: { readonly file: string @@ -16,7 +25,36 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { readonly state: string }) { const fs = yield* FileSystem.FileSystem - if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return + if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) { + const text = yield* fs.readFileString(input.file) + const errors: ParseError[] = [] + const value: any = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return + const config = Option.getOrUndefined(decodeRecord(value)) + if (config === undefined) return + const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds)) + if (keybinds === undefined) return + const updated = Object.keys(keybinds).reduce((text, name) => { + const target = + TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? + (LegacyKeybindTargets.has(name) ? name : undefined) + if (target === undefined) return text + if (target === name && target in Definitions) return text + const tree = parseTree(text) + if (tree === undefined) return text + const property = findNodeAtLocation(tree, ["keybinds", name])?.parent + if (property === undefined) return text + if (!(target in Definitions) || (target !== name && target in keybinds)) return removeProperty(text, property) + const key = property.children?.[0] + if (key === undefined) return text + return text.slice(0, key.offset) + JSON.stringify(target) + text.slice(key.offset + key.length) + }, text) + if (updated === text) return + const temp = input.file + ".tmp" + yield* fs.writeFileString(temp, updated, { mode: 0o600 }) + yield* fs.rename(temp, input.file) + return + } const legacyValue = yield* readJson(path.join(input.config, "tui.json")) const legacy = Option.getOrUndefined(decodeV1(legacyValue)) @@ -37,6 +75,37 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { }) }) +function removeProperty(text: string, property: Node) { + const properties = property.parent?.children ?? [] + const index = properties.indexOf(property) + const end = property.offset + property.length + const next = properties[index + 1] + if (next) { + const comma = findComma(text, end, next.offset) + if (comma !== undefined) return text.slice(0, property.offset) + text.slice(comma + 1) + } + const previous = properties[index - 1] + if (previous) { + const comma = findComma(text, previous.offset + previous.length, property.offset) + if (comma !== undefined) + return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end) + } + const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0)) + if (comma !== undefined) return text.slice(0, property.offset) + text.slice(comma + 1) + return text.slice(0, property.offset) + text.slice(end) +} + +function findComma(text: string, start: number, end: number) { + const scanner = createScanner(text, false) + scanner.setPosition(start) + while (true) { + scanner.scan() + const offset = scanner.getTokenOffset() + if (scanner.getTokenLength() === 0 || offset >= end) return + if (text[offset] === ",") return offset + } +} + export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record): Info { const plugins = [ ...(legacy?.plugin?.map((plugin) => @@ -55,10 +124,11 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record [ - TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name, - value, - ]), + Object.entries(legacy.keybinds).flatMap(([name, value]) => { + const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name + if (!(target in Definitions)) return [] + return [[target, value]] + }), ) return { diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 1f70b730a5f1..437f77222c2d 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/util/global" import { Effect } from "effect" import { expect, test } from "bun:test" +import { parse } from "jsonc-parser" import path from "path" import { Config } from "../src/config" @@ -24,6 +25,7 @@ test("migrates tui and kv config into cli.json", async () => { keybinds: { leader: "ctrl+o", app_exit: "ctrl+q", + app_heap_snapshot: "ctrl+h", input_paste: { key: "ctrl+v", preventDefault: false }, session_delete: false, "dialog.select.next": "ctrl+n", @@ -138,6 +140,86 @@ test("migrates before the first update and does not remigrate afterward", async } }) +test("migrates legacy keybind names in an existing cli.json", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const file = path.join(directory, "cli.json") + await Bun.write( + file, + `{ + // Preserve this comment + "keybinds": { + // Session list shortcut + "session_list": "ctrl+l", + "app_heap_snapshot": "ctrl+h", + // Legacy delete shortcut + "session_delete": "ctrl+d", + // Canonical delete shortcut + "session.delete": "ctrl+x", + "app.heap_snapshot": "ctrl+shift+h" + } +} +`, + ) + + try { + const config = await run( + directory, + Effect.gen(function* () { + const service = yield* Config.Service + return yield* service.get() + }), + ) + + expect(config.keybinds).toEqual({ + "session.list": "ctrl+l", + "session.delete": "ctrl+x", + }) + const text = await Bun.file(file).text() + expect(text).toContain("// Preserve this comment") + expect(text).toContain("// Session list shortcut") + expect(text).toContain("// Legacy delete shortcut") + expect(text).toContain("// Canonical delete shortcut") + expect(parse(text).keybinds).toEqual({ + "session.list": "ctrl+l", + "session.delete": "ctrl+x", + }) + } finally { + await Bun.$`rm -rf ${directory}` + } +}) + +test("removes a sole orphaned keybind with a trailing comma", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const file = path.join(directory, "cli.json") + await Bun.write( + file, + `{ + "keybinds": { + // Removed v2 command + "app.heap_snapshot": "ctrl+h", + }, +} +`, + ) + + try { + const config = await run( + directory, + Effect.gen(function* () { + const service = yield* Config.Service + return yield* service.get() + }), + ) + + expect(config.keybinds).toEqual({}) + const text = await Bun.file(file).text() + expect(text).toContain("// Removed v2 command") + expect(parse(text).keybinds).toEqual({}) + } 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") diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 91b286e7986d..0a8a84aa57f0 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -119,6 +119,7 @@ const appBindingCommands = [ "variant.cycle", "variant.list", "provider.connect", + "opencode.settings", "opencode.status", "server.pair", "service.restart", @@ -137,6 +138,7 @@ const appBindingCommands = [ "app.toggle.file_context", "app.toggle.diffwrap", "app.toggle.paste_summary", + "permission.mode", ] as const export type TuiInput = { diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 711e7e769a02..13c9c186187f 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -48,7 +48,7 @@ export const Definitions = { "app.exit": keybind("ctrl+c,ctrl+d,q", "Exit the application"), "app.debug": keybind("none", "Toggle debug panel"), "app.console": keybind("none", "Toggle console"), - "app.heap_snapshot": keybind("none", "Write heap snapshot"), + "app.scrap": keybind("none", "Open scrap screen"), "app.toggle.animations": keybind("none", "Toggle animations"), "app.toggle.file_context": keybind("none", "Toggle file context"), "app.toggle.diffwrap": keybind("none", "Toggle diff wrapping"), @@ -56,8 +56,16 @@ export const Definitions = { "command.palette.show": keybind("ctrl+p", "List available commands"), "help.show": keybind("none", "Open help dialog"), "docs.open": keybind("none", "Open documentation"), + "opencode.settings": keybind("none", "Open settings"), + "server.pair": keybind("none", "Pair device"), + "service.restart": keybind("none", "Restart service"), + "permission.mode": keybind("none", "Toggle auto-approve permissions"), "diff.open": keybind("none", "Open diff viewer"), "diff.close": keybind("escape,q", "Close diff viewer"), + "diff.down": keybind("j,down", "Move diff viewer down"), + "diff.up": keybind("k,up", "Move diff viewer up"), + "diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"), + "diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"), "diff.toggle": keybind("enter,space", "Toggle diff viewer item"), "diff.expand": keybind("right", "Expand diff viewer item"), "diff.expand_all": keybind("E", "Expand all diff viewer folders"), @@ -71,6 +79,7 @@ export const Definitions = { "diff.single_patch": keybind("s", "Toggle single patch view"), "diff.switch_source": keybind("d", "Switch diff viewer source"), "diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"), + "diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"), "diff.help": keybind("?", "Show more diff viewer shortcuts"), "prompt.editor": keybind("e", "Open external editor"), @@ -96,7 +105,9 @@ export const Definitions = { "session.interrupt": keybind("escape", "Interrupt current session"), "session.background": keybind("ctrl+b", "Background blocking session tools"), "session.compact": keybind("c", "Compact the session"), + "session.cd": keybind("none", "Change working directory"), "session.queued_prompts": keybind("q", "Manage queued prompts"), + "session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"), "session.child.first": keybind("down,down", "Toggle subagent picker"), "session.child.next": keybind("right", "Go to next child session"), "session.child.previous": keybind("left", "Go to previous child session"), @@ -192,6 +203,14 @@ export const Definitions = { "prompt.history.previous": keybind("up", "Previous history item"), "prompt.history.next": keybind("down", "Next history item"), + "composer.subagent.up": keybind("up", "Previous subagent"), + "composer.subagent.down": keybind("down", "Next subagent"), + "composer.subagent.select": keybind("return", "Navigate to subagent"), + "composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"), + "composer.shell.up": keybind("up", "Previous shell"), + "composer.shell.down": keybind("down", "Next shell"), + "composer.shell.kill": keybind("ctrl+d", "Kill shell command"), + "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 380a5035bdab..554d79a5ec01 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -434,7 +434,6 @@ function DiffViewer(props: { context: Plugin.Context }) { id: "diff.down", title: "Move diff viewer down", group: "VCS", - bind: "j,down", run: focusRunner({ files() { moveFileSelection(1) @@ -449,7 +448,6 @@ function DiffViewer(props: { context: Plugin.Context }) { id: "diff.up", title: "Move diff viewer up", group: "VCS", - bind: "k,up", run: focusRunner({ files() { moveFileSelection(-1) @@ -464,7 +462,6 @@ function DiffViewer(props: { context: Plugin.Context }) { id: "diff.page.down", title: "Page diff viewer down", group: "VCS", - bind: "pagedown,ctrl+f", run: focusRunner({ files() { moveFileSelection(8) @@ -479,7 +476,6 @@ function DiffViewer(props: { context: Plugin.Context }) { id: "diff.page.up", title: "Page diff viewer up", group: "VCS", - bind: "pageup,ctrl+b", run: focusRunner({ files() { moveFileSelection(-8) @@ -585,7 +581,6 @@ function DiffViewer(props: { context: Plugin.Context }) { id: "diff.mark_reviewed", title: "Toggle selected diff file reviewed", group: "VCS", - bind: "m", run() { toggleSelectedFileReviewed() }, diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 0656a83eacc8..61eb954fbd6f 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -186,7 +186,7 @@ export function RunFooterView(props: RunFooterViewProps) { const subagentShortcut = () => shortcut("session.child.first") const queuedShortcut = () => shortcut("session.queued_prompts") const backgroundShortcut = () => shortcut("session.background") - const subagentInterruptShortcut = () => shortcut("subagent.interrupt") + const subagentInterruptShortcut = () => shortcut("composer.subagent.interrupt") const interrupt = () => shortcut("session.interrupt") const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono) const clearShortcut = () => shortcut("prompt.clear") @@ -588,10 +588,9 @@ export function RunFooterView(props: RunFooterViewProps) { priority: 1, commands: [ { - id: "subagent.interrupt", + id: "composer.subagent.interrupt", title: "Interrupt subagent", group: "Session", - bind: "ctrl+d", run: () => { const current = selectedTab() if (current?.status !== "running") { diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx index b945292de916..4126ece82bf0 100644 --- a/packages/tui/src/routes/session/composer/shell-tab.tsx +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -57,7 +57,6 @@ export function ShellTab(props: { sessionID: string }) { id: "composer.shell.up", title: "Previous shell", group: "Composer", - bind: "up", run() { if (store.selected === 0) { composer.close() @@ -70,7 +69,6 @@ export function ShellTab(props: { sessionID: string }) { id: "composer.shell.down", title: "Next shell", group: "Composer", - bind: "down", run() { const list = entries() if (list.length === 0) return @@ -81,7 +79,6 @@ export function ShellTab(props: { sessionID: string }) { id: "composer.shell.kill", title: "Kill shell command", group: "Composer", - bind: "ctrl+d", run() { const entry = selectedEntry() if (!entry) return diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index 4f3c976ddde9..d3c6563244a7 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -158,7 +158,6 @@ export function SubagentsTab(props: { sessionID: string }) { id: "composer.subagent.up", title: "Previous subagent", group: "Composer", - bind: "up", run() { if (store.selected === 0) { composer.close() @@ -171,7 +170,6 @@ export function SubagentsTab(props: { sessionID: string }) { id: "composer.subagent.down", title: "Next subagent", group: "Composer", - bind: "down", run() { const list = entries() if (list.length === 0) return @@ -182,7 +180,6 @@ export function SubagentsTab(props: { sessionID: string }) { id: "composer.subagent.select", title: "Navigate to subagent", group: "Composer", - bind: "return", run() { const entry = entries()[store.selected] if (entry) navigate({ type: "session", sessionID: entry.sessionID }) @@ -192,7 +189,6 @@ export function SubagentsTab(props: { sessionID: string }) { id: "composer.subagent.interrupt", title: "Interrupt subagent", group: "Composer", - bind: "ctrl+d", run() { const entry = selectedEntry() if (!entry || entry.status !== "running") return diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index 54d5e8b995a2..ae438b9304aa 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -38,7 +38,6 @@ export function DialogPrompt(props: DialogPromptProps) { { id: "dialog.prompt.submit", title: "Submit dialog prompt", - bind: "return", group: "Dialog", run: confirm, }, diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index 5a3b9c05a310..53fe7667c143 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -135,3 +135,29 @@ test("dialog prompt submit can be rebound separately from input submit", async ( await prompt.cleanup() } }) + +test("dialog prompt submit can be disabled", async () => { + await using tmp = await tmpdir() + const confirmed: string[] = [] + const prompt = await mountPrompt({ + root: tmp.path, + keybinds: { + "input.submit": "return", + "dialog.prompt.submit": "none", + }, + onConfirm: (value) => confirmed.push(value), + }) + + try { + await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable) + const textarea = prompt.app.renderer.currentFocusedEditor + if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea") + + prompt.app.mockInput.pressEnter() + + expect(confirmed).toEqual([]) + expect(textarea.plainText).toBe("draft") + } finally { + await prompt.cleanup() + } +}) diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 832f4299e1a1..e7addc5365d1 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -11,14 +11,14 @@ import { } from "../src/config" import { TuiKeybind } from "../src/config/keybind" -test("validates mini replay settings", () => { - const decode = Schema.decodeUnknownSync(Info) +const decodeInfo = Schema.decodeUnknownSync(Info) - expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({ +test("validates mini replay settings", () => { + expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({ mini: { replay: false, replay_limit: 50 }, }) - expect(() => decode({ mini: { replay_limit: 0 } })).toThrow() - expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow() + expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow() + expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow() }) test("resolves nested config and keybind defaults", () => { @@ -52,6 +52,67 @@ test("uses command IDs as keybind keys", () => { ).toBe(true) }) +test("accepts every v2-only named command ID", () => { + const commands = [ + "server.pair", + "session.toggle.exploration_grouping", + "composer.subagent.up", + "composer.subagent.down", + "composer.subagent.select", + "composer.subagent.interrupt", + "composer.shell.up", + "composer.shell.down", + "composer.shell.kill", + "diff.down", + "diff.up", + "diff.page.down", + "diff.page.up", + "diff.mark_reviewed", + "opencode.settings", + "service.restart", + "permission.mode", + "session.cd", + "app.scrap", + ] + const config = resolve( + decodeInfo({ keybinds: Object.fromEntries(commands.map((command) => [command, "ctrl+alt+z"])) }), + { terminalSuspend: true }, + ) + + commands.forEach((command) => expect(config.keybinds.get(command)).toMatchObject([{ key: "ctrl+alt+z" }])) +}) + +test("centralizes named command defaults and honors explicit none", () => { + const defaults = { + "composer.subagent.up": "up", + "composer.subagent.down": "down", + "composer.subagent.select": "return", + "composer.subagent.interrupt": "ctrl+d", + "composer.shell.up": "up", + "composer.shell.down": "down", + "composer.shell.kill": "ctrl+d", + "diff.down": "j,down", + "diff.up": "k,up", + "diff.page.down": "pagedown,ctrl+f", + "diff.page.up": "pageup,ctrl+b", + "diff.mark_reviewed": "m", + } + const config = resolve({}, { terminalSuspend: true }) + Object.entries(defaults).forEach(([command, key]) => + expect(config.keybinds.get(command)).toMatchObject([{ key }]), + ) + + const disabled = resolve( + decodeInfo({ keybinds: Object.fromEntries(Object.keys(defaults).map((command) => [command, "none"])) }), + { terminalSuspend: true }, + ) + Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([])) +}) + +test("rejects orphaned keybind definitions", () => { + expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} }) +}) + test("provides config and its host interface", async () => { const config = resolve({}, { terminalSuspend: true }) let current = {} diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index 40d7e09813d6..6cf224985b0d 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -8,7 +8,7 @@ import { RunFooterView } from "../../src/mini/footer.view" import { RUN_THEME_FALLBACK } from "../../src/mini/theme" import type { FooterState, FooterSubagentState, FooterView } from "../../src/mini/types" -test("down opens subagents from an empty prompt", async () => { +test("subagent keybinds use v2 config", async () => { const [state] = createSignal({ phase: "idle", status: "", @@ -34,9 +34,17 @@ test("down opens subagents from an empty prompt", async () => { forms: [], }) const config = resolve( - { keybinds: { editor_open: "none", session_queued_prompts: "none" } }, + { + keybinds: { + "prompt.editor": "none", + "session.queued_prompts": "none", + "composer.subagent.interrupt": "ctrl+i", + }, + }, { terminalSuspend: true }, ) + const interrupted: string[] = [] + function Harness() { return ( @@ -82,6 +90,7 @@ test("down opens subagents from an empty prompt", async () => { onLayout={() => {}} onStatus={() => {}} onMiniSettingChange={() => {}} + onSubagentInterrupt={(sessionID) => interrupted.push(sessionID)} /> ) @@ -94,6 +103,11 @@ test("down opens subagents from an empty prompt", async () => { app.mockInput.pressArrow("down") await app.renderOnce() expect(app.captureCharFrame()).toContain("Select subagent") + app.mockInput.pressEnter() + await app.renderOnce() + expect(app.captureCharFrame()).toContain("ctrl+i") + app.mockInput.pressKey("i", { ctrl: true }) + expect(interrupted).toEqual(["subagent-1"]) } finally { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() From 3cd05ca669e39078fa4c3da776609dccb9a06386 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 15 Jul 2026 22:24:08 +0200 Subject: [PATCH 03/16] test(tui): harden v2 keybind coverage --- packages/cli/test/mini-config.test.ts | 67 ++++++ .../tui/test/cli/tui/diff-viewer.test.tsx | 209 +++++++++++------- packages/tui/test/config-v2.test.tsx | 15 +- packages/tui/test/mini/footer-keymap.test.tsx | 2 +- 4 files changed, 208 insertions(+), 85 deletions(-) create mode 100644 packages/cli/test/mini-config.test.ts diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts new file mode 100644 index 000000000000..bcd429872f03 --- /dev/null +++ b/packages/cli/test/mini-config.test.ts @@ -0,0 +1,67 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/core/global" +import { Effect, Option } from "effect" +import { expect, mock, test } from "bun:test" +import { Config } from "../src/config" +import type { RunTuiConfig } from "../src/mini/types" + +test("mini handler passes resolved CLI keybinds to the runtime", async () => { + let received: RunTuiConfig | Promise | undefined + const mini = await import("../src/mini") + mock.module("../src/mini", () => ({ + ...mini, + validateMiniTerminal() {}, + runMini(input: { tuiConfig?: RunTuiConfig | Promise }) { + received = input.tuiConfig + return Promise.resolve() + }, + })) + const server = await import("../src/services/server") + mock.module("../src/services/server", () => ({ + ...server, + Server: { + ...server.Server, + resolve: () => Effect.succeed({ endpoint: { url: "http://127.0.0.1" } }), + }, + })) + const handler = (await import("../src/commands/handlers/mini")).default + + try { + await Effect.runPromise( + handler({ + server: Option.none(), + standalone: false, + continue: false, + session: Option.none(), + fork: false, + replay: true, + replayLimit: Option.none(), + model: Option.none(), + agent: Option.none(), + prompt: Option.none(), + demo: false, + }).pipe( + Effect.provideService( + Config.Service, + Config.Service.of({ + path: "/tmp/cli.json", + get: () => Effect.succeed({ + keybinds: { "composer.subagent.interrupt": "ctrl+i" }, + leader: { timeout: 321 }, + }), + update: () => Effect.fail(new Error("not used")), + }), + ), + Effect.provide(Global.layerWith({ config: "/tmp", state: "/tmp" })), + Effect.provide(NodeFileSystem.layer), + Effect.scoped, + ), + ) + + const config = await received + expect(config?.leader_timeout).toBe(321) + expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }]) + } finally { + mock.restore() + } +}) diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 8e3614635436..60f0574dcc1d 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -13,7 +13,7 @@ import type { } from "@opencode-ai/plugin/v2/tui/context" import { ThemeProvider } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" -import { TuiKeybind } from "../../../src/config/keybind" +import type { TuiKeybind } from "../../../src/config/keybind" import { Keymap } from "../../../src/context/keymap" import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -48,7 +48,7 @@ test("closing the diff viewer returns to the route it opened from", async () => }) test("shows an error instead of an empty diff when loading fails", async () => { - const viewer = await renderDiffViewer([], 20, undefined, true) + const viewer = await renderDiffViewer([], { fail: true }) try { await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff")) expect(viewer.app.captureCharFrame()).not.toContain("No changes to show") @@ -58,7 +58,7 @@ test("shows an error instead of an empty diff when loading fails", async () => { }) test("uses the active location when opened outside a session", async () => { - const viewer = await renderDiffViewer([], 20, { type: "home" }) + const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } }) try { expect(viewer.vcsDiffInput()).toEqual({ location: { directory: "/repo/default" }, @@ -71,34 +71,7 @@ test("uses the active location when opened outside a session", async () => { }) test("brackets navigate diff hunks", async () => { - const viewer = await renderDiffViewer( - [ - { - file: "src/file.ts", - additions: 3, - deletions: 3, - status: "modified", - patch: `--- a/src/file.ts -+++ b/src/file.ts -@@ -1,3 +1,3 @@ - const first = true --const oldFirst = true -+const newFirst = true - const afterFirst = true -@@ -20,3 +20,3 @@ - const second = true --const oldSecond = true -+const newSecond = true - const afterSecond = true -@@ -40,3 +40,3 @@ - const third = true --const oldThird = true -+const newThird = true - const afterThird = true`, - }, - ], - 12, - ) + const viewer = await renderDiffViewer(hunkDiff, { height: 12 }) try { await viewer.app.waitForFrame((frame) => frame.includes("const first")) await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root))) @@ -106,29 +79,26 @@ test("brackets navigate diff hunks", async () => { const scroll = findScrollBox(viewer.app.renderer.root)! const initial = scroll.scrollTop - expect(TuiKeybind.defaultValue("diff.next_hunk")).toBe("]") - expect(TuiKeybind.defaultValue("diff.previous_hunk")).toBe("[") - - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() const first = scroll.scrollTop expect(first).toBeGreaterThan(initial) - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() const second = scroll.scrollTop expect(second).toBeGreaterThan(first) - viewer.commands.get("diff.previous_hunk")!.run() + viewer.app.mockInput.pressKey("[") await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(first) - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(second) scroll.scrollTo(initial) - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(first) } finally { @@ -136,16 +106,48 @@ test("brackets navigate diff hunks", async () => { } }) -async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) { +test("disabled diff keybinds have no component fallbacks", async () => { + const viewer = await renderDiffViewer(hunkDiff, { + height: 12, + keybinds: disabledDiffKeybinds, + }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root))) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const initial = scroll.scrollTop + + Object.keys(disabledDiffKeybinds).forEach((command) => expect(viewer.shortcut(command)).toBe("")) + + viewer.app.mockInput.pressKey("j") + await viewer.app.renderOnce() + + expect(scroll.scrollTop).toBe(initial) + } finally { + viewer.app.renderer.destroy() + } +}) + +async function renderDiffViewer( + vcsDiff: unknown[], + options: { + height?: number + initialRoute?: Route + fail?: boolean + keybinds?: TuiKeybind.KeybindOverrides + } = {}, +) { const commands = new Map() - let current = initialRoute ?? startRoute + let current = options.initialRoute ?? startRoute let renderDiff: Page["render"] | undefined let renderCommands: Slot | undefined let vcsDiffInput: unknown - const config = createTuiResolvedConfig() + let shortcut: (command: string) => string | undefined = () => undefined + const config = createTuiResolvedConfig({ keybinds: options.keybinds }) const transport = createFetch((url) => { if (url.pathname !== "/api/vcs/diff") return - if (fail) return json({ message: "boom" }, { status: 500 }) + if (options.fail) return json({ message: "boom" }, { status: 500 }) vcsDiffInput = { location: { directory: url.searchParams.get("location[directory]") }, mode: url.searchParams.get("mode"), @@ -157,45 +159,49 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: }) }, createEventStream()) function Harness() { - const context = { - options: {}, - client: createApi(transport.fetch), - data: { - session: { get: () => session }, - location: { default: () => ({ directory: "/repo/default" }) }, - }, - keymap: { - layer(input: () => KeymapLayer) { - input().commands?.forEach((command) => { - if (command.id) commands.set(command.id, command) - }) + function Content() { + const keymap = Keymap.use() + const shortcuts = Keymap.useShortcuts() + shortcut = shortcuts.get + const context = { + options: {}, + client: createApi(transport.fetch), + data: { + session: { get: () => session }, + location: { default: () => ({ directory: "/repo/default" }) }, }, - dispatch() {}, - shortcut: () => undefined, - mode: { current: () => "base", push: () => () => {} }, - }, - ui: { - router: { - register(page: Page) { - if (page.name === "diff") renderDiff = page.render - return () => {} + keymap: { + layer(input: () => KeymapLayer) { + input().commands?.forEach((command) => { + if (command.id) commands.set(command.id, command) + }) + Keymap.createLayer(input) }, - navigate(destination: Destination) { - current = destination.type === "plugin" && !("id" in destination) - ? { ...destination, id: "diff-viewer" } - : destination - }, - current: () => current, + dispatch: keymap.dispatch, + shortcut: shortcuts.get, + mode: keymap.mode, }, - slot(_name: string, render: Slot) { - renderCommands = render - return () => {} + ui: { + router: { + register(page: Page) { + if (page.name === "diff") renderDiff = page.render + return () => {} + }, + navigate(destination: Destination) { + current = destination.type === "plugin" && !("id" in destination) + ? { ...destination, id: "diff-viewer" } + : destination + }, + current: () => current, + }, + slot(_name: string, render: Slot) { + renderCommands = render + return () => {} + }, }, - }, - } as unknown as Context + } as unknown as Context - void diffViewerPlugin.setup(context) - function Content() { + void diffViewerPlugin.setup(context) const commandView = renderCommands?.({}) if (current.type !== "plugin") commands.get("diff.open")?.run() return ( @@ -223,18 +229,53 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: ) } - const app = await testRender(() => , { width: 80, height }) + const app = await testRender(() => , { width: 80, height: options.height ?? 20 }) await waitForCommand(app, commands, "diff.close") return { app, commands, current: () => current, + shortcut: (command: string) => shortcut(command), vcsDiffInput: () => vcsDiffInput, } } const startRoute: Route = { type: "session", sessionID: "session-1" } +const disabledDiffKeybinds = { + "diff.down": "none", + "diff.up": "none", + "diff.page.down": "none", + "diff.page.up": "none", + "diff.mark_reviewed": "none", +} satisfies TuiKeybind.KeybindOverrides + +const hunkDiff = [ + { + file: "src/file.txt", + additions: 3, + deletions: 3, + status: "modified", + patch: `--- a/src/file.txt ++++ b/src/file.txt +@@ -1,3 +1,3 @@ + const first = true +-const oldFirst = true ++const newFirst = true + const afterFirst = true +@@ -20,3 +20,3 @@ + const second = true +-const oldSecond = true ++const newSecond = true + const afterSecond = true +@@ -40,3 +40,3 @@ + const third = true +-const oldThird = true ++const newThird = true + const afterThird = true`, + }, +] + function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined { if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root return root.getChildren().map(findScrollBox).find(Boolean) @@ -259,11 +300,13 @@ const session = { } test("branch diff source requests branch VCS diff", async () => { - const viewer = await renderDiffViewer([], 20, { - type: "plugin", - id: "diff-viewer", - name: "diff", - data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, + const viewer = await renderDiffViewer([], { + initialRoute: { + type: "plugin", + id: "diff-viewer", + name: "diff", + data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, + }, }) try { expect(viewer.current()).toEqual({ diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index e7addc5365d1..6b29cb05bc0d 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -82,7 +82,7 @@ test("accepts every v2-only named command ID", () => { commands.forEach((command) => expect(config.keybinds.get(command)).toMatchObject([{ key: "ctrl+alt+z" }])) }) -test("centralizes named command defaults and honors explicit none", () => { +test("centralizes named command defaults and resolves explicit none", () => { const defaults = { "composer.subagent.up": "up", "composer.subagent.down": "down", @@ -113,6 +113,19 @@ test("rejects orphaned keybind definitions", () => { expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} }) }) +test("uses ctrl+z for input undo when terminal suspend is unavailable", () => { + const config = resolve({}, { terminalSuspend: false }) + expect(config.keybinds.has("terminal.suspend")).toBe(false) + expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }]) + + const overridden = resolve( + { keybinds: { "terminal.suspend": "ctrl+s", "input.undo": "ctrl+u" } }, + { terminalSuspend: false }, + ) + expect(overridden.keybinds.has("terminal.suspend")).toBe(false) + expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }]) +}) + test("provides config and its host interface", async () => { const config = resolve({}, { terminalSuspend: true }) let current = {} diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index 6cf224985b0d..c91234e3ef5a 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -8,7 +8,7 @@ import { RunFooterView } from "../../src/mini/footer.view" import { RUN_THEME_FALLBACK } from "../../src/mini/theme" import type { FooterState, FooterSubagentState, FooterView } from "../../src/mini/types" -test("subagent keybinds use v2 config", async () => { +test("configured subagent key updates its hint and action", async () => { const [state] = createSignal({ phase: "idle", status: "", From ff0b3f317300314b22e57825c29a60fef800285a Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Sat, 18 Jul 2026 17:33:50 +0200 Subject: [PATCH 04/16] fix(tui): align keybinds with latest v2 --- packages/cli/test/mini-config.test.ts | 16 +++++++--------- packages/tui/src/config/keybind.ts | 10 ++++++---- packages/tui/test/config-v2.test.tsx | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index bcd429872f03..a2271f95ef8b 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -1,5 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Option } from "effect" import { expect, mock, test } from "bun:test" import { Config } from "../src/config" @@ -16,20 +17,16 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { return Promise.resolve() }, })) - const server = await import("../src/services/server") - mock.module("../src/services/server", () => ({ - ...server, - Server: { - ...server.Server, - resolve: () => Effect.succeed({ endpoint: { url: "http://127.0.0.1" } }), - }, - })) const handler = (await import("../src/commands/handlers/mini")).default + const server = Bun.serve({ + port: 0, + fetch: () => Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }), + }) try { await Effect.runPromise( handler({ - server: Option.none(), + server: Option.some(server.url.toString()), standalone: false, continue: false, session: Option.none(), @@ -62,6 +59,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { expect(config?.leader_timeout).toBe(321) expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }]) } finally { + server.stop(true) mock.restore() } }) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 13c9c186187f..46f544d11c68 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -145,11 +145,13 @@ export const Definitions = { "session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"), "session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"), "session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"), - "session.first": keybind("ctrl+g,home", "Navigate to first message"), + "session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"), "session.last": keybind("ctrl+alt+g,end", "Navigate to last message"), - "session.message.next": keybind("none", "Navigate to next message"), - "session.message.previous": keybind("none", "Navigate to previous message"), - "session.messages_last_user": keybind("none", "Navigate to last user message"), + "session.message.next": keybind("alt+down", "Navigate to next message"), + "session.message.previous": keybind("alt+up", "Navigate to previous message"), + "session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"), + "session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"), + "session.messages_last_user": keybind("alt+end", "Navigate to last user message"), "messages.copy": keybind("y", "Copy message"), "session.undo": keybind("u", "Undo message"), "session.redo": keybind("r", "Redo message"), diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 6b29cb05bc0d..b6f5bab6a7e0 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -10,6 +10,7 @@ import { type Interface, } from "../src/config" import { TuiKeybind } from "../src/config/keybind" +import { CommandMap, Definitions } from "../src/config/v1/keybind" const decodeInfo = Schema.decodeUnknownSync(Info) @@ -52,6 +53,21 @@ test("uses command IDs as keybind keys", () => { ).toBe(true) }) +test("preserves supported v1 keybind defaults", () => { + const legacy = Object.fromEntries( + Object.entries(Definitions).flatMap(([name, item]) => { + const command = CommandMap[name as keyof typeof CommandMap] ?? name + if (command === "app.heap_snapshot") return [] + return [[command, item.default]] + }), + ) + const current = Object.fromEntries( + Object.entries(TuiKeybind.Definitions).map(([name, item]) => [name, item.default]), + ) + + expect(current).toMatchObject(legacy) +}) + test("accepts every v2-only named command ID", () => { const commands = [ "server.pair", From 5c78f96b2cf0c246d4715c0ba0117fd9c307d774 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 20 Jul 2026 16:16:21 +0200 Subject: [PATCH 05/16] fix(cli): preserve duplicate keybind value --- packages/cli/src/config/migrate.ts | 28 +++++++++++++++++++++------- packages/cli/test/config.test.ts | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index d2397f284500..10947ada586f 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -40,14 +40,18 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { (LegacyKeybindTargets.has(name) ? name : undefined) if (target === undefined) return text if (target === name && target in Definitions) return text - const tree = parseTree(text) - if (tree === undefined) return text - const property = findNodeAtLocation(tree, ["keybinds", name])?.parent - if (property === undefined) return text - if (!(target in Definitions) || (target !== name && target in keybinds)) return removeProperty(text, property) - const key = property.children?.[0] + const properties = findKeybindProperties(text, name) + if (!properties.length) return text + const remove = !(target in Definitions) || (target !== name && target in keybinds) + // The parser gives the final duplicate precedence, so remove earlier properties before renaming it. + const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => { + const property = findKeybindProperties(text, name)[0] + return property === undefined ? text : removeProperty(text, property) + }, text) + if (remove) return updated + const key = findKeybindProperties(updated, name)[0]?.children?.[0] if (key === undefined) return text - return text.slice(0, key.offset) + JSON.stringify(target) + text.slice(key.offset + key.length) + return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length) }, text) if (updated === text) return const temp = input.file + ".tmp" @@ -75,6 +79,16 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { }) }) +function findKeybindProperties(text: string, name: string) { + const tree = parseTree(text) + if (tree === undefined) return [] + return ( + findNodeAtLocation(tree, ["keybinds"])?.children?.filter( + (property) => property.children?.[0]?.value === name, + ) ?? [] + ) +} + function removeProperty(text: string, property: Node) { const properties = property.parent?.children ?? [] const index = properties.indexOf(property) diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 437f77222c2d..c3c3b2f5df91 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -188,6 +188,27 @@ test("migrates legacy keybind names in an existing cli.json", async () => { } }) +test("preserves the effective value when migrating duplicate legacy keybinds", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const file = path.join(directory, "cli.json") + await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`) + + try { + const config = await run( + directory, + Effect.gen(function* () { + const service = yield* Config.Service + return yield* service.get() + }), + ) + + expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" }) + expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" }) + } finally { + await Bun.$`rm -rf ${directory}` + } +}) + test("removes a sole orphaned keybind with a trailing comma", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const file = path.join(directory, "cli.json") From f621a05ef2cd54812e2375c811f5a577502c9417 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 20 Jul 2026 16:25:25 +0200 Subject: [PATCH 06/16] fix(tui): adopt latest keybind defaults --- packages/cli/src/commands/handlers/mini.ts | 2 +- packages/cli/test/mini-config.test.ts | 8 ++++---- packages/tui/src/config/keybind.ts | 5 +++-- packages/tui/test/keybind.test.ts | 4 ++-- packages/tui/test/mini/footer.view.test.tsx | 6 +++--- packages/tui/test/mini/runtime.boot.test.ts | 16 ++++++++-------- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 1d56fa2eb178..972c3bf962aa 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -1,8 +1,8 @@ import { Context, Effect, FileSystem, Option } from "effect" import { Commands } from "../commands" -import { Config } from "../../config" import { Runtime } from "../../framework/runtime" import { ServerConnection } from "../../services/server-connection" +import { Config } from "../../config" import { resolve } from "@opencode-ai/tui/config" export default Runtime.handler(Commands.commands.mini, (input) => diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index a2271f95ef8b..b2903299f687 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -4,15 +4,15 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Option } from "effect" import { expect, mock, test } from "bun:test" import { Config } from "../src/config" -import type { RunTuiConfig } from "../src/mini/types" +import type { MiniCommandInput } from "../src/mini" test("mini handler passes resolved CLI keybinds to the runtime", async () => { - let received: RunTuiConfig | Promise | undefined + let received: MiniCommandInput["tuiConfig"] const mini = await import("../src/mini") mock.module("../src/mini", () => ({ ...mini, validateMiniTerminal() {}, - runMini(input: { tuiConfig?: RunTuiConfig | Promise }) { + runMini(input: Pick) { received = input.tuiConfig return Promise.resolve() }, @@ -56,7 +56,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { ) const config = await received - expect(config?.leader_timeout).toBe(321) + expect(config?.leader.timeout).toBe(321) expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }]) } finally { server.stop(true) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 46f544d11c68..138881dea129 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -134,8 +134,8 @@ export const Definitions = { "mcp.list": keybind("none", "List MCP servers"), "provider.connect": keybind("none", "Connect integration"), "agent.list": keybind("a", "List agents"), - "agent.cycle": keybind("tab", "Next agent"), - "agent.cycle.reverse": keybind("shift+tab", "Previous agent"), + "agent.cycle": keybind("shift+tab", "Next agent"), + "agent.cycle.reverse": keybind("none", "Previous agent"), "variant.cycle": keybind("ctrl+t", "Cycle model variants"), "variant.list": keybind("none", "List model variants"), @@ -232,6 +232,7 @@ export const Definitions = { "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.mcp.toggle": keybind("space", "Toggle MCP server"), "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), "terminal.suspend": keybind("ctrl+z", "Suspend terminal"), diff --git a/packages/tui/test/keybind.test.ts b/packages/tui/test/keybind.test.ts index 1af999d9d336..64b007cb38f4 100644 --- a/packages/tui/test/keybind.test.ts +++ b/packages/tui/test/keybind.test.ts @@ -2,6 +2,6 @@ import { expect, test } from "bun:test" import { TuiKeybind } from "../src/config/keybind" test("binds agent cycling only to shift+tab by default", () => { - expect(TuiKeybind.Definitions.agent_cycle.default).toBe("shift+tab") - expect(TuiKeybind.Definitions.agent_cycle_reverse.default).toBe("none") + expect(TuiKeybind.Definitions["agent.cycle"].default).toBe("shift+tab") + expect(TuiKeybind.Definitions["agent.cycle.reverse"].default).toBe("none") }) diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 022e90623ba6..e3e934451783 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -973,7 +973,7 @@ test.skip("direct footer recreates the frame across command panel transitions", test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => { const calls: string[] = [] const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "t" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", "variant.cycle": "t" } }), onCycle: () => calls.push("cycle"), }) @@ -993,7 +993,7 @@ test.skip("direct footer dispatches leader variant binding only when leader is r test("direct footer keeps leader variant binding inactive when leader is disabled", async () => { const calls: string[] = [] const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "t" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", "variant.cycle": "t" } }), onCycle: () => calls.push("cycle"), }) @@ -1489,7 +1489,7 @@ test("direct footer hides the subagent hint when only completed subagents remain test("direct footer omits interrupt key hint when interrupt is unbound", async () => { const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none", "prompt.clear": "ctrl+l" } }), state: { phase: "running" }, mono: true, }) diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 734d2a49233b..91b9d61e4457 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -24,14 +24,14 @@ function config(input?: { leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout }, keybinds: { ...(input?.leader && { leader: input.leader }), - ...(bind?.commandList && { command_list: bind.commandList }), - ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }), - ...(bind?.interrupt && { session_interrupt: bind.interrupt }), - ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }), - ...(bind?.historyNext && { history_next: bind.historyNext }), - ...(bind?.inputClear && { input_clear: bind.inputClear }), - ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }), - ...(bind?.inputNewline && { input_newline: bind.inputNewline }), + ...(bind?.commandList && { "command.palette.show": bind.commandList }), + ...(bind?.variantCycle && { "variant.cycle": bind.variantCycle }), + ...(bind?.interrupt && { "session.interrupt": bind.interrupt }), + ...(bind?.historyPrevious && { "prompt.history.previous": bind.historyPrevious }), + ...(bind?.historyNext && { "prompt.history.next": bind.historyNext }), + ...(bind?.inputClear && { "prompt.clear": bind.inputClear }), + ...(bind?.inputSubmit && { "input.submit": bind.inputSubmit }), + ...(bind?.inputNewline && { "input.newline": bind.inputNewline }), }, }) } From f33507904ccff0b87c2e0da7b37a8a19c8513da6 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 20 Jul 2026 19:09:57 +0200 Subject: [PATCH 07/16] fix(cli): harden keybind migration --- packages/cli/src/config/config.ts | 12 +++-- packages/cli/src/config/migrate.ts | 8 +-- packages/cli/test/config.test.ts | 82 +++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e655802b7bf5..813af186638c 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -50,10 +50,14 @@ export const layer = Layer.effect( 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 get = Effect.fn("cli.config.get")(() => + lock.withPermits(1)( + Effect.gen(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 diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index 10947ada586f..16bec559db49 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -6,7 +6,6 @@ import { Definitions } from "@opencode-ai/tui/config/keybind" import { Effect, FileSystem, Option, Schema } from "effect" import { createScanner, - findNodeAtLocation, parse, parseTree, type Node, @@ -82,11 +81,8 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { function findKeybindProperties(text: string, name: string) { const tree = parseTree(text) if (tree === undefined) return [] - return ( - findNodeAtLocation(tree, ["keybinds"])?.children?.filter( - (property) => property.children?.[0]?.value === name, - ) ?? [] - ) + const keybinds = tree.children?.findLast((property) => property.children?.[0]?.value === "keybinds")?.children?.[1] + return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? [] } function removeProperty(text: string, property: Node) { diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index c3c3b2f5df91..8479a9b9b449 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -1,6 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/util/global" -import { Effect } from "effect" +import { Effect, FileSystem } from "effect" import { expect, test } from "bun:test" import { parse } from "jsonc-parser" import path from "path" @@ -209,6 +209,86 @@ test("preserves the effective value when migrating duplicate legacy keybinds", a } }) +test("migrates the effective duplicate top-level keybinds", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const file = path.join(directory, "cli.json") + await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`) + + try { + const config = await run( + directory, + Effect.gen(function* () { + const service = yield* Config.Service + return yield* service.get() + }), + ) + + expect(config.keybinds).toEqual({ "session.delete": "last" }) + expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "last" }) + } finally { + await Bun.$`rm -rf ${directory}` + } +}) + +test("does not overwrite a concurrent config update during migration", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const file = path.join(directory, "cli.json") + await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`) + const node = await Effect.runPromise( + Effect.gen(function* () { + return yield* FileSystem.FileSystem + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + const started = Promise.withResolvers() + const resume = Promise.withResolvers() + const state = { writes: 0 } + const writeFileString: FileSystem.FileSystem["writeFileString"] = (path, data, options) => { + state.writes++ + if (state.writes !== 1) return node.writeFileString(path, data, options) + started.resolve() + return Effect.gen(function* () { + yield* Effect.promise(() => resume.promise) + yield* node.writeFileString(path, data, options) + }) + } + const fs = new Proxy(node, { + get(target, property, receiver) { + if (property === "writeFileString") return writeFileString + return Reflect.get(target, property, receiver) + }, + }) + + try { + const config = await Effect.runPromise( + Effect.gen(function* () { + const service = yield* Config.Service + return yield* Effect.promise(async () => { + const reading = Effect.runPromise(service.get()) + await started.promise + const updating = Effect.runPromise( + service.update((draft) => { + draft.mouse = false + }), + ) + await Promise.race([updating, Bun.sleep(100)]).finally(() => resume.resolve()) + await Promise.all([reading, updating]) + return Effect.runPromise(service.get()) + }) + }).pipe( + Effect.provide(Config.layer), + Effect.provide(Global.layerWith({ config: directory, state: directory })), + Effect.provideService(FileSystem.FileSystem, fs), + ), + ) + + expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) + expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) + } finally { + resume.resolve() + await Bun.$`rm -rf ${directory}` + } +}) + test("removes a sole orphaned keybind with a trailing comma", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const file = path.join(directory, "cli.json") From f1b6a83c05220165f5d65ab8c1ed4b146224ee14 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Tue, 21 Jul 2026 21:08:42 +0200 Subject: [PATCH 08/16] test(cli): update util imports --- packages/cli/test/mini-config.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index b2903299f687..903ca8904f17 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -1,6 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" -import { Global } from "@opencode-ai/core/global" -import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Global } from "@opencode-ai/util/global" +import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Effect, Option } from "effect" import { expect, mock, test } from "bun:test" import { Config } from "../src/config" From 577666374e453f44e47b335b29c9193ce0b05dce Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Tue, 21 Jul 2026 22:43:40 +0200 Subject: [PATCH 09/16] fix(cli): synchronize config migration --- packages/cli/src/config/config.ts | 2 +- packages/cli/src/config/migrate.ts | 16 ++++-- packages/cli/test/config.test.ts | 84 ++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 813af186638c..fd3cba2828f9 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -21,6 +21,7 @@ export class Service extends Context.Service()("@opencode/cl const decode = Schema.decodeUnknownOption(Info) const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any)) const empty: Info = {} +const lock = Semaphore.makeUnsafe(1) export const layer = Layer.effect( Service, @@ -28,7 +29,6 @@ export const layer = Layer.effect( 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))) diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index 16bec559db49..5eb5522e746d 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -33,6 +33,12 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { if (config === undefined) return const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds)) if (keybinds === undefined) return + const deduped = findKeybindObjects(text) + .slice(0, -1) + .reduce((text) => { + const property = findKeybindObjects(text)[0] + return property === undefined ? text : removeProperty(text, property) + }, text) const updated = Object.keys(keybinds).reduce((text, name) => { const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? @@ -51,7 +57,7 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { const key = findKeybindProperties(updated, name)[0]?.children?.[0] if (key === undefined) return text return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length) - }, text) + }, deduped) if (updated === text) return const temp = input.file + ".tmp" yield* fs.writeFileString(temp, updated, { mode: 0o600 }) @@ -79,10 +85,14 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { }) function findKeybindProperties(text: string, name: string) { + const keybinds = findKeybindObjects(text).at(-1)?.children?.[1] + return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? [] +} + +function findKeybindObjects(text: string) { const tree = parseTree(text) if (tree === undefined) return [] - const keybinds = tree.children?.findLast((property) => property.children?.[0]?.value === "keybinds")?.children?.[1] - return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? [] + return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? [] } function removeProperty(text: string, property: Node) { diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 8479a9b9b449..d12a9380e793 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -289,6 +289,90 @@ test("does not overwrite a concurrent config update during migration", async () } }) +test("does not overwrite a concurrent update from another config layer", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const file = path.join(directory, "cli.json") + await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`) + const node = await Effect.runPromise( + Effect.gen(function* () { + return yield* FileSystem.FileSystem + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + const started = Promise.withResolvers() + const resume = Promise.withResolvers() + const state = { writes: 0 } + const writeFileString: FileSystem.FileSystem["writeFileString"] = (path, data, options) => { + state.writes++ + if (state.writes !== 1) return node.writeFileString(path, data, options) + started.resolve() + return Effect.gen(function* () { + yield* Effect.promise(() => resume.promise) + yield* node.writeFileString(path, data, options) + }) + } + const fs = new Proxy(node, { + get(target, property, receiver) { + if (property === "writeFileString") return writeFileString + return Reflect.get(target, property, receiver) + }, + }) + const make = () => + Effect.runPromise( + Effect.gen(function* () { + return yield* Config.Service + }).pipe( + Effect.provide(Config.layer), + Effect.provide(Global.layerWith({ config: directory, state: directory })), + Effect.provideService(FileSystem.FileSystem, fs), + ), + ) + + try { + const first = await make() + const second = await make() + const reading = Effect.runPromise(first.get()) + await started.promise + const updating = Effect.runPromise( + second.update((draft) => { + draft.mouse = false + }), + ) + await Promise.race([updating, Bun.sleep(100)]).finally(() => resume.resolve()) + await Promise.all([reading, updating]) + const config = await Effect.runPromise(second.get()) + + expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) + expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) + } finally { + resume.resolve() + await Bun.$`rm -rf ${directory}` + } +}) + +test("updates the effective duplicate top-level keybinds", async () => { + const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const file = path.join(directory, "cli.json") + await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`) + + try { + const config = await run( + directory, + Effect.gen(function* () { + const service = yield* Config.Service + yield* service.get() + return yield* service.update((draft) => { + draft.keybinds = { ...draft.keybinds, "session.delete": "changed" } + }) + }), + ) + + expect(config.keybinds).toEqual({ "session.delete": "changed" }) + expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" }) + } finally { + await Bun.$`rm -rf ${directory}` + } +}) + test("removes a sole orphaned keybind with a trailing comma", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const file = path.join(directory, "cli.json") From 5a84dd71c1f0772e35852de8675fa4a5d0627a61 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Wed, 22 Jul 2026 19:48:18 +0200 Subject: [PATCH 10/16] test(cli): use owned version metadata --- packages/cli/test/mini-config.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index 903ca8904f17..6d59e98e58a6 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -1,10 +1,10 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/util/global" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Effect, Option } from "effect" import { expect, mock, test } from "bun:test" import { Config } from "../src/config" import type { MiniCommandInput } from "../src/mini" +import { OPENCODE_VERSION } from "../src/version" test("mini handler passes resolved CLI keybinds to the runtime", async () => { let received: MiniCommandInput["tuiConfig"] @@ -20,7 +20,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { const handler = (await import("../src/commands/handlers/mini")).default const server = Bun.serve({ port: 0, - fetch: () => Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }), + fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }), }) try { From 885e35e0d01fc132777c4c2edc68b8e08c3f79fd Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 23 Jul 2026 16:13:34 +0200 Subject: [PATCH 11/16] test: harden keybind integration coverage --- packages/cli/test/config.test.ts | 140 ++++++------- packages/cli/test/mini-config.test.ts | 46 +++-- packages/tui/test/app-lifecycle.test.tsx | 60 +++++- .../tui/test/cli/tui/composer-keymap.test.tsx | 190 ++++++++++++++++++ packages/tui/test/mini/footer-keymap.test.tsx | 41 +++- packages/tui/test/mini/runtime.boot.test.ts | 50 +---- packages/tui/test/mini/runtime.test.ts | 9 +- 7 files changed, 384 insertions(+), 152 deletions(-) create mode 100644 packages/tui/test/cli/tui/composer-keymap.test.tsx diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index d12a9380e793..f02be3769a65 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -1,6 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/util/global" -import { Effect, FileSystem } from "effect" +import { Effect, Fiber, FileSystem, Latch } from "effect" import { expect, test } from "bun:test" import { parse } from "jsonc-parser" import path from "path" @@ -16,6 +16,36 @@ function run(directory: string, effect: Effect.Effect + state.blocked && target === file ? Effect.succeed(true) : node.exists(target) + const readFileString: FileSystem.FileSystem["readFileString"] = (target, encoding) => + state.blocked && target === file ? Effect.succeed(initial) : node.readFileString(target, encoding) + const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => { + state.writes++ + if (state.writes !== 1) return node.writeFileString(target, data, options) + return Effect.gen(function* () { + yield* release.await + state.blocked = false + yield* node.writeFileString(target, data, options) + }) + } + const fs = new Proxy(node, { + get(target, property, receiver) { + if (property === "exists") return exists + if (property === "readFileString") return readFileString + if (property === "writeFileString") return writeFileString + return Reflect.get(target, property, receiver) + }, + }) + return { fs, release, state } + }) +} + test("migrates tui and kv config into cli.json", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) await Bun.write( @@ -233,58 +263,37 @@ test("migrates the effective duplicate top-level keybinds", async () => { test("does not overwrite a concurrent config update during migration", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const file = path.join(directory, "cli.json") - await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`) - const node = await Effect.runPromise( - Effect.gen(function* () { - return yield* FileSystem.FileSystem - }).pipe(Effect.provide(NodeFileSystem.layer)), - ) - const started = Promise.withResolvers() - const resume = Promise.withResolvers() - const state = { writes: 0 } - const writeFileString: FileSystem.FileSystem["writeFileString"] = (path, data, options) => { - state.writes++ - if (state.writes !== 1) return node.writeFileString(path, data, options) - started.resolve() - return Effect.gen(function* () { - yield* Effect.promise(() => resume.promise) - yield* node.writeFileString(path, data, options) - }) - } - const fs = new Proxy(node, { - get(target, property, receiver) { - if (property === "writeFileString") return writeFileString - return Reflect.get(target, property, receiver) - }, - }) + const initial = `{"keybinds":{"session_delete":"ctrl+d"}}` + await Bun.write(file, initial) + const gated = await Effect.runPromise(gateMigrationWrite(file, initial).pipe(Effect.provide(NodeFileSystem.layer))) try { const config = await Effect.runPromise( Effect.gen(function* () { const service = yield* Config.Service - return yield* Effect.promise(async () => { - const reading = Effect.runPromise(service.get()) - await started.promise - const updating = Effect.runPromise( - service.update((draft) => { - draft.mouse = false - }), - ) - await Promise.race([updating, Bun.sleep(100)]).finally(() => resume.resolve()) - await Promise.all([reading, updating]) - return Effect.runPromise(service.get()) - }) + const reading = yield* service.get().pipe(Effect.forkChild({ startImmediately: true })) + expect(gated.state.writes).toBe(1) + const updating = yield* service + .update((draft) => { + draft.mouse = false + }) + .pipe(Effect.forkChild({ startImmediately: true })) + expect(gated.state.writes).toBe(1) + yield* gated.release.open + yield* Fiber.join(reading) + yield* Fiber.join(updating) + return yield* service.get() }).pipe( Effect.provide(Config.layer), Effect.provide(Global.layerWith({ config: directory, state: directory })), - Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(FileSystem.FileSystem, gated.fs), ), ) expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) } finally { - resume.resolve() + gated.release.openUnsafe() await Bun.$`rm -rf ${directory}` } }) @@ -292,30 +301,9 @@ test("does not overwrite a concurrent config update during migration", async () test("does not overwrite a concurrent update from another config layer", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const file = path.join(directory, "cli.json") - await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`) - const node = await Effect.runPromise( - Effect.gen(function* () { - return yield* FileSystem.FileSystem - }).pipe(Effect.provide(NodeFileSystem.layer)), - ) - const started = Promise.withResolvers() - const resume = Promise.withResolvers() - const state = { writes: 0 } - const writeFileString: FileSystem.FileSystem["writeFileString"] = (path, data, options) => { - state.writes++ - if (state.writes !== 1) return node.writeFileString(path, data, options) - started.resolve() - return Effect.gen(function* () { - yield* Effect.promise(() => resume.promise) - yield* node.writeFileString(path, data, options) - }) - } - const fs = new Proxy(node, { - get(target, property, receiver) { - if (property === "writeFileString") return writeFileString - return Reflect.get(target, property, receiver) - }, - }) + const initial = `{"keybinds":{"session_delete":"ctrl+d"}}` + await Bun.write(file, initial) + const gated = await Effect.runPromise(gateMigrationWrite(file, initial).pipe(Effect.provide(NodeFileSystem.layer))) const make = () => Effect.runPromise( Effect.gen(function* () { @@ -323,28 +311,34 @@ test("does not overwrite a concurrent update from another config layer", async ( }).pipe( Effect.provide(Config.layer), Effect.provide(Global.layerWith({ config: directory, state: directory })), - Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(FileSystem.FileSystem, gated.fs), ), ) try { const first = await make() const second = await make() - const reading = Effect.runPromise(first.get()) - await started.promise - const updating = Effect.runPromise( - second.update((draft) => { - draft.mouse = false + const config = await Effect.runPromise( + Effect.gen(function* () { + const reading = yield* first.get().pipe(Effect.forkChild({ startImmediately: true })) + expect(gated.state.writes).toBe(1) + const updating = yield* second + .update((draft) => { + draft.mouse = false + }) + .pipe(Effect.forkChild({ startImmediately: true })) + expect(gated.state.writes).toBe(1) + yield* gated.release.open + yield* Fiber.join(reading) + yield* Fiber.join(updating) + return yield* second.get() }), ) - await Promise.race([updating, Bun.sleep(100)]).finally(() => resume.resolve()) - await Promise.all([reading, updating]) - const config = await Effect.runPromise(second.get()) expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) } finally { - resume.resolve() + gated.release.openUnsafe() await Bun.$`rm -rf ${directory}` } }) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index 6d59e98e58a6..553f158e9024 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -1,22 +1,32 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/util/global" import { Effect, Option } from "effect" -import { expect, mock, test } from "bun:test" +import { expect, spyOn, test } from "bun:test" +import { mkdir, rm } from "node:fs/promises" +import path from "node:path" import { Config } from "../src/config" import type { MiniCommandInput } from "../src/mini" import { OPENCODE_VERSION } from "../src/version" test("mini handler passes resolved CLI keybinds to the runtime", async () => { + const root = await Bun.$`mktemp -d`.text().then((value) => value.trim()) + const configDirectory = path.join(root, "config") + const stateDirectory = path.join(root, "state") + await mkdir(configDirectory, { recursive: true }) + await Bun.write( + path.join(configDirectory, "cli.json"), + JSON.stringify({ + keybinds: { "composer.subagent.interrupt": "ctrl+i" }, + leader: { timeout: 321 }, + }), + ) let received: MiniCommandInput["tuiConfig"] const mini = await import("../src/mini") - mock.module("../src/mini", () => ({ - ...mini, - validateMiniTerminal() {}, - runMini(input: Pick) { - received = input.tuiConfig - return Promise.resolve() - }, - })) + const validateMiniTerminal = spyOn(mini, "validateMiniTerminal").mockImplementation(() => {}) + const runMini = spyOn(mini, "runMini").mockImplementation((input: MiniCommandInput) => { + received = input.tuiConfig + return Promise.resolve() + }) const handler = (await import("../src/commands/handlers/mini")).default const server = Bun.serve({ port: 0, @@ -38,18 +48,8 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { prompt: Option.none(), demo: false, }).pipe( - Effect.provideService( - Config.Service, - Config.Service.of({ - path: "/tmp/cli.json", - get: () => Effect.succeed({ - keybinds: { "composer.subagent.interrupt": "ctrl+i" }, - leader: { timeout: 321 }, - }), - update: () => Effect.fail(new Error("not used")), - }), - ), - Effect.provide(Global.layerWith({ config: "/tmp", state: "/tmp" })), + Effect.provide(Config.layer), + Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })), Effect.provide(NodeFileSystem.layer), Effect.scoped, ), @@ -60,6 +60,8 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }]) } finally { server.stop(true) - mock.restore() + validateMiniTerminal.mockRestore() + runMini.mockRestore() + await rm(root, { recursive: true, force: true }) } }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index a07e3e6b12b2..4bf886e888ea 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,4 +1,4 @@ -import { expect, mock, test } from "bun:test" +import { expect, mock, spyOn, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -214,3 +214,61 @@ test("session startup prompt is submitted exactly once", async () => { mock.restore() } }) + +test("configured app bindings execute settings and permission commands", async () => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + const core = await import("@opentui/core") + const createCliRenderer = spyOn(core, "createCliRenderer").mockImplementation(async () => setup.renderer) + const ready = Promise.withResolvers() + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + if (title === "OpenCode") ready.resolve() + setTitle(title) + } + const events = createEventStream() + const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ + animations: false, + keybinds: { "opencode.settings": "f6", "permission.mode": "f7" }, + }), + update: async () => ({}), + }, + packages: { resolve: async () => undefined }, + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + await ready.promise + + setup.mockInput.pressKey("F6") + const settings = await setup.waitForFrame((frame) => frame.includes("Settings")) + expect(settings).toContain("Color mode") + expect(settings).toContain("Animations") + + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Settings")) + setup.mockInput.pressKey("F7") + await setup.renderOnce() + setup.mockInput.pressKey("p", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Commands")) + await setup.mockInput.typeText("auto-approve") + const commands = await setup.waitForFrame((frame) => frame.includes("Disable auto-approve permissions")) + expect(commands).not.toContain("Enable auto-approve permissions") + + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + createCliRenderer.mockRestore() + } +}) diff --git a/packages/tui/test/cli/tui/composer-keymap.test.tsx b/packages/tui/test/cli/tui/composer-keymap.test.tsx new file mode 100644 index 000000000000..fbccd5b3f20e --- /dev/null +++ b/packages/tui/test/cli/tui/composer-keymap.test.tsx @@ -0,0 +1,190 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { onMount } from "solid-js" +import { ConfigProvider } from "../../../src/config" +import type { TuiKeybind } from "../../../src/config/keybind" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData } from "../../../src/context/data" +import { Keymap } from "../../../src/context/keymap" +import { LocationProvider } from "../../../src/context/location" +import { RouteProvider, useRoute } from "../../../src/context/route" +import { ThemeProvider } from "../../../src/context/theme" +import { Composer } from "../../../src/routes/session/composer" +import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const sessions = { + parent: session("parent", "Parent"), + "child-a": session("child-a", "First", "parent"), + "child-b": session("child-b", "Second", "parent"), +} + +const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")] + +async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial) { + const events = createEventStream() + const interrupted: string[] = [] + const removed: string[] = [] + const ready = Promise.withResolvers() + let closed = 0 + let dispatch!: ReturnType["dispatch"] + let route!: ReturnType + const calls = createFetch((url, request) => { + if (url.pathname === "/api/session/active") + return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } }) + const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1] + if (sessionID && sessionID in sessions) return json({ data: sessions[sessionID as keyof typeof sessions] }) + const interruptID = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/)?.[1] + if (interruptID && request.method === "POST") { + interrupted.push(interruptID) + return new Response(null, { status: 204 }) + } + if (url.pathname === "/api/shell" && request.method === "GET") { + const requestDirectory = url.searchParams.get("location[directory]") ?? directory + return json({ + location: { directory: requestDirectory, project: { id: "proj_test", directory: requestDirectory } }, + data: shells, + }) + } + const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1] + if (shellID && request.method === "DELETE") { + removed.push(shellID) + return new Response(null, { status: 204 }) + } + }, events) + + function Content() { + const data = useData() + route = useRoute() + dispatch = Keymap.use().dispatch + onMount(() => { + void Promise.all([ + data.session.sync("parent"), + data.session.sync("child-a"), + data.session.sync("child-b"), + data.shell.sync(), + ]) + .then(() => wait(() => data.session.status("child-a") === "running")) + .then(() => ready.resolve(), ready.reject) + }) + return closed++} /> + } + + const app = await testRender( + () => ( + + + + + + + + ({}) }}> + + + + + + + + + + ), + { width: 100, height: 20, kittyKeyboard: true }, + ) + await ready.promise + await app.renderOnce() + return { + app, + interrupted, + removed, + route: () => route.data, + dispatch: (command: string) => dispatch(command), + closed: () => closed, + } +} + +test("disabled subagent bindings have no component fallbacks", async () => { + const composer = await renderComposer("subagents", { + "composer.subagent.up": "none", + "composer.subagent.down": "none", + "composer.subagent.select": "none", + "composer.subagent.interrupt": "none", + }) + try { + expect(composer.app.captureCharFrame()).toContain("First") + composer.app.mockInput.pressArrow("up") + composer.app.mockInput.pressEnter() + composer.app.mockInput.pressKey("d", { ctrl: true }) + await composer.app.renderOnce() + expect(composer.closed()).toBe(0) + expect(composer.route()).toMatchObject({ type: "session", sessionID: "parent" }) + expect(composer.interrupted).toEqual([]) + + composer.app.mockInput.pressArrow("down") + composer.dispatch("composer.subagent.select") + expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" }) + } finally { + composer.app.renderer.destroy() + } +}) + +test("disabled shell bindings have no component fallbacks", async () => { + const composer = await renderComposer("shell", { + "composer.shell.up": "none", + "composer.shell.down": "none", + "composer.shell.kill": "none", + }) + try { + expect(composer.app.captureCharFrame()).toContain("bun test") + composer.app.mockInput.pressArrow("up") + composer.app.mockInput.pressKey("d", { ctrl: true }) + await composer.app.renderOnce() + expect(composer.closed()).toBe(0) + expect(composer.removed).toEqual([]) + + composer.app.mockInput.pressArrow("down") + composer.dispatch("composer.shell.kill") + await wait(() => composer.removed.length === 1) + expect(composer.removed).toEqual(["sh-a"]) + } finally { + composer.app.renderer.destroy() + } +}) + +function session(id: string, title: string, parentID?: string) { + return { + id, + projectID: "proj_test", + title, + agent: "build", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + ...(parentID ? { parentID } : {}), + } +} + +function shell(id: string, command: string) { + return { + id, + status: "running" as const, + command, + cwd: directory, + shell: "/bin/sh", + file: `/tmp/${id}`, + metadata: { sessionID: "parent" }, + time: { started: 1 }, + } +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index c91234e3ef5a..b5ff4ef13a38 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -8,7 +8,7 @@ import { RunFooterView } from "../../src/mini/footer.view" import { RUN_THEME_FALLBACK } from "../../src/mini/theme" import type { FooterState, FooterSubagentState, FooterView } from "../../src/mini/types" -test("configured subagent key updates its hint and action", async () => { +async function renderSubagent(interrupt: "ctrl+i" | "none") { const [state] = createSignal({ phase: "idle", status: "", @@ -38,7 +38,7 @@ test("configured subagent key updates its hint and action", async () => { keybinds: { "prompt.editor": "none", "session.queued_prompts": "none", - "composer.subagent.interrupt": "ctrl+i", + "composer.subagent.interrupt": interrupt, }, }, { terminalSuspend: true }, @@ -97,14 +97,23 @@ test("configured subagent key updates its hint and action", async () => { } const app = await testRender(() => , { width: 100, height: 8, kittyKeyboard: true }) + return { app, interrupted } +} + +async function openSubagent(app: Awaited>) { + await app.renderOnce() + expect(app.renderer.currentFocusedEditor?.plainText).toBe("") + app.mockInput.pressArrow("down") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Select subagent") + app.mockInput.pressEnter() + await app.renderOnce() +} + +test("configured subagent key updates its hint and action", async () => { + const { app, interrupted } = await renderSubagent("ctrl+i") try { - await app.renderOnce() - expect(app.renderer.currentFocusedEditor?.plainText).toBe("") - app.mockInput.pressArrow("down") - await app.renderOnce() - expect(app.captureCharFrame()).toContain("Select subagent") - app.mockInput.pressEnter() - await app.renderOnce() + await openSubagent(app) expect(app.captureCharFrame()).toContain("ctrl+i") app.mockInput.pressKey("i", { ctrl: true }) expect(interrupted).toEqual(["subagent-1"]) @@ -114,3 +123,17 @@ test("configured subagent key updates its hint and action", async () => { app.renderer.destroy() } }) + +test("disabled subagent interrupt has no component fallback", async () => { + const { app, interrupted } = await renderSubagent("none") + try { + await openSubagent(app) + expect(app.captureCharFrame()).not.toContain("ctrl+d") + app.mockInput.pressKey("d", { ctrl: true }) + expect(interrupted).toEqual([]) + } finally { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 91b9d61e4457..9a36a513c0fb 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -8,31 +8,10 @@ import { createTuiResolvedConfig } from "../fixture/tui-runtime" function config(input?: { leader?: string leaderTimeout?: number - bindings?: Partial<{ - commandList: string[] - variantCycle: string[] - interrupt: string[] - historyPrevious: string[] - historyNext: string[] - inputClear: string[] - inputSubmit: string[] - inputNewline: string[] - }> }): Resolved { - const bind = input?.bindings return createTuiResolvedConfig({ leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout }, - keybinds: { - ...(input?.leader && { leader: input.leader }), - ...(bind?.commandList && { "command.palette.show": bind.commandList }), - ...(bind?.variantCycle && { "variant.cycle": bind.variantCycle }), - ...(bind?.interrupt && { "session.interrupt": bind.interrupt }), - ...(bind?.historyPrevious && { "prompt.history.previous": bind.historyPrevious }), - ...(bind?.historyNext && { "prompt.history.next": bind.historyNext }), - ...(bind?.inputClear && { "prompt.clear": bind.inputClear }), - ...(bind?.inputSubmit && { "input.submit": bind.inputSubmit }), - ...(bind?.inputNewline && { "input.newline": bind.inputNewline }), - }, + keybinds: input?.leader ? { leader: input.leader } : undefined, }) } @@ -41,33 +20,12 @@ describe("run runtime boot", () => { mock.restore() }) - test("reads footer keybinds from resolved keybind config", async () => { - const input = config({ - leader: "ctrl+g", - bindings: { - commandList: ["ctrl+p"], - variantCycle: ["ctrl+t", "alt+t"], - interrupt: ["ctrl+c"], - historyPrevious: ["k"], - historyNext: ["j"], - inputClear: ["ctrl+l"], - inputSubmit: ["ctrl+s"], - inputNewline: ["alt+return"], - }, - }) + test("returns supplied resolved config", async () => { + const input = config({ leader: "ctrl+g" }) const result = await resolveRunTuiConfig(input) - expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g") - expect(result.leader.timeout).toBe(2000) - expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p") - expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"]) - expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c") - expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k") - expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j") - expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l") - expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s") - expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return") + expect(result).toBe(input) }) test("falls back to default tui keymap config when config load fails", async () => { diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index e55e150bc004..df87a2317682 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -5,6 +5,7 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle" import type { FooterEvent, MiniHost } from "../../src/mini/types" import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog" import { createFooterApiFixture } from "./fixture/footer-api" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" function defer() { let resolve!: (value: T | PromiseLike) => void @@ -473,7 +474,7 @@ describe("run interactive runtime", () => { expect(closedTitle).toBe("Cached title") }) - test("adopts the deferred target location for catalogs, files, and runtime placement", async () => { + test("adopts deferred target placement and supplied TUI config", async () => { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const lifecycleStarted = defer() const painted = defer() @@ -483,6 +484,8 @@ describe("run interactive runtime", () => { let getDirectory: (() => string) | undefined let findFiles: ((query: string) => Promise) | undefined let transportLocation: unknown + let runtimeConfig: LifecycleInput["tuiConfig"] | undefined + const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } }) const catalogs = stubCatalogLists(sdk, { location: { directory: "/session", workspaceID: "work-1" }, }) @@ -519,11 +522,13 @@ describe("run interactive runtime", () => { model: undefined, variant: undefined, files: [], + tuiConfig, }, { createRuntimeLifecycle: async (input) => { getDirectory = input.getDirectory findFiles = input.findFiles + runtimeConfig = input.tuiConfig lifecycleStarted.resolve() return { footer: api, @@ -562,6 +567,8 @@ describe("run interactive runtime", () => { const query = { location: { directory: "/session", workspace: "work-1" } } expect(getDirectory?.()).toBe("/session") + if (!runtimeConfig) throw new Error("runtime lifecycle did not receive TUI config") + expect(await runtimeConfig).toBe(tuiConfig) expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" }) expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) }) expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) }) From 3e5af7fb86e70783ff2c48d3f17398de08b81545 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 23 Jul 2026 16:22:01 +0200 Subject: [PATCH 12/16] test(cli): rely on mini input inference --- packages/cli/test/mini-config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index 553f158e9024..014a5ed2574f 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -23,7 +23,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { let received: MiniCommandInput["tuiConfig"] const mini = await import("../src/mini") const validateMiniTerminal = spyOn(mini, "validateMiniTerminal").mockImplementation(() => {}) - const runMini = spyOn(mini, "runMini").mockImplementation((input: MiniCommandInput) => { + const runMini = spyOn(mini, "runMini").mockImplementation((input) => { received = input.tuiConfig return Promise.resolve() }) From acb494c005dbdb5cf0b5ab88f8d8b8efcd52ea16 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 23 Jul 2026 16:31:47 +0200 Subject: [PATCH 13/16] test: stabilize keybind integration tests --- packages/cli/test/mini-config.test.ts | 18 ++++++++++-------- packages/tui/test/app-lifecycle.test.tsx | 4 +++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index 014a5ed2574f..ab241b714dd0 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { Global } from "@opencode-ai/util/global" import { Effect, Option } from "effect" -import { expect, spyOn, test } from "bun:test" +import { expect, mock, test } from "bun:test" import { mkdir, rm } from "node:fs/promises" import path from "node:path" import { Config } from "../src/config" @@ -22,11 +22,14 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { ) let received: MiniCommandInput["tuiConfig"] const mini = await import("../src/mini") - const validateMiniTerminal = spyOn(mini, "validateMiniTerminal").mockImplementation(() => {}) - const runMini = spyOn(mini, "runMini").mockImplementation((input) => { - received = input.tuiConfig - return Promise.resolve() - }) + mock.module("../src/mini", () => ({ + ...mini, + validateMiniTerminal() {}, + runMini(input: Pick) { + received = input.tuiConfig + return Promise.resolve() + }, + })) const handler = (await import("../src/commands/handlers/mini")).default const server = Bun.serve({ port: 0, @@ -60,8 +63,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }]) } finally { server.stop(true) - validateMiniTerminal.mockRestore() - runMini.mockRestore() + mock.restore() await rm(root, { recursive: true, force: true }) } }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 4bf886e888ea..215444bdb3b0 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -260,7 +260,9 @@ test("configured app bindings execute settings and permission commands", async ( await setup.renderOnce() setup.mockInput.pressKey("p", { ctrl: true }) await setup.waitForFrame((frame) => frame.includes("Commands")) - await setup.mockInput.typeText("auto-approve") + const search = setup.renderer.currentFocusedEditor + if (!search) throw new Error("command palette search was not focused") + search.insertText("auto-approve") const commands = await setup.waitForFrame((frame) => frame.includes("Disable auto-approve permissions")) expect(commands).not.toContain("Enable auto-approve permissions") From ddb9ce7d2cce3507e056f0da9fabccabab88ab92 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 23 Jul 2026 16:41:15 +0200 Subject: [PATCH 14/16] test: fix cross-platform keybind fixtures --- packages/cli/test/mini-config.test.ts | 2 +- packages/tui/test/app-lifecycle.test.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts index ab241b714dd0..a93990d03341 100644 --- a/packages/cli/test/mini-config.test.ts +++ b/packages/cli/test/mini-config.test.ts @@ -44,7 +44,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => { continue: false, session: Option.none(), fork: false, - replay: true, + replay: true as never, replayLimit: Option.none(), model: Option.none(), agent: Option.none(), diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 215444bdb3b0..aebb032cbb0f 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -260,9 +260,10 @@ test("configured app bindings execute settings and permission commands", async ( await setup.renderOnce() setup.mockInput.pressKey("p", { ctrl: true }) await setup.waitForFrame((frame) => frame.includes("Commands")) - const search = setup.renderer.currentFocusedEditor - if (!search) throw new Error("command palette search was not focused") - search.insertText("auto-approve") + setup.mockInput.pressKey("END") + setup.mockInput.pressArrow("up") + setup.mockInput.pressArrow("up") + setup.mockInput.pressArrow("up") const commands = await setup.waitForFrame((frame) => frame.includes("Disable auto-approve permissions")) expect(commands).not.toContain("Enable auto-approve permissions") From 8ff6b5d427a5a18620e604f6fbad934bcd5c0139 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Fri, 24 Jul 2026 15:59:13 +0200 Subject: [PATCH 15/16] fix(cli): normalize canonical keybind duplicates --- packages/cli/src/config/migrate.ts | 4 +- packages/cli/test/config.test.ts | 67 ++++++------------- .../tui/test/cli/tui/diff-viewer.test.tsx | 17 ++--- packages/tui/test/mini/runtime.boot.test.ts | 25 ------- 4 files changed, 25 insertions(+), 88 deletions(-) diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index 5eb5522e746d..c36e8830e098 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -42,9 +42,8 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { const updated = Object.keys(keybinds).reduce((text, name) => { const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? - (LegacyKeybindTargets.has(name) ? name : undefined) + (name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined) if (target === undefined) return text - if (target === name && target in Definitions) return text const properties = findKeybindProperties(text, name) if (!properties.length) return text const remove = !(target in Definitions) || (target !== name && target in keybinds) @@ -54,6 +53,7 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: { return property === undefined ? text : removeProperty(text, property) }, text) if (remove) return updated + if (target === name) return updated const key = findKeybindProperties(updated, name)[0]?.children?.[0] if (key === undefined) return text return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length) diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index f02be3769a65..3f43141b47c7 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -239,7 +239,7 @@ test("preserves the effective value when migrating duplicate legacy keybinds", a } }) -test("migrates the effective duplicate top-level keybinds", async () => { +test("migrates and updates the effective duplicate top-level keybinds", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const file = path.join(directory, "cli.json") await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`) @@ -249,51 +249,16 @@ test("migrates the effective duplicate top-level keybinds", async () => { directory, Effect.gen(function* () { const service = yield* Config.Service - return yield* service.get() + expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" }) + return yield* service.update((draft) => { + draft.keybinds = { ...draft.keybinds, "session.delete": "changed" } + }) }), ) - expect(config.keybinds).toEqual({ "session.delete": "last" }) - expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "last" }) - } finally { - await Bun.$`rm -rf ${directory}` - } -}) - -test("does not overwrite a concurrent config update during migration", async () => { - const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) - const file = path.join(directory, "cli.json") - const initial = `{"keybinds":{"session_delete":"ctrl+d"}}` - await Bun.write(file, initial) - const gated = await Effect.runPromise(gateMigrationWrite(file, initial).pipe(Effect.provide(NodeFileSystem.layer))) - - try { - const config = await Effect.runPromise( - Effect.gen(function* () { - const service = yield* Config.Service - const reading = yield* service.get().pipe(Effect.forkChild({ startImmediately: true })) - expect(gated.state.writes).toBe(1) - const updating = yield* service - .update((draft) => { - draft.mouse = false - }) - .pipe(Effect.forkChild({ startImmediately: true })) - expect(gated.state.writes).toBe(1) - yield* gated.release.open - yield* Fiber.join(reading) - yield* Fiber.join(updating) - return yield* service.get() - }).pipe( - Effect.provide(Config.layer), - Effect.provide(Global.layerWith({ config: directory, state: directory })), - Effect.provideService(FileSystem.FileSystem, gated.fs), - ), - ) - - expect(config).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) - expect(await Bun.file(file).json()).toMatchObject({ keybinds: { "session.delete": "ctrl+d" }, mouse: false }) + expect(config.keybinds).toEqual({ "session.delete": "changed" }) + expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" }) } finally { - gated.release.openUnsafe() await Bun.$`rm -rf ${directory}` } }) @@ -343,25 +308,31 @@ test("does not overwrite a concurrent update from another config layer", async ( } }) -test("updates the effective duplicate top-level keybinds", async () => { +test("updates effective duplicate canonical keybinds", async () => { const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim()) const file = path.join(directory, "cli.json") - await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`) + await Bun.write( + file, + `{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`, + ) try { const config = await run( directory, Effect.gen(function* () { const service = yield* Config.Service - yield* service.get() + expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" }) return yield* service.update((draft) => { - draft.keybinds = { ...draft.keybinds, "session.delete": "changed" } + draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" } }) }), ) - expect(config.keybinds).toEqual({ "session.delete": "changed" }) - expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" }) + expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" }) + expect(parse(await Bun.file(file).text()).keybinds).toEqual({ + "session.delete": "changed", + "permission.mode": "changed", + }) } finally { await Bun.$`rm -rf ${directory}` } diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 60f0574dcc1d..cf8ca1a6f0a2 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -230,7 +230,10 @@ async function renderDiffViewer( } const app = await testRender(() => , { width: 80, height: options.height ?? 20 }) - await waitForCommand(app, commands, "diff.close") + await app.waitFor(async () => { + await Bun.sleep(25) + return commands.has("diff.close") + }) return { app, commands, @@ -324,15 +327,3 @@ test("branch diff source requests branch VCS diff", async () => { viewer.app.renderer.destroy() } }) - -async function waitForCommand( - app: Awaited>, - commands: Map, - command: string, -) { - for (let attempt = 0; attempt < 10; attempt++) { - await app.renderOnce() - if (commands.has(command)) return - await new Promise((resolve) => setTimeout(resolve, 25)) - } -} diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 9a36a513c0fb..5d92d6c65ee7 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -1,33 +1,14 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" -import type { Resolved } from "../../src/config" import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot" import { catalogModel, catalogProvider } from "./fixture/catalog" import { createTuiResolvedConfig } from "../fixture/tui-runtime" -function config(input?: { - leader?: string - leaderTimeout?: number -}): Resolved { - return createTuiResolvedConfig({ - leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout }, - keybinds: input?.leader ? { leader: input.leader } : undefined, - }) -} - describe("run runtime boot", () => { afterEach(() => { mock.restore() }) - test("returns supplied resolved config", async () => { - const input = config({ leader: "ctrl+g" }) - - const result = await resolveRunTuiConfig(input) - - expect(result).toBe(input) - }) - test("falls back to default tui keymap config when config load fails", async () => { const result = await resolveRunTuiConfig(Promise.reject(new Error("boom"))) @@ -43,12 +24,6 @@ describe("run runtime boot", () => { expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j") }) - test("preserves disabled leader from resolved tui config", async () => { - const result = await resolveRunTuiConfig(config({ leader: "none" })) - - expect(result.keybinds.get("leader")).toEqual([]) - }) - test("preserves shared config while resolving independent Mini defaults", async () => { const result = await resolveRunTuiConfig( createTuiResolvedConfig({ From 6ac6ea40fc5dd75404e94595c3c21ac6723c51e5 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Fri, 24 Jul 2026 16:06:44 +0200 Subject: [PATCH 16/16] test(tui): update new keybind fixture --- packages/tui/test/mini/footer.view.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index e3e934451783..e793df7e1ab1 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -1429,7 +1429,7 @@ test("direct footer keeps the command hint at its minimum width", async () => { test("direct footer keeps complete status text ahead of the spinner", async () => { const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none" } }), state: { phase: "running" }, width: 22, })