Explorar el Código

flatten to keybind compatible config (#26421)

Sebastian hace 3 meses
padre
commit
a0fc27e424
Se han modificado 38 ficheros con 1081 adiciones y 1503 borrados
  1. 89 119
      .opencode/plugins/tui-smoke.tsx
  2. 6 14
      .opencode/tui.json
  3. 15 15
      bun.lock
  4. 3 3
      package.json
  5. 11 2
      packages/opencode/specs/tui-plugins.md
  6. 0 36
      packages/opencode/specs/v2/keymappings.md
  7. 19 11
      packages/opencode/src/cli/cmd/run/runtime.boot.ts
  8. 37 4
      packages/opencode/src/cli/cmd/tui/app.tsx
  9. 1 1
      packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx
  10. 0 3
      packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
  11. 3 3
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  12. 2 2
      packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx
  13. 7 4
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  14. 5 6
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  15. 384 0
      packages/opencode/src/cli/cmd/tui/config/keybind.ts
  16. 0 187
      packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts
  17. 5 338
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  18. 13 29
      packages/opencode/src/cli/cmd/tui/config/tui.ts
  19. 1 1
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
  20. 3 4
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
  21. 17 16
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx
  22. 56 5
      packages/opencode/src/cli/cmd/tui/keymap.tsx
  23. 37 4
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  24. 5 7
      packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
  25. 5 9
      packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
  26. 10 8
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  27. 0 143
      packages/opencode/src/config/keybinds.ts
  28. 19 30
      packages/opencode/test/cli/run/runtime.boot.test.ts
  29. 14 25
      packages/opencode/test/cli/tui/plugin-loader.test.ts
  30. 10 10
      packages/opencode/test/cli/tui/plugin-toggle.test.ts
  31. 62 116
      packages/opencode/test/config/tui.test.ts
  32. 2 6
      packages/opencode/test/fixture/tui-plugin.ts
  33. 14 30
      packages/opencode/test/fixture/tui-runtime.ts
  34. 3 3
      packages/plugin/package.json
  35. 22 19
      packages/plugin/src/tui.ts
  36. 5 11
      packages/web/src/content/docs/config.mdx
  37. 190 269
      packages/web/src/content/docs/keybinds.mdx
  38. 6 10
      packages/web/src/content/docs/tui.mdx

+ 89 - 119
.opencode/plugins/tui-smoke.tsx

@@ -2,87 +2,62 @@
 import { useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
 import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
-import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras"
-import type { Binding } from "@opentui/keymap"
+import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras"
 import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
 
 const tabs = ["overview", "counter", "help"]
 const command = {
-  modal: "plugin.smoke.modal",
-  screen: "plugin.smoke.screen",
-  alert: "plugin.smoke.alert",
-  confirm: "plugin.smoke.confirm",
-  prompt: "plugin.smoke.prompt",
-  select: "plugin.smoke.select",
-  host: "plugin.smoke.host",
-  home: "plugin.smoke.home",
-  toast: "plugin.smoke.toast",
-  dialog_close: "plugin.smoke.dialog.close",
-  local_push: "plugin.smoke.local.push",
-  local_pop: "plugin.smoke.local.pop",
-  screen_home: "plugin.smoke.screen.home",
-  screen_left: "plugin.smoke.screen.left",
-  screen_right: "plugin.smoke.screen.right",
-  screen_up: "plugin.smoke.screen.up",
-  screen_down: "plugin.smoke.screen.down",
-  screen_modal: "plugin.smoke.screen.modal",
-  screen_local: "plugin.smoke.screen.local",
-  screen_host: "plugin.smoke.screen.host",
-  screen_alert: "plugin.smoke.screen.alert",
-  screen_confirm: "plugin.smoke.screen.confirm",
-  screen_prompt: "plugin.smoke.screen.prompt",
-  screen_select: "plugin.smoke.screen.select",
-  modal_accept: "plugin.smoke.modal.accept",
-  modal_close: "plugin.smoke.modal.close",
-} as const
-
-const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const
-type SectionName = (typeof sectionNames)[number]
-type SectionConfig = Record<string, BindingValue<Renderable, KeyEvent>>
-type ResolvedSections = Record<SectionName, Binding<Renderable, KeyEvent>[]>
-type SmokeKeymap = {
-  sections?: Partial<Record<SectionName, SectionConfig>>
+  modal: "smoke_modal",
+  screen: "smoke_screen",
+  alert: "smoke_alert",
+  confirm: "smoke_confirm",
+  prompt: "smoke_prompt",
+  select: "smoke_select",
+  host: "smoke_host",
+  home: "smoke_home",
+  toast: "smoke_toast",
+  dialog_close: "smoke_dialog_close",
+  local_push: "smoke_local_push",
+  local_pop: "smoke_local_pop",
+  screen_home: "smoke_screen_home",
+  screen_left: "smoke_screen_left",
+  screen_right: "smoke_screen_right",
+  screen_up: "smoke_screen_up",
+  screen_down: "smoke_screen_down",
+  screen_modal: "smoke_screen_modal",
+  screen_local: "smoke_screen_local",
+  screen_host: "smoke_screen_host",
+  screen_alert: "smoke_screen_alert",
+  screen_confirm: "smoke_screen_confirm",
+  screen_prompt: "smoke_screen_prompt",
+  screen_select: "smoke_screen_select",
+  modal_accept: "smoke_modal_accept",
+  modal_close: "smoke_modal_close",
 }
 
-type SmokeOptions = {
-  enabled?: boolean
-  label?: unknown
-  route?: unknown
-  vignette?: unknown
-  keymap?: SmokeKeymap
-}
+type SmokeBindings = BindingConfig<Renderable, KeyEvent>
 
 const defaultKeymap = {
-  global: {
-    [command.modal]: "ctrl+shift+m",
-    [command.screen]: "ctrl+shift+o",
-  },
-  dialog: {
-    [command.dialog_close]: "escape",
-  },
-  local: {
-    [command.local_push]: "enter,return",
-    [command.local_pop]: "escape,q,backspace",
-  },
-  screen: {
-    [command.screen_home]: "escape,ctrl+h",
-    [command.screen_left]: "left,h",
-    [command.screen_right]: "right,l",
-    [command.screen_up]: "up,k",
-    [command.screen_down]: "down,j",
-    [command.screen_modal]: "ctrl+shift+m",
-    [command.screen_local]: "x",
-    [command.screen_host]: "z",
-    [command.screen_alert]: "a",
-    [command.screen_confirm]: "c",
-    [command.screen_prompt]: "p",
-    [command.screen_select]: "s",
-  },
-  modal: {
-    [command.modal_accept]: "enter,return",
-    [command.modal_close]: "escape",
-  },
-} satisfies Record<SectionName, SectionConfig>
+  [command.modal]: "ctrl+shift+m",
+  [command.screen]: "ctrl+shift+o",
+  [command.dialog_close]: "escape",
+  [command.local_push]: "enter,return",
+  [command.local_pop]: "escape,q,backspace",
+  [command.screen_home]: "escape,ctrl+h",
+  [command.screen_left]: "left,h",
+  [command.screen_right]: "right,l",
+  [command.screen_up]: "up,k",
+  [command.screen_down]: "down,j",
+  [command.screen_modal]: "ctrl+shift+m",
+  [command.screen_local]: "x",
+  [command.screen_host]: "z",
+  [command.screen_alert]: "a",
+  [command.screen_confirm]: "c",
+  [command.screen_prompt]: "p",
+  [command.screen_select]: "s",
+  [command.modal_accept]: "enter,return",
+  [command.modal_close]: "escape",
+}
 
 const pick = (value: unknown, fallback: string) => {
   if (typeof value !== "string") return fallback
@@ -95,11 +70,14 @@ const num = (value: unknown, fallback: number) => {
   return value
 }
 
+const record = (value: unknown): value is Record<string, unknown> =>
+  !!value && typeof value === "object" && !Array.isArray(value)
+
 type Cfg = {
   label: string
   route: string
   vignette: number
-  keymap: SmokeKeymap | undefined
+  keybinds: SmokeBindings | undefined
 }
 
 type Route = {
@@ -116,12 +94,12 @@ type State = {
   local: number
 }
 
-const cfg = (options: SmokeOptions | undefined) => {
+const cfg = (options: Record<string, unknown> | undefined) => {
   return {
     label: pick(options?.label, "smoke"),
     route: pick(options?.route, "workspace-smoke"),
     vignette: Math.max(0, num(options?.vignette, 0.35)),
-    keymap: options?.keymap,
+    keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined,
   }
 }
 
@@ -132,21 +110,8 @@ const names = (input: Cfg) => {
   }
 }
 
-function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } {
-  const sections = resolveBindingSections(
-    {
-      global: { ...defaultKeymap.global, ...input?.sections?.global },
-      dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog },
-      local: { ...defaultKeymap.local, ...input?.sections?.local },
-      screen: { ...defaultKeymap.screen, ...input?.sections?.screen },
-      modal: { ...defaultKeymap.modal, ...input?.sections?.modal },
-    } satisfies BindingSectionsConfig<Renderable, KeyEvent>,
-    { sections: sectionNames },
-  ).sections
-
-  return {
-    sections,
-  }
+function createKeys(input: SmokeBindings | undefined) {
+  return createBindingLookup({ ...defaultKeymap, ...input })
 }
 
 type Keys = ReturnType<typeof createKeys>
@@ -376,7 +341,7 @@ const Screen = (props: {
         },
       },
     ],
-    bindings: props.keys.sections.dialog,
+    bindings: props.keys.gather("smoke.dialog", [command.dialog_close]),
   }))
 
   useBindings(() => ({
@@ -395,7 +360,7 @@ const Screen = (props: {
         },
       },
     ],
-    bindings: props.keys.sections.local,
+    bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]),
   }))
 
   useBindings(() => ({
@@ -478,7 +443,20 @@ const Screen = (props: {
         },
       },
     ],
-    bindings: props.keys.sections.screen,
+    bindings: props.keys.gather("smoke.screen", [
+      command.screen_home,
+      command.screen_left,
+      command.screen_right,
+      command.screen_up,
+      command.screen_down,
+      command.screen_modal,
+      command.screen_local,
+      command.screen_host,
+      command.screen_alert,
+      command.screen_confirm,
+      command.screen_prompt,
+      command.screen_select,
+    ]),
   }))
   const shortcuts = useKeymapSelector((keymap) => {
     const bindings = keymap.getCommandBindings({
@@ -687,7 +665,7 @@ const Modal = (props: {
         },
       },
     ],
-    bindings: props.keys.sections.modal,
+    bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]),
   }))
   const shortcuts = useKeymapSelector((keymap) => {
     const bindings = keymap.getCommandBindings({
@@ -766,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
     },
     home_prompt(ctx, value) {
       const skin = look(ctx.theme.current)
-      type Prompt = (props: {
-        workspaceID?: string
-        visible?: boolean
-        disabled?: boolean
-        onSubmit?: () => void
-        hint?: JSX.Element
-        right?: JSX.Element
-        showPlaceholder?: boolean
-        placeholders?: {
-          normal?: string[]
-          shell?: string[]
-        }
-      }) => JSX.Element
-      type Slot = (
-        props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
-      ) => JSX.Element | null
-      const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
-      const Prompt = ui.Prompt
-      const Slot = ui.Slot
+      const Prompt = api.ui.Prompt
+      const Slot = api.ui.Slot
       const normal = [
         `[SMOKE] route check for ${input.label}`,
         "[SMOKE] confirm home_prompt slot override",
@@ -1003,20 +964,29 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
         },
       },
     ],
-    bindings: keys.sections.global,
+    bindings: keys.gather("smoke.global", [
+      command.modal,
+      command.screen,
+      command.alert,
+      command.confirm,
+      command.prompt,
+      command.select,
+      command.host,
+      command.home,
+      command.toast,
+    ]),
   })
 }
 
 const tui: TuiPlugin = async (api, options, meta) => {
-  const input = options as SmokeOptions | undefined
-  if (input?.enabled === false) return
+  if (options?.enabled === false) return
 
   await api.theme.install("./smoke-theme.json")
   api.theme.set("smoke-theme")
 
-  const value = cfg(input)
+  const value = cfg(options)
   const route = names(value)
-  const keys = createKeys(value.keymap)
+  const keys = createKeys(value.keybinds)
   const fx = new VignetteEffect(value.vignette)
   const post = fx.apply.bind(fx)
   api.renderer.addPostProcessFn(post)

+ 6 - 14
.opencode/tui.json

@@ -6,20 +6,12 @@
       {
         "enabled": false,
         "label": "workspace",
-        "keymap": {
-          "sections": {
-            "global": {
-              "plugin.smoke.modal": "ctrl+alt+m",
-              "plugin.smoke.screen": "ctrl+alt+o"
-            },
-            "screen": {
-              "plugin.smoke.screen.home": "escape,ctrl+shift+h",
-              "plugin.smoke.screen.modal": "ctrl+alt+m"
-            },
-            "dialog": {
-              "plugin.smoke.dialog.close": "escape,q"
-            }
-          }
+        "keybinds": {
+          "smoke_modal": "ctrl+alt+m",
+          "smoke_screen": "ctrl+alt+o",
+          "smoke_screen_home": "escape,ctrl+shift+h",
+          "smoke_screen_modal": "ctrl+alt+m",
+          "smoke_dialog_close": "escape,q"
         }
       }
     ]

+ 15 - 15
bun.lock

@@ -519,9 +519,9 @@
         "typescript": "catalog:",
       },
       "peerDependencies": {
-        "@opentui/core": ">=0.2.5",
-        "@opentui/keymap": ">=0.2.5",
-        "@opentui/solid": ">=0.2.5",
+        "@opentui/core": ">=0.2.6",
+        "@opentui/keymap": ">=0.2.6",
+        "@opentui/solid": ">=0.2.6",
       },
       "optionalPeers": [
         "@opentui/core",
@@ -700,9 +700,9 @@
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@opentui/core": "0.2.5",
-    "@opentui/keymap": "0.2.5",
-    "@opentui/solid": "0.2.5",
+    "@opentui/core": "0.2.6",
+    "@opentui/keymap": "0.2.6",
+    "@opentui/solid": "0.2.6",
     "@pierre/diffs": "1.1.0-beta.18",
     "@playwright/test": "1.59.1",
     "@sentry/solid": "10.36.0",
@@ -1631,23 +1631,23 @@
 
     "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
 
-    "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="],
+    "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="],
+    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="],
+    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="],
+    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
 
-    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="],
+    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="],
+    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
 
-    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="],
+    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
 
-    "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="],
+    "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
 
-    "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="],
+    "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
 
     "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 

+ 3 - 3
package.json

@@ -35,9 +35,9 @@
       "@types/cross-spawn": "6.0.6",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
-      "@opentui/core": "0.2.5",
-      "@opentui/keymap": "0.2.5",
-      "@opentui/solid": "0.2.5",
+      "@opentui/core": "0.2.6",
+      "@opentui/keymap": "0.2.6",
+      "@opentui/solid": "0.2.6",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@types/luxon": "3.7.1",

+ 11 - 2
packages/opencode/specs/tui-plugins.md

@@ -20,6 +20,12 @@ Example:
 {
   "$schema": "https://opencode.ai/tui.json",
   "theme": "smoke-theme",
+  "leader_timeout": 2000,
+  "keybinds": {
+    "leader": "ctrl+x",
+    "command_list": "ctrl+p",
+    "session_new": "<leader>n"
+  },
   "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
   "plugin_enabled": {
     "acme.demo": false
@@ -39,6 +45,9 @@ Example:
 - Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
 - `plugin_enabled` is merged across config layers.
 - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
+- `leader_timeout` is a top-level TUI setting.
+- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects).
+- `keybinds.leader` sets the key used by `<leader>` shortcuts.
 
 ## Author package shape
 
@@ -228,14 +237,14 @@ Top-level API groups exposed to `tui(api, options, meta)`:
 - To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command.
 - Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution.
 - Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
-- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options.
+- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options.
 
 ### Keys
 
 - `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
 - `formatSequence(parts)` formats parsed key sequence parts using the host's display policy.
 - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
-- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`.
+- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
 
 ### Routes
 

+ 0 - 36
packages/opencode/specs/v2/keymappings.md

@@ -1,36 +0,0 @@
-# Keybindings vs. Keymappings
-
-Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like
-
-```ts
-{ key: "ctrl+w", cmd: string | function, description }
-```
-
-_Why_
-Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right.
-
-## OpenTUI Keymap Migration
-
-The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`:
-
-- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names.
-- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections.
-- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`.
-- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema.
-- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured.
-- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config.
-
-The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI.
-
-## Removing Legacy Later
-
-When switching fully to the new config style, remove legacy support with these exact changes:
-
-- Delete `packages/opencode/src/config/keybinds.ts`.
-- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`.
-- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`.
-- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`.
-- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead.
-- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only.
-- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`.
-- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests.

+ 19 - 11
packages/opencode/src/cli/cmd/run/runtime.boot.ts

@@ -6,7 +6,9 @@
 // history ring. All are async because they read config or hit the SDK, but
 // none block each other.
 import { Context, Effect, Layer } from "effect"
+import { stringifyKeyStroke } from "@opentui/keymap"
 import { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
 import { makeRuntime } from "@/effect/run-service"
 import { reusePendingTask } from "./runtime.shared"
 import { resolveSession, sessionHistory } from "./session.shared"
@@ -14,7 +16,7 @@ import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } f
 import { pickVariant } from "./variant.shared"
 
 const DEFAULT_KEYBINDS: FooterKeybinds = {
-  leader: "ctrl+x",
+  leader: TuiKeybind.LeaderDefault,
   leaderTimeout: 2000,
   commandList: [{ key: "ctrl+p" }],
   variantCycle: [{ key: "ctrl+t" }],
@@ -78,22 +80,28 @@ function emptySessionInfo(): SessionInfo {
   }
 }
 
+function leaderKey(config: Config) {
+  const key = config.keybinds.get("leader")?.[0]?.key
+  if (!key) return TuiKeybind.LeaderDefault
+  return typeof key === "string" ? key : stringifyKeyStroke(key)
+}
+
 function footerKeybinds(config: Config | undefined): FooterKeybinds {
   if (!config) {
     return DEFAULT_KEYBINDS
   }
 
   return {
-    leader: config.keymap.leader,
-    leaderTimeout: config.keymap.leader_timeout,
-    commandList: config.keymap.get("global", "command.palette.show") ?? [],
-    variantCycle: config.keymap.get("global", "variant.cycle") ?? [],
-    interrupt: config.keymap.get("prompt", "session.interrupt") ?? [],
-    historyPrevious: config.keymap.get("prompt", "prompt.history.previous") ?? [],
-    historyNext: config.keymap.get("prompt", "prompt.history.next") ?? [],
-    inputClear: config.keymap.get("prompt", "prompt.clear") ?? [],
-    inputSubmit: config.keymap.get("input", "input.submit") ?? [],
-    inputNewline: config.keymap.get("input", "input.newline") ?? [],
+    leader: leaderKey(config),
+    leaderTimeout: config.leader_timeout,
+    commandList: config.keybinds.get("command.palette.show"),
+    variantCycle: config.keybinds.get("variant.cycle"),
+    interrupt: config.keybinds.get("session.interrupt"),
+    historyPrevious: config.keybinds.get("prompt.history.previous"),
+    historyNext: config.keybinds.get("prompt.history.next"),
+    inputClear: config.keybinds.get("prompt.clear"),
+    inputSubmit: config.keybinds.get("input.submit"),
+    inputNewline: config.keybinds.get("input.newline"),
   }
 }
 

+ 37 - 4
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -70,6 +70,42 @@ import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencod
 import type { EventSource } from "./context/sdk"
 import { DialogVariant } from "./component/dialog-variant"
 
+const appBindingCommands = [
+  "command.palette.show",
+  "session.list",
+  "session.new",
+  "model.list",
+  "model.cycle_recent",
+  "model.cycle_recent_reverse",
+  "model.cycle_favorite",
+  "model.cycle_favorite_reverse",
+  "agent.list",
+  "mcp.list",
+  "agent.cycle",
+  "agent.cycle.reverse",
+  "variant.cycle",
+  "variant.list",
+  "provider.connect",
+  "console.org.switch",
+  "opencode.status",
+  "theme.switch",
+  "theme.switch_mode",
+  "theme.mode.lock",
+  "help.show",
+  "docs.open",
+  "app.exit",
+  "app.debug",
+  "app.console",
+  "app.heap_snapshot",
+  "terminal.suspend",
+  "terminal.title.toggle",
+  "app.toggle.animations",
+  "app.toggle.file_context",
+  "app.toggle.diffwrap",
+  "app.toggle.paste_summary",
+  "app.toggle.session_directory_filter",
+] as const
+
 function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
   const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
 
@@ -215,9 +251,6 @@ export function tui(input: {
 
 function App(props: { onSnapshot?: () => Promise<string[]> }) {
   const tuiConfig = useTuiConfig()
-  const {
-    keymap: { sections },
-  } = tuiConfig
   const route = useRoute()
   const dimensions = useTerminalDimensions()
   const renderer = useRenderer()
@@ -749,7 +782,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
 
   useBindings(() => ({
     enabled: command.matcher,
-    bindings: sections.global,
+    bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
   }))
 
   event.on(TuiEvent.CommandExecute.type, (evt) => {

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

@@ -46,7 +46,7 @@ export function DialogMcp() {
 
   const actions = createMemo(() => [
     {
-      command: "dialog.action.toggle",
+      command: "dialog.mcp.toggle",
       title: "toggle",
       onTrigger: async (option: DialogSelectOption<string>) => {
         // Prevent toggling while an operation is already in progress

+ 0 - 3
packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

@@ -8,13 +8,11 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
 import { DialogVariant } from "./dialog-variant"
 import * as fuzzysort from "fuzzysort"
 import { useConnected } from "./use-connected"
-import { useTuiConfig } from "../context/tui-config"
 
 export function DialogModel(props: { providerID?: string }) {
   const local = useLocal()
   const sync = useSync()
   const dialog = useDialog()
-  const tuiConfig = useTuiConfig()
   const [query, setQuery] = createSignal("")
 
   const connected = useConnected()
@@ -167,7 +165,6 @@ export function DialogModel(props: { providerID?: string }) {
           },
         },
       ]}
-      bindings={tuiConfig.keymap.sections.model}
       onFilter={setQuery}
       flat={true}
       skipFilter={true}

+ 3 - 3
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -28,7 +28,7 @@ export function DialogSessionList() {
   const toast = useToast()
   const [toDelete, setToDelete] = createSignal<string>()
   const [search, setSearch] = createDebouncedSignal("", 150)
-  const deleteHint = useCommandShortcut("dialog.action.delete")
+  const deleteHint = useCommandShortcut("session.delete")
 
   const [searchResults, { refetch }] = createResource(
     () => ({ query: search(), filter: sync.session.query() }),
@@ -190,7 +190,7 @@ export function DialogSessionList() {
       }}
       actions={[
         {
-          command: "dialog.action.delete",
+          command: "session.delete",
           title: "delete",
           onTrigger: async (option) => {
             if (toDelete() === option.value) {
@@ -238,7 +238,7 @@ export function DialogSessionList() {
           },
         },
         {
-          command: "dialog.action.rename",
+          command: "session.rename",
           title: "rename",
           onTrigger: async (option) => {
             dialog.replace(() => <DialogSessionRename session={option.value} />)

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

@@ -32,7 +32,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
   const { theme } = useTheme()
 
   const [toDelete, setToDelete] = createSignal<number>()
-  const deleteHint = useCommandShortcut("dialog.action.delete")
+  const deleteHint = useCommandShortcut("stash.delete")
 
   const options = createMemo(() => {
     const entries = stash.list()
@@ -70,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
       }}
       actions={[
         {
-          command: "dialog.action.delete",
+          command: "stash.delete",
           title: "delete",
           onTrigger: (option) => {
             if (toDelete() === option.value) {

+ 7 - 4
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -87,9 +87,6 @@ export function Autocomplete(props: {
   const dimensions = useTerminalDimensions()
   const frecency = useFrecency()
   const tuiConfig = useTuiConfig()
-  const {
-    keymap: { sections },
-  } = tuiConfig
   const [store, setStore] = createStore({
     index: 0,
     selected: 0,
@@ -575,7 +572,13 @@ export function Autocomplete(props: {
         },
       },
     ],
-    bindings: sections.autocomplete,
+    bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [
+      "prompt.autocomplete.prev",
+      "prompt.autocomplete.next",
+      "prompt.autocomplete.hide",
+      "prompt.autocomplete.select",
+      "prompt.autocomplete.complete",
+    ]),
   }))
 
   function show(mode: "@" | "/") {

+ 5 - 6
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -147,7 +147,6 @@ export function Prompt(props: PromptProps) {
   const project = useProject()
   const sync = useSync()
   const tuiConfig = useTuiConfig()
-  const keymapConfig = tuiConfig.keymap
   const dialog = useDialog()
   const toast = useToast()
   const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
@@ -630,7 +629,7 @@ export function Prompt(props: PromptProps) {
 
   useBindings(() => ({
     enabled: command.matcher,
-    bindings: keymapConfig.pick("prompt", [
+    bindings: tuiConfig.keybinds.gather("prompt.palette", [
       "prompt.submit",
       "prompt.editor",
       "prompt.editor_context.clear",
@@ -865,7 +864,7 @@ export function Prompt(props: PromptProps) {
     return {
       target: inputTarget,
       enabled: inputTarget() !== undefined && !props.disabled,
-      bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
+      bindings: tuiConfig.keybinds.get("prompt.paste"),
     }
   })
 
@@ -873,7 +872,7 @@ export function Prompt(props: PromptProps) {
     return {
       target: inputTarget,
       enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
-      bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
+      bindings: tuiConfig.keybinds.get("prompt.clear"),
     }
   })
 
@@ -957,7 +956,7 @@ export function Prompt(props: PromptProps) {
           },
         },
       ],
-      bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
+      bindings: tuiConfig.keybinds.get("prompt.history.previous"),
     }
   })
 
@@ -995,7 +994,7 @@ export function Prompt(props: PromptProps) {
           },
         },
       ],
-      bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
+      bindings: tuiConfig.keybinds.get("prompt.history.next"),
     }
   })
 

+ 384 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -0,0 +1,384 @@
+export * as TuiKeybind from "./keybind"
+
+import type { KeyEvent, Renderable } from "@opentui/core"
+import type { Binding } from "@opentui/keymap"
+import type { BindingCommandMap, BindingConfig, BindingDefaults, BindingValue } from "@opentui/keymap/extras"
+import z from "zod"
+
+const KeyStroke = z
+  .object({
+    name: z.string(),
+    ctrl: z.boolean().optional(),
+    shift: z.boolean().optional(),
+    meta: z.boolean().optional(),
+    super: z.boolean().optional(),
+    hyper: z.boolean().optional(),
+  })
+  .strict()
+
+const BindingObject = z
+  .object({
+    key: z.union([z.string(), KeyStroke]),
+    event: z.enum(["press", "release"]).optional(),
+    preventDefault: z.boolean().optional(),
+    fallthrough: z.boolean().optional(),
+  })
+  .passthrough()
+
+const BindingItem = z.union([z.string(), KeyStroke, BindingObject])
+export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)])
+
+type Definition = {
+  default: z.input<typeof BindingValueSchema>
+  description: string
+}
+
+const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
+export const LeaderDefault = "ctrl+x"
+
+const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
+
+const Definitions = {
+  leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
+
+  app_exit: keybind("ctrl+c,ctrl+d,<leader>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"),
+  app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"),
+  command_list: keybind("ctrl+p", "List available commands"),
+  help_show: keybind("none", "Open help dialog"),
+  docs_open: keybind("none", "Open documentation"),
+
+  editor_open: keybind("<leader>e", "Open external editor"),
+  theme_list: keybind("<leader>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"),
+  sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
+  scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
+  status_view: keybind("<leader>s", "View status"),
+
+  session_export: keybind("<leader>x", "Export session to editor"),
+  session_copy: keybind("none", "Copy session transcript"),
+  session_new: keybind("<leader>n", "Create a new session"),
+  session_list: keybind("<leader>l", "List all sessions"),
+  session_timeline: keybind("<leader>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_compact: keybind("<leader>c", "Compact the session"),
+  session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
+  session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
+  session_child_first: keybind("<leader>down", "Go to first child session"),
+  session_child_cycle: keybind("right", "Go to next child session"),
+  session_child_cycle_reverse: keybind("left", "Go to previous child session"),
+  session_parent: keybind("up", "Go to parent session"),
+
+  stash_delete: keybind("ctrl+d", "Delete stash entry"),
+  model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
+  model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
+  model_list: keybind("<leader>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 provider"),
+  console_org_switch: keybind("none", "Switch console organization"),
+  agent_list: keybind("<leader>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"),
+
+  messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
+  messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
+  messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
+  messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
+  messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
+  messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
+  messages_first: keybind("ctrl+g,home", "Navigate to first message"),
+  messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
+  messages_next: keybind("none", "Navigate to next message"),
+  messages_previous: keybind("none", "Navigate to previous message"),
+  messages_last_user: keybind("none", "Navigate to last user message"),
+  messages_copy: keybind("<leader>y", "Copy message"),
+  messages_undo: keybind("<leader>u", "Undo message"),
+  messages_redo: keybind("<leader>r", "Redo message"),
+  messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
+  tool_details: keybind("none", "Toggle tool details visibility"),
+  display_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"),
+  workspace_set: keybind("none", "Set workspace"),
+
+  input_clear: keybind("ctrl+c", "Clear input field"),
+  input_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(inputUndoDefault, "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"),
+  history_previous: keybind("up", "Previous history item"),
+  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.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
+  "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"),
+  tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
+  plugin_manager: keybind("none", "Open plugin manager dialog"),
+  plugin_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<string, Definition>
+
+type KeybindName = keyof typeof Definitions & string
+
+const KeybindShape = Object.fromEntries(
+  Object.entries(Definitions).map(([name, item]) => [
+    name,
+    BindingValueSchema.optional().default(item.default).describe(item.description),
+  ]),
+) as Record<KeybindName, z.ZodDefault<z.ZodOptional<typeof BindingValueSchema>>>
+
+const KeybindOverrideShape = Object.fromEntries(
+  Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]),
+) as Record<KeybindName, z.ZodOptional<typeof BindingValueSchema>>
+
+export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration")
+export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides")
+export const Descriptions = Object.fromEntries(
+  Object.entries(Definitions).map(([name, item]) => [name, item.description]),
+) as Record<KeybindName, string>
+export const CommandMap = {
+  app_exit: "app.exit",
+  app_debug: "app.debug",
+  app_console: "app.console",
+  app_heap_snapshot: "app.heap_snapshot",
+  app_toggle_animations: "app.toggle.animations",
+  app_toggle_file_context: "app.toggle.file_context",
+  app_toggle_diffwrap: "app.toggle.diffwrap",
+  app_toggle_paste_summary: "app.toggle.paste_summary",
+  app_toggle_session_directory_filter: "app.toggle.session_directory_filter",
+  command_list: "command.palette.show",
+  help_show: "help.show",
+  docs_open: "docs.open",
+  editor_open: "prompt.editor",
+  theme_list: "theme.switch",
+  theme_switch_mode: "theme.switch_mode",
+  theme_mode_lock: "theme.mode.lock",
+  sidebar_toggle: "session.sidebar.toggle",
+  scrollbar_toggle: "session.toggle.scrollbar",
+  status_view: "opencode.status",
+  session_export: "session.export",
+  session_copy: "session.copy",
+  session_new: "session.new",
+  session_list: "session.list",
+  session_timeline: "session.timeline",
+  session_fork: "session.fork",
+  session_rename: "session.rename",
+  session_delete: "session.delete",
+  session_share: "session.share",
+  session_unshare: "session.unshare",
+  session_interrupt: "session.interrupt",
+  session_compact: "session.compact",
+  session_toggle_timestamps: "session.toggle.timestamps",
+  session_toggle_generic_tool_output: "session.toggle.generic_tool_output",
+  session_child_first: "session.child.first",
+  session_child_cycle: "session.child.next",
+  session_child_cycle_reverse: "session.child.previous",
+  session_parent: "session.parent",
+  stash_delete: "stash.delete",
+  model_provider_list: "model.dialog.provider",
+  model_favorite_toggle: "model.dialog.favorite",
+  model_list: "model.list",
+  model_cycle_recent: "model.cycle_recent",
+  model_cycle_recent_reverse: "model.cycle_recent_reverse",
+  model_cycle_favorite: "model.cycle_favorite",
+  model_cycle_favorite_reverse: "model.cycle_favorite_reverse",
+  mcp_list: "mcp.list",
+  provider_connect: "provider.connect",
+  console_org_switch: "console.org.switch",
+  agent_list: "agent.list",
+  agent_cycle: "agent.cycle",
+  agent_cycle_reverse: "agent.cycle.reverse",
+  variant_cycle: "variant.cycle",
+  variant_list: "variant.list",
+  messages_page_up: "session.page.up",
+  messages_page_down: "session.page.down",
+  messages_line_up: "session.line.up",
+  messages_line_down: "session.line.down",
+  messages_half_page_up: "session.half.page.up",
+  messages_half_page_down: "session.half.page.down",
+  messages_first: "session.first",
+  messages_last: "session.last",
+  messages_next: "session.message.next",
+  messages_previous: "session.message.previous",
+  messages_last_user: "session.messages_last_user",
+  messages_copy: "messages.copy",
+  messages_undo: "session.undo",
+  messages_redo: "session.redo",
+  messages_toggle_conceal: "session.toggle.conceal",
+  tool_details: "session.toggle.actions",
+  display_thinking: "session.toggle.thinking",
+  prompt_submit: "prompt.submit",
+  prompt_editor_context_clear: "prompt.editor_context.clear",
+  prompt_skills: "prompt.skills",
+  prompt_stash: "prompt.stash",
+  prompt_stash_pop: "prompt.stash.pop",
+  prompt_stash_list: "prompt.stash.list",
+  workspace_set: "workspace.set",
+  input_clear: "prompt.clear",
+  input_paste: "prompt.paste",
+  input_submit: "input.submit",
+  input_newline: "input.newline",
+  input_move_left: "input.move.left",
+  input_move_right: "input.move.right",
+  input_move_up: "input.move.up",
+  input_move_down: "input.move.down",
+  input_select_left: "input.select.left",
+  input_select_right: "input.select.right",
+  input_select_up: "input.select.up",
+  input_select_down: "input.select.down",
+  input_line_home: "input.line.home",
+  input_line_end: "input.line.end",
+  input_select_line_home: "input.select.line.home",
+  input_select_line_end: "input.select.line.end",
+  input_visual_line_home: "input.visual.line.home",
+  input_visual_line_end: "input.visual.line.end",
+  input_select_visual_line_home: "input.select.visual.line.home",
+  input_select_visual_line_end: "input.select.visual.line.end",
+  input_buffer_home: "input.buffer.home",
+  input_buffer_end: "input.buffer.end",
+  input_select_buffer_home: "input.select.buffer.home",
+  input_select_buffer_end: "input.select.buffer.end",
+  input_delete_line: "input.delete.line",
+  input_delete_to_line_end: "input.delete.to.line.end",
+  input_delete_to_line_start: "input.delete.to.line.start",
+  input_backspace: "input.backspace",
+  input_delete: "input.delete",
+  input_undo: "input.undo",
+  input_redo: "input.redo",
+  input_word_forward: "input.word.forward",
+  input_word_backward: "input.word.backward",
+  input_select_word_forward: "input.select.word.forward",
+  input_select_word_backward: "input.select.word.backward",
+  input_delete_word_forward: "input.delete.word.forward",
+  input_delete_word_backward: "input.delete.word.backward",
+  input_select_all: "input.select.all",
+  history_previous: "prompt.history.previous",
+  history_next: "prompt.history.next",
+  terminal_suspend: "terminal.suspend",
+  terminal_title_toggle: "terminal.title.toggle",
+  tips_toggle: "tips.toggle",
+  plugin_manager: "plugins.list",
+  plugin_install: "plugins.install",
+  which_key_toggle: "which-key.toggle",
+  which_key_layout_toggle: "which-key.layout.toggle",
+  which_key_pending_toggle: "which-key.pending.toggle",
+  which_key_group_previous: "which-key.group.previous",
+  which_key_group_next: "which-key.group.next",
+  which_key_scroll_up: "which-key.scroll.up",
+  which_key_scroll_down: "which-key.scroll.down",
+  which_key_page_up: "which-key.page.up",
+  which_key_page_down: "which-key.page.down",
+  which_key_home: "which-key.home",
+  which_key_end: "which-key.end",
+} satisfies BindingCommandMap
+const CommandDescriptions = Object.fromEntries(
+  Object.entries(Definitions).map(([name, item]) => [
+    CommandMap[name as keyof typeof CommandMap] ?? name,
+    item.description,
+  ]),
+) as Record<string, string>
+
+export type Keybinds = z.output<typeof Keybinds>
+export type KeybindOverrides = z.output<typeof KeybindOverrides>
+export type BindingLookupView = {
+  readonly bindings: readonly Binding<Renderable, KeyEvent>[]
+  get(command: string): readonly Binding<Renderable, KeyEvent>[]
+  has(command: string): boolean
+  gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
+  pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
+  omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
+}
+
+export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
+  return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
+}
+
+export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
+  return ({ command, binding }) => {
+    if (binding.desc !== undefined) return
+    return { desc: CommandDescriptions[command] }
+  }
+}

+ 0 - 187
packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts

@@ -1,187 +0,0 @@
-import type { KeyEvent, Renderable } from "@opentui/core"
-import type { Binding } from "@opentui/keymap"
-import type { BindingValue } from "@opentui/keymap/extras"
-import { ConfigKeybinds } from "@/config/keybinds"
-import { type KeymapConfigInput, type KeymapSection } from "./tui-schema"
-
-type LegacyKeybinds = Partial<ConfigKeybinds.Keybinds>
-type SectionsConfig = Record<string, Record<string, BindingValue<Renderable, KeyEvent>>>
-
-const inputCommands = {
-  input_submit: "input.submit",
-  input_newline: "input.newline",
-  input_move_left: "input.move.left",
-  input_move_right: "input.move.right",
-  input_move_up: "input.move.up",
-  input_move_down: "input.move.down",
-  input_select_left: "input.select.left",
-  input_select_right: "input.select.right",
-  input_select_up: "input.select.up",
-  input_select_down: "input.select.down",
-  input_line_home: "input.line.home",
-  input_line_end: "input.line.end",
-  input_select_line_home: "input.select.line.home",
-  input_select_line_end: "input.select.line.end",
-  input_visual_line_home: "input.visual.line.home",
-  input_visual_line_end: "input.visual.line.end",
-  input_select_visual_line_home: "input.select.visual.line.home",
-  input_select_visual_line_end: "input.select.visual.line.end",
-  input_buffer_home: "input.buffer.home",
-  input_buffer_end: "input.buffer.end",
-  input_select_buffer_home: "input.select.buffer.home",
-  input_select_buffer_end: "input.select.buffer.end",
-  input_delete_line: "input.delete.line",
-  input_delete_to_line_end: "input.delete.to.line.end",
-  input_delete_to_line_start: "input.delete.to.line.start",
-  input_backspace: "input.backspace",
-  input_delete: "input.delete",
-  input_undo: "input.undo",
-  input_redo: "input.redo",
-  input_word_forward: "input.word.forward",
-  input_word_backward: "input.word.backward",
-  input_select_word_forward: "input.select.word.forward",
-  input_select_word_backward: "input.select.word.backward",
-  input_delete_word_forward: "input.delete.word.forward",
-  input_delete_word_backward: "input.delete.word.backward",
-  input_select_all: "input.select.all",
-} as const satisfies Partial<Record<keyof LegacyKeybinds, string>>
-
-function add(
-  config: SectionsConfig,
-  section: KeymapSection,
-  command: string,
-  binding: BindingValue<Renderable, KeyEvent> | undefined,
-) {
-  if (binding === undefined) return
-  config[section] ??= {}
-  config[section][command] = binding
-}
-
-function bindingWith(key: string | undefined, input: Omit<Binding<Renderable, KeyEvent>, "key" | "cmd">) {
-  if (!key) return undefined
-  if (key === "none") return "none"
-  return { ...input, key }
-}
-
-function combineBindings(...keys: (string | undefined)[]) {
-  const result = Array.from(
-    new Set(
-      keys.flatMap((key) => {
-        if (!key || key === "none") return []
-        return key
-          .split(",")
-          .map((part) => part.trim())
-          .filter((part) => part && part !== "none")
-      }),
-    ),
-  )
-  if (result.length) return result.join(",")
-  if (keys.some((key) => key === "none")) return "none"
-  return undefined
-}
-
-export function create(keybinds: LegacyKeybinds): KeymapConfigInput {
-  const config: SectionsConfig = {}
-
-  add(config, "global", "command.palette.show", keybinds.command_list)
-  add(config, "global", "session.list", keybinds.session_list)
-  add(config, "global", "session.new", keybinds.session_new)
-  add(config, "global", "model.list", keybinds.model_list)
-  add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent)
-  add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse)
-  add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite)
-  add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse)
-  add(config, "global", "agent.list", keybinds.agent_list)
-  add(config, "global", "agent.cycle", keybinds.agent_cycle)
-  add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse)
-  add(config, "global", "variant.cycle", keybinds.variant_cycle)
-  add(config, "global", "variant.list", keybinds.variant_list)
-  add(config, "prompt", "prompt.editor", keybinds.editor_open)
-  add(config, "global", "opencode.status", keybinds.status_view)
-  add(config, "global", "theme.switch", keybinds.theme_list)
-  add(config, "global", "app.exit", keybinds.app_exit)
-  add(config, "global", "terminal.suspend", keybinds.terminal_suspend)
-  add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle)
-
-  add(config, "session", "session.share", keybinds.session_share)
-  add(config, "session", "session.rename", keybinds.session_rename)
-  add(config, "session", "session.timeline", keybinds.session_timeline)
-  add(config, "session", "session.fork", keybinds.session_fork)
-  add(config, "session", "session.compact", keybinds.session_compact)
-  add(config, "session", "session.unshare", keybinds.session_unshare)
-  add(config, "session", "session.undo", keybinds.messages_undo)
-  add(config, "session", "session.redo", keybinds.messages_redo)
-  add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle)
-  add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal)
-  add(config, "session", "session.toggle.thinking", keybinds.display_thinking)
-  add(config, "session", "session.toggle.actions", keybinds.tool_details)
-  add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle)
-  add(config, "session", "session.page.up", keybinds.messages_page_up)
-  add(config, "session", "session.page.down", keybinds.messages_page_down)
-  add(config, "session", "session.line.up", keybinds.messages_line_up)
-  add(config, "session", "session.line.down", keybinds.messages_line_down)
-  add(config, "session", "session.half.page.up", keybinds.messages_half_page_up)
-  add(config, "session", "session.half.page.down", keybinds.messages_half_page_down)
-  add(config, "session", "session.first", keybinds.messages_first)
-  add(config, "session", "session.last", keybinds.messages_last)
-  add(config, "session", "session.messages_last_user", keybinds.messages_last_user)
-  add(config, "session", "session.message.next", keybinds.messages_next)
-  add(config, "session", "session.message.previous", keybinds.messages_previous)
-  add(config, "session", "messages.copy", keybinds.messages_copy)
-  add(config, "session", "session.export", keybinds.session_export)
-  add(config, "session", "session.child.first", keybinds.session_child_first)
-  add(config, "session", "session.parent", keybinds.session_parent)
-  add(config, "session", "session.child.next", keybinds.session_child_cycle)
-  add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse)
-
-  add(config, "prompt", "session.interrupt", keybinds.session_interrupt)
-  add(config, "prompt", "prompt.clear", keybinds.input_clear)
-  add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false }))
-  add(config, "prompt", "prompt.history.previous", keybinds.history_previous)
-  add(config, "prompt", "prompt.history.next", keybinds.history_next)
-
-  add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"])
-  add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"])
-  add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"])
-  add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"])
-  add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"])
-
-  for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) {
-    add(config, "input", command, keybinds[legacy])
-  }
-
-  add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"])
-  add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"])
-  add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"])
-  add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"])
-  add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"])
-  add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"])
-  add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"])
-  add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete))
-  add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename)
-  add(
-    config,
-    "dialog_actions",
-    "dialog.action.toggle",
-    combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]),
-  )
-  add(config, "model", "model.dialog.provider", keybinds.model_provider_list)
-  add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle)
-
-  add(config, "permission", "permission.reject.cancel", keybinds.app_exit)
-  add(config, "permission", "permission.prompt.escape", keybinds.app_exit)
-  add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"])
-  add(config, "question", "question.reject", keybinds.app_exit)
-  add(config, "question", "question.edit.clear", keybinds.input_clear)
-
-  add(config, "plugins", "plugins.list", keybinds.plugin_manager)
-  add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"])
-  add(config, "home_tips", "tips.toggle", keybinds.tips_toggle)
-
-  return {
-    ...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }),
-    sections: config,
-  }
-}
-
-export * as LegacyKeymapTransform from "./legacy-keymap-transform"

+ 5 - 338
packages/opencode/src/cli/cmd/tui/config/tui-schema.ts

@@ -1,339 +1,12 @@
 import z from "zod"
-import type { KeyEvent, Renderable } from "@opentui/core"
-import type { Binding } from "@opentui/keymap"
-import type { ResolvedBindingSections } from "@opentui/keymap/extras"
 import { ConfigPlugin } from "@/config/plugin"
-import { ConfigKeybinds } from "@/config/keybinds"
+import { TuiKeybind } from "./keybind"
 
-const KeybindOverride = z
-  .object(
-    Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
-      string,
-      z.ZodOptional<z.ZodString>
-    >,
-  )
-  .strict()
-
-const KeyStroke = z
-  .object({
-    name: z.string(),
-    ctrl: z.boolean().optional(),
-    shift: z.boolean().optional(),
-    meta: z.boolean().optional(),
-    super: z.boolean().optional(),
-    hyper: z.boolean().optional(),
-  })
-  .strict()
-
-const KeymapBindingObject = z
-  .object({
-    key: z.union([z.string(), KeyStroke]),
-    event: z.enum(["press", "release"]).optional(),
-    preventDefault: z.boolean().optional(),
-    fallthrough: z.boolean().optional(),
-  })
-  .passthrough()
-
-const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject])
-const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)])
-
-const keymapBinding = (value: z.input<typeof KeymapBindingValue> | (() => z.input<typeof KeymapBindingValue>)) =>
-  KeymapBindingValue.prefault(value)
-const keymapSection = <Shape extends z.ZodRawShape>(shape: Shape) => {
-  const schema = z.object(shape).strict()
-  return schema.prefault({} as z.input<typeof schema>)
-}
-const keymapSectionInput = <Shape extends z.ZodRawShape>(shape: Shape) =>
-  z
-    .object(
-      Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as {
-        [Key in keyof Shape]: z.ZodOptional<typeof KeymapBindingValue>
-      },
-    )
-    .strict()
-
-const GlobalKeymapSection = {
-  "command.palette.show": keymapBinding("ctrl+p"),
-  "session.list": keymapBinding("<leader>l"),
-  "session.new": keymapBinding("<leader>n"),
-  "model.list": keymapBinding("<leader>m"),
-  "model.cycle_recent": keymapBinding("f2"),
-  "model.cycle_recent_reverse": keymapBinding("shift+f2"),
-  "model.cycle_favorite": keymapBinding("none"),
-  "model.cycle_favorite_reverse": keymapBinding("none"),
-  "agent.list": keymapBinding("<leader>a"),
-  "mcp.list": keymapBinding("none"),
-  "agent.cycle": keymapBinding("tab"),
-  "agent.cycle.reverse": keymapBinding("shift+tab"),
-  "variant.cycle": keymapBinding("ctrl+t"),
-  "variant.list": keymapBinding("none"),
-  "provider.connect": keymapBinding("none"),
-  "console.org.switch": keymapBinding("none"),
-  "opencode.status": keymapBinding("<leader>s"),
-  "theme.switch": keymapBinding("<leader>t"),
-  "theme.switch_mode": keymapBinding("none"),
-  "theme.mode.lock": keymapBinding("none"),
-  "help.show": keymapBinding("none"),
-  "docs.open": keymapBinding("none"),
-  "app.exit": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
-  "app.debug": keymapBinding("none"),
-  "app.console": keymapBinding("none"),
-  "app.heap_snapshot": keymapBinding("none"),
-  "app.toggle.animations": keymapBinding("none"),
-  "app.toggle.file_context": keymapBinding("none"),
-  "app.toggle.diffwrap": keymapBinding("none"),
-  "app.toggle.paste_summary": keymapBinding("none"),
-  "app.toggle.session_directory_filter": keymapBinding("none"),
-  "terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")),
-  "terminal.title.toggle": keymapBinding("none"),
-}
-
-const WhichKeyKeymapSection = {
-  "tui-which-key.toggle": keymapBinding("ctrl+alt+k"),
-  "tui-which-key.layout.toggle": keymapBinding("ctrl+alt+shift+k"),
-  "tui-which-key.pending.toggle": keymapBinding("ctrl+alt+shift+p"),
-  "tui-which-key.group.previous": keymapBinding("ctrl+alt+left,ctrl+alt+["),
-  "tui-which-key.group.next": keymapBinding("ctrl+alt+right,ctrl+alt+]"),
-  "tui-which-key.scroll.up": keymapBinding("ctrl+alt+up,ctrl+alt+p"),
-  "tui-which-key.scroll.down": keymapBinding("ctrl+alt+down,ctrl+alt+n"),
-  "tui-which-key.page.up": keymapBinding("ctrl+alt+pageup"),
-  "tui-which-key.page.down": keymapBinding("ctrl+alt+pagedown"),
-  "tui-which-key.home": keymapBinding("ctrl+alt+home"),
-  "tui-which-key.end": keymapBinding("ctrl+alt+end"),
-}
-
-const SessionKeymapSection = {
-  "session.share": keymapBinding("none"),
-  "session.rename": keymapBinding("ctrl+r"),
-  "session.timeline": keymapBinding("<leader>g"),
-  "session.fork": keymapBinding("none"),
-  "session.compact": keymapBinding("<leader>c"),
-  "session.unshare": keymapBinding("none"),
-  "session.undo": keymapBinding("<leader>u"),
-  "session.redo": keymapBinding("<leader>r"),
-  "session.sidebar.toggle": keymapBinding("<leader>b"),
-  "session.toggle.conceal": keymapBinding("<leader>h"),
-  "session.toggle.timestamps": keymapBinding("none"),
-  "session.toggle.thinking": keymapBinding("none"),
-  "session.toggle.actions": keymapBinding("none"),
-  "session.toggle.scrollbar": keymapBinding("none"),
-  "session.toggle.generic_tool_output": keymapBinding("none"),
-  "session.page.up": keymapBinding("pageup,ctrl+alt+b"),
-  "session.page.down": keymapBinding("pagedown,ctrl+alt+f"),
-  "session.line.up": keymapBinding("ctrl+alt+y"),
-  "session.line.down": keymapBinding("ctrl+alt+e"),
-  "session.half.page.up": keymapBinding("ctrl+alt+u"),
-  "session.half.page.down": keymapBinding("ctrl+alt+d"),
-  "session.first": keymapBinding("ctrl+g,home"),
-  "session.last": keymapBinding("ctrl+alt+g,end"),
-  "session.messages_last_user": keymapBinding("none"),
-  "session.message.next": keymapBinding("none"),
-  "session.message.previous": keymapBinding("none"),
-  "messages.copy": keymapBinding("<leader>y"),
-  "session.copy": keymapBinding("none"),
-  "session.export": keymapBinding("<leader>x"),
-  "session.child.first": keymapBinding("<leader>down"),
-  "session.parent": keymapBinding("up"),
-  "session.child.next": keymapBinding("right"),
-  "session.child.previous": keymapBinding("left"),
-}
-
-const PromptKeymapSection = {
-  "prompt.submit": keymapBinding("none"),
-  "prompt.editor": keymapBinding("<leader>e"),
-  "prompt.editor_context.clear": keymapBinding("none"),
-  "prompt.skills": keymapBinding("none"),
-  "prompt.stash": keymapBinding("none"),
-  "prompt.stash.pop": keymapBinding("none"),
-  "prompt.stash.list": keymapBinding("none"),
-  "workspace.set": keymapBinding("none"),
-  "session.interrupt": keymapBinding("escape"),
-  "prompt.clear": keymapBinding("ctrl+c"),
-  "prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }),
-  "prompt.history.previous": keymapBinding("up"),
-  "prompt.history.next": keymapBinding("down"),
-}
-
-const AutocompleteKeymapSection = {
-  "prompt.autocomplete.prev": keymapBinding("up,ctrl+p"),
-  "prompt.autocomplete.next": keymapBinding("down,ctrl+n"),
-  "prompt.autocomplete.hide": keymapBinding("escape"),
-  "prompt.autocomplete.select": keymapBinding("return"),
-  "prompt.autocomplete.complete": keymapBinding("tab"),
-}
-
-const InputKeymapSection = {
-  "input.submit": keymapBinding("return"),
-  "input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"),
-  "input.move.left": keymapBinding("left,ctrl+b"),
-  "input.move.right": keymapBinding("right,ctrl+f"),
-  "input.move.up": keymapBinding("up"),
-  "input.move.down": keymapBinding("down"),
-  "input.select.left": keymapBinding("shift+left"),
-  "input.select.right": keymapBinding("shift+right"),
-  "input.select.up": keymapBinding("shift+up"),
-  "input.select.down": keymapBinding("shift+down"),
-  "input.line.home": keymapBinding("ctrl+a"),
-  "input.line.end": keymapBinding("ctrl+e"),
-  "input.select.line.home": keymapBinding("ctrl+shift+a"),
-  "input.select.line.end": keymapBinding("ctrl+shift+e"),
-  "input.visual.line.home": keymapBinding("alt+a"),
-  "input.visual.line.end": keymapBinding("alt+e"),
-  "input.select.visual.line.home": keymapBinding("alt+shift+a"),
-  "input.select.visual.line.end": keymapBinding("alt+shift+e"),
-  "input.buffer.home": keymapBinding("home"),
-  "input.buffer.end": keymapBinding("end"),
-  "input.select.buffer.home": keymapBinding("shift+home"),
-  "input.select.buffer.end": keymapBinding("shift+end"),
-  "input.delete.line": keymapBinding("ctrl+shift+d"),
-  "input.delete.to.line.end": keymapBinding("ctrl+k"),
-  "input.delete.to.line.start": keymapBinding("ctrl+u"),
-  "input.backspace": keymapBinding("backspace,shift+backspace"),
-  "input.delete": keymapBinding("ctrl+d,delete,shift+delete"),
-  "input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")),
-  "input.redo": keymapBinding("ctrl+.,super+shift+z"),
-  "input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"),
-  "input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"),
-  "input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"),
-  "input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"),
-  "input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"),
-  "input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"),
-  "input.select.all": keymapBinding("super+a"),
-}
-
-const DialogSelectKeymapSection = {
-  "dialog.select.prev": keymapBinding("up,ctrl+p"),
-  "dialog.select.next": keymapBinding("down,ctrl+n"),
-  "dialog.select.page_up": keymapBinding("pageup"),
-  "dialog.select.page_down": keymapBinding("pagedown"),
-  "dialog.select.home": keymapBinding("home"),
-  "dialog.select.end": keymapBinding("end"),
-  "dialog.select.submit": keymapBinding("return"),
-}
-
-const DialogActionsKeymapSection = {
-  "dialog.action.toggle": keymapBinding("space"),
-  "dialog.action.delete": keymapBinding("ctrl+d"),
-  "dialog.action.rename": keymapBinding("ctrl+r"),
-}
-
-const ModelKeymapSection = {
-  "model.dialog.provider": keymapBinding("ctrl+a"),
-  "model.dialog.favorite": keymapBinding("ctrl+f"),
-}
-
-const PermissionKeymapSection = {
-  "permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
-  "permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
-  "permission.prompt.fullscreen": keymapBinding("ctrl+f"),
-}
-
-const QuestionKeymapSection = {
-  "question.reject": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
-  "question.edit.clear": keymapBinding("ctrl+c"),
-}
-
-const PluginsKeymapSection = {
-  "plugins.list": keymapBinding("none"),
-  "plugins.install": keymapBinding("none"),
-  "plugin.dialog.install": keymapBinding("shift+i"),
-}
-
-const HomeTipsKeymapSection = {
-  "tips.toggle": keymapBinding("<leader>h"),
-}
-
-const KeymapSectionsShape = {
-  global: keymapSection(GlobalKeymapSection),
-  which_key: keymapSection(WhichKeyKeymapSection),
-  session: keymapSection(SessionKeymapSection),
-  prompt: keymapSection(PromptKeymapSection),
-  autocomplete: keymapSection(AutocompleteKeymapSection),
-  input: keymapSection(InputKeymapSection),
-  dialog_select: keymapSection(DialogSelectKeymapSection),
-  dialog_actions: keymapSection(DialogActionsKeymapSection),
-  model: keymapSection(ModelKeymapSection),
-  permission: keymapSection(PermissionKeymapSection),
-  question: keymapSection(QuestionKeymapSection),
-  plugins: keymapSection(PluginsKeymapSection),
-  home_tips: keymapSection(HomeTipsKeymapSection),
-}
-
-const KeymapSectionsInputShape = {
-  global: keymapSectionInput(GlobalKeymapSection).optional(),
-  which_key: keymapSectionInput(WhichKeyKeymapSection).optional(),
-  session: keymapSectionInput(SessionKeymapSection).optional(),
-  prompt: keymapSectionInput(PromptKeymapSection).optional(),
-  autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
-  input: keymapSectionInput(InputKeymapSection).optional(),
-  dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(),
-  dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(),
-  model: keymapSectionInput(ModelKeymapSection).optional(),
-  permission: keymapSectionInput(PermissionKeymapSection).optional(),
-  question: keymapSectionInput(QuestionKeymapSection).optional(),
-  plugins: keymapSectionInput(PluginsKeymapSection).optional(),
-  home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(),
-}
-
-export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({})
-export type KeymapSections = z.output<typeof KeymapSections>
-export type KeymapSection = keyof KeymapSections
-export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[]
 export const KeymapLeaderTimeoutDefault = 2000
-export type KeymapInfo = {
-  leader: string
-  leader_timeout: number
-} & ResolvedBindingSections<Renderable, KeyEvent, KeymapSection>
-
-export const KeymapSectionGroups = {
-  global: "Global",
-  which_key: "System",
-  session: "Session",
-  prompt: "Prompt",
-  autocomplete: "Autocomplete",
-  input: "Text Editing",
-  dialog_select: "Dialog",
-  dialog_actions: "Dialog",
-  model: "Model",
-  permission: "Permission",
-  question: "Question",
-  plugins: "Plugins",
-  home_tips: "Home",
-} satisfies Record<KeymapSection, string>
-
-export function keymapBindingDefaults(input: { section: string; binding: Readonly<Binding<Renderable, KeyEvent>> }) {
-  if (input.binding.group !== undefined) return
-  if (!Object.hasOwn(KeymapSectionGroups, input.section)) return
-  return { group: KeymapSectionGroups[input.section as KeymapSection] }
-}
-
-export const KeymapConfig = z
-  .object({
-    leader: z.string().prefault("ctrl+x"),
-    leader_timeout: z
-      .number()
-      .int()
-      .positive()
-      .prefault(KeymapLeaderTimeoutDefault)
-      .describe("Leader key timeout in milliseconds"),
-    sections: KeymapSections,
-  })
-  .strict()
-  .describe("TUI keymap configuration")
-export type KeymapConfig = z.output<typeof KeymapConfig>
-
-const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional()
-export const KeymapConfigInput = z
-  .object({
-    leader: z.string().optional(),
-    leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"),
-    sections: KeymapSectionsInput,
-  })
-  .strict()
-  .describe("TUI keymap configuration")
-export type KeymapConfigInput = z.output<typeof KeymapConfigInput>
+const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds")
 
 export const TuiOptions = z.object({
+  leader_timeout: KeymapLeaderTimeout.optional(),
   scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
   scroll_acceleration: z
     .object({
@@ -352,17 +25,11 @@ export const TuiInfo = z
   .object({
     $schema: z.string().optional(),
     theme: z.string().optional(),
-    keybinds: KeybindOverride.optional().meta({
-      deprecated: true,
-      description: "Use keymap instead. This will be removed in opencode v2.0.",
-    }),
-    keymap: KeymapConfigInput.optional(),
+    keybinds: TuiKeybind.KeybindOverrides.optional(),
     plugin: ConfigPlugin.Spec.zod.array().optional(),
     plugin_enabled: z.record(z.string(), z.boolean()).optional(),
   })
   .extend(TuiOptions.shape)
   .strict()
 
-export const TuiJsonSchemaInfo = TuiInfo.extend({
-  keymap: KeymapConfig.optional(),
-}).strict()
+export const TuiJsonSchemaInfo = TuiInfo

+ 13 - 29
packages/opencode/src/cli/cmd/tui/config/tui.ts

@@ -1,29 +1,26 @@
 export * as TuiConfig from "./tui"
 
 import type z from "zod"
-import type { KeyEvent, Renderable } from "@opentui/core"
-import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
+import { createBindingLookup } from "@opentui/keymap/extras"
 import { mergeDeep, unique } from "remeda"
 import { Context, Effect, Fiber, Layer } from "effect"
 import { ConfigParse } from "@/config/parse"
 import * as ConfigPaths from "@/config/paths"
 import { migrateTuiConfig } from "./tui-migrate"
-import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
+import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { isRecord } from "@/util/record"
 import { Global } from "@opencode-ai/core/global"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { CurrentWorkingDirectory } from "./cwd"
 import { ConfigPlugin } from "@/config/plugin"
-import { ConfigKeybinds } from "@/config/keybinds"
+import { TuiKeybind } from "./keybind"
 import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
 import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import { Filesystem } from "@/util/filesystem"
 import * as Log from "@opencode-ai/core/util/log"
 import { ConfigVariable } from "@/config/variable"
 import { Npm } from "@opencode-ai/core/npm"
-import { LegacyKeymapTransform } from "./legacy-keymap-transform"
-import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema"
 
 const log = Log.create({ service: "tui.config" })
 
@@ -36,9 +33,9 @@ type Acc = {
   plugin_origins: ConfigPlugin.Origin[]
 }
 
-export type Resolved = Omit<Info, "keybinds" | "keymap"> & {
-  keybinds: ConfigKeybinds.Keybinds
-  keymap: KeymapInfo
+export type Resolved = Omit<Info, "keybinds" | "leader_timeout"> & {
+  keybinds: TuiKeybind.BindingLookupView
+  leader_timeout: number
   // Internal resolved plugin list used by runtime loading.
   plugin_origins?: ConfigPlugin.Origin[]
 }
@@ -186,31 +183,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
     keybinds.terminal_suspend = "none"
     keybinds.input_undo ??= unique([
       "ctrl+z",
-      ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
+      ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","),
     ]).join(",")
   }
-  const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds)
-  const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {})
-  const keymapConfig = KeymapConfig.parse(keymapInput)
-  const keymap = {
-    leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader,
-    leader_timeout: keymapConfig.leader_timeout,
-    ...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
-      keymapConfig.sections,
-      {
-        sections: KeymapSectionNames,
-        bindingDefaults: keymapBindingDefaults,
-      },
-    ),
-  }
+  const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds)
   const result: Resolved = {
     ...acc.result,
-    keybinds: parsedKeybinds,
+    keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
+      commandMap: TuiKeybind.CommandMap,
+      bindingDefaults: TuiKeybind.bindingDefaults(),
+    }),
+    leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault,
     plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
-    // `keybinds` is deprecated and will be removed in opencode v2.0. Keep it
-    // only as the legacy fallback; once `keymap` is configured, ignore
-    // `keybinds` for keymap resolution.
-    keymap,
   }
 
   return {

+ 1 - 1
packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx

@@ -20,7 +20,7 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
         },
       },
     ],
-    bindings: props.api.tuiConfig.keymap.sections.home_tips,
+    bindings: props.api.tuiConfig.keybinds.get("tips.toggle"),
   }))
 
   return (

+ 3 - 4
packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx

@@ -207,7 +207,7 @@ function View(props: { api: TuiPluginApi }) {
       actions={[
         {
           title: "toggle",
-          command: "dialog.action.toggle",
+          command: "plugins.toggle",
           disabled: lock(),
           onTrigger: (item) => {
             setCur(item.value)
@@ -216,14 +216,13 @@ function View(props: { api: TuiPluginApi }) {
         },
         {
           title: "install",
-          command: "plugin.dialog.install",
+          command: "dialog.plugins.install",
           disabled: lock(),
           onTrigger: () => {
             showInstall(props.api)
           },
         },
       ]}
-      bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])}
       onSelect={(item) => {
         setCur(item.value)
         flip(item.value)
@@ -258,7 +257,7 @@ const tui: TuiPlugin = async (api) => {
         },
       },
     ],
-    bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]),
+    bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]),
   })
 }
 

+ 17 - 16
packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx

@@ -8,17 +8,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { InternalTuiPlugin } from "../../plugin/internal"
 
 const command = {
-  toggle: "tui-which-key.toggle",
-  toggleLayout: "tui-which-key.layout.toggle",
-  togglePending: "tui-which-key.pending.toggle",
-  groupPrevious: "tui-which-key.group.previous",
-  groupNext: "tui-which-key.group.next",
-  scrollUp: "tui-which-key.scroll.up",
-  scrollDown: "tui-which-key.scroll.down",
-  pageUp: "tui-which-key.page.up",
-  pageDown: "tui-which-key.page.down",
-  home: "tui-which-key.home",
-  end: "tui-which-key.end",
+  toggle: "which-key.toggle",
+  toggleLayout: "which-key.layout.toggle",
+  togglePending: "which-key.pending.toggle",
+  groupPrevious: "which-key.group.previous",
+  groupNext: "which-key.group.next",
+  scrollUp: "which-key.scroll.up",
+  scrollDown: "which-key.scroll.down",
+  pageUp: "which-key.page.up",
+  pageDown: "which-key.page.down",
+  home: "which-key.home",
+  end: "which-key.end",
 } as const
 
 const LAYER_PRIORITY = 900
@@ -112,8 +112,7 @@ function skin(api: TuiPluginApi): Skin {
 }
 
 function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
-  const group = text(active.bindingAttrs?.group)
-  if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN
+  if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
   return (
     text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
   )
@@ -361,7 +360,9 @@ function WhichKeyPanel(props: {
         },
       },
     ],
-    bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands),
+    bindings: pendingMode()
+      ? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands)
+      : props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands),
   }))
 
   createEffect(() => {
@@ -571,7 +572,7 @@ const tui: TuiPlugin = async (api) => {
         },
       },
     ],
-    bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands),
+    bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands),
   })
 
   api.slots.register({
@@ -599,7 +600,7 @@ const tui: TuiPlugin = async (api) => {
 }
 
 const plugin: InternalTuiPlugin = {
-  id: "tui-which-key",
+  id: "which-key",
   enabled: false,
   tui,
 }

+ 56 - 5
packages/opencode/src/cli/cmd/tui/keymap.tsx

@@ -1,5 +1,6 @@
 import { type CliRenderer } from "@opentui/core"
 import * as addons from "@opentui/keymap/addons/opentui"
+import { stringifyKeyStroke } from "@opentui/keymap"
 import {
   formatCommandBindings as formatCommandBindingsExtra,
   formatKeySequence as formatKeySequenceExtra,
@@ -14,6 +15,7 @@ import {
 import type { Accessor } from "solid-js"
 import type { TuiConfig } from "./config/tui"
 import { useTuiConfig } from "./context/tui-config"
+import { TuiKeybind } from "./config/keybind"
 
 export const LEADER_TOKEN = "leader"
 
@@ -24,10 +26,55 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
 
 export type OpenTuiKeymap = ReturnType<typeof useKeymap>
 
+const inputCommands = [
+  "input.move.left",
+  "input.move.right",
+  "input.move.up",
+  "input.move.down",
+  "input.select.left",
+  "input.select.right",
+  "input.select.up",
+  "input.select.down",
+  "input.line.home",
+  "input.line.end",
+  "input.select.line.home",
+  "input.select.line.end",
+  "input.visual.line.home",
+  "input.visual.line.end",
+  "input.select.visual.line.home",
+  "input.select.visual.line.end",
+  "input.buffer.home",
+  "input.buffer.end",
+  "input.select.buffer.home",
+  "input.select.buffer.end",
+  "input.delete.line",
+  "input.delete.to.line.end",
+  "input.delete.to.line.start",
+  "input.backspace",
+  "input.delete",
+  "input.newline",
+  "input.undo",
+  "input.redo",
+  "input.word.forward",
+  "input.word.backward",
+  "input.select.word.forward",
+  "input.select.word.backward",
+  "input.delete.word.forward",
+  "input.delete.word.backward",
+  "input.select.all",
+  "input.submit",
+] as const
+
+function leaderDisplay(config: TuiConfig.Resolved) {
+  const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
+  if (!key) return TuiKeybind.LeaderDefault
+  return typeof key === "string" ? key : stringifyKeyStroke(key)
+}
+
 function formatOptions(config: TuiConfig.Resolved) {
   return {
     tokenDisplay: {
-      [LEADER_TOKEN]: config.keymap.leader,
+      [LEADER_TOKEN]: leaderDisplay(config),
     },
     keyNameAliases: {
       pageup: "pgup",
@@ -55,19 +102,23 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
   const offCommaBindings = addons.registerCommaBindings(keymap)
   const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
   const offLeader = addons.registerTimedLeader(keymap, {
-    trigger: config.keymap.leader,
+    trigger: config.keybinds.get(LEADER_TOKEN),
     name: LEADER_TOKEN,
-    timeoutMs: config.keymap.leader_timeout,
+    timeoutMs: config.leader_timeout,
   })
   const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
   const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
-  const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
+  const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
+  const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
+  const offInputBindings = keymap.registerLayer({
     enabled: () => renderer.currentFocusedEditor !== null,
-    bindings: config.keymap.sections.input,
+    bindings: config.keybinds.gather("input", inputCommands),
   })
 
   return () => {
     offInputBindings()
+    offInputSuspension()
+    offInputCommands()
     offBackspace()
     offEscape()
     offLeader()

+ 37 - 4
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -117,6 +117,42 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
   }
 }
 
+const sessionBindingCommands = [
+  "session.share",
+  "session.rename",
+  "session.timeline",
+  "session.fork",
+  "session.compact",
+  "session.unshare",
+  "session.undo",
+  "session.redo",
+  "session.sidebar.toggle",
+  "session.toggle.conceal",
+  "session.toggle.timestamps",
+  "session.toggle.thinking",
+  "session.toggle.actions",
+  "session.toggle.scrollbar",
+  "session.toggle.generic_tool_output",
+  "session.page.up",
+  "session.page.down",
+  "session.line.up",
+  "session.line.down",
+  "session.half.page.up",
+  "session.half.page.down",
+  "session.first",
+  "session.last",
+  "session.messages_last_user",
+  "session.message.next",
+  "session.message.previous",
+  "messages.copy",
+  "session.copy",
+  "session.export",
+  "session.child.first",
+  "session.parent",
+  "session.child.next",
+  "session.child.previous",
+] as const
+
 const context = createContext<{
   width: number
   sessionID: string
@@ -144,9 +180,6 @@ export function Session() {
   const event = useEvent()
   const project = useProject()
   const tuiConfig = useTuiConfig()
-  const {
-    keymap: { sections },
-  } = tuiConfig
   const kv = useKV()
   const { theme } = useTheme()
   const promptRef = usePromptRef()
@@ -1015,7 +1048,7 @@ export function Session() {
 
   useBindings(() => ({
     enabled: command.matcher,
-    bindings: sections.session,
+    bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
   }))
 
   const revertInfo = createMemo(() => session()?.revert)

+ 5 - 7
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -463,7 +463,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
   let input: TextareaRenderable
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
-  const keymapConfig = tuiConfig.keymap
   const dimensions = useTerminalDimensions()
   const narrow = createMemo(() => dimensions().width < 80)
   const dialog = useDialog()
@@ -471,7 +470,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
     enabled: dialog.stack.length === 0,
     commands: [
       {
-        name: "permission.reject.cancel",
+        name: "app.exit",
         title: "Cancel permission rejection",
         category: "Permission",
         run() {
@@ -481,7 +480,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
     ],
     bindings: [
       { key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() },
-      ...keymapConfig.pick("permission", ["permission.reject.cancel"]),
+      ...tuiConfig.keybinds.get("app.exit"),
       {
         key: "return",
         desc: "Confirm permission rejection",
@@ -553,7 +552,6 @@ function Prompt<const T extends Record<string, string>>(props: {
 }) {
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
-  const keymapConfig = tuiConfig.keymap
   const dimensions = useTerminalDimensions()
   const keys = Object.keys(props.options) as (keyof T)[]
   const [store, setStore] = createStore({
@@ -568,7 +566,7 @@ function Prompt<const T extends Record<string, string>>(props: {
     enabled: dialog.stack.length === 0,
     commands: [
       {
-        name: "permission.prompt.escape",
+        name: "app.exit",
         title: "Reject permission",
         category: "Permission",
         run() {
@@ -643,8 +641,8 @@ function Prompt<const T extends Record<string, string>>(props: {
             },
           ]
         : []),
-      ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
-      ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
+      ...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []),
+      ...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []),
     ],
   }))
 

+ 5 - 9
packages/opencode/src/cli/cmd/tui/routes/session/question.tsx

@@ -13,10 +13,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
   const sdk = useSDK()
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
-  const {
-    keymap: { sections },
-  } = tuiConfig
-  const keymapConfig = tuiConfig.keymap
 
   const questions = createMemo(() => props.request.questions)
   const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -128,7 +124,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
     enabled: store.editing && !confirm(),
     commands: [
       {
-        name: "question.edit.clear",
+        name: "prompt.clear",
         title: "Clear answer edit",
         category: "Question",
         run() {
@@ -150,7 +146,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
           setStore("editing", false)
         },
       },
-      ...keymapConfig.pick("question", ["question.edit.clear"]),
+      ...tuiConfig.keybinds.get("prompt.clear"),
       {
         key: "return",
         desc: "Submit answer edit",
@@ -208,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
       enabled: dialog.stack.length === 0 && !store.editing,
       commands: [
         {
-          name: "question.reject",
+          name: "app.exit",
           title: "Reject question",
           category: "Question",
           run() {
@@ -243,7 +239,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
           ? [
               { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
               { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
-              ...sections.question,
+              ...tuiConfig.keybinds.get("app.exit"),
             ]
           : [
               ...Array.from({ length: max }, (_, index) => ({
@@ -271,7 +267,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
               { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
               { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
               { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
-              ...sections.question,
+              ...tuiConfig.keybinds.get("app.exit"),
             ]),
       ],
     }

+ 10 - 8
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -65,9 +65,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   const dialog = useDialog()
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
-  const {
-    keymap: { sections },
-  } = tuiConfig
   const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
 
   const [store, setStore] = createStore({
@@ -308,11 +305,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         })),
       ],
       bindings: [
-        ...sections.dialog_select,
-        ...tuiConfig.keymap.pick(
-          "dialog_actions",
-          enabledActions.map((item) => item.command),
-        ),
+        ...tuiConfig.keybinds.gather("dialog.select", [
+          "dialog.select.prev",
+          "dialog.select.next",
+          "dialog.select.page_up",
+          "dialog.select.page_down",
+          "dialog.select.home",
+          "dialog.select.end",
+          "dialog.select.submit",
+        ]),
+        ...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
         ...(props.bindings ?? []).filter((binding) => {
           if (typeof binding.cmd !== "string") return true
           return enabledActions.some((item) => item.command === binding.cmd)

+ 0 - 143
packages/opencode/src/config/keybinds.ts

@@ -1,143 +0,0 @@
-export * as ConfigKeybinds from "./keybinds"
-
-import { Effect, Schema } from "effect"
-import type z from "zod"
-import { zod } from "@/util/effect-zod"
-
-// Every keybind field has the same shape: an optional string with a default
-// binding and a human description.  `keybind()` keeps the declaration list
-// below dense and readable.
-const keybind = (value: string, description: string) =>
-  Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description })
-
-// Windows prepends ctrl+z to the undo binding because `terminal_suspend`
-// cannot consume ctrl+z on native Windows terminals (no POSIX suspend).
-const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
-
-const KeybindsSchema = Schema.Struct({
-  leader: keybind("ctrl+x", "Leader key for keybind combinations"),
-  app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
-  editor_open: keybind("<leader>e", "Open external editor"),
-  theme_list: keybind("<leader>t", "List available themes"),
-  sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
-  scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
-  status_view: keybind("<leader>s", "View status"),
-  session_export: keybind("<leader>x", "Export session to editor"),
-  session_new: keybind("<leader>n", "Create a new session"),
-  session_list: keybind("<leader>l", "List all sessions"),
-  session_timeline: keybind("<leader>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"),
-  stash_delete: keybind("ctrl+d", "Delete stash entry"),
-  model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
-  model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
-  session_share: keybind("none", "Share current session"),
-  session_unshare: keybind("none", "Unshare current session"),
-  session_interrupt: keybind("escape", "Interrupt current session"),
-  session_compact: keybind("<leader>c", "Compact the session"),
-  messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
-  messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
-  messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
-  messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
-  messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
-  messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
-  messages_first: keybind("ctrl+g,home", "Navigate to first message"),
-  messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
-  messages_next: keybind("none", "Navigate to next message"),
-  messages_previous: keybind("none", "Navigate to previous message"),
-  messages_last_user: keybind("none", "Navigate to last user message"),
-  messages_copy: keybind("<leader>y", "Copy message"),
-  messages_undo: keybind("<leader>u", "Undo message"),
-  messages_redo: keybind("<leader>r", "Redo message"),
-  messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
-  tool_details: keybind("none", "Toggle tool details visibility"),
-  model_list: keybind("<leader>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"),
-  command_list: keybind("ctrl+p", "List available commands"),
-  "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.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
-  "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"),
-  agent_list: keybind("<leader>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"),
-  input_clear: keybind("ctrl+c", "Clear input field"),
-  input_paste: keybind("ctrl+v", "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(inputUndoDefault, "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"),
-  history_previous: keybind("up", "Previous history item"),
-  history_next: keybind("down", "Next history item"),
-  session_child_first: keybind("<leader>down", "Go to first child session"),
-  session_child_cycle: keybind("right", "Go to next child session"),
-  session_child_cycle_reverse: keybind("left", "Go to previous child session"),
-  session_parent: keybind("up", "Go to parent session"),
-  // `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`,
-  // but `tui.ts` already forces the binding to "none" on win32 before calling
-  // `Keybinds.parse(...)`, so the schema-level transform was redundant.
-  terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
-  terminal_title_toggle: keybind("none", "Toggle terminal title"),
-  tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
-  plugin_manager: keybind("none", "Open plugin manager dialog"),
-  display_thinking: keybind("none", "Toggle thinking blocks visibility"),
-}).annotate({ identifier: "KeybindsConfig" })
-
-export type Keybinds = Schema.Schema.Type<typeof KeybindsSchema>
-
-// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`,
-// which requires the runtime type to be a ZodObject, not just ZodType.  Every
-// field is `string().optional().default(...)` at runtime, so widen to that.
-export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject<
-  Record<keyof Keybinds, z.ZodDefault<z.ZodOptional<z.ZodString>>>
->

+ 19 - 30
packages/opencode/test/cli/run/runtime.boot.test.ts

@@ -1,12 +1,11 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import type { KeyEvent, Renderable } from "@opentui/core"
 import type { Binding } from "@opentui/keymap"
-import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
+import { createBindingLookup } from "@opentui/keymap/extras"
 import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
 import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
 import { formatBindings } from "@/cli/cmd/run/keymap.shared"
-import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema"
-import { ConfigKeybinds } from "@/config/keybinds"
+import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
 import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
 
 type RunBinding = Binding<Renderable, KeyEvent>
@@ -82,34 +81,24 @@ function config(input?: {
   }>
 }): Resolved {
   const bind = input?.bindings
-  const sections = {
-    global: Object.fromEntries([
-      ...(bind?.commandList ? [["command.palette.show", bind.commandList] as const] : []),
-      ...(bind?.variantCycle ? [["variant.cycle", bind.variantCycle] as const] : []),
-    ]),
-    prompt: Object.fromEntries([
-      ...(bind?.interrupt ? [["session.interrupt", bind.interrupt] as const] : []),
-      ...(bind?.historyPrevious ? [["prompt.history.previous", bind.historyPrevious] as const] : []),
-      ...(bind?.historyNext ? [["prompt.history.next", bind.historyNext] as const] : []),
-      ...(bind?.inputClear ? [["prompt.clear", bind.inputClear] as const] : []),
-    ]),
-    input: Object.fromEntries([
-      ...(bind?.inputSubmit ? [["input.submit", bind.inputSubmit] as const] : []),
-      ...(bind?.inputNewline ? [["input.newline", bind.inputNewline] as const] : []),
-    ]),
-  } satisfies BindingSectionsConfig<Renderable, KeyEvent>
-
+  const keybinds = TuiKeybind.Keybinds.parse({
+    ...(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 }),
+  })
   return {
     diff_style: input?.diff_style,
-    keybinds: ConfigKeybinds.Keybinds.parse({}),
-    keymap: {
-      leader: input?.leader ?? "ctrl+x",
-      leader_timeout: input?.leaderTimeout ?? 2000,
-      ...resolveBindingSections<Renderable, KeyEvent, typeof sections, KeymapSection>(sections, {
-        sections: KeymapSectionNames,
-        bindingDefaults: keymapBindingDefaults,
-      }),
-    },
+    keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
+      commandMap: TuiKeybind.CommandMap,
+      bindingDefaults: TuiKeybind.bindingDefaults(),
+    }),
+    leader_timeout: input?.leaderTimeout ?? 2000,
   }
 }
 
@@ -118,7 +107,7 @@ describe("run runtime boot", () => {
     mock.restore()
   })
 
-  test("reads footer keybinds from resolved keymap config", async () => {
+  test("reads footer keybinds from resolved keybind config", async () => {
     spyOn(TuiConfig, "get").mockResolvedValue(
       config({
         leader: "ctrl+g",

+ 14 - 25
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -81,7 +81,7 @@ async function load(): Promise<Data> {
 
       await Bun.write(
         localPluginPath,
-        `import { resolveBindingSections } from "@opentui/keymap/extras"
+        `import { createBindingLookup } from "@opentui/keymap/extras"
 import { useBindings } from "@opentui/keymap/solid"
 
 export const ignored = async (_input, options) => {
@@ -97,20 +97,18 @@ export default {
     const cfg_diff = api.tuiConfig.diff_style
     const cfg_speed = api.tuiConfig.scroll_speed
     const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
-    const cfg_submit = api.tuiConfig.keybinds?.input_submit
     const has_keys = typeof api.keys.formatBindings === "function"
-    const keymap = resolveBindingSections(options.keymap?.sections ?? {
-      main: {
-        "plugin.loader.local": "ctrl+shift+m",
-        "plugin.loader.close": "escape",
-      },
-    }, { sections: ["main"] }).sections
-    const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key
-    const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key
+    const keybinds = createBindingLookup(options.keybinds ?? {
+      "plugin.loader.local": "ctrl+shift+m",
+      "plugin.loader.close": "escape",
+    })
+    const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"])
+    const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key
+    const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key
     const key_unknown = "ctrl+k"
     const off = api.keymap.registerLayer({
       commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
-      bindings: keymap.main,
+      bindings,
     })
     off()
     const kv_before = api.kv.get(options.kv_key, "missing")
@@ -153,7 +151,7 @@ export default {
         key_unknown,
         has_keys,
         has_keymap: typeof api.keymap.registerLayer === "function",
-        has_resolve_binding_sections: typeof resolveBindingSections === "function",
+        has_create_binding_lookup: typeof createBindingLookup === "function",
         has_keymap_solid: typeof useBindings === "function",
         kv_before,
         kv_after,
@@ -176,7 +174,6 @@ export default {
         cfg_diff,
         cfg_speed,
         cfg_accel,
-        cfg_submit,
       }),
     )
   },
@@ -356,13 +353,9 @@ export default {
       theme_name: tmp.extra.localThemeName,
       kv_key: "plugin_state_key",
       session_id: "ses_test",
-      keymap: {
-        sections: {
-          main: {
-            "plugin.loader.local": "ctrl+alt+m",
-            "plugin.loader.close": "q",
-          },
-        },
+      keybinds: {
+        "plugin.loader.local": "ctrl+alt+m",
+        "plugin.loader.close": "q",
       },
     }
     const invalidOpts = {
@@ -408,9 +401,6 @@ export default {
           diff_style: "stacked",
           scroll_speed: 1.5,
           scroll_acceleration: { enabled: true },
-          keybinds: {
-            input_submit: "ctrl+enter",
-          },
         },
         state: {
           session: {
@@ -670,7 +660,7 @@ describe("tui.plugin.loader", () => {
     expect(data.local.key_unknown).toBe("ctrl+k")
     expect(data.local.has_keys).toBe(true)
     expect(data.local.has_keymap).toBe(true)
-    expect(data.local.has_resolve_binding_sections).toBe(true)
+    expect(data.local.has_create_binding_lookup).toBe(true)
     expect(data.local.has_keymap_solid).toBe(true)
     expect(data.local.kv_before).toBe("missing")
     expect(data.local.kv_after).toBe("stored")
@@ -693,7 +683,6 @@ describe("tui.plugin.loader", () => {
     expect(data.local.cfg_diff).toBe("stacked")
     expect(data.local.cfg_speed).toBe(1.5)
     expect(data.local.cfg_accel).toBe(true)
-    expect(data.local.cfg_submit).toBe("ctrl+enter")
   })
 
   test("installs themes in the correct scope and remains resilient", () => {

+ 10 - 10
packages/opencode/test/cli/tui/plugin-toggle.test.ts

@@ -171,26 +171,26 @@ test("loads disabled-by-default internal plugin inactive and activates on demand
       enabled: true,
       active: true,
     })
-    expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({
-      id: "tui-which-key",
+    expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
+      id: "which-key",
       source: "internal",
-      spec: "tui-which-key",
-      target: "tui-which-key",
+      spec: "which-key",
+      target: "which-key",
       enabled: false,
       active: false,
     })
 
-    await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true)
-    expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({
-      id: "tui-which-key",
+    await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true)
+    expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
+      id: "which-key",
       source: "internal",
-      spec: "tui-which-key",
-      target: "tui-which-key",
+      spec: "which-key",
+      target: "which-key",
       enabled: true,
       active: true,
     })
     expect(api.kv.get("plugin_enabled", {})).toEqual({
-      "tui-which-key": true,
+      "which-key": true,
     })
   } finally {
     await TuiPluginRuntime.dispose()

+ 62 - 116
packages/opencode/test/config/tui.test.ts

@@ -163,7 +163,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist
   const config = await getTuiConfig(tmp.path)
   expect(config.theme).toBe("migrated-theme")
   expect(config.scroll_speed).toBe(5)
-  expect(config.keybinds?.app_exit).toBe("ctrl+q")
+  expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
   const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
   expect(JSON.parse(text)).toMatchObject({
     theme: "migrated-theme",
@@ -398,83 +398,64 @@ test("merges keybind overrides across precedence layers", async () => {
     },
   })
   const config = await getTuiConfig(tmp.path)
-  expect(config.keybinds?.app_exit).toBe("ctrl+q")
-  expect(config.keybinds?.theme_list).toBe("ctrl+k")
+  expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
+  expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k")
 })
 
-test("resolves semantic keymap sections", async () => {
+test("resolves keybind lookup from canonical keybinds", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
         path.join(dir, "tui.json"),
         JSON.stringify({
-          keybinds: { command_list: "ctrl+z" },
-          keymap: {
-            sections: {
-              global: { "command.palette.show": "alt+p" },
-              which_key: { "tui-which-key.toggle": "alt+k" },
-              prompt: { "prompt.editor": "ctrl+e" },
-              autocomplete: { "prompt.autocomplete.next": "ctrl+j" },
-              dialog_actions: { "dialog.action.toggle": "ctrl+t" },
-              model: { "model.dialog.favorite": "ctrl+f" },
-              plugins: { "plugin.dialog.install": "shift+i" },
-            },
+          keybinds: {
+            leader: { key: { name: "g", ctrl: true } },
+            command_list: "alt+p",
+            which_key_toggle: "alt+k",
+            editor_open: "ctrl+e",
+            "prompt.autocomplete.next": "ctrl+j",
+            "dialog.mcp.toggle": "ctrl+t",
+            model_favorite_toggle: "ctrl+f",
+            "dialog.plugins.install": "shift+i",
           },
+          leader_timeout: 1234,
         }),
       )
     },
   })
 
   const config = await getTuiConfig(tmp.path)
-  expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
-  expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("<leader>n")
-  expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe("alt+k")
-  expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.layout.toggle")?.key).toBe(
-    "ctrl+alt+shift+k",
-  )
-  expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.pending.toggle")?.key).toBe(
-    "ctrl+alt+shift+p",
-  )
-  expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.group.next")?.key).toBe(
-    "ctrl+alt+right,ctrl+alt+]",
+  expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true })
+  expect(config.leader_timeout).toBe(1234)
+  expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p")
+  expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
+  expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k")
+  expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k")
+  expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p")
+  expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]")
+  expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe(
+    "Toggle which-key panel",
   )
-  expect(
-    (
-      config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle") as
-        | { group?: unknown }
-        | undefined
-    )?.group,
-  ).toBe("System")
-  expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
-  expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
-    "ctrl+j",
-  )
-  expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe(
-    "ctrl+t",
-  )
-  expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
-  expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
-  expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
-    "plugin.dialog.install",
+  expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e")
+  expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j")
+  expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t")
+  expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f")
+  expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i")
+  expect(config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd)).toEqual([
+    "dialog.plugins.install",
   ])
-  expect((config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe(
-    "Plugins",
-  )
-  expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([])
 })
 
-test("legacy keybinds transform into semantic keymap sections", async () => {
+test("keybinds accept OpenTUI binding specs", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
         path.join(dir, "tui.json"),
         JSON.stringify({
           keybinds: {
-            command_list: "alt+p",
-            editor_open: "ctrl+e",
-            "prompt.autocomplete.next": "ctrl+j",
-            "dialog.mcp.toggle": "ctrl+t",
-            "dialog.plugins.install": "shift+i",
+            command_list: [{ key: "alt+p", preventDefault: false }],
+            editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" },
+            "prompt.autocomplete.next": false,
             plugin_manager: "ctrl+shift+p",
           },
         }),
@@ -483,52 +464,23 @@ test("legacy keybinds transform into semantic keymap sections", async () => {
   })
 
   const config = await getTuiConfig(tmp.path)
-  expect(Object.keys(config.keymap.sections)).toEqual([
-    "global",
-    "which_key",
-    "session",
-    "prompt",
-    "autocomplete",
-    "input",
-    "dialog_select",
-    "dialog_actions",
-    "model",
-    "permission",
-    "question",
-    "plugins",
-    "home_tips",
-  ])
-  expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
-  expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe(
-    "ctrl+alt+k",
-  )
-  expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
-  expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe(
-    "ctrl+j",
-  )
-  expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe(
-    "ctrl+t",
-  )
-  expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a")
-  expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
-  expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
-  expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p")
-  expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
-    "plugin.dialog.install",
-  ])
-  expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe(
-    "Plugins",
-  )
-  expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
-    "plugins.list",
+  expect(config.keybinds.get("command.palette.show")).toEqual([
+    { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" },
   ])
+  expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({
+    key: { name: "e", ctrl: true },
+    cmd: "prompt.editor",
+    group: "Explicit",
+  })
+  expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([])
+  expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p")
 })
 
 wintest("defaults Ctrl+Z to input undo on Windows", async () => {
   await using tmp = await tmpdir()
   const config = await getTuiConfig(tmp.path)
-  expect(config.keybinds?.terminal_suspend).toBe("none")
-  expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
+  expect(config.keybinds.get("terminal.suspend")).toEqual([])
+  expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
 })
 
 wintest("keeps explicit input undo overrides on Windows", async () => {
@@ -538,8 +490,8 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
     },
   })
   const config = await getTuiConfig(tmp.path)
-  expect(config.keybinds?.terminal_suspend).toBe("none")
-  expect(config.keybinds?.input_undo).toBe("ctrl+y")
+  expect(config.keybinds.get("terminal.suspend")).toEqual([])
+  expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
 })
 
 wintest("ignores terminal suspend bindings on Windows", async () => {
@@ -550,33 +502,29 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
   })
 
   const config = await getTuiConfig(tmp.path)
-  expect(config.keybinds?.terminal_suspend).toBe("none")
-  expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
+  expect(config.keybinds.get("terminal.suspend")).toEqual([])
+  expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
 })
 
-test("applies Windows keymap defaults", async () => {
+test("applies Windows keybind defaults", async () => {
   await withPlatform("win32", async () => {
     await using tmp = await tmpdir()
 
     const config = await getTuiConfig(tmp.path)
-    expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined()
-    expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe(
-      "ctrl+z,ctrl+-,super+z",
-    )
+    expect(config.keybinds.get("terminal.suspend")).toEqual([])
+    expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z")
   })
 })
 
-test("keeps explicit configured keymap terminal suspend binding on Windows", async () => {
+test("ignores explicit keybind terminal suspend binding on Windows", async () => {
   await withPlatform("win32", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
         await Bun.write(
           path.join(dir, "tui.json"),
           JSON.stringify({
-            keymap: {
-              sections: {
-                global: { "terminal.suspend": "alt+z" },
-              },
+            keybinds: {
+              terminal_suspend: "alt+z",
             },
           }),
         )
@@ -584,21 +532,19 @@ test("keeps explicit configured keymap terminal suspend binding on Windows", asy
     })
 
     const config = await getTuiConfig(tmp.path)
-    expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z")
+    expect(config.keybinds.get("terminal.suspend")).toEqual([])
   })
 })
 
-test("keeps explicit configured keymap input undo on Windows", async () => {
+test("keeps explicit configured keybind input undo on Windows", async () => {
   await withPlatform("win32", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
         await Bun.write(
           path.join(dir, "tui.json"),
           JSON.stringify({
-            keymap: {
-              sections: {
-                input: { "input.undo": "ctrl+y" },
-              },
+            keybinds: {
+              input_undo: "ctrl+y",
             },
           }),
         )
@@ -606,7 +552,7 @@ test("keeps explicit configured keymap input undo on Windows", async () => {
     })
 
     const config = await getTuiConfig(tmp.path)
-    expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y")
+    expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y")
   })
 })
 
@@ -655,7 +601,7 @@ test("applies env and file substitutions in tui.json", async () => {
     })
     const config = await getTuiConfig(tmp.path)
     expect(config.theme).toBe("env-theme")
-    expect(config.keybinds?.app_exit).toBe("ctrl+q")
+    expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q")
   } finally {
     if (original === undefined) delete process.env.TUI_THEME_TEST
     else process.env.TUI_THEME_TEST = original

+ 2 - 6
packages/opencode/test/fixture/tui-plugin.ts

@@ -1,9 +1,7 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { RGBA, type CliRenderer } from "@opentui/core"
 import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
-import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
-import { ConfigKeybinds } from "../../src/config/keybinds"
-import { createTuiResolvedKeymap } from "./tui-runtime"
+import { createTuiResolvedConfig } from "./tui-runtime"
 
 type Count = {
   event_add: number
@@ -112,11 +110,9 @@ type Opts = {
 }
 
 function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
-  const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {})
   return {
+    ...createTuiResolvedConfig(),
     ...input,
-    keybinds,
-    keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})),
   }
 }
 

+ 14 - 30
packages/opencode/test/fixture/tui-runtime.ts

@@ -1,45 +1,29 @@
 import { spyOn } from "bun:test"
 import path from "path"
-import type { KeyEvent, Renderable } from "@opentui/core"
-import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
+import { createBindingLookup } from "@opentui/keymap/extras"
 import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
-import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
-import { ConfigKeybinds } from "../../src/config/keybinds"
-import {
-  KeymapConfig,
-  KeymapSectionNames,
-  keymapBindingDefaults,
-  type KeymapConfigInput,
-  type KeymapSection,
-} from "../../src/cli/cmd/tui/config/tui-schema"
+import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind"
 
 type PluginSpec = string | [string, Record<string, unknown>]
-type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "keymap"> & {
-  keybinds?: TuiConfig.Resolved["keybinds"]
-  keymap?: TuiConfig.Resolved["keymap"]
+type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "leader_timeout"> & {
+  keybinds?: Partial<TuiKeybind.Keybinds>
+  leader_timeout?: number
 }
 
-export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] {
-  const config = KeymapConfig.parse(input)
-  return {
-    leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader,
-    leader_timeout: config.leader_timeout,
-    ...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
-      config.sections,
-      {
-        sections: KeymapSectionNames,
-        bindingDefaults: keymapBindingDefaults,
-      },
-    ),
-  }
+export function createTuiResolvedKeybinds(input: Partial<TuiKeybind.Keybinds> = {}): TuiConfig.Resolved["keybinds"] {
+  const keybinds = TuiKeybind.Keybinds.parse(input)
+  return createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
+    commandMap: TuiKeybind.CommandMap,
+    bindingDefaults: TuiKeybind.bindingDefaults(),
+  })
 }
 
 export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved {
-  const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({})
+  const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {})
   return {
     ...input,
-    keybinds,
-    keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})),
+    keybinds: createTuiResolvedKeybinds(keybinds),
+    leader_timeout: input.leader_timeout ?? 2000,
   }
 }
 

+ 3 - 3
packages/plugin/package.json

@@ -22,9 +22,9 @@
     "zod": "catalog:"
   },
   "peerDependencies": {
-    "@opentui/core": ">=0.2.5",
-    "@opentui/keymap": ">=0.2.5",
-    "@opentui/solid": ">=0.2.5"
+    "@opentui/core": ">=0.2.6",
+    "@opentui/keymap": ">=0.2.6",
+    "@opentui/solid": ">=0.2.6"
   },
   "peerDependenciesMeta": {
     "@opentui/core": {

+ 22 - 19
packages/plugin/src/tui.ts

@@ -18,8 +18,9 @@ import type {
 import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
 import type { Binding, Keymap } from "@opentui/keymap"
 import {
-  resolveBindingSections as resolveKeymapBindingSections,
-  type BindingSectionsConfig,
+  createBindingLookup as createKeymapBindingLookup,
+  type BindingConfig,
+  type CreateBindingLookupOptions,
   type KeySequenceFormatPart,
   type SequenceBindingLike,
 } from "@opentui/keymap/extras"
@@ -31,22 +32,21 @@ export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
 export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap"
 export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
 export type {
-  BindingSectionsConfig,
+  BindingConfig,
+  BindingLookup,
   BindingValue,
+  CreateBindingLookupOptions,
   FormatCommandBindingsOptions,
   FormatKeySequenceOptions,
   KeySequenceFormatPart,
   SequenceBindingLike,
 } from "@opentui/keymap/extras"
 
-export function resolveBindingSections<Section extends string>(
-  config: BindingSectionsConfig<Renderable, KeyEvent> | undefined,
-  options: { sections: readonly Section[] },
+export function createBindingLookup(
+  config: BindingConfig<Renderable, KeyEvent> | undefined,
+  options?: CreateBindingLookupOptions<Renderable, KeyEvent>,
 ) {
-  return resolveKeymapBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, Section>(
-    config ?? {},
-    options,
-  )
+  return createKeymapBindingLookup<Renderable, KeyEvent>(config ?? {}, options)
 }
 
 export type TuiRouteCurrent =
@@ -286,17 +286,20 @@ export type TuiState = {
   mcp: () => ReadonlyArray<TuiSidebarMcpItem>
 }
 
-type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
+type TuiBindingLookupView = {
+  readonly bindings: ReadonlyArray<Binding<Renderable, KeyEvent>>
+  get: (command: string) => ReadonlyArray<Binding<Renderable, KeyEvent>>
+  has: (command: string) => boolean
+  gather: (name: string, commands: readonly string[]) => ReadonlyArray<Binding<Renderable, KeyEvent>>
+  pick: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
+  omit: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
+}
+
+type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
   NonNullable<PluginConfig["tui"]> & {
+    leader_timeout: number
     plugin_enabled?: Record<string, boolean>
-    keymap: {
-      leader: string
-      leader_timeout: number
-      sections: Record<string, ReadonlyArray<Binding<Renderable, KeyEvent>>>
-      get: (section: string, cmd: string) => ReadonlyArray<Binding<Renderable, KeyEvent>> | undefined
-      pick: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
-      omit: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
-    }
+    keybinds: TuiBindingLookupView
   }
 
 export type TuiApp = {

+ 5 - 11
packages/web/src/content/docs/config.mdx

@@ -525,26 +525,20 @@ You can also define commands using markdown files in `~/.config/opencode/command
 
 ---
 
-### Keymap
+### Keybinds
 
-Customize TUI keyboard shortcuts in `tui.json` with `keymap`.
+Customize TUI keyboard shortcuts in `tui.json` with `keybinds`.
 
 ```json title="tui.json"
 {
   "$schema": "https://opencode.ai/tui.json",
-  "keymap": {
-    "sections": {
-      "global": {
-        "command.palette.show": "ctrl+p"
-      }
-    }
+  "keybinds": {
+    "command_list": "ctrl+p"
   }
 }
 ```
 
-`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
-
-The older `keybinds` field is deprecated and only applies when `keymap` is not present.
+`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
 
 [Learn more here](/docs/keybinds).
 

+ 190 - 269
packages/web/src/content/docs/keybinds.mdx

@@ -1,286 +1,218 @@
 ---
 title: Keybinds
-description: Customize your keyboard shortcuts.
+description: Customize your keybinds.
 ---
 
-OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`.
-
-The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution.
-
-`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
-
----
-
-## Leader key
-
-OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal.
-
-By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
-
-You do not need to use a leader key, but we recommend doing so.
-
----
-
-## Minimal example
+OpenCode has a list of keybinds that you can customize through `tui.json`.
 
 ```json title="tui.json"
 {
   "$schema": "https://opencode.ai/tui.json",
-  "keymap": {
+  "leader_timeout": 2000,
+  "keybinds": {
     "leader": "ctrl+x",
-    "leader_timeout": 2000,
-    "sections": {
-      "global": {
-        "command.palette.show": "ctrl+p",
-        "session.new": "<leader>n",
-        "session.list": "<leader>l"
-      },
-      "session": {
-        "session.compact": "<leader>c",
-        "session.undo": "<leader>u",
-        "session.redo": "<leader>r"
-      },
-      "input": {
-        "input.submit": "return",
-        "input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"]
-      }
-    }
+    "app_exit": "ctrl+c,ctrl+d,<leader>q",
+    "app_debug": "none",
+    "app_console": "none",
+    "app_heap_snapshot": "none",
+    "app_toggle_animations": "none",
+    "app_toggle_file_context": "none",
+    "app_toggle_diffwrap": "none",
+    "app_toggle_paste_summary": "none",
+    "app_toggle_session_directory_filter": "none",
+    "command_list": "ctrl+p",
+    "help_show": "none",
+    "docs_open": "none",
+
+    "editor_open": "<leader>e",
+    "theme_list": "<leader>t",
+    "theme_switch_mode": "none",
+    "theme_mode_lock": "none",
+    "sidebar_toggle": "<leader>b",
+    "scrollbar_toggle": "none",
+    "status_view": "<leader>s",
+
+    "session_export": "<leader>x",
+    "session_copy": "none",
+    "session_new": "<leader>n",
+    "session_list": "<leader>l",
+    "session_timeline": "<leader>g",
+    "session_fork": "none",
+    "session_rename": "ctrl+r",
+    "session_delete": "ctrl+d",
+    "session_share": "none",
+    "session_unshare": "none",
+    "session_interrupt": "escape",
+    "session_compact": "<leader>c",
+    "session_toggle_timestamps": "none",
+    "session_toggle_generic_tool_output": "none",
+    "session_child_first": "<leader>down",
+    "session_child_cycle": "right",
+    "session_child_cycle_reverse": "left",
+    "session_parent": "up",
+
+    "stash_delete": "ctrl+d",
+    "model_provider_list": "ctrl+a",
+    "model_favorite_toggle": "ctrl+f",
+    "model_list": "<leader>m",
+    "model_cycle_recent": "f2",
+    "model_cycle_recent_reverse": "shift+f2",
+    "model_cycle_favorite": "none",
+    "model_cycle_favorite_reverse": "none",
+    "mcp_list": "none",
+    "provider_connect": "none",
+    "console_org_switch": "none",
+    "agent_list": "<leader>a",
+    "agent_cycle": "tab",
+    "agent_cycle_reverse": "shift+tab",
+    "variant_cycle": "ctrl+t",
+    "variant_list": "none",
+
+    "messages_page_up": "pageup,ctrl+alt+b",
+    "messages_page_down": "pagedown,ctrl+alt+f",
+    "messages_line_up": "ctrl+alt+y",
+    "messages_line_down": "ctrl+alt+e",
+    "messages_half_page_up": "ctrl+alt+u",
+    "messages_half_page_down": "ctrl+alt+d",
+    "messages_first": "ctrl+g,home",
+    "messages_last": "ctrl+alt+g,end",
+    "messages_next": "none",
+    "messages_previous": "none",
+    "messages_last_user": "none",
+    "messages_copy": "<leader>y",
+    "messages_undo": "<leader>u",
+    "messages_redo": "<leader>r",
+    "messages_toggle_conceal": "<leader>h",
+    "tool_details": "none",
+    "display_thinking": "none",
+
+    "prompt_submit": "none",
+    "prompt_editor_context_clear": "none",
+    "prompt_skills": "none",
+    "prompt_stash": "none",
+    "prompt_stash_pop": "none",
+    "prompt_stash_list": "none",
+    "workspace_set": "none",
+
+    "input_clear": "ctrl+c",
+    "input_paste": {
+      "key": "ctrl+v",
+      "preventDefault": false
+    },
+    "input_submit": "return",
+    "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j",
+    "input_move_left": "left,ctrl+b",
+    "input_move_right": "right,ctrl+f",
+    "input_move_up": "up",
+    "input_move_down": "down",
+    "input_select_left": "shift+left",
+    "input_select_right": "shift+right",
+    "input_select_up": "shift+up",
+    "input_select_down": "shift+down",
+    "input_line_home": "ctrl+a",
+    "input_line_end": "ctrl+e",
+    "input_select_line_home": "ctrl+shift+a",
+    "input_select_line_end": "ctrl+shift+e",
+    "input_visual_line_home": "alt+a",
+    "input_visual_line_end": "alt+e",
+    "input_select_visual_line_home": "alt+shift+a",
+    "input_select_visual_line_end": "alt+shift+e",
+    "input_buffer_home": "home",
+    "input_buffer_end": "end",
+    "input_select_buffer_home": "shift+home",
+    "input_select_buffer_end": "shift+end",
+    "input_delete_line": "ctrl+shift+d",
+    "input_delete_to_line_end": "ctrl+k",
+    "input_delete_to_line_start": "ctrl+u",
+    "input_backspace": "backspace,shift+backspace",
+    "input_delete": "ctrl+d,delete,shift+delete",
+    "input_undo": "ctrl+-,super+z",
+    "input_redo": "ctrl+.,super+shift+z",
+    "input_word_forward": "alt+f,alt+right,ctrl+right",
+    "input_word_backward": "alt+b,alt+left,ctrl+left",
+    "input_select_word_forward": "alt+shift+f,alt+shift+right",
+    "input_select_word_backward": "alt+shift+b,alt+shift+left",
+    "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete",
+    "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace",
+    "input_select_all": "super+a",
+    "history_previous": "up",
+    "history_next": "down",
+
+    "dialog.select.prev": "up,ctrl+p",
+    "dialog.select.next": "down,ctrl+n",
+    "dialog.select.page_up": "pageup",
+    "dialog.select.page_down": "pagedown",
+    "dialog.select.home": "home",
+    "dialog.select.end": "end",
+    "dialog.select.submit": "return",
+    "dialog.mcp.toggle": "space",
+    "prompt.autocomplete.prev": "up,ctrl+p",
+    "prompt.autocomplete.next": "down,ctrl+n",
+    "prompt.autocomplete.hide": "escape",
+    "prompt.autocomplete.select": "return",
+    "prompt.autocomplete.complete": "tab",
+    "permission.prompt.fullscreen": "ctrl+f",
+    "plugins.toggle": "space",
+    "dialog.plugins.install": "shift+i",
+
+    "terminal_suspend": "ctrl+z",
+    "terminal_title_toggle": "none",
+    "tips_toggle": "<leader>h",
+    "plugin_manager": "none",
+    "plugin_install": "none",
+
+    "which_key_toggle": "ctrl+alt+k",
+    "which_key_layout_toggle": "ctrl+alt+shift+k",
+    "which_key_pending_toggle": "ctrl+alt+shift+p",
+    "which_key_group_previous": "ctrl+alt+left,ctrl+alt+[",
+    "which_key_group_next": "ctrl+alt+right,ctrl+alt+]",
+    "which_key_scroll_up": "ctrl+alt+up,ctrl+alt+p",
+    "which_key_scroll_down": "ctrl+alt+down,ctrl+alt+n",
+    "which_key_page_up": "ctrl+alt+pageup",
+    "which_key_page_down": "ctrl+alt+pagedown",
+    "which_key_home": "ctrl+alt+home",
+    "which_key_end": "ctrl+alt+end"
   }
 }
 ```
 
----
-
-## Keymap structure
-
-`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them.
+:::note
+On Windows, the defaults for `input_undo` and `terminal_suspend` are different:
 
-| Field            | Description                                                                                         |
-| ---------------- | --------------------------------------------------------------------------------------------------- |
-| `leader`         | The key used by `<leader>` sequences. Defaults to `ctrl+x`.                                         |
-| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. |
-| `sections`       | A map of TUI areas to command bindings.                                                             |
+- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured. The `ctrl+z` binding is added because Windows terminals do not support POSIX suspend.
+- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend.
+  :::
 
 ---
 
-## Binding values
+## Leader Key
 
-A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command.
+OpenCode uses a `leader` key for many keybinds. This avoids conflicts in your terminal.
 
-```json title="tui.json"
-{
-  "$schema": "https://opencode.ai/tui.json",
-  "keymap": {
-    "sections": {
-      "session": {
-        "session.compact": "none",
-        "session.export": "<leader>x,ctrl+shift+x",
-        "session.copy": ["<leader>y", "ctrl+shift+c"]
-      }
-    }
-  }
-}
-```
+By default, `ctrl+x` is the leader key and many actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
 
-For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
+You don't need to use a leader key for your keybinds but we recommend doing so.
 
-```json title="tui.json"
-{
-  "$schema": "https://opencode.ai/tui.json",
-  "keymap": {
-    "sections": {
-      "prompt": {
-        "prompt.paste": {
-          "key": "ctrl+v",
-          "preventDefault": false
-        }
-      }
-    }
-  }
-}
-```
+Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `<leader>down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`.
+
+`leader_timeout` controls how long OpenCode waits for the next key after the leader key. It defaults to `2000` milliseconds.
 
 ---
 
-## Complete keymap reference
+## Binding Values
+
+A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts.
 
-This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default.
+For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
 
 ```json title="tui.json"
 {
   "$schema": "https://opencode.ai/tui.json",
-  "keymap": {
-    "leader": "ctrl+x",
-    "leader_timeout": 2000,
-    "sections": {
-      "global": {
-        "command.palette.show": "ctrl+p",
-        "session.list": "<leader>l",
-        "session.new": "<leader>n",
-        "model.list": "<leader>m",
-        "model.cycle_recent": "f2",
-        "model.cycle_recent_reverse": "shift+f2",
-        "model.cycle_favorite": "none",
-        "model.cycle_favorite_reverse": "none",
-        "agent.list": "<leader>a",
-        "mcp.list": "none",
-        "agent.cycle": "tab",
-        "agent.cycle.reverse": "shift+tab",
-        "variant.cycle": "ctrl+t",
-        "variant.list": "none",
-        "provider.connect": "none",
-        "console.org.switch": "none",
-        "opencode.status": "<leader>s",
-        "theme.switch": "<leader>t",
-        "theme.switch_mode": "none",
-        "theme.mode.lock": "none",
-        "help.show": "none",
-        "docs.open": "none",
-        "app.exit": "ctrl+c,ctrl+d,<leader>q",
-        "app.debug": "none",
-        "app.console": "none",
-        "app.heap_snapshot": "none",
-        "app.toggle.animations": "none",
-        "app.toggle.file_context": "none",
-        "app.toggle.diffwrap": "none",
-        "app.toggle.paste_summary": "none",
-        "app.toggle.session_directory_filter": "none",
-        "terminal.suspend": "ctrl+z",
-        "terminal.title.toggle": "none"
-      },
-      "session": {
-        "session.share": "none",
-        "session.rename": "ctrl+r",
-        "session.timeline": "<leader>g",
-        "session.fork": "none",
-        "session.compact": "<leader>c",
-        "session.unshare": "none",
-        "session.undo": "<leader>u",
-        "session.redo": "<leader>r",
-        "session.sidebar.toggle": "<leader>b",
-        "session.toggle.conceal": "<leader>h",
-        "session.toggle.timestamps": "none",
-        "session.toggle.thinking": "none",
-        "session.toggle.actions": "none",
-        "session.toggle.scrollbar": "none",
-        "session.toggle.generic_tool_output": "none",
-        "session.page.up": "pageup,ctrl+alt+b",
-        "session.page.down": "pagedown,ctrl+alt+f",
-        "session.line.up": "ctrl+alt+y",
-        "session.line.down": "ctrl+alt+e",
-        "session.half.page.up": "ctrl+alt+u",
-        "session.half.page.down": "ctrl+alt+d",
-        "session.first": "ctrl+g,home",
-        "session.last": "ctrl+alt+g,end",
-        "session.messages_last_user": "none",
-        "session.message.next": "none",
-        "session.message.previous": "none",
-        "messages.copy": "<leader>y",
-        "session.copy": "none",
-        "session.export": "<leader>x",
-        "session.child.first": "<leader>down",
-        "session.parent": "up",
-        "session.child.next": "right",
-        "session.child.previous": "left"
-      },
-      "prompt": {
-        "prompt.submit": "none",
-        "prompt.editor": "<leader>e",
-        "prompt.editor_context.clear": "none",
-        "prompt.skills": "none",
-        "prompt.stash": "none",
-        "prompt.stash.pop": "none",
-        "prompt.stash.list": "none",
-        "workspace.set": "none",
-        "session.interrupt": "escape",
-        "prompt.clear": "ctrl+c",
-        "prompt.paste": {
-          "key": "ctrl+v",
-          "preventDefault": false
-        },
-        "prompt.history.previous": "up",
-        "prompt.history.next": "down"
-      },
-      "autocomplete": {
-        "prompt.autocomplete.prev": "up,ctrl+p",
-        "prompt.autocomplete.next": "down,ctrl+n",
-        "prompt.autocomplete.hide": "escape",
-        "prompt.autocomplete.select": "return",
-        "prompt.autocomplete.complete": "tab"
-      },
-      "input": {
-        "input.submit": "return",
-        "input.newline": "shift+return,ctrl+return,alt+return,ctrl+j",
-        "input.move.left": "left,ctrl+b",
-        "input.move.right": "right,ctrl+f",
-        "input.move.up": "up",
-        "input.move.down": "down",
-        "input.select.left": "shift+left",
-        "input.select.right": "shift+right",
-        "input.select.up": "shift+up",
-        "input.select.down": "shift+down",
-        "input.line.home": "ctrl+a",
-        "input.line.end": "ctrl+e",
-        "input.select.line.home": "ctrl+shift+a",
-        "input.select.line.end": "ctrl+shift+e",
-        "input.visual.line.home": "alt+a",
-        "input.visual.line.end": "alt+e",
-        "input.select.visual.line.home": "alt+shift+a",
-        "input.select.visual.line.end": "alt+shift+e",
-        "input.buffer.home": "home",
-        "input.buffer.end": "end",
-        "input.select.buffer.home": "shift+home",
-        "input.select.buffer.end": "shift+end",
-        "input.delete.line": "ctrl+shift+d",
-        "input.delete.to.line.end": "ctrl+k",
-        "input.delete.to.line.start": "ctrl+u",
-        "input.backspace": "backspace,shift+backspace",
-        "input.delete": "ctrl+d,delete,shift+delete",
-        "input.undo": "ctrl+-,super+z",
-        "input.redo": "ctrl+.,super+shift+z",
-        "input.word.forward": "alt+f,alt+right,ctrl+right",
-        "input.word.backward": "alt+b,alt+left,ctrl+left",
-        "input.select.word.forward": "alt+shift+f,alt+shift+right",
-        "input.select.word.backward": "alt+shift+b,alt+shift+left",
-        "input.delete.word.forward": "alt+d,alt+delete,ctrl+delete",
-        "input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace",
-        "input.select.all": "super+a"
-      },
-      "dialog_select": {
-        "dialog.select.prev": "up,ctrl+p",
-        "dialog.select.next": "down,ctrl+n",
-        "dialog.select.page_up": "pageup",
-        "dialog.select.page_down": "pagedown",
-        "dialog.select.home": "home",
-        "dialog.select.end": "end",
-        "dialog.select.submit": "return"
-      },
-      "dialog_actions": {
-        "dialog.action.toggle": "space",
-        "dialog.action.delete": "ctrl+d",
-        "dialog.action.rename": "ctrl+r"
-      },
-      "model": {
-        "model.dialog.provider": "ctrl+a",
-        "model.dialog.favorite": "ctrl+f"
-      },
-      "permission": {
-        "permission.reject.cancel": "ctrl+c,ctrl+d,<leader>q",
-        "permission.prompt.escape": "ctrl+c,ctrl+d,<leader>q",
-        "permission.prompt.fullscreen": "ctrl+f"
-      },
-      "question": {
-        "question.reject": "ctrl+c,ctrl+d,<leader>q",
-        "question.edit.clear": "ctrl+c"
-      },
-      "plugins": {
-        "plugins.list": "none",
-        "plugins.install": "none",
-        "plugin.dialog.install": "shift+i"
-      },
-      "home_tips": {
-        "tips.toggle": "<leader>h"
-      }
+  "keybinds": {
+    "messages_copy": ["<leader>y", "ctrl+shift+c"],
+    "input_paste": {
+      "key": "ctrl+v",
+      "preventDefault": false
     }
   }
 }
@@ -288,33 +220,22 @@ This example lists the built-in sections, command names, and default fallback bi
 
 ---
 
-## Legacy keybinds
-
-`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`.
+## Disable Keybind
 
-Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution.
+You can disable a keybind by adding the key to `tui.json` with a value of `"none"` or `false`.
 
 ```json title="tui.json"
 {
   "$schema": "https://opencode.ai/tui.json",
   "keybinds": {
-    "command_list": "ctrl+p",
-    "session_new": "<leader>n",
-    "session_compact": "<leader>c"
+    "session_compact": "none"
   }
 }
 ```
 
-:::note
-On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`:
-
-- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend).
-- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend.
-  :::
-
 ---
 
-## Desktop prompt shortcuts
+## Desktop Prompt Shortcuts
 
 The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`.
 

+ 6 - 10
packages/web/src/content/docs/tui.mdx

@@ -353,14 +353,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
 {
   "$schema": "https://opencode.ai/tui.json",
   "theme": "opencode",
-  "keymap": {
+  "leader_timeout": 2000,
+  "keybinds": {
     "leader": "ctrl+x",
-    "leader_timeout": 2000,
-    "sections": {
-      "global": {
-        "command.palette.show": "ctrl+p"
-      }
-    }
+    "command_list": "ctrl+p"
   },
   "scroll_speed": 3,
   "scroll_acceleration": {
@@ -373,13 +369,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
 
 This is separate from `opencode.json`, which configures server/runtime behavior.
 
-`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
+`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
 
 ### Options
 
 - `theme` - Sets your UI theme. [Learn more](/docs/themes).
-- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
-- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present.
+- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
+- `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`.
 - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
 - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
 - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.