Forráskód Böngészése

introduce opentui keymap as sole key/cmd engine (#26053)

Sebastian 4 hónapja
szülő
commit
98f5e6e713
67 módosított fájl, 3889 hozzáadás és 3008 törlés
  1. 386 274
      .opencode/plugins/tui-smoke.tsx
  2. 14 5
      .opencode/tui.json
  3. 19 12
      bun.lock
  4. 4 2
      package.json
  5. 1 1
      packages/opencode/package.json
  6. 1 1
      packages/opencode/script/schema.ts
  7. 31 30
      packages/opencode/specs/tui-plugins.md
  8. 26 0
      packages/opencode/specs/v2/keymappings.md
  9. 426 456
      packages/opencode/src/cli/cmd/tui/app.tsx
  10. 0 172
      packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
  11. 24 13
      packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx
  12. 3 4
      packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx
  13. 6 5
      packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
  14. 14 9
      packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
  15. 10 14
      packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx
  16. 6 6
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  17. 5 5
      packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx
  18. 8 20
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx
  19. 56 62
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  20. 379 268
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  21. 8 1
      packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts
  22. 0 73
      packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts
  23. 177 0
      packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts
  24. 309 1
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  25. 46 14
      packages/opencode/src/cli/cmd/tui/config/tui.ts
  26. 163 0
      packages/opencode/src/cli/cmd/tui/context/command-palette.tsx
  27. 0 105
      packages/opencode/src/cli/cmd/tui/context/keybind.tsx
  28. 0 41
      packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts
  29. 1 1
      packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
  30. 20 17
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx
  31. 32 33
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx
  32. 29 21
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
  33. 91 0
      packages/opencode/src/cli/cmd/tui/keymap.tsx
  34. 10 27
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  35. 65 20
      packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
  36. 72 87
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  37. 83 59
      packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
  38. 115 121
      packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
  39. 12 10
      packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx
  40. 12 9
      packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx
  41. 26 15
      packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
  42. 35 35
      packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx
  43. 9 11
      packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx
  44. 0 16
      packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx
  45. 124 40
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  46. 35 20
      packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
  47. 3 1
      packages/opencode/src/cli/cmd/tui/util/scroll.ts
  48. 41 1
      packages/opencode/src/cli/cmd/tui/util/selection.ts
  49. 17 1
      packages/opencode/src/config/keybinds.ts
  50. 0 103
      packages/opencode/src/util/keybind.ts
  51. 0 90
      packages/opencode/test/cli/tui/keybind-plugin.test.ts
  52. 5 6
      packages/opencode/test/cli/tui/plugin-add.test.ts
  53. 3 3
      packages/opencode/test/cli/tui/plugin-install.test.ts
  54. 17 16
      packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
  55. 3 2
      packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
  56. 278 30
      packages/opencode/test/cli/tui/plugin-loader.test.ts
  57. 5 4
      packages/opencode/test/cli/tui/plugin-toggle.test.ts
  58. 161 0
      packages/opencode/test/config/tui.test.ts
  59. 40 27
      packages/opencode/test/fixture/tui-plugin.ts
  60. 41 2
      packages/opencode/test/fixture/tui-runtime.ts
  61. 0 421
      packages/opencode/test/keybind.test.ts
  62. 7 2
      packages/plugin/package.json
  63. 44 43
      packages/plugin/src/tui.ts
  64. 13 3
      packages/web/src/content/docs/config.mdx
  65. 280 107
      packages/web/src/content/docs/keybinds.mdx
  66. 13 4
      packages/web/src/content/docs/tui.mdx
  67. 25 6
      script/upgrade-opentui.ts

+ 386 - 274
.opencode/plugins/tui-smoke.tsx

@@ -1,37 +1,89 @@
 /** @jsxImportSource @opentui/solid */
-import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
-import { RGBA, VignetteEffect } from "@opentui/core"
-import type {
-  TuiKeybindSet,
-  TuiPlugin,
-  TuiPluginApi,
-  TuiPluginMeta,
-  TuiPluginModule,
-  TuiSlotPlugin,
-} from "@opencode-ai/plugin/tui"
+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 type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
 
 const tabs = ["overview", "counter", "help"]
-const bind = {
-  modal: "ctrl+shift+m",
-  screen: "ctrl+shift+o",
-  home: "escape,ctrl+h",
-  left: "left,h",
-  right: "right,l",
-  up: "up,k",
-  down: "down,j",
-  alert: "a",
-  confirm: "c",
-  prompt: "p",
-  select: "s",
-  modal_accept: "enter,return",
-  modal_close: "escape",
-  dialog_close: "escape",
-  local: "x",
-  local_push: "enter,return",
-  local_close: "q,backspace",
-  host: "z",
+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>>
 }
 
+type SmokeOptions = {
+  enabled?: boolean
+  label?: unknown
+  route?: unknown
+  vignette?: unknown
+  keymap?: SmokeKeymap
+}
+
+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>
+
 const pick = (value: unknown, fallback: string) => {
   if (typeof value !== "string") return fallback
   if (!value.trim()) return fallback
@@ -43,16 +95,11 @@ const num = (value: unknown, fallback: number) => {
   return value
 }
 
-const rec = (value: unknown) => {
-  if (!value || typeof value !== "object" || Array.isArray(value)) return
-  return Object.fromEntries(Object.entries(value))
-}
-
 type Cfg = {
   label: string
   route: string
   vignette: number
-  keybinds: Record<string, unknown> | undefined
+  keymap: SmokeKeymap | undefined
 }
 
 type Route = {
@@ -69,12 +116,12 @@ type State = {
   local: number
 }
 
-const cfg = (options: Record<string, unknown> | undefined) => {
+const cfg = (options: SmokeOptions | undefined) => {
   return {
     label: pick(options?.label, "smoke"),
     route: pick(options?.route, "workspace-smoke"),
     vignette: Math.max(0, num(options?.vignette, 0.35)),
-    keybinds: rec(options?.keybinds),
+    keymap: options?.keymap,
   }
 }
 
@@ -85,7 +132,25 @@ const names = (input: Cfg) => {
   }
 }
 
-type Keys = TuiKeybindSet
+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,
+  }
+}
+
+type Keys = ReturnType<typeof createKeys>
+
 const ui = {
   panel: "#1d1d1d",
   border: "#4a4a4a",
@@ -292,125 +357,161 @@ const Screen = (props: {
   }
   const pop = (base?: State) => {
     const next = base ?? current(props.api, props.route)
-    const local = Math.max(0, next.local - 1)
-    set(local, next)
+    set(Math.max(0, next.local - 1), next)
   }
   const show = () => {
     setTimeout(() => {
       open()
     }, 0)
   }
-  useKeyboard((evt) => {
-    if (props.api.route.current.name !== props.route.screen) return
-    const next = current(props.api, props.route)
-    if (props.api.ui.dialog.open) {
-      if (props.keys.match("dialog_close", evt)) {
-        evt.preventDefault()
-        evt.stopPropagation()
-        props.api.ui.dialog.clear()
-        return
-      }
-      return
-    }
-
-    if (next.local > 0) {
-      if (evt.name === "escape" || props.keys.match("local_close", evt)) {
-        evt.preventDefault()
-        evt.stopPropagation()
-        pop(next)
-        return
-      }
-
-      if (props.keys.match("local_push", evt)) {
-        evt.preventDefault()
-        evt.stopPropagation()
-        push(next)
-        return
-      }
-      return
-    }
-
-    if (props.keys.match("home", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate("home")
-      return
-    }
-
-    if (props.keys.match("left", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
-      return
-    }
-
-    if (props.keys.match("right", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
-      return
-    }
-
-    if (props.keys.match("up", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
-      return
-    }
-
-    if (props.keys.match("down", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
-      return
-    }
-
-    if (props.keys.match("modal", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate(props.route.modal, next)
-      return
-    }
-
-    if (props.keys.match("local", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      open()
-      return
-    }
-
-    if (props.keys.match("host", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      host(props.api, props.input, skin)
-      return
-    }
-
-    if (props.keys.match("alert", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      warn(props.api, props.route, next)
-      return
-    }
-
-    if (props.keys.match("confirm", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      check(props.api, props.route, next)
-      return
-    }
-
-    if (props.keys.match("prompt", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      entry(props.api, props.route, next)
-      return
-    }
-
-    if (props.keys.match("select", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      picker(props.api, props.route, next)
+  const screenActive = () => props.api.route.current.name === props.route.screen
+
+  useBindings(() => ({
+    enabled: () => screenActive() && props.api.ui.dialog.open,
+    commands: [
+      {
+        name: command.dialog_close,
+        run() {
+          props.api.ui.dialog.clear()
+        },
+      },
+    ],
+    bindings: props.keys.sections.dialog,
+  }))
+
+  useBindings(() => ({
+    enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0,
+    commands: [
+      {
+        name: command.local_push,
+        run() {
+          push(current(props.api, props.route))
+        },
+      },
+      {
+        name: command.local_pop,
+        run() {
+          pop(current(props.api, props.route))
+        },
+      },
+    ],
+    bindings: props.keys.sections.local,
+  }))
+
+  useBindings(() => ({
+    enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0,
+    commands: [
+      {
+        name: command.screen_home,
+        run() {
+          props.api.route.navigate("home")
+        },
+      },
+      {
+        name: command.screen_left,
+        run() {
+          const next = current(props.api, props.route)
+          props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
+        },
+      },
+      {
+        name: command.screen_right,
+        run() {
+          const next = current(props.api, props.route)
+          props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
+        },
+      },
+      {
+        name: command.screen_up,
+        run() {
+          const next = current(props.api, props.route)
+          props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
+        },
+      },
+      {
+        name: command.screen_down,
+        run() {
+          const next = current(props.api, props.route)
+          props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
+        },
+      },
+      {
+        name: command.screen_modal,
+        run() {
+          props.api.route.navigate(props.route.modal, current(props.api, props.route))
+        },
+      },
+      {
+        name: command.screen_local,
+        run() {
+          open()
+        },
+      },
+      {
+        name: command.screen_host,
+        run() {
+          host(props.api, props.input, skin)
+        },
+      },
+      {
+        name: command.screen_alert,
+        run() {
+          warn(props.api, props.route, current(props.api, props.route))
+        },
+      },
+      {
+        name: command.screen_confirm,
+        run() {
+          check(props.api, props.route, current(props.api, props.route))
+        },
+      },
+      {
+        name: command.screen_prompt,
+        run() {
+          entry(props.api, props.route, current(props.api, props.route))
+        },
+      },
+      {
+        name: command.screen_select,
+        run() {
+          picker(props.api, props.route, current(props.api, props.route))
+        },
+      },
+    ],
+    bindings: props.keys.sections.screen,
+  }))
+  const shortcuts = useKeymapSelector((keymap) => {
+    const bindings = keymap.getCommandBindings({
+      visibility: "registered",
+      commands: [
+        command.screen_home,
+        command.screen_up,
+        command.screen_down,
+        command.screen_modal,
+        command.screen_alert,
+        command.screen_confirm,
+        command.screen_prompt,
+        command.screen_select,
+        command.screen_local,
+        command.screen_host,
+        command.local_push,
+        command.local_pop,
+      ],
+    })
+
+    return {
+      screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "",
+      screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "",
+      screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "",
+      screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "",
+      screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "",
+      screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "",
+      screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "",
+      screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "",
+      screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "",
+      screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "",
+      local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "",
+      local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "",
     }
   })
 
@@ -430,7 +531,7 @@ const Screen = (props: {
             <b>{props.input.label} screen</b>
             <span style={{ fg: skin.muted }}> plugin route</span>
           </text>
-          <text fg={skin.muted}>{props.keys.print("home")} home</text>
+          <text fg={skin.muted}>{shortcuts().screen_home} home</text>
         </box>
 
         <box flexDirection="row" gap={1} paddingBottom={1}>
@@ -477,7 +578,7 @@ const Screen = (props: {
             <box flexDirection="column" gap={1}>
               <text fg={skin.text}>Counter: {value.count}</text>
               <text fg={skin.muted}>
-                {props.keys.print("up")} / {props.keys.print("down")} change value
+                {shortcuts().screen_up} / {shortcuts().screen_down} change value
               </text>
             </box>
           ) : null}
@@ -485,17 +586,16 @@ const Screen = (props: {
           {value.tab === 2 ? (
             <box flexDirection="column" gap={1}>
               <text fg={skin.muted}>
-                {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
-                confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
+                {shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "}
+                confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select
               </text>
               <text fg={skin.muted}>
-                {props.keys.print("local")} local stack | {props.keys.print("host")} host stack
+                {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack
               </text>
               <text fg={skin.muted}>
-                local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
-                close
+                local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close
               </text>
-              <text fg={skin.muted}>{props.keys.print("home")} returns home</text>
+              <text fg={skin.muted}>{shortcuts().screen_home} returns home</text>
             </box>
           ) : null}
         </box>
@@ -548,7 +648,7 @@ const Screen = (props: {
           </text>
           <text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
           <text fg={skin.muted}>
-            {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
+            {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close
           </text>
           <box flexDirection="row" gap={1}>
             <Btn txt="push" run={push} skin={skin} on />
@@ -571,20 +671,35 @@ const Modal = (props: {
   const value = parse(props.params)
   const skin = tone(props.api)
 
-  useKeyboard((evt) => {
-    if (props.api.route.current.name !== props.route.modal) return
-
-    if (props.keys.match("modal_accept", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
-      return
-    }
-
-    if (props.keys.match("modal_close", evt)) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.api.route.navigate("home")
+  useBindings(() => ({
+    enabled: () => props.api.route.current.name === props.route.modal,
+    commands: [
+      {
+        name: command.modal_accept,
+        run() {
+          props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" })
+        },
+      },
+      {
+        name: command.modal_close,
+        run() {
+          props.api.route.navigate("home")
+        },
+      },
+    ],
+    bindings: props.keys.sections.modal,
+  }))
+  const shortcuts = useKeymapSelector((keymap) => {
+    const bindings = keymap.getCommandBindings({
+      visibility: "registered",
+      commands: [command.modal, command.screen, command.modal_accept, command.modal_close],
+    })
+
+    return {
+      modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "",
+      screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "",
+      modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "",
+      modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "",
     }
   })
 
@@ -595,10 +710,10 @@ const Modal = (props: {
           <text fg={skin.text}>
             <b>{props.input.label} modal</b>
           </text>
-          <text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
-          <text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
+          <text fg={skin.muted}>{shortcuts().modal} modal command</text>
+          <text fg={skin.muted}>{shortcuts().screen} screen command</text>
           <text fg={skin.muted}>
-            {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
+            {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes
           </text>
           <box flexDirection="row" gap={1}>
             <Btn
@@ -791,120 +906,117 @@ const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
 
 const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
   const route = names(input)
-  api.command.register(() => [
-    {
-      title: `${input.label} modal`,
-      value: "plugin.smoke.modal",
-      keybind: keys.get("modal"),
-      category: "Plugin",
-      slash: {
-        name: "smoke",
-      },
-      onSelect: () => {
-        api.route.navigate(route.modal, { source: "command" })
-      },
-    },
-    {
-      title: `${input.label} screen`,
-      value: "plugin.smoke.screen",
-      keybind: keys.get("screen"),
-      category: "Plugin",
-      slash: {
-        name: "smoke-screen",
+  api.keymap.registerLayer({
+    commands: [
+      {
+        name: command.modal,
+        title: `${input.label} modal`,
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "smoke",
+        run() {
+          api.route.navigate(route.modal, { source: "command" })
+        },
       },
-      onSelect: () => {
-        api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
+      {
+        name: command.screen,
+        title: `${input.label} screen`,
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "smoke-screen",
+        run() {
+          api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
+        },
       },
-    },
-    {
-      title: `${input.label} alert dialog`,
-      value: "plugin.smoke.alert",
-      category: "Plugin",
-      slash: {
-        name: "smoke-alert",
-      },
-      onSelect: () => {
-        warn(api, route, current(api, route))
-      },
-    },
-    {
-      title: `${input.label} confirm dialog`,
-      value: "plugin.smoke.confirm",
-      category: "Plugin",
-      slash: {
-        name: "smoke-confirm",
+      {
+        name: command.alert,
+        title: `${input.label} alert dialog`,
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "smoke-alert",
+        run() {
+          warn(api, route, current(api, route))
+        },
       },
-      onSelect: () => {
-        check(api, route, current(api, route))
+      {
+        name: command.confirm,
+        title: `${input.label} confirm dialog`,
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "smoke-confirm",
+        run() {
+          check(api, route, current(api, route))
+        },
       },
-    },
-    {
-      title: `${input.label} prompt dialog`,
-      value: "plugin.smoke.prompt",
-      category: "Plugin",
-      slash: {
-        name: "smoke-prompt",
+      {
+        name: command.prompt,
+        title: `${input.label} prompt dialog`,
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "smoke-prompt",
+        run() {
+          entry(api, route, current(api, route))
+        },
       },
-      onSelect: () => {
-        entry(api, route, current(api, route))
+      {
+        name: command.select,
+        title: `${input.label} select dialog`,
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "smoke-select",
+        run() {
+          picker(api, route, current(api, route))
+        },
       },
-    },
-    {
-      title: `${input.label} select dialog`,
-      value: "plugin.smoke.select",
-      category: "Plugin",
-      slash: {
-        name: "smoke-select",
+      {
+        name: command.host,
+        title: `${input.label} host overlay`,
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "smoke-host",
+        run() {
+          host(api, input, tone(api))
+        },
       },
-      onSelect: () => {
-        picker(api, route, current(api, route))
+      {
+        name: command.home,
+        title: `${input.label} go home`,
+        category: "Plugin",
+        namespace: "palette",
+        enabled: () => api.route.current.name !== "home",
+        run() {
+          api.route.navigate("home")
+        },
       },
-    },
-    {
-      title: `${input.label} host overlay`,
-      value: "plugin.smoke.host",
-      category: "Plugin",
-      slash: {
-        name: "smoke-host",
+      {
+        name: command.toast,
+        title: `${input.label} toast`,
+        category: "Plugin",
+        namespace: "palette",
+        run() {
+          api.ui.toast({
+            variant: "info",
+            title: "Smoke",
+            message: "Plugin toast works",
+            duration: 2000,
+          })
+        },
       },
-      onSelect: () => {
-        host(api, input, tone(api))
-      },
-    },
-    {
-      title: `${input.label} go home`,
-      value: "plugin.smoke.home",
-      category: "Plugin",
-      enabled: api.route.current.name !== "home",
-      onSelect: () => {
-        api.route.navigate("home")
-      },
-    },
-    {
-      title: `${input.label} toast`,
-      value: "plugin.smoke.toast",
-      category: "Plugin",
-      onSelect: () => {
-        api.ui.toast({
-          variant: "info",
-          title: "Smoke",
-          message: "Plugin toast works",
-          duration: 2000,
-        })
-      },
-    },
-  ])
+    ],
+    bindings: keys.sections.global,
+  })
 }
 
 const tui: TuiPlugin = async (api, options, meta) => {
-  if (options?.enabled === false) return
+  const input = options as SmokeOptions | undefined
+  if (input?.enabled === false) return
 
   await api.theme.install("./smoke-theme.json")
   api.theme.set("smoke-theme")
 
-  const value = cfg(options ?? undefined)
+  const value = cfg(input)
   const route = names(value)
-  const keys = api.keybind.create(bind, value.keybinds)
+  const keys = createKeys(value.keymap)
   const fx = new VignetteEffect(value.vignette)
   const post = fx.apply.bind(fx)
   api.renderer.addPostProcessFn(post)

+ 14 - 5
.opencode/tui.json

@@ -6,11 +6,20 @@
       {
         "enabled": false,
         "label": "workspace",
-        "keybinds": {
-          "modal": "ctrl+alt+m",
-          "screen": "ctrl+alt+o",
-          "home": "escape,ctrl+shift+h",
-          "dialog_close": "escape,q"
+        "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"
+            }
+          }
         }
       }
     ]

+ 19 - 12
bun.lock

@@ -379,6 +379,7 @@
         "@opentelemetry/sdk-trace-base": "2.6.1",
         "@opentelemetry/sdk-trace-node": "2.6.1",
         "@opentui/core": "catalog:",
+        "@opentui/keymap": "catalog:",
         "@opentui/solid": "catalog:",
         "@parcel/watcher": "2.5.1",
         "@pierre/diffs": "catalog:",
@@ -477,6 +478,7 @@
       },
       "devDependencies": {
         "@opentui/core": "catalog:",
+        "@opentui/keymap": "catalog:",
         "@opentui/solid": "catalog:",
         "@tsconfig/node22": "catalog:",
         "@types/node": "catalog:",
@@ -484,11 +486,13 @@
         "typescript": "catalog:",
       },
       "peerDependencies": {
-        "@opentui/core": ">=0.2.2",
-        "@opentui/solid": ">=0.2.2",
+        "@opentui/core": ">=0.2.4",
+        "@opentui/keymap": ">=0.2.4",
+        "@opentui/solid": ">=0.2.4",
       },
       "optionalPeers": [
         "@opentui/core",
+        "@opentui/keymap",
         "@opentui/solid",
       ],
     },
@@ -663,8 +667,9 @@
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@opentui/core": "0.2.2",
-    "@opentui/solid": "0.2.2",
+    "@opentui/core": "0.2.4",
+    "@opentui/keymap": "0.2.4",
+    "@opentui/solid": "0.2.4",
     "@pierre/diffs": "1.1.0-beta.18",
     "@playwright/test": "1.59.1",
     "@sentry/solid": "10.36.0",
@@ -1589,21 +1594,23 @@
 
     "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
 
-    "@opentui/core": ["@opentui/core@0.2.2", "", { "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.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
+    "@opentui/core": ["@opentui/core@0.2.4", "", { "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.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
+    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
+    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
+    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="],
 
-    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
+    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
+    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="],
 
-    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
+    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="],
 
-    "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "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-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
+    "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="],
+
+    "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "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-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="],
 
     "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 

+ 4 - 2
package.json

@@ -13,6 +13,7 @@
     "dev:storybook": "bun --cwd packages/storybook storybook",
     "lint": "oxlint",
     "typecheck": "bun turbo typecheck",
+    "upgrade-opentui": "bun run script/upgrade-opentui.ts",
     "postinstall": "bun run --cwd packages/opencode fix-node-pty",
     "prepare": "husky",
     "random": "echo 'Random script'",
@@ -34,8 +35,9 @@
       "@types/cross-spawn": "6.0.6",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
-      "@opentui/core": "0.2.2",
-      "@opentui/solid": "0.2.2",
+      "@opentui/core": "0.2.4",
+      "@opentui/keymap": "0.2.4",
+      "@opentui/solid": "0.2.4",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@types/luxon": "3.7.1",

+ 1 - 1
packages/opencode/package.json

@@ -11,7 +11,6 @@
     "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
     "build": "bun run script/build.ts",
     "fix-node-pty": "bun run script/fix-node-pty.ts",
-    "upgrade-opentui": "bun run script/upgrade-opentui.ts",
     "dev": "bun run --conditions=browser ./src/index.ts",
     "dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
     "db": "bun drizzle-kit"
@@ -125,6 +124,7 @@
     "@opentelemetry/sdk-trace-base": "2.6.1",
     "@opentelemetry/sdk-trace-node": "2.6.1",
     "@opentui/core": "catalog:",
+    "@opentui/keymap": "catalog:",
     "@opentui/solid": "catalog:",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",

+ 1 - 1
packages/opencode/script/schema.ts

@@ -59,5 +59,5 @@ await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))
 
 if (tuiFile) {
   console.log(tuiFile)
-  await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
+  await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2))
 }

+ 31 - 30
packages/opencode/specs/tui-plugins.md

@@ -53,13 +53,21 @@ Minimal module shape:
 import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
 
 const tui: TuiPlugin = async (api, options, meta) => {
-  api.command.register(() => [
-    {
-      title: "Demo",
-      value: "demo.open",
-      onSelect: () => api.route.navigate("demo"),
-    },
-  ])
+  api.keymap.registerLayer({
+    commands: [
+      {
+        name: "demo.open",
+        title: "Demo",
+        category: "Plugin",
+        namespace: "palette",
+        slashName: "demo",
+        run() {
+          api.route.navigate("demo")
+        },
+      },
+    ],
+    bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
+  })
 
   api.route.register([
     {
@@ -194,10 +202,10 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug
 Top-level API groups exposed to `tui(api, options, meta)`:
 
 - `api.app.version`
-- `api.command.register(cb)` / `api.command.trigger(value)` / `api.command.show()`
+- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
+- `api.keymap`
 - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
 - `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
-- `api.keybind.match`, `print`, `create`
 - `api.tuiConfig`
 - `api.kv.get`, `set`, `ready`
 - `api.state`
@@ -209,23 +217,23 @@ Top-level API groups exposed to `tui(api, options, meta)`:
 - `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)`
 - `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)`
 
-### Commands
+### Keymap
 
-`api.command.register` returns an unregister function. Command rows support:
+- `api.keymap` exposes the raw `Keymap<Renderable, KeyEvent>` instance from the host.
+- The host already installs the default OpenTUI bundle (`default keys`, metadata fields, and enabled fields) plus OpenCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer.
+- Register commands with `api.keymap.registerLayer({ commands: [...] })`.
+- Register key bindings with `bindings: [{ key, cmd, desc }]` in the same layer or a separate layer.
+- Use `api.keymap.acquireResource(...)` for shared plugin addon setup that should ref-count against the host keymap.
+- 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.
 
-- `title`, `value`
-- `description`, `category`
-- `keybind`
-- `suggested`, `hidden`, `enabled`
-- `slash: { name, aliases? }`
-- `onSelect`
+### Keys
 
-Command behavior:
-
-- Registrations are reactive.
-- Later registrations win for duplicate `value` and for keybind handling.
-- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`.
-- `api.command.show()` opens the host command dialog directly.
+- `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`.
 
 ### Routes
 
@@ -252,13 +260,6 @@ Command behavior:
   - `setSize("medium" | "large" | "xlarge")`
   - readonly `size`, `depth`, `open`
 
-### Keybinds
-
-- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer.
-- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set.
-- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
-- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`.
-
 ### KV, state, client, events
 
 - `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.

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

@@ -8,3 +8,29 @@ Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Comm
 
 _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.

+ 426 - 456
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -1,4 +1,5 @@
-import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
 import * as Clipboard from "@tui/util/clipboard"
 import * as Selection from "@tui/util/selection"
 import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
@@ -11,6 +12,7 @@ import {
   ErrorBoundary,
   createSignal,
   onMount,
+  onCleanup,
   batch,
   Show,
   on,
@@ -36,11 +38,9 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
 import { DialogStatus } from "@tui/component/dialog-status"
 import { DialogThemeList } from "@tui/component/dialog-theme-list"
 import { DialogHelp } from "./ui/dialog-help"
-import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
 import { DialogAgent } from "@tui/component/dialog-agent"
 import { DialogSessionList } from "@tui/component/dialog-session-list"
 import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
-import { KeybindProvider, useKeybind } from "@tui/context/keybind"
 import { ThemeProvider, useTheme } from "@tui/context/theme"
 import { Home } from "@tui/routes/home"
 import { Session } from "@tui/routes/session"
@@ -60,15 +60,17 @@ import open from "open"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
 import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
 import { TuiConfig } from "@/cli/cmd/tui/config/tui"
-import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
 import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
+import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
 import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
 import { FormatError, FormatUnknownError } from "@/cli/error"
+import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
+import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
 
 import type { EventSource } from "./context/sdk"
 import { DialogVariant } from "./component/dialog-variant"
 
-function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
+function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
   const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
 
   return {
@@ -111,7 +113,7 @@ function errorMessage(error: unknown) {
 export function tui(input: {
   url: string
   args: Args
-  config: TuiConfig.Info
+  config: TuiConfig.Resolved
   onSnapshot?: () => Promise<string[]>
   directory?: string
   fetch?: typeof fetch
@@ -130,6 +132,7 @@ export function tui(input: {
     }
 
     const onBeforeExit = async () => {
+      offKeymap()
       await TuiPluginRuntime.dispose()
     }
 
@@ -138,6 +141,9 @@ export function tui(input: {
     void renderer.getPalette({ size: 16 }).catch(() => undefined)
     const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
 
+    const keymap = createDefaultOpenTuiKeymap(renderer)
+    const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
+
     await render(() => {
       return (
         <ErrorBoundary
@@ -145,37 +151,37 @@ export function tui(input: {
             <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
           )}
         >
-          <ArgsProvider {...input.args}>
-            <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
-              <KVProvider>
-                <ToastProvider>
-                  <RouteProvider
-                    initialRoute={
-                      input.args.continue
-                        ? {
-                            type: "session",
-                            sessionID: "dummy",
-                          }
-                        : undefined
-                    }
-                  >
-                    <TuiConfigProvider config={input.config}>
-                      <SDKProvider
-                        url={input.url}
-                        directory={input.directory}
-                        fetch={input.fetch}
-                        headers={input.headers}
-                        events={input.events}
-                      >
-                        <ProjectProvider>
-                          <SyncProvider>
-                            <SyncProviderV2>
-                              <ThemeProvider mode={mode}>
-                                <LocalProvider>
-                                  <KeybindProvider>
+          <OpencodeKeymapProvider keymap={keymap}>
+            <ArgsProvider {...input.args}>
+              <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
+                <KVProvider>
+                  <ToastProvider>
+                    <RouteProvider
+                      initialRoute={
+                        input.args.continue
+                          ? {
+                              type: "session",
+                              sessionID: "dummy",
+                            }
+                          : undefined
+                      }
+                    >
+                      <TuiConfigProvider config={input.config}>
+                        <SDKProvider
+                          url={input.url}
+                          directory={input.directory}
+                          fetch={input.fetch}
+                          headers={input.headers}
+                          events={input.events}
+                        >
+                          <ProjectProvider>
+                            <SyncProvider>
+                              <SyncProviderV2>
+                                <ThemeProvider mode={mode}>
+                                  <LocalProvider>
                                     <PromptStashProvider>
                                       <DialogProvider>
-                                        <CommandProvider>
+                                        <CommandPaletteProvider>
                                           <FrecencyProvider>
                                             <PromptHistoryProvider>
                                               <PromptRefProvider>
@@ -185,22 +191,22 @@ export function tui(input: {
                                               </PromptRefProvider>
                                             </PromptHistoryProvider>
                                           </FrecencyProvider>
-                                        </CommandProvider>
+                                        </CommandPaletteProvider>
                                       </DialogProvider>
                                     </PromptStashProvider>
-                                  </KeybindProvider>
-                                </LocalProvider>
-                              </ThemeProvider>
-                            </SyncProviderV2>
-                          </SyncProvider>
-                        </ProjectProvider>
-                      </SDKProvider>
-                    </TuiConfigProvider>
-                  </RouteProvider>
-                </ToastProvider>
-              </KVProvider>
-            </ExitProvider>
-          </ArgsProvider>
+                                  </LocalProvider>
+                                </ThemeProvider>
+                              </SyncProviderV2>
+                            </SyncProvider>
+                          </ProjectProvider>
+                        </SDKProvider>
+                      </TuiConfigProvider>
+                    </RouteProvider>
+                  </ToastProvider>
+                </KVProvider>
+              </ExitProvider>
+            </ArgsProvider>
+          </OpencodeKeymapProvider>
         </ErrorBoundary>
       )
     }, renderer)
@@ -209,14 +215,17 @@ 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()
   const dialog = useDialog()
   const local = useLocal()
   const kv = useKV()
-  const command = useCommandDialog()
-  const keybind = useKeybind()
+  const command = useCommandPalette()
+  const keymap = useOpencodeKeymap()
   const event = useEvent()
   const sdk = useSDK()
   const toast = useToast()
@@ -233,10 +242,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   }
 
   const api = createTuiApi({
-    command,
     tuiConfig,
     dialog,
-    keybind,
+    keymap,
     kv,
     route,
     routes,
@@ -260,40 +268,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
       setReady(true)
     })
 
-  useKeyboard((evt) => {
-    if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
-    const sel = renderer.getSelection()
-    if (!sel) return
-
-    // Windows Terminal-like behavior:
-    // - Ctrl+C copies and dismisses selection
-    // - Esc dismisses selection
-    // - Most other key input dismisses selection and is passed through
-    if (evt.ctrl && evt.name === "c") {
-      if (!Selection.copy(renderer, toast)) {
-        renderer.clearSelection()
-        return
-      }
-
-      evt.preventDefault()
-      evt.stopPropagation()
-      return
-    }
-
-    if (evt.name === "escape") {
-      renderer.clearSelection()
-      evt.preventDefault()
-      evt.stopPropagation()
-      return
-    }
-
-    const focus = renderer.currentFocusedRenderable
-    if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) {
-      return
-    }
-
-    renderer.clearSelection()
-  })
+  // Let selection copy/dismiss win ahead of normal bindings when the feature flag is on.
+  const offSelectionKeys = keymap.intercept(
+    "key",
+    ({ event }) => {
+      if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+      Selection.handleSelectionKey(renderer, toast, event)
+    },
+    { priority: 1 },
+  )
+  onCleanup(offSelectionKeys)
 
   // Wire up console copy-to-clipboard via opentui's onCopySelection callback
   renderer.console.onCopySelection = async (text: string) => {
@@ -410,379 +394,365 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   )
 
   const connected = useConnected()
-  command.register(() => [
-    {
-      title: "Switch session",
-      value: "session.list",
-      keybind: "session_list",
-      category: "Session",
-      suggested: sync.data.session.length > 0,
-      slash: {
-        name: "sessions",
-        aliases: ["resume", "continue"],
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogSessionList />)
-      },
-    },
-    {
-      title: "New session",
-      suggested: route.data.type === "session",
-      value: "session.new",
-      keybind: "session_new",
-      category: "Session",
-      slash: {
-        name: "new",
-        aliases: ["clear"],
-      },
-      onSelect: () => {
-        route.navigate({
-          type: "home",
-        })
-        dialog.clear()
-      },
-    },
-    {
-      title: "Switch model",
-      value: "model.list",
-      keybind: "model_list",
-      suggested: true,
-      category: "Agent",
-      slash: {
-        name: "models",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogModel />)
-      },
-    },
-    {
-      title: "Model cycle",
-      value: "model.cycle_recent",
-      keybind: "model_cycle_recent",
-      category: "Agent",
-      hidden: true,
-      onSelect: () => {
-        local.model.cycle(1)
-      },
-    },
-    {
-      title: "Model cycle reverse",
-      value: "model.cycle_recent_reverse",
-      keybind: "model_cycle_recent_reverse",
-      category: "Agent",
-      hidden: true,
-      onSelect: () => {
-        local.model.cycle(-1)
-      },
-    },
-    {
-      title: "Favorite cycle",
-      value: "model.cycle_favorite",
-      keybind: "model_cycle_favorite",
-      category: "Agent",
-      hidden: true,
-      onSelect: () => {
-        local.model.cycleFavorite(1)
-      },
-    },
-    {
-      title: "Favorite cycle reverse",
-      value: "model.cycle_favorite_reverse",
-      keybind: "model_cycle_favorite_reverse",
-      category: "Agent",
-      hidden: true,
-      onSelect: () => {
-        local.model.cycleFavorite(-1)
-      },
-    },
-    {
-      title: "Switch agent",
-      value: "agent.list",
-      keybind: "agent_list",
-      category: "Agent",
-      slash: {
-        name: "agents",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogAgent />)
-      },
-    },
-    {
-      title: "Toggle MCPs",
-      value: "mcp.list",
-      category: "Agent",
-      slash: {
-        name: "mcps",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogMcp />)
-      },
-    },
-    {
-      title: "Agent cycle",
-      value: "agent.cycle",
-      keybind: "agent_cycle",
-      category: "Agent",
-      hidden: true,
-      onSelect: () => {
-        local.agent.move(1)
-      },
-    },
-    {
-      title: "Variant cycle",
-      value: "variant.cycle",
-      keybind: "variant_cycle",
-      category: "Agent",
-      onSelect: () => {
-        local.model.variant.cycle()
-      },
-    },
-    {
-      title: "Switch model variant",
-      value: "variant.list",
-      keybind: "variant_list",
-      category: "Agent",
-      hidden: local.model.variant.list().length === 0,
-      slash: {
-        name: "variants",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogVariant />)
-      },
-    },
-    {
-      title: "Agent cycle reverse",
-      value: "agent.cycle.reverse",
-      keybind: "agent_cycle_reverse",
-      category: "Agent",
-      hidden: true,
-      onSelect: () => {
-        local.agent.move(-1)
-      },
-    },
-    {
-      title: "Connect provider",
-      value: "provider.connect",
-      suggested: !connected(),
-      slash: {
-        name: "connect",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogProviderList />)
-      },
-      category: "Provider",
-    },
-    ...(sync.data.console_state.switchableOrgCount > 1
-      ? [
-          {
-            title: "Switch org",
-            value: "console.org.switch",
-            suggested: Boolean(sync.data.console_state.activeOrgName),
-            slash: {
-              name: "org",
-              aliases: ["orgs", "switch-org"],
-            },
-            onSelect: () => {
-              dialog.replace(() => <DialogConsoleOrg />)
+  const appCommands = createMemo(() =>
+    [
+      {
+        name: "command.palette.show",
+        title: "Show command palette",
+        hidden: true,
+        run: () => {
+          command.show()
+        },
+      },
+      {
+        name: "session.list",
+        title: "Switch session",
+        category: "Session",
+        suggested: sync.data.session.length > 0,
+        slashName: "sessions",
+        slashAliases: ["resume", "continue"],
+        run: () => {
+          dialog.replace(() => <DialogSessionList />)
+        },
+      },
+      {
+        name: "session.new",
+        title: "New session",
+        suggested: route.data.type === "session",
+        category: "Session",
+        slashName: "new",
+        slashAliases: ["clear"],
+        run: () => {
+          route.navigate({
+            type: "home",
+          })
+          dialog.clear()
+        },
+      },
+      {
+        name: "model.list",
+        title: "Switch model",
+        suggested: true,
+        category: "Agent",
+        slashName: "models",
+        run: () => {
+          dialog.replace(() => <DialogModel />)
+        },
+      },
+      {
+        name: "model.cycle_recent",
+        title: "Model cycle",
+        category: "Agent",
+        hidden: true,
+        run: () => {
+          local.model.cycle(1)
+        },
+      },
+      {
+        name: "model.cycle_recent_reverse",
+        title: "Model cycle reverse",
+        category: "Agent",
+        hidden: true,
+        run: () => {
+          local.model.cycle(-1)
+        },
+      },
+      {
+        name: "model.cycle_favorite",
+        title: "Favorite cycle",
+        category: "Agent",
+        hidden: true,
+        run: () => {
+          local.model.cycleFavorite(1)
+        },
+      },
+      {
+        name: "model.cycle_favorite_reverse",
+        title: "Favorite cycle reverse",
+        category: "Agent",
+        hidden: true,
+        run: () => {
+          local.model.cycleFavorite(-1)
+        },
+      },
+      {
+        name: "agent.list",
+        title: "Switch agent",
+        category: "Agent",
+        slashName: "agents",
+        run: () => {
+          dialog.replace(() => <DialogAgent />)
+        },
+      },
+      {
+        name: "mcp.list",
+        title: "Toggle MCPs",
+        category: "Agent",
+        slashName: "mcps",
+        run: () => {
+          dialog.replace(() => <DialogMcp />)
+        },
+      },
+      {
+        name: "agent.cycle",
+        title: "Agent cycle",
+        category: "Agent",
+        hidden: true,
+        run: () => {
+          local.agent.move(1)
+        },
+      },
+      {
+        name: "variant.cycle",
+        title: "Variant cycle",
+        category: "Agent",
+        run: () => {
+          local.model.variant.cycle()
+        },
+      },
+      {
+        name: "variant.list",
+        title: "Switch model variant",
+        category: "Agent",
+        hidden: local.model.variant.list().length === 0,
+        slashName: "variants",
+        run: () => {
+          dialog.replace(() => <DialogVariant />)
+        },
+      },
+      {
+        name: "agent.cycle.reverse",
+        title: "Agent cycle reverse",
+        category: "Agent",
+        hidden: true,
+        run: () => {
+          local.agent.move(-1)
+        },
+      },
+      {
+        name: "provider.connect",
+        title: "Connect provider",
+        suggested: !connected(),
+        slashName: "connect",
+        run: () => {
+          dialog.replace(() => <DialogProviderList />)
+        },
+        category: "Provider",
+      },
+      ...(sync.data.console_state.switchableOrgCount > 1
+        ? [
+            {
+              name: "console.org.switch",
+              title: "Switch org",
+              suggested: Boolean(sync.data.console_state.activeOrgName),
+              slashName: "org",
+              slashAliases: ["orgs", "switch-org"],
+              run: () => {
+                dialog.replace(() => <DialogConsoleOrg />)
+              },
+              category: "Provider",
             },
-            category: "Provider",
-          },
-        ]
-      : []),
-    {
-      title: "View status",
-      keybind: "status_view",
-      value: "opencode.status",
-      slash: {
-        name: "status",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogStatus />)
-      },
-      category: "System",
-    },
-    {
-      title: "Switch theme",
-      value: "theme.switch",
-      keybind: "theme_list",
-      slash: {
-        name: "themes",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogThemeList />)
-      },
-      category: "System",
-    },
-    {
-      title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
-      value: "theme.switch_mode",
-      onSelect: (dialog) => {
-        setMode(mode() === "dark" ? "light" : "dark")
-        dialog.clear()
-      },
-      category: "System",
-    },
-    {
-      title: locked() ? "Unlock theme mode" : "Lock theme mode",
-      value: "theme.mode.lock",
-      onSelect: (dialog) => {
-        if (locked()) unlock()
-        else lock()
-        dialog.clear()
-      },
-      category: "System",
-    },
-    {
-      title: "Help",
-      value: "help.show",
-      slash: {
-        name: "help",
-      },
-      onSelect: () => {
-        dialog.replace(() => <DialogHelp />)
-      },
-      category: "System",
-    },
-    {
-      title: "Open docs",
-      value: "docs.open",
-      onSelect: () => {
-        open("https://opencode.ai/docs").catch(() => {})
-        dialog.clear()
-      },
-      category: "System",
-    },
-    {
-      title: "Exit the app",
-      value: "app.exit",
-      slash: {
-        name: "exit",
-        aliases: ["quit", "q"],
-      },
-      onSelect: () => exit(),
-      category: "System",
-    },
-    {
-      title: "Toggle debug panel",
-      category: "System",
-      value: "app.debug",
-      onSelect: (dialog) => {
-        renderer.toggleDebugOverlay()
-        dialog.clear()
-      },
-    },
-    {
-      title: "Toggle console",
-      category: "System",
-      value: "app.console",
-      onSelect: (dialog) => {
-        renderer.console.toggle()
-        dialog.clear()
-      },
-    },
-    {
-      title: "Write heap snapshot",
-      category: "System",
-      value: "app.heap_snapshot",
-      onSelect: async (dialog) => {
-        const files = await props.onSnapshot?.()
-        toast.show({
-          variant: "info",
-          message: `Heap snapshot written to ${files?.join(", ")}`,
-          duration: 5000,
-        })
-        dialog.clear()
-      },
-    },
-    {
-      title: "Suspend terminal",
-      value: "terminal.suspend",
-      keybind: "terminal_suspend",
-      category: "System",
-      hidden: true,
-      enabled: tuiConfig.keybinds?.terminal_suspend !== "none",
-      onSelect: () => {
-        process.once("SIGCONT", () => {
-          renderer.resume()
-        })
+          ]
+        : []),
+      {
+        name: "opencode.status",
+        title: "View status",
+        slashName: "status",
+        run: () => {
+          dialog.replace(() => <DialogStatus />)
+        },
+        category: "System",
+      },
+      {
+        name: "theme.switch",
+        title: "Switch theme",
+        slashName: "themes",
+        run: () => {
+          dialog.replace(() => <DialogThemeList />)
+        },
+        category: "System",
+      },
+      {
+        name: "theme.switch_mode",
+        title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
+        run: () => {
+          setMode(mode() === "dark" ? "light" : "dark")
+          dialog.clear()
+        },
+        category: "System",
+      },
+      {
+        name: "theme.mode.lock",
+        title: locked() ? "Unlock theme mode" : "Lock theme mode",
+        run: () => {
+          if (locked()) unlock()
+          else lock()
+          dialog.clear()
+        },
+        category: "System",
+      },
+      {
+        name: "help.show",
+        title: "Help",
+        slashName: "help",
+        run: () => {
+          dialog.replace(() => <DialogHelp />)
+        },
+        category: "System",
+      },
+      {
+        name: "docs.open",
+        title: "Open docs",
+        run: () => {
+          open("https://opencode.ai/docs").catch(() => {})
+          dialog.clear()
+        },
+        category: "System",
+      },
+      {
+        name: "app.exit",
+        title: "Exit the app",
+        slashName: "exit",
+        slashAliases: ["quit", "q"],
+        enabled: () => {
+          const current = promptRef.current
+          if (!current?.focused) return true
+          return current.current.input === ""
+        },
+        run: () => exit(),
+        category: "System",
+      },
+      {
+        name: "app.debug",
+        title: "Toggle debug panel",
+        category: "System",
+        run: () => {
+          renderer.toggleDebugOverlay()
+          dialog.clear()
+        },
+      },
+      {
+        name: "app.console",
+        title: "Toggle console",
+        category: "System",
+        run: () => {
+          renderer.console.toggle()
+          dialog.clear()
+        },
+      },
+      {
+        name: "app.heap_snapshot",
+        title: "Write heap snapshot",
+        category: "System",
+        run: async () => {
+          const files = await props.onSnapshot?.()
+          toast.show({
+            variant: "info",
+            message: `Heap snapshot written to ${files?.join(", ")}`,
+            duration: 5000,
+          })
+          dialog.clear()
+        },
+      },
+      {
+        name: "terminal.suspend",
+        title: "Suspend terminal",
+        category: "System",
+        hidden: true,
+        enabled: process.platform !== "win32",
+        run: () => {
+          process.once("SIGCONT", () => {
+            renderer.resume()
+          })
 
-        renderer.suspend()
-        // pid=0 means send the signal to all processes in the process group
-        process.kill(0, "SIGTSTP")
-      },
-    },
-    {
-      title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
-      value: "terminal.title.toggle",
-      keybind: "terminal_title_toggle",
-      category: "System",
-      onSelect: (dialog) => {
-        setTerminalTitleEnabled((prev) => {
-          const next = !prev
-          kv.set("terminal_title_enabled", next)
-          if (!next) renderer.setTerminalTitle("")
-          return next
-        })
-        dialog.clear()
-      },
-    },
-    {
-      title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
-      value: "app.toggle.animations",
-      category: "System",
-      onSelect: (dialog) => {
-        kv.set("animations_enabled", !kv.get("animations_enabled", true))
-        dialog.clear()
-      },
-    },
-    {
-      title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
-      value: "app.toggle.file_context",
-      category: "System",
-      onSelect: (dialog) => {
-        kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
-        dialog.clear()
-      },
-    },
-    {
-      title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
-      value: "app.toggle.paste_summary",
-      category: "System",
-      onSelect: (dialog) => {
-        setPasteSummaryEnabled((prev) => {
-          const next = !prev
-          kv.set("paste_summary_enabled", next)
-          return next
-        })
-        dialog.clear()
-      },
-    },
-    {
-      title: kv.get("session_directory_filter_enabled", true)
-        ? "Disable session directory filtering"
-        : "Enable session directory filtering",
-      value: "app.toggle.session_directory_filter",
-      category: "System",
-      onSelect: async (dialog) => {
-        kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
-        await sync.session.refresh()
-        dialog.clear()
-      },
-    },
-    {
-      title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
-      value: "app.toggle.diffwrap",
-      category: "System",
-      onSelect: (dialog) => {
-        const current = kv.get("diff_wrap_mode", "word")
-        kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
-        dialog.clear()
-      },
-    },
-  ])
+          renderer.suspend()
+          process.kill(0, "SIGTSTP")
+        },
+      },
+      {
+        name: "terminal.title.toggle",
+        title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
+        category: "System",
+        run: () => {
+          setTerminalTitleEnabled((prev) => {
+            const next = !prev
+            kv.set("terminal_title_enabled", next)
+            if (!next) renderer.setTerminalTitle("")
+            return next
+          })
+          dialog.clear()
+        },
+      },
+      {
+        name: "app.toggle.animations",
+        title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
+        category: "System",
+        run: () => {
+          kv.set("animations_enabled", !kv.get("animations_enabled", true))
+          dialog.clear()
+        },
+      },
+      {
+        name: "app.toggle.file_context",
+        title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
+        category: "System",
+        run: () => {
+          kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
+          dialog.clear()
+        },
+      },
+      {
+        name: "app.toggle.diffwrap",
+        title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
+        category: "System",
+        run: () => {
+          const current = kv.get("diff_wrap_mode", "word")
+          kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
+          dialog.clear()
+        },
+      },
+      {
+        name: "app.toggle.paste_summary",
+        title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
+        category: "System",
+        run: () => {
+          setPasteSummaryEnabled((prev) => {
+            const next = !prev
+            kv.set("paste_summary_enabled", next)
+            return next
+          })
+          dialog.clear()
+        },
+      },
+      {
+        name: "app.toggle.session_directory_filter",
+        title: kv.get("session_directory_filter_enabled", true)
+          ? "Disable session directory filtering"
+          : "Enable session directory filtering",
+        category: "System",
+        run: async () => {
+          kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
+          await sync.session.refresh()
+          dialog.clear()
+        },
+      },
+    ].map((command) => ({
+      namespace: "palette",
+      ...command,
+    })),
+  )
+
+  useBindings(() => ({
+    commands: appCommands(),
+  }))
+
+  useBindings(() => ({
+    enabled: command.matcher,
+    bindings: sections.global,
+  }))
 
   event.on(TuiEvent.CommandExecute.type, (evt) => {
-    command.trigger(evt.properties.command)
+    command.run(evt.properties.command)
   })
 
   event.on(TuiEvent.ToastShow.type, (evt) => {

+ 0 - 172
packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx

@@ -1,172 +0,0 @@
-import { useDialog } from "@tui/ui/dialog"
-import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
-import {
-  createContext,
-  createMemo,
-  createSignal,
-  getOwner,
-  onCleanup,
-  runWithOwner,
-  useContext,
-  type Accessor,
-  type ParentProps,
-} from "solid-js"
-import { useKeyboard } from "@opentui/solid"
-import { useKeybind } from "@tui/context/keybind"
-
-type Context = ReturnType<typeof init>
-const ctx = createContext<Context>()
-
-export type Slash = {
-  name: string
-  aliases?: string[]
-}
-
-export type CommandOption = DialogSelectOption<string> & {
-  keybind?: string
-  suggested?: boolean
-  slash?: Slash
-  hidden?: boolean
-  enabled?: boolean
-}
-
-function init() {
-  const root = getOwner()
-  const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
-  const [suspendCount, setSuspendCount] = createSignal(0)
-  const dialog = useDialog()
-  const keybind = useKeybind()
-
-  const entries = createMemo(() => {
-    const all = registrations().flatMap((x) => x())
-    return all.map((x) => ({
-      ...x,
-      footer: x.keybind ? keybind.print(x.keybind) : undefined,
-    }))
-  })
-
-  const isEnabled = (option: CommandOption) => option.enabled !== false
-  const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
-
-  const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
-  const suggestedOptions = createMemo(() =>
-    visibleOptions()
-      .filter((option) => option.suggested)
-      .map((option) => ({
-        ...option,
-        value: `suggested:${option.value}`,
-        category: "Suggested",
-      })),
-  )
-  const suspended = () => suspendCount() > 0
-
-  useKeyboard((evt) => {
-    if (suspended()) return
-    if (dialog.stack.length > 0) return
-    if (evt.defaultPrevented) return
-    for (const option of entries()) {
-      if (!isEnabled(option)) continue
-      if (option.keybind && keybind.match(option.keybind, evt)) {
-        evt.preventDefault()
-        option.onSelect?.(dialog)
-        return
-      }
-    }
-  })
-
-  const result = {
-    trigger(name: string) {
-      for (const option of entries()) {
-        if (option.value === name) {
-          if (!isEnabled(option)) return
-          option.onSelect?.(dialog)
-          return
-        }
-      }
-    },
-    slashes() {
-      return visibleOptions().flatMap((option) => {
-        const slash = option.slash
-        if (!slash) return []
-        return {
-          display: "/" + slash.name,
-          description: option.description ?? option.title,
-          aliases: slash.aliases?.map((alias) => "/" + alias),
-          onSelect: () => result.trigger(option.value),
-        }
-      })
-    },
-    keybinds(enabled: boolean) {
-      setSuspendCount((count) => count + (enabled ? -1 : 1))
-    },
-    suspended,
-    show() {
-      dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
-    },
-    register(cb: () => CommandOption[]) {
-      const owner = getOwner() ?? root
-      if (!owner) return () => {}
-
-      let list: Accessor<CommandOption[]> | undefined
-
-      // TUI plugins now register commands via an async store that runs outside an active reactive scope.
-      // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
-      runWithOwner(owner, () => {
-        list = createMemo(cb)
-        const ref = list
-        if (!ref) return
-        setRegistrations((arr) => [ref, ...arr])
-        onCleanup(() => {
-          setRegistrations((arr) => arr.filter((x) => x !== ref))
-        })
-      })
-
-      if (!list) return () => {}
-      let done = false
-      return () => {
-        if (done) return
-        done = true
-        const ref = list
-        if (!ref) return
-        setRegistrations((arr) => arr.filter((x) => x !== ref))
-      }
-    },
-  }
-  return result
-}
-
-export function useCommandDialog() {
-  const value = useContext(ctx)
-  if (!value) {
-    throw new Error("useCommandDialog must be used within a CommandProvider")
-  }
-  return value
-}
-
-export function CommandProvider(props: ParentProps) {
-  const value = init()
-  const dialog = useDialog()
-  const keybind = useKeybind()
-
-  useKeyboard((evt) => {
-    if (value.suspended()) return
-    if (dialog.stack.length > 0) return
-    if (evt.defaultPrevented) return
-    if (keybind.match("command_list", evt)) {
-      evt.preventDefault()
-      value.show()
-      return
-    }
-  })
-
-  return <ctx.Provider value={value}>{props.children}</ctx.Provider>
-}
-
-function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
-  let ref: DialogSelectRef<string>
-  const list = () => {
-    if (ref?.filter) return props.options
-    return [...props.suggestedOptions, ...props.options]
-  }
-  return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
-}

+ 24 - 13
packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx

@@ -1,5 +1,4 @@
 import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
-import { useKeyboard } from "@opentui/solid"
 import open from "open"
 import { createSignal, onCleanup, onMount } from "solid-js"
 import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
 import { Link } from "@tui/ui/link"
 import { GoLogo } from "./logo"
 import { BgPulse, type BgPulseMask } from "./bg-pulse"
+import { useBindings } from "../keymap"
 
 const GO_URL = "https://opencode.ai/go"
 const PAD_X = 3
@@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
     for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
   })
 
-  useKeyboard((evt) => {
-    if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
-      setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
-      return
-    }
-    if (evt.name === "return") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      if (selected() === "subscribe") subscribe(props, dialog)
-      else dismiss(props, dialog)
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      {
+        key: "left",
+        cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
+      },
+      {
+        key: "right",
+        cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
+      },
+      {
+        key: "tab",
+        cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
+      },
+      {
+        key: "return",
+        cmd: () => {
+          if (selected() === "subscribe") subscribe(props, dialog)
+          else dismiss(props, dialog)
+        },
+      },
+    ],
+  }))
 
   return (
     <box ref={(item: BoxRenderable) => (content = item)}>

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

@@ -4,7 +4,6 @@ import { useSync } from "@tui/context/sync"
 import { map, pipe, entries, sortBy } from "remeda"
 import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
 import { useTheme } from "../context/theme"
-import { Keybind } from "@/util/keybind"
 import { TextAttributes } from "@opentui/core"
 import { useSDK } from "@tui/context/sdk"
 
@@ -45,9 +44,9 @@ export function DialogMcp() {
     )
   })
 
-  const keybinds = createMemo(() => [
+  const actions = createMemo(() => [
     {
-      keybind: Keybind.parse("space")[0],
+      command: "dialog.action.toggle",
       title: "toggle",
       onTrigger: async (option: DialogSelectOption<string>) => {
         // Prevent toggling while an operation is already in progress
@@ -77,7 +76,7 @@ export function DialogMcp() {
       ref={setRef}
       title="MCPs"
       options={options()}
-      keybind={keybinds()}
+      actions={actions()}
       onSelect={(_option) => {
         // Don't close on select, only on escape
       }}

+ 6 - 5
packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

@@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select"
 import { useDialog } from "@tui/ui/dialog"
 import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
 import { DialogVariant } from "./dialog-variant"
-import { useKeybind } from "../context/keybind"
 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 keybind = useKeybind()
+  const tuiConfig = useTuiConfig()
   const [query, setQuery] = createSignal("")
 
   const connected = useConnected()
@@ -150,16 +150,16 @@ export function DialogModel(props: { providerID?: string }) {
   return (
     <DialogSelect<ReturnType<typeof options>[number]["value"]>
       options={options()}
-      keybind={[
+      actions={[
         {
-          keybind: keybind.all.model_provider_list?.[0],
+          command: "model.dialog.provider",
           title: connected() ? "Connect provider" : "View all providers",
           onTrigger() {
             dialog.replace(() => <DialogProvider />)
           },
         },
         {
-          keybind: keybind.all.model_favorite_toggle?.[0],
+          command: "model.dialog.favorite",
           title: "Favorite",
           disabled: !connected(),
           onTrigger: (option) => {
@@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) {
           },
         },
       ]}
+      bindings={tuiConfig.keymap.sections.model}
       onFilter={setQuery}
       flat={true}
       skipFilter={true}

+ 14 - 9
packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

@@ -10,11 +10,11 @@ import { useTheme } from "../context/theme"
 import { TextAttributes } from "@opentui/core"
 import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
 import { DialogModel } from "./dialog-model"
-import { useKeyboard } from "@opentui/solid"
 import * as Clipboard from "@tui/util/clipboard"
 import { useToast } from "../ui/toast"
 import { isConsoleManagedProvider } from "@tui/util/provider-origin"
 import { useConnected } from "./use-connected"
+import { useBindings } from "../keymap"
 
 const PROVIDER_PRIORITY: Record<string, number> = {
   opencode: 0,
@@ -239,14 +239,19 @@ function AutoMethod(props: AutoMethodProps) {
   const sync = useSync()
   const toast = useToast()
 
-  useKeyboard((evt) => {
-    if (evt.name === "c" && !evt.ctrl && !evt.meta) {
-      const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
-      Clipboard.copy(code)
-        .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
-        .catch(toast.error)
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      {
+        key: "c",
+        cmd: () => {
+          const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
+          Clipboard.copy(code)
+            .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
+            .catch(toast.error)
+        },
+      },
+    ],
+  }))
 
   onMount(async () => {
     const result = await sdk.client.provider.oauth.callback({

+ 10 - 14
packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx

@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
 import { useDialog } from "../ui/dialog"
 import { createStore } from "solid-js/store"
 import { For } from "solid-js"
-import { useKeyboard } from "@opentui/solid"
+import { useBindings } from "../keymap"
 
 export function DialogSessionDeleteFailed(props: {
   session: string
@@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: {
     if (!props.onDone) dialog.clear()
   }
 
-  useKeyboard((evt) => {
-    if (evt.name === "return") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      void confirm()
-    }
-    if (evt.name === "left" || evt.name === "up") {
-      setStore("active", "delete")
-    }
-    if (evt.name === "right" || evt.name === "down") {
-      setStore("active", "restore")
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      { key: "return", cmd: () => void confirm() },
+      { key: "left", cmd: () => setStore("active", "delete") },
+      { key: "up", cmd: () => setStore("active", "delete") },
+      { key: "right", cmd: () => setStore("active", "restore") },
+      { key: "down", cmd: () => setStore("active", "restore") },
+    ],
+  }))
 
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>

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

@@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync"
 import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
 import { Locale } from "@/util/locale"
 import { useProject } from "@tui/context/project"
-import { useKeybind } from "../context/keybind"
 import { useTheme } from "../context/theme"
 import { useSDK } from "../context/sdk"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -17,18 +16,19 @@ import { Spinner } from "./spinner"
 import { errorMessage } from "@/util/error"
 import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
 import { WorkspaceLabel } from "./workspace-label"
+import { useCommandShortcut } from "../keymap"
 
 export function DialogSessionList() {
   const dialog = useDialog()
   const route = useRoute()
   const sync = useSync()
   const project = useProject()
-  const keybind = useKeybind()
   const { theme } = useTheme()
   const sdk = useSDK()
   const toast = useToast()
   const [toDelete, setToDelete] = createSignal<string>()
   const [search, setSearch] = createDebouncedSignal("", 150)
+  const deleteHint = useCommandShortcut("dialog.action.delete")
 
   const [searchResults, { refetch }] = createResource(
     () => ({ query: search(), filter: sync.session.query() }),
@@ -156,7 +156,7 @@ export function DialogSessionList() {
         const status = sync.data.session_status?.[x.id]
         const isWorking = status?.type === "busy"
         return {
-          title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
+          title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
           bg: isDeleting ? theme.error : undefined,
           value: x.id,
           category,
@@ -187,9 +187,9 @@ export function DialogSessionList() {
         })
         dialog.clear()
       }}
-      keybind={[
+      actions={[
         {
-          keybind: keybind.all.session_delete?.[0],
+          command: "dialog.action.delete",
           title: "delete",
           onTrigger: async (option) => {
             if (toDelete() === option.value) {
@@ -237,7 +237,7 @@ export function DialogSessionList() {
           },
         },
         {
-          keybind: keybind.all.session_rename?.[0],
+          command: "dialog.action.rename",
           title: "rename",
           onTrigger: async (option) => {
             dialog.replace(() => <DialogSessionRename session={option.value} />)

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

@@ -3,8 +3,8 @@ import { DialogSelect } from "@tui/ui/dialog-select"
 import { createMemo, createSignal } from "solid-js"
 import { Locale } from "@/util/locale"
 import { useTheme } from "../context/theme"
-import { useKeybind } from "../context/keybind"
 import { usePromptStash, type StashEntry } from "./prompt/stash"
+import { useCommandShortcut } from "../keymap"
 
 function getRelativeTime(timestamp: number): string {
   const now = Date.now()
@@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
   const dialog = useDialog()
   const stash = usePromptStash()
   const { theme } = useTheme()
-  const keybind = useKeybind()
 
   const [toDelete, setToDelete] = createSignal<number>()
+  const deleteHint = useCommandShortcut("dialog.action.delete")
 
   const options = createMemo(() => {
     const entries = stash.list()
@@ -42,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
         const isDeleting = toDelete() === index
         const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
         return {
-          title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
+          title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
           bg: isDeleting ? theme.error : undefined,
           value: index,
           description: getRelativeTime(entry.timestamp),
@@ -68,9 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
         }
         dialog.clear()
       }}
-      keybind={[
+      actions={[
         {
-          keybind: keybind.all.stash_delete?.[0],
+          command: "dialog.action.delete",
           title: "delete",
           onTrigger: (option) => {
             if (toDelete() === option.value) {

+ 8 - 20
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx

@@ -1,9 +1,9 @@
 import { TextAttributes } from "@opentui/core"
-import { useKeyboard } from "@opentui/solid"
 import { createStore } from "solid-js/store"
 import { For } from "solid-js"
 import { useTheme } from "../context/theme"
 import { useDialog } from "../ui/dialog"
+import { useBindings } from "../keymap"
 
 export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
   const dialog = useDialog()
@@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean |
     if (result === false) return
   }
 
-  useKeyboard((evt) => {
-    if (evt.name === "return") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      void confirm()
-      return
-    }
-    if (evt.name === "left") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      setStore("active", "cancel")
-      return
-    }
-    if (evt.name === "right") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      setStore("active", "restore")
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      { key: "return", cmd: () => void confirm() },
+      { key: "left", cmd: () => setStore("active", "cancel") },
+      { key: "right", cmd: () => setStore("active", "restore") },
+    ],
+  }))
 
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>

+ 56 - 62
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -1,4 +1,4 @@
-import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
+import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
 import { pathToFileURL } from "bun"
 import fuzzysort from "fuzzysort"
 import path from "path"
@@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll"
 import { useTuiConfig } from "../../context/tui-config"
 import { useTheme, selectedForeground } from "@tui/context/theme"
 import { SplitBorder } from "@tui/component/border"
-import { useCommandDialog } from "@tui/component/dialog-command"
+import { useCommandPalette } from "../../context/command-palette"
 import { useTerminalDimensions } from "@opentui/solid"
 import { Locale } from "@/util/locale"
 import type { PromptInfo } from "./history"
 import { useFrecency } from "./frecency"
+import { useBindings } from "../../keymap"
 
 function removeLineRange(input: string) {
   const hashIndex = input.lastIndexOf("#")
@@ -52,7 +53,6 @@ function extractLineRange(input: string) {
 
 export type AutocompleteRef = {
   onInput: (value: string) => void
-  onKeyDown: (e: KeyEvent) => void
   visible: false | "@" | "/"
 }
 
@@ -82,12 +82,14 @@ export function Autocomplete(props: {
   const editor = useEditorContext()
   const sdk = useSDK()
   const sync = useSync()
-  const command = useCommandDialog()
+  const command = useCommandPalette()
   const { theme } = useTheme()
   const dimensions = useTerminalDimensions()
   const frecency = useFrecency()
   const tuiConfig = useTuiConfig()
-
+  const {
+    keymap: { sections },
+  } = tuiConfig
   const [store, setStore] = createStore({
     index: 0,
     selected: 0,
@@ -282,7 +284,7 @@ export function Autocomplete(props: {
     const { filename, part } = createFilePart(item, lineRange)
     const index = store.visible === "@" ? store.index : props.input().cursorOffset
 
-    command.keybinds(true)
+    command.suspend(false)
     setStore("visible", false)
     setStore("index", index)
     insertPart(filename, part)
@@ -520,8 +522,54 @@ export function Autocomplete(props: {
     setStore("selected", 0)
   }
 
+  useBindings(() => ({
+    target: props.input,
+    enabled: () => Boolean(store.visible),
+    commands: [
+      {
+        name: "prompt.autocomplete.prev",
+        run() {
+          setStore("input", "keyboard")
+          move(-1)
+        },
+      },
+      {
+        name: "prompt.autocomplete.next",
+        run() {
+          setStore("input", "keyboard")
+          move(1)
+        },
+      },
+      {
+        name: "prompt.autocomplete.hide",
+        run() {
+          hide()
+        },
+      },
+      {
+        name: "prompt.autocomplete.select",
+        run() {
+          select()
+        },
+      },
+      {
+        name: "prompt.autocomplete.complete",
+        run() {
+          const selected = options()[store.selected]
+          if (selected?.isDirectory) {
+            expandDirectory()
+            return
+          }
+
+          select()
+        },
+      },
+    ],
+    bindings: sections.autocomplete,
+  }))
+
   function show(mode: "@" | "/") {
-    command.keybinds(false)
+    command.suspend(true)
     setStore({
       visible: mode,
       index: props.input().cursorOffset,
@@ -538,7 +586,7 @@ export function Autocomplete(props: {
         draft.input = props.input().plainText
       })
     }
-    command.keybinds(true)
+    command.suspend(false)
     setStore("visible", false)
   }
 
@@ -593,60 +641,6 @@ export function Autocomplete(props: {
           setStore("index", idx)
         }
       },
-      onKeyDown(e: KeyEvent) {
-        if (store.visible) {
-          const name = e.name?.toLowerCase()
-          const ctrlOnly = e.ctrl && !e.meta && !e.shift
-          const isNavUp = name === "up" || (ctrlOnly && name === "p")
-          const isNavDown = name === "down" || (ctrlOnly && name === "n")
-
-          if (isNavUp) {
-            setStore("input", "keyboard")
-            move(-1)
-            e.preventDefault()
-            return
-          }
-          if (isNavDown) {
-            setStore("input", "keyboard")
-            move(1)
-            e.preventDefault()
-            return
-          }
-          if (name === "escape") {
-            hide()
-            e.preventDefault()
-            return
-          }
-          if (name === "return") {
-            select()
-            e.preventDefault()
-            return
-          }
-          if (name === "tab") {
-            const selected = options()[store.selected]
-            if (selected?.isDirectory) {
-              expandDirectory()
-            } else {
-              select()
-            }
-            e.preventDefault()
-            return
-          }
-        }
-        if (!store.visible) {
-          if (e.name === "@") {
-            const cursorOffset = props.input().cursorOffset
-            const charBeforeCursor =
-              cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
-            const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
-            if (canTrigger) show("@")
-          }
-
-          if (e.name === "/") {
-            if (props.input().cursorOffset === 0) show("/")
-          }
-        }
-      },
     })
   })
 

+ 379 - 268
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -1,4 +1,14 @@
-import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
+import {
+  BoxRenderable,
+  RGBA,
+  TextareaRenderable,
+  MouseEvent,
+  PasteEvent,
+  decodePasteBytes,
+  type KeyEvent,
+  type Renderable,
+} from "@opentui/core"
+import type { CommandContext } from "@opentui/keymap"
 import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
 import "opentui-spinner/solid"
 import path from "path"
@@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event"
 import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
 import { MessageID, PartID } from "@/session/schema"
 import { createStore, produce, unwrap } from "solid-js/store"
-import { useKeybind } from "@tui/context/keybind"
 import { usePromptHistory, type PromptInfo } from "./history"
 import { computePromptTraits } from "./traits"
 import { assign } from "./part"
 import { usePromptStash } from "./stash"
 import { DialogStash } from "../dialog-stash"
 import { type AutocompleteRef, Autocomplete } from "./autocomplete"
-import { useCommandDialog } from "../dialog-command"
 import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import * as Editor from "@tui/util/editor"
 import { useExit } from "../../context/exit"
@@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert"
 import { useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
-import { useTextareaKeybindings } from "../textarea-keybindings"
 import { DialogSkill } from "../dialog-skill"
 import {
   confirmWorkspaceFileChanges,
@@ -51,7 +58,15 @@ import {
 import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
 import { useArgs } from "@tui/context/args"
 import { Flag } from "@opencode-ai/core/flag/flag"
-import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
+import { type WorkspaceStatus } from "../workspace-label"
+import { useCommandPalette } from "../../context/command-palette"
+import {
+  useBindings,
+  useCommandShortcut,
+  useLeaderActive,
+  useOpencodeKeymap,
+} from "../../keymap"
+import { useTuiConfig } from "../../context/tui-config"
 
 export type PromptProps = {
   sessionID?: string
@@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined
 export function Prompt(props: PromptProps) {
   let input: TextareaRenderable
   let anchor: BoxRenderable
-  let autocomplete: AutocompleteRef
+  const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
 
-  const keybind = useKeybind()
+  const leader = useLeaderActive()
   const local = useLocal()
   const args = useArgs()
   const sdk = useSDK()
@@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) {
   const route = useRoute()
   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" })
   const history = usePromptHistory()
   const stash = usePromptStash()
-  const command = useCommandDialog()
+  const command = useCommandPalette()
+  const keymap = useOpencodeKeymap()
+  const agentShortcut = useCommandShortcut("agent.cycle")
+  const paletteShortcut = useCommandShortcut("command.palette.show")
   const renderer = useRenderer()
   const dimensions = useTerminalDimensions()
   const { theme, syntax } = useTheme()
@@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) {
   const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
   const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
   const [warpNotice, setWarpNotice] = createSignal<string>()
+  const [cursorVersion, setCursorVersion] = createSignal(0)
   const currentProviderLabel = createMemo(() => local.model.parsed().provider)
   const hasRightContent = createMemo(() => Boolean(props.right))
   const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
@@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) {
     setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
     editor.clearSelection()
   }
-
-  const textareaKeybindings = useTextareaKeybindings()
-
   const fileStyleId = syntax().getStyleId("extmark.file")!
   const agentStyleId = syntax().getStyleId("extmark.agent")!
   const pasteStyleId = syntax().getStyleId("extmark.paste")!
@@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) {
     }
   })
 
-  command.register(() => {
-    return [
+  const promptCommands = createMemo(() =>
+    [
       {
         title: "Clear prompt",
-        value: "prompt.clear",
+        name: "prompt.clear",
         category: "Prompt",
         hidden: true,
-        onSelect: (dialog) => {
-          input.extmarks.clear()
+        run: () => {
           input.clear()
+          input.extmarks.clear()
+          setStore("prompt", {
+            input: "",
+            parts: [],
+          })
+          setStore("extmarkToPartIndex", new Map())
           dialog.clear()
         },
       },
       {
         title: "Submit prompt",
-        value: "prompt.submit",
-        keybind: "input_submit",
+        name: "prompt.submit",
         category: "Prompt",
         hidden: true,
-        onSelect: async (dialog) => {
+        run: async () => {
           if (!input.focused) return
           const handled = await submit()
           if (!handled) return
@@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) {
       },
       {
         title: "Remove editor context",
-        value: "prompt.editor_context.clear",
+        name: "prompt.editor_context.clear",
         category: "Prompt",
         enabled: Boolean(editorContext()),
-        onSelect: (dialog) => {
+        run: () => {
           dismissEditorContext()
           dialog.clear()
         },
       },
       {
         title: "Paste",
-        value: "prompt.paste",
-        keybind: "input_paste",
+        name: "prompt.paste",
         category: "Prompt",
         hidden: true,
-        onSelect: async () => {
+        run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
+          ctx.event.preventDefault()
+          ctx.event.stopPropagation()
           const content = await Clipboard.read()
           if (content?.mime.startsWith("image/")) {
             await pasteAttachment({
@@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) {
               mime: content.mime,
               content: content.data,
             })
+            return
+          }
+          if (content?.mime === "text/plain") {
+            await pasteInputText(content.data)
           }
         },
       },
       {
         title: "Interrupt session",
-        value: "session.interrupt",
-        keybind: "session_interrupt",
+        name: "session.interrupt",
         category: "Session",
         hidden: true,
         enabled: status().type !== "idle",
-        onSelect: (dialog) => {
-          if (autocomplete.visible) return
+        run: () => {
+          if (auto()?.visible) return
           if (!input.focused) return
           // TODO: this should be its own command
           if (store.mode === "shell") {
@@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) {
       {
         title: "Open editor",
         category: "Session",
-        keybind: "editor_open",
-        value: "prompt.editor",
-        slash: {
-          name: "editor",
-        },
-        onSelect: async (dialog) => {
+        name: "prompt.editor",
+        slashName: "editor",
+        run: async () => {
           dialog.clear()
 
           // replace summarized text parts with the actual text
@@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) {
       },
       {
         title: "Skills",
-        value: "prompt.skills",
+        name: "prompt.skills",
         category: "Prompt",
-        slash: {
-          name: "skills",
-        },
-        onSelect: () => {
+        slashName: "skills",
+        run: () => {
           dialog.replace(() => (
             <DialogSkill
               onSelect={(skill) => {
@@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) {
       },
       {
         title: "Warp",
-        description: "Change the workspace for the session",
-        value: "workspace.set",
+        desc: "Change the workspace for the session",
+        name: "workspace.set",
         category: "Session",
         enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
-        slash: {
-          name: "warp",
-        },
-        onSelect: (dialog) => {
+        slashName: "warp",
+        run: () => {
           void openWorkspaceSelect({
             dialog,
             sdk,
@@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) {
           })
         },
       },
-    ]
-  })
+    ].map((entry) => ({
+      namespace: "palette",
+      ...entry,
+    })),
+  )
+
+  useBindings(() => ({
+    commands: promptCommands(),
+  }))
+
+  useBindings(() => ({
+    enabled: command.matcher,
+    bindings: keymapConfig.pick("prompt", [
+      "prompt.submit",
+      "prompt.editor",
+      "prompt.editor_context.clear",
+      "prompt.stash",
+      "prompt.stash.pop",
+      "prompt.stash.list",
+      "session.interrupt",
+      "workspace.set",
+    ]),
+  }))
 
   const ref: PromptRef = {
     get focused() {
@@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) {
     if (store.prompt.input) {
       stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
     }
+    setInputTarget(undefined)
     props.ref?.(undefined)
   })
 
@@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) {
 
   createEffect(() => {
     if (!input || input.isDestroyed) return
-    input.traits = computePromptTraits({
-      mode: store.mode,
-      disabled: !!props.disabled,
-      autocompleteVisible: !!auto()?.visible,
-    })
+    input.traits = {
+      ...input.traits,
+      ...computePromptTraits({
+        mode: store.mode,
+        disabled: !!props.disabled,
+        autocompleteVisible: !!auto()?.visible,
+      }),
+    }
   })
 
   function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) {
     )
   }
 
-  command.register(() => [
-    {
-      title: "Stash prompt",
-      value: "prompt.stash",
-      category: "Prompt",
-      enabled: !!store.prompt.input,
-      onSelect: (dialog) => {
-        if (!store.prompt.input) return
-        stash.push({
-          input: store.prompt.input,
-          parts: store.prompt.parts,
-        })
-        input.extmarks.clear()
-        input.clear()
-        setStore("prompt", { input: "", parts: [] })
-        setStore("extmarkToPartIndex", new Map())
-        dialog.clear()
+  const stashCommands = createMemo(() =>
+    [
+      {
+        title: "Stash prompt",
+        name: "prompt.stash",
+        category: "Prompt",
+        enabled: !!store.prompt.input,
+        run: () => {
+          if (!store.prompt.input) return
+          stash.push({
+            input: store.prompt.input,
+            parts: store.prompt.parts,
+          })
+          input.extmarks.clear()
+          input.clear()
+          setStore("prompt", { input: "", parts: [] })
+          setStore("extmarkToPartIndex", new Map())
+          dialog.clear()
+        },
       },
-    },
-    {
-      title: "Stash pop",
-      value: "prompt.stash.pop",
-      category: "Prompt",
-      enabled: stash.list().length > 0,
-      onSelect: (dialog) => {
-        const entry = stash.pop()
-        if (entry) {
-          input.setText(entry.input)
-          setStore("prompt", { input: entry.input, parts: entry.parts })
-          restoreExtmarksFromParts(entry.parts)
-          input.gotoBufferEnd()
-        }
-        dialog.clear()
+      {
+        title: "Stash pop",
+        name: "prompt.stash.pop",
+        category: "Prompt",
+        enabled: stash.list().length > 0,
+        run: () => {
+          const entry = stash.pop()
+          if (entry) {
+            input.setText(entry.input)
+            setStore("prompt", { input: entry.input, parts: entry.parts })
+            restoreExtmarksFromParts(entry.parts)
+            input.gotoBufferEnd()
+          }
+          dialog.clear()
+        },
       },
-    },
-    {
-      title: "Stash list",
-      value: "prompt.stash.list",
-      category: "Prompt",
-      enabled: stash.list().length > 0,
-      onSelect: (dialog) => {
-        dialog.replace(() => (
-          <DialogStash
-            onSelect={(entry) => {
-              input.setText(entry.input)
-              setStore("prompt", { input: entry.input, parts: entry.parts })
-              restoreExtmarksFromParts(entry.parts)
-              input.gotoBufferEnd()
-            }}
-          />
-        ))
+      {
+        title: "Stash list",
+        name: "prompt.stash.list",
+        category: "Prompt",
+        enabled: stash.list().length > 0,
+        run: () => {
+          dialog.replace(() => (
+            <DialogStash
+              onSelect={(entry) => {
+                input.setText(entry.input)
+                setStore("prompt", { input: entry.input, parts: entry.parts })
+                restoreExtmarksFromParts(entry.parts)
+                input.gotoBufferEnd()
+              }}
+            />
+          ))
+        },
       },
-    },
-  ])
+    ].map((entry) => ({
+      namespace: "palette",
+      ...entry,
+    })),
+  )
+
+  useBindings(() => ({
+    commands: stashCommands(),
+  }))
+
+  useBindings(() => {
+    return {
+      target: inputTarget,
+      enabled: inputTarget() !== undefined && !props.disabled,
+      bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
+    }
+  })
+
+  useBindings(() => {
+    return {
+      target: inputTarget,
+      enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
+      bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
+    }
+  })
+
+  useBindings(() => {
+    return {
+      target: inputTarget,
+      enabled: (() => {
+        cursorVersion()
+        return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0
+      })(),
+      bindings: [
+        {
+          key: "!",
+          cmd: () => {
+            setStore("placeholder", randomIndex(shell().length))
+            setStore("mode", "shell")
+          },
+        },
+      ],
+    }
+  })
+
+  useBindings(() => {
+    return {
+      target: inputTarget,
+      enabled: inputTarget() !== undefined && store.mode === "shell",
+      bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
+    }
+  })
+
+  useBindings(() => {
+    return {
+      target: inputTarget,
+      enabled: (() => {
+        cursorVersion()
+        return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
+      })(),
+      bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }],
+    }
+  })
+
+  useBindings(() => {
+    return {
+      target: inputTarget,
+      enabled: (() => {
+        cursorVersion()
+        return (
+          inputTarget() !== undefined &&
+          !props.disabled &&
+          !auto()?.visible &&
+          input !== undefined &&
+          (input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
+        )
+      })(),
+      commands: [
+        {
+          name: "prompt.history.previous",
+          run() {
+            if (input.cursorOffset !== 0) {
+              input.cursorOffset = 0
+              return
+            }
+
+            const item = history.move(-1, input.plainText)
+            if (!item) return
+            input.setText(item.input)
+            setStore("prompt", item)
+            setStore("mode", item.mode ?? "normal")
+            restoreExtmarksFromParts(item.parts)
+            input.cursorOffset = 0
+          },
+        },
+      ],
+      bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
+    }
+  })
+
+  useBindings(() => {
+    return {
+      target: inputTarget,
+      enabled: (() => {
+        cursorVersion()
+        return (
+          inputTarget() !== undefined &&
+          !props.disabled &&
+          !auto()?.visible &&
+          input !== undefined &&
+          (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
+        )
+      })(),
+      commands: [
+        {
+          name: "prompt.history.next",
+          run() {
+            if (input.cursorOffset !== input.plainText.length) {
+              input.cursorOffset = input.plainText.length
+              return
+            }
+
+            const item = history.move(1, input.plainText)
+            if (!item) return
+            input.setText(item.input)
+            setStore("prompt", item)
+            setStore("mode", item.mode ?? "normal")
+            restoreExtmarksFromParts(item.parts)
+            input.cursorOffset = input.plainText.length
+          },
+        },
+      ],
+      bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
+    }
+  })
 
   async function submit() {
     setWarpNotice(undefined)
@@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) {
     }
     if (props.disabled) return false
     if (workspaceCreating()) return false
-    if (autocomplete?.visible) return false
+    if (auto()?.visible) return false
     if (!store.prompt.input) return false
     const agent = local.agent.current()
     if (!agent) return false
@@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) {
     )
   }
 
+  async function pasteInputText(text: string) {
+    const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+    const pastedContent = normalizedText.trim()
+    const filepath = iife(() => {
+      const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
+      if (raw.startsWith("file://")) {
+        try {
+          return fileURLToPath(raw)
+        } catch {}
+      }
+      if (process.platform === "win32") return raw
+      return raw.replace(/\\(.)/g, "$1")
+    })
+    const isUrl = /^(https?):\/\//.test(filepath)
+    if (!isUrl) {
+      try {
+        const mime = await Filesystem.mimeType(filepath)
+        const filename = path.basename(filepath)
+        if (mime === "image/svg+xml") {
+          const content = await Filesystem.readText(filepath).catch(() => {})
+          if (content) {
+            pasteText(content, `[SVG: ${filename ?? "image"}]`)
+            return
+          }
+        }
+        if (mime.startsWith("image/") || mime === "application/pdf") {
+          const content = await Filesystem.readArrayBuffer(filepath)
+            .then((buffer) => Buffer.from(buffer).toString("base64"))
+            .catch(() => {})
+          if (content) {
+            await pasteAttachment({
+              filename,
+              filepath,
+              mime,
+              content,
+            })
+            return
+          }
+        }
+      } catch {}
+    }
+
+    const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
+    if (
+      (lineCount >= 3 || pastedContent.length > 150) &&
+      kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
+    ) {
+      pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
+      return
+    }
+
+    input.insertText(normalizedText)
+
+    setTimeout(() => {
+      if (!input || input.isDestroyed) return
+      input.getLayoutNode().markDirty()
+      renderer.requestRender()
+    }, 0)
+  }
+
   async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
     const currentOffset = input.visualCursor.offset
     const extmarkStart = currentOffset
@@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) {
   }
 
   const highlight = createMemo(() => {
-    if (keybind.leader) return theme.border
+    if (leader()) return theme.border
     if (store.mode === "shell") return theme.primary
     const agent = local.agent.current()
     if (!agent) return theme.border
@@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) {
 
   return (
     <>
-      <Autocomplete
-        sessionID={props.sessionID}
-        ref={(r) => {
-          autocomplete = r
-          setAuto(() => r)
-        }}
-        anchor={() => anchor}
-        input={() => input}
-        setPrompt={(cb) => {
-          setStore("prompt", produce(cb))
-        }}
-        setExtmark={(partIndex, extmarkId) => {
-          setStore("extmarkToPartIndex", (map: Map<number, number>) => {
-            const newMap = new Map(map)
-            newMap.set(extmarkId, partIndex)
-            return newMap
-          })
-        }}
-        value={store.prompt.input}
-        fileStyleId={fileStyleId}
-        agentStyleId={agentStyleId}
-        promptPartTypeId={() => promptPartTypeId}
-      />
-      <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
+      <box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
         <box
           border={["left"]}
           borderColor={borderHighlight()}
@@ -1249,94 +1465,23 @@ export function Prompt(props: PromptProps) {
             <textarea
               placeholder={placeholderText()}
               placeholderColor={theme.textMuted}
-              textColor={keybind.leader ? theme.textMuted : theme.text}
-              focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
+              textColor={leader() ? theme.textMuted : theme.text}
+              focusedTextColor={leader() ? theme.textMuted : theme.text}
               minHeight={1}
               maxHeight={6}
               onContentChange={() => {
                 const value = input.plainText
                 setStore("prompt", "input", value)
-                autocomplete.onInput(value)
+                auto()?.onInput(value)
                 syncExtmarksWithPromptParts()
+                setCursorVersion((value) => value + 1)
               }}
-              keyBindings={textareaKeybindings()}
-              onKeyDown={async (e) => {
+              onCursorChange={() => setCursorVersion((value) => value + 1)}
+              onKeyDown={(e: { preventDefault(): void }) => {
                 if (props.disabled) {
                   e.preventDefault()
                   return
                 }
-                // Check clipboard for images before terminal-handled paste runs.
-                // This helps terminals that forward Ctrl+V to the app; Windows
-                // Terminal 1.25+ usually handles Ctrl+V before this path.
-                if (keybind.match("input_paste", e)) {
-                  const content = await Clipboard.read()
-                  if (content?.mime.startsWith("image/")) {
-                    e.preventDefault()
-                    await pasteAttachment({
-                      filename: "clipboard",
-                      mime: content.mime,
-                      content: content.data,
-                    })
-                    return
-                  }
-                  // If no image, let the default paste behavior continue
-                }
-                if (keybind.match("input_clear", e) && store.prompt.input !== "") {
-                  input.clear()
-                  input.extmarks.clear()
-                  setStore("prompt", {
-                    input: "",
-                    parts: [],
-                  })
-                  setStore("extmarkToPartIndex", new Map())
-                  return
-                }
-                if (keybind.match("app_exit", e)) {
-                  if (store.prompt.input === "") {
-                    await exit()
-                    // Don't preventDefault - let textarea potentially handle the event
-                    e.preventDefault()
-                    return
-                  }
-                }
-                if (e.name === "!" && input.visualCursor.offset === 0) {
-                  setStore("placeholder", randomIndex(shell().length))
-                  setStore("mode", "shell")
-                  e.preventDefault()
-                  return
-                }
-                if (store.mode === "shell") {
-                  if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
-                    setStore("mode", "normal")
-                    e.preventDefault()
-                    return
-                  }
-                }
-                if (store.mode === "normal") autocomplete.onKeyDown(e)
-                if (!autocomplete.visible) {
-                  if (
-                    (keybind.match("history_previous", e) && input.cursorOffset === 0) ||
-                    (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
-                  ) {
-                    const direction = keybind.match("history_previous", e) ? -1 : 1
-                    const item = history.move(direction, input.plainText)
-
-                    if (item) {
-                      input.setText(item.input)
-                      setStore("prompt", item)
-                      setStore("mode", item.mode ?? "normal")
-                      restoreExtmarksFromParts(item.parts)
-                      e.preventDefault()
-                      if (direction === -1) input.cursorOffset = 0
-                      if (direction === 1) input.cursorOffset = input.plainText.length
-                    }
-                    return
-                  }
-
-                  if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
-                  if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
-                    input.cursorOffset = input.plainText.length
-                }
               }}
               onSubmit={() => {
                 // IME: double-defer so the last composed character (e.g. Korean
@@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) {
                 // Windows Terminal <1.25 can surface image-only clipboard as an
                 // empty bracketed paste. Windows Terminal 1.25+ does not.
                 if (!pastedContent) {
-                  command.trigger("prompt.paste")
+                  keymap.dispatchCommand("prompt.paste")
                   return
                 }
 
@@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) {
                 // default paste unless we suppress it first and handle insertion ourselves.
                 event.preventDefault()
 
-                const filepath = iife(() => {
-                  const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
-                  if (raw.startsWith("file://")) {
-                    try {
-                      return fileURLToPath(raw)
-                    } catch {}
-                  }
-                  if (process.platform === "win32") return raw
-                  return raw.replace(/\\(.)/g, "$1")
-                })
-                const isUrl = /^(https?):\/\//.test(filepath)
-                if (!isUrl) {
-                  try {
-                    const mime = await Filesystem.mimeType(filepath)
-                    const filename = path.basename(filepath)
-                    // Handle SVG as raw text content, not as base64 image
-                    if (mime === "image/svg+xml") {
-                      const content = await Filesystem.readText(filepath).catch(() => {})
-                      if (content) {
-                        pasteText(content, `[SVG: ${filename ?? "image"}]`)
-                        return
-                      }
-                    }
-                    if (mime.startsWith("image/") || mime === "application/pdf") {
-                      const content = await Filesystem.readArrayBuffer(filepath)
-                        .then((buffer) => Buffer.from(buffer).toString("base64"))
-                        .catch(() => {})
-                      if (content) {
-                        await pasteAttachment({
-                          filename,
-                          filepath,
-                          mime,
-                          content,
-                        })
-                        return
-                      }
-                    }
-                  } catch {}
-                }
-
-                const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
-                if (
-                  (lineCount >= 3 || pastedContent.length > 150) &&
-                  kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
-                ) {
-                  pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
-                  return
-                }
-
-                input.insertText(normalizedText)
-
-                // Force layout update and render for the pasted content
-                setTimeout(() => {
-                  // setTimeout is a workaround and needs to be addressed properly
-                  if (!input || input.isDestroyed) return
-                  input.getLayoutNode().markDirty()
-                  renderer.requestRender()
-                }, 0)
+                await pasteInputText(normalizedText)
               }}
               ref={(r: TextareaRenderable) => {
                 input = r
+                setInputTarget(r)
                 if (promptPartTypeId === 0) {
                   promptPartTypeId = input.extmarks.registerType("prompt-part")
                 }
@@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) {
                           <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
                           <text
                             flexShrink={0}
-                            fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
+                            fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
                           >
                             {local.model.parsed().model}
                           </text>
@@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) {
                     </Match>
                     <Match when={true}>
                       <text fg={theme.text}>
-                        {keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
+                        {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
                       </text>
                     </Match>
                   </Switch>
                   <text fg={theme.text}>
-                    {keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
+                    {paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
                   </text>
                 </Match>
                 <Match when={store.mode === "shell"}>
@@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) {
           </Show>
         </box>
       </box>
+      <Autocomplete
+        sessionID={props.sessionID}
+        ref={(r) => {
+          setAuto(() => r)
+        }}
+        anchor={() => anchor}
+        input={() => input}
+        setPrompt={(cb) => {
+          setStore("prompt", produce(cb))
+        }}
+        setExtmark={(partIndex, extmarkId) => {
+          setStore("extmarkToPartIndex", (map: Map<number, number>) => {
+            const newMap = new Map(map)
+            newMap.set(extmarkId, partIndex)
+            return newMap
+          })
+        }}
+        value={store.prompt.input}
+        fileStyleId={fileStyleId}
+        agentStyleId={agentStyleId}
+        promptPartTypeId={() => promptPartTypeId}
+      />
     </>
   )
 }

+ 8 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts

@@ -8,6 +8,11 @@ export interface PromptTraitsInput {
   autocompleteVisible: boolean
 }
 
+export type PromptTraits = EditorTraits & {
+  owner: "opencode"
+  role: "prompt"
+}
+
 /**
  * Compute the textarea editor traits for the prompt.
  *
@@ -16,7 +21,7 @@ export interface PromptTraitsInput {
  * editing mode — only `disabled` should suspend the textarea, otherwise
  * users can type in shell mode but cannot delete or move the cursor.
  */
-export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
+export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
   const capture =
     input.mode === "normal"
       ? input.autocompleteVisible
@@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
     capture,
     suspend: input.disabled,
     status: input.mode === "shell" ? "SHELL" : undefined,
+    owner: "opencode",
+    role: "prompt",
   }
 }

+ 0 - 73
packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts

@@ -1,73 +0,0 @@
-import { createMemo } from "solid-js"
-import type { KeyBinding } from "@opentui/core"
-import { useKeybind } from "../context/keybind"
-import { Keybind } from "@/util/keybind"
-
-const TEXTAREA_ACTIONS = [
-  "submit",
-  "newline",
-  "move-left",
-  "move-right",
-  "move-up",
-  "move-down",
-  "select-left",
-  "select-right",
-  "select-up",
-  "select-down",
-  "line-home",
-  "line-end",
-  "select-line-home",
-  "select-line-end",
-  "visual-line-home",
-  "visual-line-end",
-  "select-visual-line-home",
-  "select-visual-line-end",
-  "buffer-home",
-  "buffer-end",
-  "select-buffer-home",
-  "select-buffer-end",
-  "delete-line",
-  "delete-to-line-end",
-  "delete-to-line-start",
-  "backspace",
-  "delete",
-  "undo",
-  "redo",
-  "word-forward",
-  "word-backward",
-  "select-word-forward",
-  "select-word-backward",
-  "delete-word-forward",
-  "delete-word-backward",
-] as const
-
-function mapTextareaKeybindings(
-  keybinds: Record<string, Keybind.Info[]>,
-  action: (typeof TEXTAREA_ACTIONS)[number],
-): KeyBinding[] {
-  const configKey = `input_${action.replace(/-/g, "_")}`
-  const bindings = keybinds[configKey]
-  if (!bindings) return []
-  return bindings.map((binding) => ({
-    name: binding.name,
-    ctrl: binding.ctrl || undefined,
-    meta: binding.meta || undefined,
-    shift: binding.shift || undefined,
-    super: binding.super || undefined,
-    action,
-  }))
-}
-
-export function useTextareaKeybindings() {
-  const keybind = useKeybind()
-
-  return createMemo(() => {
-    const keybinds = keybind.all
-
-    return [
-      { name: "return", action: "submit" },
-      { name: "return", meta: true, action: "newline" },
-      ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
-    ] satisfies KeyBinding[]
-  })
-}

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

@@ -0,0 +1,177 @@
+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"

+ 309 - 1
packages/opencode/src/cli/cmd/tui/config/tui-schema.ts

@@ -1,4 +1,7 @@
 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"
 
@@ -11,6 +14,303 @@ const KeybindOverride = z
   )
   .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 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),
+  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(),
+  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",
+  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>
+
 export const TuiOptions = z.object({
   scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
   scroll_acceleration: z
@@ -30,9 +330,17 @@ export const TuiInfo = z
   .object({
     $schema: z.string().optional(),
     theme: z.string().optional(),
-    keybinds: KeybindOverride.optional(),
+    keybinds: KeybindOverride.optional().meta({
+      deprecated: true,
+      description: "Use keymap instead. This will be removed in opencode v2.0.",
+    }),
+    keymap: KeymapConfigInput.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()

+ 46 - 14
packages/opencode/src/cli/cmd/tui/config/tui.ts

@@ -1,12 +1,14 @@
 export * as TuiConfig from "./tui"
 
-import z from "zod"
+import type z from "zod"
+import type { KeyEvent, Renderable } from "@opentui/core"
+import { resolveBindingSections, type BindingSectionsConfig } 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 { TuiInfo } from "./tui-schema"
+import { KeymapConfig, 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"
@@ -20,27 +22,34 @@ 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" })
 
 export const Info = TuiInfo
+export const JsonSchemaInfo = TuiJsonSchemaInfo
+export type Info = z.output<typeof Info>
 
 type Acc = {
   result: Info
+  plugin_origins: ConfigPlugin.Origin[]
 }
 
-type State = {
-  config: Info
-  deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
-}
-
-export type Info = z.output<typeof Info> & {
+export type Resolved = Omit<Info, "keybinds" | "keymap"> & {
+  keybinds: ConfigKeybinds.Keybinds
+  keymap: KeymapInfo
   // Internal resolved plugin list used by runtime loading.
   plugin_origins?: ConfigPlugin.Origin[]
 }
 
 export interface Interface {
-  readonly get: () => Effect.Effect<Info>
+  readonly get: () => Effect.Effect<Resolved>
   readonly waitForDependencies: () => Effect.Effect<void>
 }
 
@@ -128,11 +137,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
 
       const scope = pluginScope(file, ctx)
       const plugins = ConfigPlugin.deduplicatePluginOrigins([
-        ...(acc.result.plugin_origins ?? []),
+        ...acc.plugin_origins,
         ...data.plugin.map((spec) => ({ spec, scope, source: file })),
       ])
       acc.result.plugin = plugins.map((item) => item.spec)
-      acc.result.plugin_origins = plugins
+      acc.plugin_origins = plugins
     })
 
   // Every config dir we may read from: global config dir, any `.opencode`
@@ -144,6 +153,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
 
   const acc: Acc = {
     result: {},
+    plugin_origins: [],
   }
 
   // 1. Global tui config (lowest precedence).
@@ -184,11 +194,33 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
       ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
     ]).join(",")
   }
-  acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
+  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 result: Resolved = {
+    ...acc.result,
+    keybinds: parsedKeybinds,
+    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 {
-    config: acc.result,
-    dirs: acc.result.plugin?.length ? dirs : [],
+    config: result,
+    dirs: result.plugin?.length ? dirs : [],
   }
 })
 

+ 163 - 0
packages/opencode/src/cli/cmd/tui/context/command-palette.tsx

@@ -0,0 +1,163 @@
+import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js"
+import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
+import { useDialog, type DialogContext } from "@tui/ui/dialog"
+import {
+  formatKeyBindings,
+  reactiveMatcherFromSignal,
+  type OpenTuiKeymap,
+  useKeymapSelector,
+  useOpencodeKeymap,
+} from "../keymap"
+import { useTuiConfig } from "./tui-config"
+
+type SlashEntry = {
+  display: string
+  description?: string
+  aliases?: string[]
+  onSelect: () => void
+}
+
+type CommandPaletteContext = {
+  run(command: string): void
+  show(): void
+  slashes: Accessor<readonly SlashEntry[]>
+  suspend(enabled: boolean): void
+  readonly suspended: boolean
+  matcher: ReturnType<typeof reactiveMatcherFromSignal>
+}
+
+const COMMAND_PALETTE_DIALOG = "command.palette.show"
+const ctx = createContext<CommandPaletteContext>()
+type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
+
+function isVisiblePaletteCommand(entry: PaletteCommandEntry) {
+  return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG
+}
+
+function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
+  const suggested = entry.command.suggested
+  if (typeof suggested === "boolean") return suggested
+  if (typeof suggested === "function") return suggested() === true
+  return false
+}
+
+export function CommandPaletteProvider(props: ParentProps) {
+  const dialog = useDialog()
+  const keymap = useOpencodeKeymap()
+  const [suspendCount, setSuspendCount] = createSignal(0)
+  const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
+    keymap
+      .getCommandEntries({
+        visibility: "reachable",
+        namespace: "palette",
+      })
+      .filter(isVisiblePaletteCommand),
+  )
+
+  const run = (command: string) => {
+    keymap.dispatchCommand(command)
+  }
+
+  const slashes = createMemo<SlashEntry[]>(() =>
+    entries().flatMap((entry) => {
+      const slashName = entry.command.slashName
+      if (typeof slashName !== "string" || !slashName) return []
+      const slashAliases = entry.command.slashAliases
+      return {
+        display: `/${slashName}`,
+        description:
+          typeof entry.command.desc === "string"
+            ? entry.command.desc
+            : typeof entry.command.title === "string"
+              ? entry.command.title
+              : undefined,
+        aliases: Array.isArray(slashAliases)
+          ? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
+          : undefined,
+        onSelect: () => run(entry.command.name),
+      }
+    }),
+  )
+
+  const value: CommandPaletteContext = {
+    run,
+    show() {
+      dialog.replace(() => <CommandPaletteDialog run={run} />)
+    },
+    slashes,
+    suspend(enabled: boolean) {
+      setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1)))
+    },
+    get suspended() {
+      return suspendCount() > 0 || dialog.stack.length > 0
+    },
+    matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0),
+  }
+
+  return <ctx.Provider value={value}>{props.children}</ctx.Provider>
+}
+
+export function useCommandPalette() {
+  const value = useContext(ctx)
+  if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider")
+  return value
+}
+
+function CommandPaletteDialog(props: { run(command: string): void }) {
+  const config = useTuiConfig()
+  const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
+    const query = {
+      namespace: "palette",
+    }
+    const reachable = keymap
+      .getCommandEntries({
+        ...query,
+        visibility: "reachable",
+      })
+      .filter(isVisiblePaletteCommand)
+    const registeredBindings = keymap.getCommandBindings({
+      visibility: "registered",
+      commands: reachable.map((entry) => entry.command.name),
+    })
+
+    return reachable.map((entry) => ({
+      ...entry,
+      bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
+    }))
+  })
+  const options = createMemo(() =>
+    entries().map((entry) => ({
+      title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
+      description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
+      category: typeof entry.command.category === "string" ? entry.command.category : undefined,
+      footer: formatKeyBindings(entry.bindings, config),
+      value: entry.command.name,
+      suggested: isSuggestedPaletteCommand(entry),
+      onSelect: (dialog: DialogContext) => {
+        dialog.clear()
+        props.run(entry.command.name)
+      },
+    })),
+  )
+
+  let ref: DialogSelectRef<string>
+  const list = () => {
+    if (ref?.filter) return options()
+    return [
+      ...options()
+        .filter((option) => option.suggested)
+        .map((option) => ({
+          ...option,
+          value: `suggested:${option.value}`,
+          category: "Suggested",
+        })),
+      ...options(),
+    ]
+  }
+
+  return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
+}
+
+export function useCommandSlashes(): Accessor<readonly SlashEntry[]> {
+  return useCommandPalette().slashes
+}

+ 0 - 105
packages/opencode/src/cli/cmd/tui/context/keybind.tsx

@@ -1,105 +0,0 @@
-import { createMemo } from "solid-js"
-import { Keybind } from "@/util/keybind"
-import { pipe, mapValues } from "remeda"
-import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
-import type { ParsedKey, Renderable } from "@opentui/core"
-import { createStore } from "solid-js/store"
-import { useKeyboard, useRenderer } from "@opentui/solid"
-import { createSimpleContext } from "./helper"
-import { useTuiConfig } from "./tui-config"
-
-export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
-
-export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
-  name: "Keybind",
-  init: () => {
-    const config = useTuiConfig()
-    const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
-      return pipe(
-        (config.keybinds ?? {}) as Record<string, string>,
-        mapValues((value) => Keybind.parse(value)),
-      )
-    })
-    const [store, setStore] = createStore({
-      leader: false,
-    })
-    const renderer = useRenderer()
-
-    let focus: Renderable | null
-    let timeout: NodeJS.Timeout
-    function leader(active: boolean) {
-      if (active) {
-        setStore("leader", true)
-        focus = renderer.currentFocusedRenderable
-        focus?.blur()
-        if (timeout) clearTimeout(timeout)
-        timeout = setTimeout(() => {
-          if (!store.leader) return
-          leader(false)
-          if (!focus || focus.isDestroyed) return
-          focus.focus()
-        }, 2000)
-        return
-      }
-
-      if (!active) {
-        if (focus && !renderer.currentFocusedRenderable) {
-          focus.focus()
-        }
-        setStore("leader", false)
-      }
-    }
-
-    useKeyboard(async (evt) => {
-      if (!store.leader && result.match("leader", evt)) {
-        leader(true)
-        return
-      }
-
-      if (store.leader && evt.name) {
-        setImmediate(() => {
-          if (focus && renderer.currentFocusedRenderable === focus) {
-            focus.focus()
-          }
-          leader(false)
-        })
-      }
-    })
-
-    const result = {
-      get all() {
-        return keybinds()
-      },
-      get leader() {
-        return store.leader
-      },
-      parse(evt: ParsedKey): Keybind.Info {
-        // Handle special case for Ctrl+Underscore (represented as \x1F)
-        if (evt.name === "\x1F") {
-          return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
-        }
-        return Keybind.fromParsedKey(evt, store.leader)
-      },
-      match(key: string, evt: ParsedKey) {
-        const list = keybinds()[key] ?? Keybind.parse(key)
-        if (!list.length) return false
-        const parsed: Keybind.Info = result.parse(evt)
-        for (const item of list) {
-          if (Keybind.match(item, parsed)) {
-            return true
-          }
-        }
-        return false
-      },
-      print(key: string) {
-        const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
-        if (!first) return ""
-        const text = Keybind.toString(first)
-        const lead = keybinds().leader?.[0]
-        if (!lead) return text
-        return text.replace("<leader>", Keybind.toString(lead))
-      },
-    }
-    return result
-  },
-})

+ 0 - 41
packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts

@@ -1,41 +0,0 @@
-import type { ParsedKey } from "@opentui/core"
-
-export type PluginKeybindMap = Record<string, string>
-
-type Base = {
-  match: (key: string, evt: ParsedKey) => boolean
-  print: (key: string) => string
-}
-
-export type PluginKeybind = {
-  readonly all: PluginKeybindMap
-  get: (name: string) => string
-  match: (name: string, evt: ParsedKey) => boolean
-  print: (name: string) => string
-}
-
-const txt = (value: unknown) => {
-  if (typeof value !== "string") return
-  if (!value.trim()) return
-  return value
-}
-
-export function createPluginKeybind(
-  base: Base,
-  defaults: PluginKeybindMap,
-  overrides?: Record<string, unknown>,
-): PluginKeybind {
-  const all = Object.freeze(
-    Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
-  )
-  const get = (name: string) => all[name] ?? name
-
-  return {
-    get all() {
-      return all
-    },
-    get,
-    match: (name, evt) => base.match(get(name), evt),
-    print: (name) => base.print(get(name)),
-  }
-}

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

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

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

@@ -1,10 +1,27 @@
-import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import { createMemo, Show } from "solid-js"
 import { Tips } from "./tips-view"
+import { useBindings } from "../../keymap"
 
 const id = "internal:home-tips"
 
-function View(props: { show: boolean; connected: boolean }) {
+function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
+  useBindings(() => ({
+    commands: [
+      {
+        name: "tips.toggle",
+        title: props.hidden ? "Show tips" : "Hide tips",
+        category: "System",
+        namespace: "palette",
+        run() {
+          props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
+          props.api.ui.dialog.clear()
+        },
+      },
+    ],
+    bindings: props.api.tuiConfig.keymap.sections.home_tips,
+  }))
+
   return (
     <box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
       <Show when={props.show}>
@@ -15,20 +32,6 @@ function View(props: { show: boolean; connected: boolean }) {
 }
 
 const tui: TuiPlugin = async (api) => {
-  api.command.register(() => [
-    {
-      title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips",
-      value: "tips.toggle",
-      keybind: "tips_toggle",
-      category: "System",
-      hidden: api.route.current.name !== "home",
-      onSelect() {
-        api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
-        api.ui.dialog.clear()
-      },
-    },
-  ])
-
   api.slots.register({
     order: 100,
     slots: {
@@ -41,7 +44,7 @@ const tui: TuiPlugin = async (api) => {
           ),
         )
         const show = createMemo(() => (!first() || !connected()) && !hidden())
-        return <View show={show()} connected={connected()} />
+        return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
       },
     },
   })

+ 32 - 33
packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx

@@ -1,14 +1,11 @@
-import { Keybind } from "@/util/keybind"
 import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
-import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { useTerminalDimensions } from "@opentui/solid"
 import { fileURLToPath } from "url"
 import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
 import { Show, createEffect, createMemo, createSignal } from "solid-js"
+import { useBindings } from "../../keymap"
 
 const id = "internal:plugin-manager"
-const key = Keybind.parse("space").at(0)
-const add = Keybind.parse("shift+i").at(0)
-const tab = Keybind.parse("tab").at(0)
 
 function state(api: TuiPluginApi, item: TuiPluginStatus) {
   if (!item.enabled) {
@@ -41,13 +38,10 @@ function Install(props: { api: TuiPluginApi }) {
   const [global, setGlobal] = createSignal(false)
   const [busy, setBusy] = createSignal(false)
 
-  useKeyboard((evt) => {
-    if (evt.name !== "tab") return
-    evt.preventDefault()
-    evt.stopPropagation()
-    if (busy()) return
-    setGlobal((x) => !x)
-  })
+  useBindings(() => ({
+    enabled: !busy(),
+    bindings: [{ key: "tab", cmd: () => setGlobal((value) => !value) }],
+  }))
 
   return (
     <props.api.ui.DialogPrompt
@@ -62,7 +56,7 @@ function Install(props: { api: TuiPluginApi }) {
             {global() ? "global" : "local"}
           </text>
           <Show when={!busy()}>
-            <text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
+            <text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
           </Show>
         </box>
       )}
@@ -209,10 +203,10 @@ function View(props: { api: TuiPluginApi }) {
       options={rows()}
       current={cur()}
       onMove={(item) => setCur(item.value)}
-      keybind={[
+      actions={[
         {
           title: "toggle",
-          keybind: key,
+          command: "dialog.action.toggle",
           disabled: lock(),
           onTrigger: (item) => {
             setCur(item.value)
@@ -221,13 +215,14 @@ function View(props: { api: TuiPluginApi }) {
         },
         {
           title: "install",
-          keybind: add,
+          command: "plugin.dialog.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)
@@ -241,25 +236,29 @@ function show(api: TuiPluginApi) {
 }
 
 const tui: TuiPlugin = async (api) => {
-  api.command.register(() => [
-    {
-      title: "Plugins",
-      value: "plugins.list",
-      keybind: "plugin_manager",
-      category: "System",
-      onSelect() {
-        show(api)
+  api.keymap.registerLayer({
+    commands: [
+      {
+        name: "plugins.list",
+        title: "Plugins",
+        category: "System",
+        namespace: "palette",
+        run() {
+          show(api)
+        },
       },
-    },
-    {
-      title: "Install plugin",
-      value: "plugins.install",
-      category: "System",
-      onSelect() {
-        showInstall(api)
+      {
+        name: "plugins.install",
+        title: "Install plugin",
+        category: "System",
+        namespace: "palette",
+        run() {
+          showInstall(api)
+        },
       },
-    },
-  ])
+    ],
+    bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]),
+  })
 }
 
 const plugin: TuiPluginModule & { id: string } = {

+ 29 - 21
packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

@@ -4,8 +4,9 @@ import { SplitBorder } from "@tui/component/border"
 import { Spinner } from "@tui/component/spinner"
 import { useTheme } from "@tui/context/theme"
 import { useLocal } from "@tui/context/local"
-import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
+import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
+import { useBindings } from "../../keymap"
 import { Locale } from "@/util/locale"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
 import path from "path"
@@ -53,12 +54,16 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
     void sync.session.message.sync(props.sessionID)
   })
 
-  useKeyboard((event) => {
-    if (event.name !== "escape") return
-    event.preventDefault()
-    event.stopPropagation()
-    props.api.route.navigate("session", { sessionID: props.sessionID })
-  })
+  useBindings(() => ({
+    bindings: [
+      {
+        key: "escape",
+        cmd() {
+          props.api.route.navigate("session", { sessionID: props.sessionID })
+        },
+      },
+    ],
+  }))
 
   return (
     <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
@@ -1113,21 +1118,24 @@ const tui: TuiPlugin = async (api) => {
     },
   ])
 
-  api.command.register(() => [
-    {
-      title: "View v2 session messages",
-      value: route,
-      category: "Debug",
-      suggested: api.route.current.name === "session",
-      enabled: api.route.current.name === "session",
-      onSelect() {
-        const sessionID = currentSessionID(api)
-        if (!sessionID) return
-        api.route.navigate(route, { sessionID })
-        api.ui.dialog.clear()
+  api.keymap.registerLayer({
+    commands: [
+      {
+        name: route,
+        title: "View v2 session messages",
+        category: "Debug",
+        namespace: "palette",
+        suggested: () => api.route.current.name === "session",
+        enabled: () => api.route.current.name === "session",
+        run() {
+          const sessionID = currentSessionID(api)
+          if (!sessionID) return
+          api.route.navigate(route, { sessionID })
+          api.ui.dialog.clear()
+        },
       },
-    },
-  ])
+    ],
+  })
 }
 
 const plugin: TuiPluginModule & { id: string } = {

+ 91 - 0
packages/opencode/src/cli/cmd/tui/keymap.tsx

@@ -0,0 +1,91 @@
+import { type CliRenderer } from "@opentui/core"
+import * as addons from "@opentui/keymap/addons/opentui"
+import {
+  formatCommandBindings as formatCommandBindingsExtra,
+  formatKeySequence as formatKeySequenceExtra,
+} from "@opentui/keymap/extras"
+import {
+  KeymapProvider,
+  reactiveMatcherFromSignal,
+  useBindings,
+  useKeymap,
+  useKeymapSelector,
+} from "@opentui/keymap/solid"
+import type { Accessor } from "solid-js"
+import type { TuiConfig } from "./config/tui"
+import { useTuiConfig } from "./context/tui-config"
+
+export const LEADER_TOKEN = "leader"
+
+export const OpencodeKeymapProvider = KeymapProvider
+export const useOpencodeKeymap = useKeymap
+
+export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
+
+export type OpenTuiKeymap = ReturnType<typeof useKeymap>
+
+function formatOptions(config: TuiConfig.Resolved) {
+  return {
+    tokenDisplay: {
+      [LEADER_TOKEN]: config.keymap.leader,
+    },
+    keyNameAliases: {
+      pageup: "pgup",
+      pagedown: "pgdn",
+      delete: "del",
+    },
+    modifierAliases: {
+      meta: "alt",
+    },
+  } as const
+}
+
+export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: TuiConfig.Resolved) {
+  return formatKeySequenceExtra(parts, formatOptions(config))
+}
+
+export function formatKeyBindings(
+  bindings: Parameters<typeof formatCommandBindingsExtra>[0],
+  config: TuiConfig.Resolved,
+) {
+  return formatCommandBindingsExtra(bindings, formatOptions(config))
+}
+
+export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
+  const offCommaBindings = addons.registerCommaBindings(keymap)
+  const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
+  const offLeader = addons.registerTimedLeader(keymap, {
+    trigger: config.keymap.leader,
+    name: LEADER_TOKEN,
+    timeoutMs: config.keymap.leader_timeout,
+  })
+  const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
+  const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
+  const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
+    enabled: () => renderer.currentFocusedEditor !== null,
+    bindings: config.keymap.sections.input,
+  })
+
+  return () => {
+    offInputBindings()
+    offBackspace()
+    offEscape()
+    offLeader()
+    offBaseLayout()
+    offCommaBindings()
+  }
+}
+
+export function useCommandShortcut(command: string): Accessor<string> {
+  const config = useTuiConfig()
+  return useKeymapSelector((keymap) =>
+    formatKeySequence(
+      keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
+      config,
+    ),
+  )
+}
+
+export function useLeaderActive(): Accessor<boolean> {
+  return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
+}

+ 10 - 27
packages/opencode/src/cli/cmd/tui/plugin/api.tsx

@@ -1,15 +1,12 @@
-import type { ParsedKey } from "@opentui/core"
 import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
-import type { useCommandDialog } from "@tui/component/dialog-command"
 import type { useEvent } from "@tui/context/event"
-import type { useKeybind } from "@tui/context/keybind"
 import type { useRoute } from "@tui/context/route"
 import type { useSDK } from "@tui/context/sdk"
 import type { useSync } from "@tui/context/sync"
 import type { useTheme } from "@tui/context/theme"
 import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
 import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
-import { createPluginKeybind } from "../context/plugin-keybinds"
+import type { useOpencodeKeymap } from "../keymap"
 import type { useKV } from "../context/kv"
 import { DialogAlert } from "../ui/dialog-alert"
 import { DialogConfirm } from "../ui/dialog-confirm"
@@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt"
 import { Slot as HostSlot } from "./slots"
 import type { useToast } from "../ui/toast"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import * as Keymap from "../keymap"
 
 type RouteEntry = {
   key: symbol
@@ -28,10 +26,9 @@ type RouteEntry = {
 export type RouteMap = Map<string, RouteEntry[]>
 
 type Input = {
-  command: ReturnType<typeof useCommandDialog>
-  tuiConfig: TuiConfig.Info
+  tuiConfig: TuiConfig.Resolved
   dialog: ReturnType<typeof useDialog>
-  keybind: ReturnType<typeof useKeybind>
+  keymap: ReturnType<typeof useOpencodeKeymap>
   kv: ReturnType<typeof useKV>
   route: ReturnType<typeof useRoute>
   routes: RouteMap
@@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi {
       return () => {}
     },
   }
-
   return {
     app: appApi(),
-    command: {
-      register(cb) {
-        return input.command.register(() => cb())
-      },
-      trigger(value) {
-        input.command.trigger(value)
+    keys: {
+      formatSequence(parts) {
+        return Keymap.formatKeySequence(parts, input.tuiConfig)
       },
-      show() {
-        input.command.show()
+      formatBindings(bindings) {
+        return Keymap.formatKeyBindings(bindings, input.tuiConfig)
       },
     },
+    keymap: input.keymap,
     route: {
       register(list) {
         return routeRegister(input.routes, list, input.bump)
@@ -306,17 +300,6 @@ export function createTuiApi(input: Input): TuiPluginApi {
         },
       },
     },
-    keybind: {
-      match(key, evt: ParsedKey) {
-        return input.keybind.match(key, evt)
-      },
-      print(key) {
-        return input.keybind.print(key)
-      },
-      create(defaults, overrides) {
-        return createPluginKeybind(input.keybind, defaults, overrides)
-      },
-    },
     get tuiConfig() {
       return input.tuiConfig
     },

+ 65 - 20
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -1,4 +1,5 @@
-import "@opentui/solid/runtime-plugin-support"
+import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules"
+import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
 import {
   type TuiDispose,
   type TuiPlugin,
@@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots"
 import type { HostPluginApi, HostSlots } from "./slots"
 import { ConfigPlugin } from "@/config/plugin"
 
+ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
+
 type PluginLoad = {
   options: ConfigPlugin.Options | undefined
   spec: string
@@ -70,6 +73,36 @@ type PluginEntry = {
   scope?: PluginScope
 }
 
+const ScopedKeymapMethods = new Set<PropertyKey>([
+  "acquireResource",
+  "registerLayer",
+  "registerLayerFields",
+  "prependLayerBindingsTransformer",
+  "appendLayerBindingsTransformer",
+  "prependBindingTransformer",
+  "appendBindingTransformer",
+  "prependBindingParser",
+  "appendBindingParser",
+  "registerToken",
+  "registerSequencePattern",
+  "prependBindingExpander",
+  "appendBindingExpander",
+  "registerBindingFields",
+  "registerCommandFields",
+  "prependCommandTransformer",
+  "appendCommandTransformer",
+  "prependCommandResolver",
+  "appendCommandResolver",
+  "prependLayerAnalyzer",
+  "appendLayerAnalyzer",
+  "intercept",
+  "on",
+  "prependEventMatchResolver",
+  "appendEventMatchResolver",
+  "prependDisambiguationResolver",
+  "appendDisambiguationResolver",
+])
+
 type RuntimeState = {
   directory: string
   api: Api
@@ -104,6 +137,25 @@ function warn(message: string, data: Record<string, unknown>) {
   console.warn(`[tui.plugin] ${message}`, data)
 }
 
+function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] {
+  const cache = new Map<PropertyKey, unknown>()
+  return new Proxy(keymap, {
+    get(target, prop) {
+      const value = Reflect.get(target, prop, target)
+      if (typeof value !== "function") return value
+      if (cache.has(prop)) return cache.get(prop)
+      const fn = ScopedKeymapMethods.has(prop)
+        ? (...args: unknown[]) => {
+            const dispose = (value as (...args: unknown[]) => unknown).apply(target, args)
+            return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined)
+          }
+        : (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args)
+      cache.set(prop, fn)
+      return fn
+    },
+  })
+}
+
 type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
 
 function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -327,14 +379,16 @@ function createPluginScope(load: PluginLoad, id: string) {
 
   const track = (fn: (() => void) | undefined) => {
     if (!fn) return () => {}
-    const off = onDispose(fn)
     let drop = false
-    return () => {
+    let off = () => {}
+    const wrapped = () => {
       if (drop) return
       drop = true
       off()
       fn()
     }
+    off = onDispose(wrapped)
+    return wrapped
   }
 
   const lifecycle: TuiPluginApi["lifecycle"] = {
@@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) {
   )
 }
 
-function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
+function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
   return {
     ...readPluginEnabledMap(config.plugin_enabled),
     ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
@@ -484,17 +538,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
   const api = runtime.api
   const host = runtime.slots
   const load = plugin.load
-  const command: TuiPluginApi["command"] = {
-    register(cb) {
-      return scope.track(api.command.register(cb))
-    },
-    trigger(value) {
-      api.command.trigger(value)
-    },
-    show() {
-      api.command.show()
-    },
-  }
 
   const route: TuiPluginApi["route"] = {
     register(list) {
@@ -518,6 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
     },
   }
 
+  const keymap = createScopedKeymap(api.keymap, scope)
+
   let count = 0
 
   const slots: TuiPluginApi["slots"] = {
@@ -531,10 +576,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
 
   return {
     app: api.app,
-    command,
+    keys: api.keys,
+    keymap,
     route,
     ui: api.ui,
-    keybind: api.keybind,
     tuiConfig: api.tuiConfig,
     kv: api.kv,
     state: api.state,
@@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
   return true
 }
 
-function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
+function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
   const map = pluginEnabledState(state, config)
   for (const plugin of state.plugins) {
     const enabled = map[plugin.id]
@@ -923,7 +968,7 @@ let loaded: Promise<void> | undefined
 let runtime: RuntimeState | undefined
 export const Slot = View
 
-export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
+export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
   const cwd = process.cwd()
   if (loaded) {
     if (dir !== cwd) {
@@ -972,7 +1017,7 @@ export async function dispose() {
   }
 }
 
-async function load(input: { api: Api; config: TuiConfig.Info }) {
+async function load(input: { api: Api; config: TuiConfig.Resolved }) {
   const { api, config } = input
   const cwd = process.cwd()
   const slots = setupSlots(api)

+ 72 - 87
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -49,12 +49,10 @@ import type { WebSearchTool } from "@/tool/websearch"
 import type { TaskTool } from "@/tool/task"
 import type { QuestionTool } from "@/tool/question"
 import type { SkillTool } from "@/tool/skill"
-import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
+import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useSDK } from "@tui/context/sdk"
 import { useEditorContext } from "@tui/context/editor"
-import { useCommandDialog } from "@tui/component/dialog-command"
 import type { DialogContext } from "@tui/ui/dialog"
-import { useKeybind } from "@tui/context/keybind"
 import { useDialog } from "../../ui/dialog"
 import { TodoItem } from "../../component/todo-item"
 import { DialogMessage } from "./dialog-message"
@@ -90,6 +88,8 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
 import { DialogGoUpsell } from "../../component/dialog-go-upsell"
 import { SessionRetry } from "@/session/retry"
 import { getRevertDiffFiles } from "../../util/revert-diff"
+import { useCommandPalette } from "../../context/command-palette"
+import { useBindings, useCommandShortcut } from "../../keymap"
 
 addDefaultParsers(parsers.parsers)
 
@@ -124,6 +124,9 @@ 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()
@@ -250,7 +253,7 @@ export function Session() {
     seeded = true
     r.set(route.prompt)
   }
-  const keybind = useKeybind()
+  const command = useCommandPalette()
   const dialog = useDialog()
   const renderer = useRenderer()
 
@@ -271,7 +274,6 @@ export function Session() {
     })
   })
 
-  // Allow exit when in child session (prompt is hidden)
   const exit = useExit()
 
   createEffect(() => {
@@ -293,13 +295,6 @@ export function Session() {
     )
   })
 
-  useKeyboard((evt) => {
-    if (!session()?.parentID) return
-    if (keybind.match("app_exit", evt)) {
-      void exit()
-    }
-  })
-
   // Helper: Find next visible message boundary in direction
   const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
     const children = scroll.getChildren()
@@ -382,26 +377,24 @@ export function Session() {
     }
   }
 
-  function childSessionHandler(func: (dialog: DialogContext) => void) {
-    return (dialog: DialogContext) => {
+  function childSessionHandler(func: () => void) {
+    return () => {
       if (!session()?.parentID || dialog.stack.length > 0) return
-      func(dialog)
+      func()
     }
   }
 
-  const command = useCommandDialog()
-  command.register(() => [
+  const sessionCommandList = createMemo(() => [
     {
       title: session()?.share?.url ? "Copy share link" : "Share session",
       value: "session.share",
       suggested: route.type === "session",
-      keybind: "session_share",
       category: "Session",
       enabled: sync.data.config.share !== "disabled",
       slash: {
         name: "share",
       },
-      onSelect: async (dialog) => {
+      run: async () => {
         const copy = (url: string) =>
           Clipboard.copy(url)
             .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
@@ -434,24 +427,22 @@ export function Session() {
     {
       title: "Rename session",
       value: "session.rename",
-      keybind: "session_rename",
       category: "Session",
       slash: {
         name: "rename",
       },
-      onSelect: (dialog) => {
+      run: () => {
         dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
       },
     },
     {
       title: "Jump to message",
       value: "session.timeline",
-      keybind: "session_timeline",
       category: "Session",
       slash: {
         name: "timeline",
       },
-      onSelect: (dialog) => {
+      run: () => {
         dialog.replace(() => (
           <DialogTimeline
             onMove={(messageID) => {
@@ -469,12 +460,11 @@ export function Session() {
     {
       title: "Fork session",
       value: "session.fork",
-      keybind: "session_fork",
       category: "Session",
       slash: {
         name: "fork",
       },
-      onSelect: (dialog) => {
+      run: () => {
         dialog.replace(() => (
           <DialogForkFromTimeline
             onMove={(messageID) => {
@@ -492,13 +482,12 @@ export function Session() {
     {
       title: "Compact session",
       value: "session.compact",
-      keybind: "session_compact",
       category: "Session",
       slash: {
         name: "compact",
         aliases: ["summarize"],
       },
-      onSelect: (dialog) => {
+      run: () => {
         const selectedModel = local.model.current()
         if (!selectedModel) {
           toast.show({
@@ -519,13 +508,12 @@ export function Session() {
     {
       title: "Unshare session",
       value: "session.unshare",
-      keybind: "session_unshare",
       category: "Session",
       enabled: !!session()?.share?.url,
       slash: {
         name: "unshare",
       },
-      onSelect: async (dialog) => {
+      run: async () => {
         await sdk.client.session
           .unshare({
             sessionID: route.sessionID,
@@ -543,12 +531,11 @@ export function Session() {
     {
       title: "Undo previous message",
       value: "session.undo",
-      keybind: "messages_undo",
       category: "Session",
       slash: {
         name: "undo",
       },
-      onSelect: async (dialog) => {
+      run: async () => {
         const status = sync.data.session_status?.[route.sessionID]
         if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
         const revert = session()?.revert?.messageID
@@ -581,13 +568,12 @@ export function Session() {
     {
       title: "Redo",
       value: "session.redo",
-      keybind: "messages_redo",
       category: "Session",
       enabled: !!session()?.revert?.messageID,
       slash: {
         name: "redo",
       },
-      onSelect: (dialog) => {
+      run: () => {
         dialog.clear()
         const messageID = session()?.revert?.messageID
         if (!messageID) return
@@ -608,9 +594,8 @@ export function Session() {
     {
       title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
       value: "session.sidebar.toggle",
-      keybind: "sidebar_toggle",
       category: "Session",
-      onSelect: (dialog) => {
+      run: () => {
         batch(() => {
           const isVisible = sidebarVisible()
           setSidebar(() => (isVisible ? "hide" : "auto"))
@@ -622,9 +607,8 @@ export function Session() {
     {
       title: conceal() ? "Disable code concealment" : "Enable code concealment",
       value: "session.toggle.conceal",
-      keybind: "messages_toggle_conceal",
       category: "Session",
-      onSelect: (dialog) => {
+      run: () => {
         setConceal((prev) => !prev)
         dialog.clear()
       },
@@ -637,7 +621,7 @@ export function Session() {
         name: "timestamps",
         aliases: ["toggle-timestamps"],
       },
-      onSelect: (dialog) => {
+      run: () => {
         setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
         dialog.clear()
       },
@@ -645,13 +629,12 @@ export function Session() {
     {
       title: showThinking() ? "Hide thinking" : "Show thinking",
       value: "session.toggle.thinking",
-      keybind: "display_thinking",
       category: "Session",
       slash: {
         name: "thinking",
         aliases: ["toggle-thinking"],
       },
-      onSelect: (dialog) => {
+      run: () => {
         setShowThinking((prev) => !prev)
         dialog.clear()
       },
@@ -659,9 +642,8 @@ export function Session() {
     {
       title: showDetails() ? "Hide tool details" : "Show tool details",
       value: "session.toggle.actions",
-      keybind: "tool_details",
       category: "Session",
-      onSelect: (dialog) => {
+      run: () => {
         setShowDetails((prev) => !prev)
         dialog.clear()
       },
@@ -669,9 +651,8 @@ export function Session() {
     {
       title: "Toggle session scrollbar",
       value: "session.toggle.scrollbar",
-      keybind: "scrollbar_toggle",
       category: "Session",
-      onSelect: (dialog) => {
+      run: () => {
         setShowScrollbar((prev) => !prev)
         dialog.clear()
       },
@@ -680,7 +661,7 @@ export function Session() {
       title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
       value: "session.toggle.generic_tool_output",
       category: "Session",
-      onSelect: (dialog) => {
+      run: () => {
         setShowGenericToolOutput((prev) => !prev)
         dialog.clear()
       },
@@ -688,10 +669,9 @@ export function Session() {
     {
       title: "Page up",
       value: "session.page.up",
-      keybind: "messages_page_up",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => {
+      run: () => {
         scroll.scrollBy(-scroll.height / 2)
         dialog.clear()
       },
@@ -699,10 +679,9 @@ export function Session() {
     {
       title: "Page down",
       value: "session.page.down",
-      keybind: "messages_page_down",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => {
+      run: () => {
         scroll.scrollBy(scroll.height / 2)
         dialog.clear()
       },
@@ -710,10 +689,9 @@ export function Session() {
     {
       title: "Line up",
       value: "session.line.up",
-      keybind: "messages_line_up",
       category: "Session",
-      disabled: true,
-      onSelect: (dialog) => {
+      enabled: false,
+      run: () => {
         scroll.scrollBy(-1)
         dialog.clear()
       },
@@ -721,10 +699,9 @@ export function Session() {
     {
       title: "Line down",
       value: "session.line.down",
-      keybind: "messages_line_down",
       category: "Session",
-      disabled: true,
-      onSelect: (dialog) => {
+      enabled: false,
+      run: () => {
         scroll.scrollBy(1)
         dialog.clear()
       },
@@ -732,10 +709,9 @@ export function Session() {
     {
       title: "Half page up",
       value: "session.half.page.up",
-      keybind: "messages_half_page_up",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => {
+      run: () => {
         scroll.scrollBy(-scroll.height / 4)
         dialog.clear()
       },
@@ -743,10 +719,9 @@ export function Session() {
     {
       title: "Half page down",
       value: "session.half.page.down",
-      keybind: "messages_half_page_down",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => {
+      run: () => {
         scroll.scrollBy(scroll.height / 4)
         dialog.clear()
       },
@@ -754,10 +729,9 @@ export function Session() {
     {
       title: "First message",
       value: "session.first",
-      keybind: "messages_first",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => {
+      run: () => {
         scroll.scrollTo(0)
         dialog.clear()
       },
@@ -765,10 +739,9 @@ export function Session() {
     {
       title: "Last message",
       value: "session.last",
-      keybind: "messages_last",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => {
+      run: () => {
         scroll.scrollTo(scroll.scrollHeight)
         dialog.clear()
       },
@@ -776,10 +749,9 @@ export function Session() {
     {
       title: "Jump to last user message",
       value: "session.messages_last_user",
-      keybind: "messages_last_user",
       category: "Session",
       hidden: true,
-      onSelect: () => {
+      run: () => {
         const messages = sync.data.message[route.sessionID]
         if (!messages || !messages.length) return
 
@@ -808,25 +780,22 @@ export function Session() {
     {
       title: "Next message",
       value: "session.message.next",
-      keybind: "messages_next",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => scrollToMessage("next", dialog),
+      run: () => scrollToMessage("next", dialog),
     },
     {
       title: "Previous message",
       value: "session.message.previous",
-      keybind: "messages_previous",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => scrollToMessage("prev", dialog),
+      run: () => scrollToMessage("prev", dialog),
     },
     {
       title: "Copy last assistant message",
       value: "messages.copy",
-      keybind: "messages_copy",
       category: "Session",
-      onSelect: (dialog) => {
+      run: () => {
         const revertID = session()?.revert?.messageID
         const lastAssistantMessage = messages().findLast(
           (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
@@ -871,7 +840,7 @@ export function Session() {
       slash: {
         name: "copy",
       },
-      onSelect: async (dialog) => {
+      run: async () => {
         try {
           const sessionData = session()
           if (!sessionData) return
@@ -897,12 +866,11 @@ export function Session() {
     {
       title: "Export session transcript",
       value: "session.export",
-      keybind: "session_export",
       category: "Session",
       slash: {
         name: "export",
       },
-      onSelect: async (dialog) => {
+      run: async () => {
         try {
           const sessionData = session()
           if (!sessionData) return
@@ -959,10 +927,9 @@ export function Session() {
     {
       title: "Go to child session",
       value: "session.child.first",
-      keybind: "session_child_first",
       category: "Session",
       hidden: true,
-      onSelect: (dialog) => {
+      run: () => {
         moveFirstChild()
         dialog.clear()
       },
@@ -970,11 +937,10 @@ export function Session() {
     {
       title: "Go to parent session",
       value: "session.parent",
-      keybind: "session_parent",
       category: "Session",
       hidden: true,
       enabled: !!session()?.parentID,
-      onSelect: childSessionHandler((dialog) => {
+      run: childSessionHandler(() => {
         const parentID = session()?.parentID
         if (parentID) {
           navigate({
@@ -988,11 +954,10 @@ export function Session() {
     {
       title: "Next child session",
       value: "session.child.next",
-      keybind: "session_child_cycle",
       category: "Session",
       hidden: true,
       enabled: !!session()?.parentID,
-      onSelect: childSessionHandler((dialog) => {
+      run: childSessionHandler(() => {
         moveChild(1)
         dialog.clear()
       }),
@@ -1000,17 +965,36 @@ export function Session() {
     {
       title: "Previous child session",
       value: "session.child.previous",
-      keybind: "session_child_cycle_reverse",
       category: "Session",
       hidden: true,
       enabled: !!session()?.parentID,
-      onSelect: childSessionHandler((dialog) => {
+      run: childSessionHandler(() => {
         moveChild(-1)
         dialog.clear()
       }),
     },
   ])
 
+  const sessionCommands = createMemo(() =>
+    sessionCommandList().map((command) => ({
+      namespace: "palette",
+      name: command.value,
+      desc: "description" in command ? command.description : undefined,
+      slashName: "slash" in command ? command.slash?.name : undefined,
+      slashAliases: "slash" in command ? command.slash?.aliases : undefined,
+      ...command,
+    })),
+  )
+
+  useBindings(() => ({
+    commands: sessionCommands(),
+  }))
+
+  useBindings(() => ({
+    enabled: command.matcher,
+    bindings: sections.session,
+  }))
+
   const revertInfo = createMemo(() => session()?.revert)
   const revertMessageID = createMemo(() => revertInfo()?.messageID)
 
@@ -1082,7 +1066,8 @@ export function Session() {
                   <Switch>
                     <Match when={message.id === revert()?.messageID}>
                       {(function () {
-                        const command = useCommandDialog()
+                        const command = useCommandPalette()
+                        const redoShortcut = useCommandShortcut("session.redo")
                         const [hover, setHover] = createSignal(false)
                         const dialog = useDialog()
 
@@ -1093,7 +1078,7 @@ export function Session() {
                             "Are you sure you want to restore the reverted messages?",
                           )
                           if (confirmed) {
-                            command.trigger("session.redo")
+                            command.run("session.redo")
                           }
                         }
 
@@ -1116,7 +1101,7 @@ export function Session() {
                             >
                               <text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
                               <text fg={theme.textMuted}>
-                                <span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
+                                <span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to
                                 restore
                               </text>
                               <Show when={revert()!.diffFiles?.length}>
@@ -1370,7 +1355,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
     return props.message.time.completed - user.time.created
   })
 
-  const keybind = useKeybind()
+  const childShortcut = useCommandShortcut("session.child.first")
 
   return (
     <>
@@ -1392,7 +1377,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
       <Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
         <box paddingTop={1} paddingLeft={3}>
           <text fg={theme.text}>
-            {keybind.print("session_child_first")}
+            {childShortcut()}
             <span style={{ fg: theme.textMuted }}> view subagents</span>
           </text>
         </box>

+ 83 - 59
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -1,24 +1,22 @@
 import { createStore } from "solid-js/store"
-import { createMemo, For, Match, Show, Switch } from "solid-js"
-import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
+import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
+import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import type { TextareaRenderable } from "@opentui/core"
-import { useKeybind } from "../../context/keybind"
 import { useTheme, selectedForeground } from "../../context/theme"
 import type { PermissionRequest } from "@opencode-ai/sdk/v2"
 import { useSDK } from "../../context/sdk"
 import { SplitBorder } from "../../component/border"
 import { useSync } from "../../context/sync"
-import { useTextareaKeybindings } from "../../component/textarea-keybindings"
 import { useProject } from "../../context/project"
 import path from "path"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
-import { Keybind } from "@/util/keybind"
 import { Locale } from "@/util/locale"
 import { Global } from "@opencode-ai/core/global"
 import { ShellID } from "@/tool/shell/id"
 import { useDialog } from "../../ui/dialog"
 import { getScrollAcceleration } from "../../util/scroll"
 import { useTuiConfig } from "../../context/tui-config"
+import { useBindings, useCommandShortcut } from "../../keymap"
 
 type PermissionStage = "permission" | "always" | "reject"
 
@@ -463,25 +461,27 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
 function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
   let input: TextareaRenderable
   const { theme } = useTheme()
-  const keybind = useKeybind()
-  const textareaKeybindings = useTextareaKeybindings()
+  const tuiConfig = useTuiConfig()
+  const keymapConfig = tuiConfig.keymap
   const dimensions = useTerminalDimensions()
   const narrow = createMemo(() => dimensions().width < 80)
   const dialog = useDialog()
-
-  useKeyboard((evt) => {
-    if (dialog.stack.length > 0) return
-
-    if (evt.name === "escape" || keybind.match("app_exit", evt)) {
-      evt.preventDefault()
-      props.onCancel()
-      return
-    }
-    if (evt.name === "return") {
-      evt.preventDefault()
-      props.onConfirm(input.plainText)
-    }
-  })
+  useBindings(() => ({
+    enabled: dialog.stack.length === 0,
+    commands: [
+      {
+        name: "permission.reject.cancel",
+        run() {
+          props.onCancel()
+        },
+      },
+    ],
+    bindings: [
+      { key: "escape", cmd: () => props.onCancel() },
+      ...keymapConfig.pick("permission", ["permission.reject.cancel"]),
+      { key: "return", cmd: () => props.onConfirm(input.plainText) },
+    ],
+  }))
 
   return (
     <box
@@ -520,7 +520,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
           textColor={theme.text}
           focusedTextColor={theme.text}
           cursorColor={theme.primary}
-          keyBindings={textareaKeybindings()}
         />
         <box flexDirection="row" gap={2} flexShrink={0}>
           <text fg={theme.text}>
@@ -545,50 +544,75 @@ function Prompt<const T extends Record<string, string>>(props: {
   onSelect: (option: keyof T) => void
 }) {
   const { theme } = useTheme()
-  const keybind = useKeybind()
+  const tuiConfig = useTuiConfig()
+  const keymapConfig = tuiConfig.keymap
   const dimensions = useTerminalDimensions()
   const keys = Object.keys(props.options) as (keyof T)[]
   const [store, setStore] = createStore({
     selected: keys[0],
     expanded: false,
   })
-  const diffKey = Keybind.parse("ctrl+f")[0]
   const narrow = createMemo(() => dimensions().width < 80)
   const dialog = useDialog()
-
-  useKeyboard((evt) => {
-    if (dialog.stack.length > 0) return
-
-    if (evt.name === "left" || evt.name == "h") {
-      evt.preventDefault()
-      const idx = keys.indexOf(store.selected)
-      const next = keys[(idx - 1 + keys.length) % keys.length]
-      setStore("selected", next)
-    }
-
-    if (evt.name === "right" || evt.name == "l") {
-      evt.preventDefault()
-      const idx = keys.indexOf(store.selected)
-      const next = keys[(idx + 1) % keys.length]
-      setStore("selected", next)
-    }
-
-    if (evt.name === "return") {
-      evt.preventDefault()
-      props.onSelect(store.selected)
-    }
-
-    if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) {
-      evt.preventDefault()
-      props.onSelect(props.escapeKey)
-    }
-
-    if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
-      evt.preventDefault()
-      evt.stopPropagation()
-      setStore("expanded", (v) => !v)
-    }
-  })
+  const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
+
+  useBindings(() => ({
+    enabled: dialog.stack.length === 0,
+    commands: [
+      {
+        name: "permission.prompt.escape",
+        run() {
+          if (!props.escapeKey) return
+          props.onSelect(props.escapeKey)
+        },
+      },
+      {
+        name: "permission.prompt.fullscreen",
+        run() {
+          if (!props.fullscreen) return
+          setStore("expanded", (v) => !v)
+        },
+      },
+    ],
+    bindings: [
+      {
+        key: "left",
+        cmd: () => {
+          const idx = keys.indexOf(store.selected)
+          const next = keys[(idx - 1 + keys.length) % keys.length]
+          setStore("selected", next)
+        },
+      },
+      {
+        key: "h",
+        cmd: () => {
+          const idx = keys.indexOf(store.selected)
+          const next = keys[(idx - 1 + keys.length) % keys.length]
+          setStore("selected", next)
+        },
+      },
+      {
+        key: "right",
+        cmd: () => {
+          const idx = keys.indexOf(store.selected)
+          const next = keys[(idx + 1) % keys.length]
+          setStore("selected", next)
+        },
+      },
+      {
+        key: "l",
+        cmd: () => {
+          const idx = keys.indexOf(store.selected)
+          const next = keys[(idx + 1) % keys.length]
+          setStore("selected", next)
+        },
+      },
+      { key: "return", cmd: () => props.onSelect(store.selected) },
+      ...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []),
+      ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
+      ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
+    ],
+  }))
 
   const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
   useRenderer()
@@ -661,7 +685,7 @@ function Prompt<const T extends Record<string, string>>(props: {
         <box flexDirection="row" gap={2} flexShrink={0}>
           <Show when={props.fullscreen}>
             <text fg={theme.text}>
-              {"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
+              {fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>
             </text>
           </Show>
           <text fg={theme.text}>

+ 115 - 121
packages/opencode/src/cli/cmd/tui/routes/session/question.tsx

@@ -1,20 +1,22 @@
 import { createStore } from "solid-js/store"
 import { createMemo, createSignal, For, Show } from "solid-js"
-import { useKeyboard } from "@opentui/solid"
 import type { TextareaRenderable } from "@opentui/core"
-import { useKeybind } from "../../context/keybind"
 import { selectedForeground, tint, useTheme } from "../../context/theme"
 import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
 import { useSDK } from "../../context/sdk"
 import { SplitBorder } from "../../component/border"
-import { useTextareaKeybindings } from "../../component/textarea-keybindings"
 import { useDialog } from "../../ui/dialog"
+import { useTuiConfig } from "../../context/tui-config"
+import { useBindings } from "../../keymap"
 
 export function QuestionPrompt(props: { request: QuestionRequest }) {
   const sdk = useSDK()
   const { theme } = useTheme()
-  const keybind = useKeybind()
-  const bindings = useTextareaKeybindings()
+  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)
@@ -122,131 +124,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
 
   const dialog = useDialog()
 
-  useKeyboard((evt) => {
-    // Skip processing if a dialog (e.g., command palette) is open
-    if (dialog.stack.length > 0) return
-
-    // When editing custom answer textarea
-    if (store.editing && !confirm()) {
-      if (evt.name === "escape") {
-        evt.preventDefault()
-        setStore("editing", false)
-        return
-      }
-      if (keybind.match("input_clear", evt)) {
-        evt.preventDefault()
-        const text = textarea?.plainText ?? ""
-        if (!text) {
+  useBindings(() => ({
+    enabled: store.editing && !confirm(),
+    commands: [
+      {
+        name: "question.edit.clear",
+        run() {
+          const text = textarea?.plainText ?? ""
+          if (!text) {
+            setStore("editing", false)
+            return
+          }
+          textarea?.setText("")
+        },
+      },
+    ],
+    bindings: [
+      {
+        key: "escape",
+        cmd: () => {
           setStore("editing", false)
-          return
-        }
-        textarea?.setText("")
-        return
-      }
-      if (evt.name === "return") {
-        evt.preventDefault()
-        const text = textarea?.plainText?.trim() ?? ""
-        const prev = store.custom[store.tab]
+        },
+      },
+      ...keymapConfig.pick("question", ["question.edit.clear"]),
+      {
+        key: "return",
+        cmd: () => {
+          const text = textarea?.plainText?.trim() ?? ""
+          const prev = store.custom[store.tab]
+
+          if (!text) {
+            if (prev) {
+              const inputs = [...store.custom]
+              inputs[store.tab] = ""
+              setStore("custom", inputs)
+
+              const answers = [...store.answers]
+              answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
+              setStore("answers", answers)
+            }
+            setStore("editing", false)
+            return
+          }
 
-        if (!text) {
-          if (prev) {
+          if (multi()) {
             const inputs = [...store.custom]
-            inputs[store.tab] = ""
+            inputs[store.tab] = text
             setStore("custom", inputs)
 
+            const existing = store.answers[store.tab] ?? []
+            const next = [...existing]
+            if (prev) {
+              const index = next.indexOf(prev)
+              if (index !== -1) next.splice(index, 1)
+            }
+            if (!next.includes(text)) next.push(text)
             const answers = [...store.answers]
-            answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
+            answers[store.tab] = next
             setStore("answers", answers)
+            setStore("editing", false)
+            return
           }
-          setStore("editing", false)
-          return
-        }
-
-        if (multi()) {
-          const inputs = [...store.custom]
-          inputs[store.tab] = text
-          setStore("custom", inputs)
 
-          const existing = store.answers[store.tab] ?? []
-          const next = [...existing]
-          if (prev) {
-            const index = next.indexOf(prev)
-            if (index !== -1) next.splice(index, 1)
-          }
-          if (!next.includes(text)) next.push(text)
-          const answers = [...store.answers]
-          answers[store.tab] = next
-          setStore("answers", answers)
+          pick(text, true)
           setStore("editing", false)
-          return
-        }
-
-        pick(text, true)
-        setStore("editing", false)
-        return
-      }
-      // Let textarea handle all other keys
-      return
-    }
-
-    if (evt.name === "left" || evt.name === "h") {
-      evt.preventDefault()
-      selectTab((store.tab - 1 + tabs()) % tabs())
-    }
-
-    if (evt.name === "right" || evt.name === "l") {
-      evt.preventDefault()
-      selectTab((store.tab + 1) % tabs())
-    }
-
-    if (evt.name === "tab") {
-      evt.preventDefault()
-      const direction = evt.shift ? -1 : 1
-      selectTab((store.tab + direction + tabs()) % tabs())
-    }
-
-    if (confirm()) {
-      if (evt.name === "return") {
-        evt.preventDefault()
-        submit()
-      }
-      if (evt.name === "escape" || keybind.match("app_exit", evt)) {
-        evt.preventDefault()
-        reject()
-      }
-    } else {
-      const opts = options()
-      const total = opts.length + (custom() ? 1 : 0)
-      const max = Math.min(total, 9)
-      const digit = Number(evt.name)
-
-      if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
-        evt.preventDefault()
-        const index = digit - 1
-        moveTo(index)
-        selectOption()
-        return
-      }
-
-      if (evt.name === "up" || evt.name === "k") {
-        evt.preventDefault()
-        moveTo((store.selected - 1 + total) % total)
-      }
-
-      if (evt.name === "down" || evt.name === "j") {
-        evt.preventDefault()
-        moveTo((store.selected + 1) % total)
-      }
-
-      if (evt.name === "return") {
-        evt.preventDefault()
-        selectOption()
-      }
-
-      if (evt.name === "escape" || keybind.match("app_exit", evt)) {
-        evt.preventDefault()
-        reject()
-      }
+        },
+      },
+    ],
+  }))
+
+  useBindings(() => {
+    const opts = options()
+    const total = opts.length + (custom() ? 1 : 0)
+    const max = Math.min(total, 9)
+
+    return {
+      enabled: dialog.stack.length === 0 && !store.editing,
+      commands: [
+        {
+          name: "question.reject",
+          run() {
+            reject()
+          },
+        },
+      ],
+      bindings: [
+        { key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
+        { key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
+        { key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) },
+        { key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) },
+        {
+          key: "tab",
+          cmd: ({ event }: { event: { shift: boolean } }) => {
+            selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
+          },
+        },
+        ...(confirm()
+          ? [
+              { key: "return", cmd: () => submit() },
+              { key: "escape", cmd: () => reject() },
+              ...sections.question,
+            ]
+          : [
+              ...Array.from({ length: max }, (_, index) => ({
+                key: String(index + 1),
+                cmd: () => {
+                  moveTo(index)
+                  selectOption()
+                },
+              })),
+              { key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) },
+              { key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) },
+              { key: "down", cmd: () => moveTo((store.selected + 1) % total) },
+              { key: "j", cmd: () => moveTo((store.selected + 1) % total) },
+              { key: "return", cmd: () => selectOption() },
+              { key: "escape", cmd: () => reject() },
+              ...sections.question,
+            ]),
+      ],
     }
   })
 
@@ -394,7 +389,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
                         textColor={theme.text}
                         focusedTextColor={theme.text}
                         cursorColor={theme.primary}
-                        keyBindings={bindings()}
                       />
                     </box>
                   </Show>

+ 12 - 10
packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx

@@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync"
 import { useTheme } from "@tui/context/theme"
 import { SplitBorder } from "@tui/component/border"
 import type { AssistantMessage } from "@opencode-ai/sdk/v2"
-import { useCommandDialog } from "@tui/component/dialog-command"
-import { useKeybind } from "../../context/keybind"
 import { Locale } from "@/util/locale"
 import { useTerminalDimensions } from "@opentui/solid"
+import { useCommandPalette } from "../../context/command-palette"
+import { useCommandShortcut } from "../../keymap"
 
 export function SubagentFooter() {
   const route = useRouteData("session")
@@ -56,8 +56,10 @@ export function SubagentFooter() {
   })
 
   const { theme } = useTheme()
-  const keybind = useKeybind()
-  const command = useCommandDialog()
+  const command = useCommandPalette()
+  const parentShortcut = useCommandShortcut("session.parent")
+  const previousShortcut = useCommandShortcut("session.child.previous")
+  const nextShortcut = useCommandShortcut("session.child.next")
   const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
   useTerminalDimensions()
 
@@ -96,31 +98,31 @@ export function SubagentFooter() {
             <box
               onMouseOver={() => setHover("parent")}
               onMouseOut={() => setHover(null)}
-              onMouseUp={() => command.trigger("session.parent")}
+              onMouseUp={() => command.run("session.parent")}
               backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
             >
               <text fg={theme.text}>
-                Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
+                Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
               </text>
             </box>
             <box
               onMouseOver={() => setHover("prev")}
               onMouseOut={() => setHover(null)}
-              onMouseUp={() => command.trigger("session.child.previous")}
+              onMouseUp={() => command.run("session.child.previous")}
               backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
             >
               <text fg={theme.text}>
-                Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
+                Prev <span style={{ fg: theme.textMuted }}>{previousShortcut()}</span>
               </text>
             </box>
             <box
               onMouseOver={() => setHover("next")}
               onMouseOut={() => setHover(null)}
-              onMouseUp={() => command.trigger("session.child.next")}
+              onMouseUp={() => command.run("session.child.next")}
               backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
             >
               <text fg={theme.text}>
-                Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
+                Next <span style={{ fg: theme.textMuted }}>{nextShortcut()}</span>
               </text>
             </box>
           </box>

+ 12 - 9
packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx

@@ -1,7 +1,7 @@
 import { TextAttributes } from "@opentui/core"
 import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
-import { useKeyboard } from "@opentui/solid"
+import { useBindings } from "../keymap"
 
 export type DialogAlertProps = {
   title: string
@@ -13,14 +13,17 @@ export function DialogAlert(props: DialogAlertProps) {
   const dialog = useDialog()
   const { theme } = useTheme()
 
-  useKeyboard((evt) => {
-    if (evt.name === "return") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.onConfirm?.()
-      dialog.clear()
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      {
+        key: "return",
+        cmd: () => {
+          props.onConfirm?.()
+          dialog.clear()
+        },
+      },
+    ],
+  }))
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">

+ 26 - 15
packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx

@@ -3,8 +3,8 @@ import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
 import { createStore } from "solid-js/store"
 import { For } from "solid-js"
-import { useKeyboard } from "@opentui/solid"
 import { Locale } from "@/util/locale"
+import { useBindings } from "../keymap"
 
 export type DialogConfirmProps = {
   title: string
@@ -23,19 +23,30 @@ export function DialogConfirm(props: DialogConfirmProps) {
     active: "confirm" as "confirm" | "cancel",
   })
 
-  useKeyboard((evt) => {
-    if (evt.name === "return") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      if (store.active === "confirm") props.onConfirm?.()
-      if (store.active === "cancel") props.onCancel?.()
-      dialog.clear()
-    }
-
-    if (evt.name === "left" || evt.name === "right") {
-      setStore("active", store.active === "confirm" ? "cancel" : "confirm")
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      {
+        key: "return",
+        cmd: () => {
+          if (store.active === "confirm") props.onConfirm?.()
+          if (store.active === "cancel") props.onCancel?.()
+          dialog.clear()
+        },
+      },
+      {
+        key: "left",
+        cmd: () => {
+          setStore("active", store.active === "confirm" ? "cancel" : "confirm")
+        },
+      },
+      {
+        key: "right",
+        cmd: () => {
+          setStore("active", store.active === "confirm" ? "cancel" : "confirm")
+        },
+      },
+    ],
+  }))
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
       <box flexDirection="row" justifyContent="space-between">
@@ -56,7 +67,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
               paddingLeft={1}
               paddingRight={1}
               backgroundColor={key === store.active ? theme.primary : undefined}
-              onMouseUp={(_evt) => {
+              onMouseUp={() => {
                 if (key === "confirm") props.onConfirm?.()
                 if (key === "cancel") props.onCancel?.()
                 dialog.clear()

+ 35 - 35
packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx

@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
 import { createStore } from "solid-js/store"
 import { onMount, Show } from "solid-js"
-import { useKeyboard } from "@opentui/solid"
+import { useBindings } from "../keymap"
 
 export type DialogExportOptionsProps = {
   defaultFilename: string
@@ -33,39 +33,40 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
     active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
   })
 
-  useKeyboard((evt) => {
-    if (evt.name === "return") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.onConfirm?.({
-        filename: textarea.plainText,
-        thinking: store.thinking,
-        toolDetails: store.toolDetails,
-        assistantMetadata: store.assistantMetadata,
-        openWithoutSaving: store.openWithoutSaving,
-      })
-    }
-    if (evt.name === "tab") {
-      const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
-        "filename",
-        "thinking",
-        "toolDetails",
-        "assistantMetadata",
-        "openWithoutSaving",
-      ]
-      const currentIndex = order.indexOf(store.active)
-      const nextIndex = (currentIndex + 1) % order.length
-      setStore("active", order[nextIndex])
-      evt.preventDefault()
-    }
-    if (evt.name === "space" || evt.name === " ") {
-      if (store.active === "thinking") setStore("thinking", !store.thinking)
-      if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
-      if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
-      if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
-      evt.preventDefault()
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      {
+        key: "tab",
+        cmd: () => {
+          const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
+            "filename",
+            "thinking",
+            "toolDetails",
+            "assistantMetadata",
+            "openWithoutSaving",
+          ]
+          const currentIndex = order.indexOf(store.active)
+          const nextIndex = (currentIndex + 1) % order.length
+          setStore("active", order[nextIndex])
+        },
+      },
+    ],
+  }))
+
+  useBindings(() => ({
+    enabled: store.active !== "filename",
+    bindings: [
+      {
+        key: "space",
+        cmd: () => {
+          if (store.active === "thinking") setStore("thinking", !store.thinking)
+          if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
+          if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
+          if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
+        },
+      },
+    ],
+  }))
 
   onMount(() => {
     dialog.setSize("medium")
@@ -101,7 +102,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
             })
           }}
           height={3}
-          keyBindings={[{ name: "return", action: "submit" }]}
           ref={(val: TextareaRenderable) => {
             textarea = val
             val.traits = { status: "FILENAME" }

+ 9 - 11
packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx

@@ -1,21 +1,19 @@
 import { TextAttributes } from "@opentui/core"
 import { useTheme } from "@tui/context/theme"
 import { useDialog } from "./dialog"
-import { useKeyboard } from "@opentui/solid"
-import { useKeybind } from "@tui/context/keybind"
+import { useBindings, useCommandShortcut } from "../keymap"
 
 export function DialogHelp() {
   const dialog = useDialog()
   const { theme } = useTheme()
-  const keybind = useKeybind()
+  const commandShortcut = useCommandShortcut("command.palette.show")
 
-  useKeyboard((evt) => {
-    if (evt.name === "return" || evt.name === "escape") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      dialog.clear()
-    }
-  })
+  useBindings(() => ({
+    bindings: [
+      { key: "return", cmd: () => dialog.clear() },
+      { key: "escape", cmd: () => dialog.clear() },
+    ],
+  }))
 
   return (
     <box paddingLeft={2} paddingRight={2} gap={1}>
@@ -29,7 +27,7 @@ export function DialogHelp() {
       </box>
       <box paddingBottom={1}>
         <text fg={theme.textMuted}>
-          Press {keybind.print("command_list")} to see all available actions and commands in any context.
+          Press {commandShortcut()} to see all available actions and commands in any context.
         </text>
       </box>
       <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>

+ 0 - 16
packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx

@@ -2,7 +2,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
 import { useTheme } from "../context/theme"
 import { useDialog, type DialogContext } from "./dialog"
 import { Show, createEffect, onMount, type JSX } from "solid-js"
-import { useKeyboard } from "@opentui/solid"
 import { Spinner } from "../component/spinner"
 
 export type DialogPromptProps = {
@@ -21,20 +20,6 @@ export function DialogPrompt(props: DialogPromptProps) {
   const { theme } = useTheme()
   let textarea: TextareaRenderable
 
-  useKeyboard((evt) => {
-    if (props.busy) {
-      if (evt.name === "escape") return
-      evt.preventDefault()
-      evt.stopPropagation()
-      return
-    }
-    if (evt.name === "return") {
-      evt.preventDefault()
-      evt.stopPropagation()
-      props.onConfirm?.(textarea.plainText)
-    }
-  })
-
   onMount(() => {
     dialog.setSize("medium")
     setTimeout(() => {
@@ -79,7 +64,6 @@ export function DialogPrompt(props: DialogPromptProps) {
             props.onConfirm?.(textarea.plainText)
           }}
           height={3}
-          keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
           ref={(val: TextareaRenderable) => {
             textarea = val
           }}

+ 124 - 40
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -1,17 +1,24 @@
-import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
+import {
+  InputRenderable,
+  RGBA,
+  ScrollBoxRenderable,
+  TextAttributes,
+  type KeyEvent,
+  type Renderable,
+} from "@opentui/core"
+import type { Binding } from "@opentui/keymap"
 import { useTheme, selectedForeground } from "@tui/context/theme"
 import { entries, filter, flatMap, groupBy, pipe } from "remeda"
 import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
 import { createStore } from "solid-js/store"
-import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { useTerminalDimensions } from "@opentui/solid"
 import * as fuzzysort from "fuzzysort"
 import { isDeepEqual } from "remeda"
 import { useDialog, type DialogContext } from "@tui/ui/dialog"
-import { useKeybind } from "@tui/context/keybind"
-import { Keybind } from "@/util/keybind"
 import { Locale } from "@/util/locale"
 import { getScrollAcceleration } from "../util/scroll"
 import { useTuiConfig } from "../context/tui-config"
+import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
 
 export interface DialogSelectProps<T> {
   title: string
@@ -24,13 +31,14 @@ export interface DialogSelectProps<T> {
   onSelect?: (option: DialogSelectOption<T>) => void
   skipFilter?: boolean
   renderFilter?: boolean
-  keybind?: {
-    keybind?: Keybind.Info
+  actions?: {
+    command: string
     title: string
     side?: "left" | "right"
     disabled?: boolean
     onTrigger: (option: DialogSelectOption<T>) => void
   }[]
+  bindings?: readonly Binding<Renderable, KeyEvent>[]
   current?: T
 }
 
@@ -57,6 +65,9 @@ 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({
@@ -81,6 +92,25 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
   let input: InputRenderable
 
+  const actions = createMemo(() => props.actions ?? [])
+  const actionBindings = useKeymapSelector((keymap) =>
+    keymap.getCommandBindings({
+      visibility: "registered",
+      commands: actions().map((item) => item.command),
+    }),
+  )
+
+  const actionLabels = createMemo(() => {
+    const labels = new Map<string, string>()
+
+    for (const action of actions()) {
+      const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
+      if (label) labels.set(action.command, label)
+    }
+
+    return labels
+  })
+
   const filtered = createMemo(() => {
     if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
     const needle = store.filter.toLowerCase()
@@ -171,7 +201,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     const option = selected()
     if (option) props.onMove?.(option)
     if (!scroll) return
-    const target = scroll.getChildren().find((child) => {
+    const target = scroll.getChildren().find((child: { id?: string }) => {
       return child.id === JSON.stringify(selected()?.value)
     })
     if (!target) return
@@ -192,36 +222,86 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     }
   }
 
-  const keybind = useKeybind()
-  useKeyboard((evt) => {
+  function submit() {
     setStore("input", "keyboard")
+    const option = selected()
+    if (!option) return
+    option.onSelect?.(dialog)
+    props.onSelect?.(option)
+  }
 
-    if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
-    if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
-    if (evt.name === "pageup") move(-10)
-    if (evt.name === "pagedown") move(10)
-    if (evt.name === "home") moveTo(0)
-    if (evt.name === "end") moveTo(flat().length - 1)
-
-    if (evt.name === "return") {
-      const option = selected()
-      if (option) {
-        evt.preventDefault()
-        evt.stopPropagation()
-        if (option.onSelect) option.onSelect(dialog)
-        props.onSelect?.(option)
-      }
-    }
+  useBindings(() => {
+    const enabledActions = actions().filter((item) => !item.disabled)
 
-    for (const item of props.keybind ?? []) {
-      if (item.disabled || !item.keybind) continue
-      if (Keybind.match(item.keybind, keybind.parse(evt))) {
-        const s = selected()
-        if (s) {
-          evt.preventDefault()
-          item.onTrigger(s)
-        }
-      }
+    return {
+      commands: [
+        {
+          name: "dialog.select.prev",
+          run() {
+            setStore("input", "keyboard")
+            move(-1)
+          },
+        },
+        {
+          name: "dialog.select.next",
+          run() {
+            setStore("input", "keyboard")
+            move(1)
+          },
+        },
+        {
+          name: "dialog.select.page_up",
+          run() {
+            setStore("input", "keyboard")
+            move(-10)
+          },
+        },
+        {
+          name: "dialog.select.page_down",
+          run() {
+            setStore("input", "keyboard")
+            move(10)
+          },
+        },
+        {
+          name: "dialog.select.home",
+          run() {
+            setStore("input", "keyboard")
+            moveTo(0)
+          },
+        },
+        {
+          name: "dialog.select.end",
+          run() {
+            setStore("input", "keyboard")
+            moveTo(flat().length - 1)
+          },
+        },
+        {
+          name: "dialog.select.submit",
+          run: submit,
+        },
+        ...enabledActions.map((item) => ({
+          name: item.command,
+          run() {
+            setStore("input", "keyboard")
+            const option = selected()
+            if (!option) return
+            item.onTrigger(option)
+          },
+        })),
+      ],
+      bindings: [
+        ...sections.dialog_select,
+        ...tuiConfig.keymap.pick(
+          "dialog_actions",
+          enabledActions.map((item) => item.command),
+        ),
+        ...(props.bindings ?? []).filter((binding) => {
+          if (typeof binding.cmd !== "string") return true
+          return enabledActions.some((item) => item.command === binding.cmd)
+        }),
+      ],
     }
   })
 
@@ -236,9 +316,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   }
   props.ref?.(ref)
 
-  const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
-  const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
-  const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
+  const visibleActions = createMemo(() =>
+    actions()
+      .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
+      .filter((item) => !item.disabled && item.label),
+  )
+  const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
+  const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
 
   return (
     <box gap={1} paddingBottom={1}>
@@ -365,7 +449,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           </For>
         </scrollbox>
       </Show>
-      <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
+      <Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
         <box
           paddingRight={2}
           paddingLeft={4}
@@ -381,7 +465,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                   <span style={{ fg: theme.text }}>
                     <b>{item.title}</b>{" "}
                   </span>
-                  <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
+                  <span style={{ fg: theme.textMuted }}>{item.label}</span>
                 </text>
               )}
             </For>
@@ -393,7 +477,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                   <span style={{ fg: theme.text }}>
                     <b>{item.title}</b>{" "}
                   </span>
-                  <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
+                  <span style={{ fg: theme.textMuted }}>{item.label}</span>
                 </text>
               )}
             </For>

+ 35 - 20
packages/opencode/src/cli/cmd/tui/ui/dialog.tsx

@@ -1,4 +1,4 @@
-import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { useRenderer, useTerminalDimensions } from "@opentui/solid"
 import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
 import { useTheme } from "@tui/context/theme"
 import { MouseButton, Renderable, RGBA } from "@opentui/core"
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
 import { useToast } from "./toast"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import * as Selection from "@tui/util/selection"
+import { useBindings } from "../keymap"
 
 export function Dialog(
   props: ParentProps<{
@@ -47,7 +48,7 @@ export function Dialog(
       backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
     >
       <box
-        onMouseUp={(e) => {
+        onMouseUp={(e: { stopPropagation(): void }) => {
           dismiss = false
           e.stopPropagation()
         }}
@@ -73,23 +74,6 @@ function init() {
 
   const renderer = useRenderer()
 
-  useKeyboard((evt) => {
-    if (store.stack.length === 0) return
-    if (evt.defaultPrevented) return
-    if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
-    if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
-      if (renderer.getSelection()) {
-        renderer.clearSelection()
-      }
-      const current = store.stack.at(-1)!
-      current.onClose?.()
-      setStore("stack", store.stack.slice(0, -1))
-      evt.preventDefault()
-      evt.stopPropagation()
-      refocus()
-    }
-  })
-
   let focus: Renderable | null
   function refocus() {
     setTimeout(() => {
@@ -108,6 +92,36 @@ function init() {
     }, 1)
   }
 
+  useBindings(() => ({
+    enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
+    bindings: [
+      {
+        key: "escape",
+        cmd: () => {
+          if (renderer.getSelection()) {
+            renderer.clearSelection()
+          }
+          const current = store.stack.at(-1)
+          current?.onClose?.()
+          setStore("stack", store.stack.slice(0, -1))
+          refocus()
+        },
+      },
+      {
+        key: "ctrl+c",
+        cmd: () => {
+          if (renderer.getSelection()) {
+            renderer.clearSelection()
+          }
+          const current = store.stack.at(-1)
+          current?.onClose?.()
+          setStore("stack", store.stack.slice(0, -1))
+          refocus()
+        },
+      },
+    ],
+  }))
+
   return {
     clear() {
       for (const item of store.stack) {
@@ -155,13 +169,14 @@ export function DialogProvider(props: ParentProps) {
   const value = init()
   const renderer = useRenderer()
   const toast = useToast()
+
   return (
     <ctx.Provider value={value}>
       {props.children}
       <box
         position="absolute"
         zIndex={3000}
-        onMouseDown={(evt) => {
+        onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
           if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
           if (evt.button !== MouseButton.RIGHT) return
 

+ 3 - 1
packages/opencode/src/cli/cmd/tui/util/scroll.ts

@@ -11,7 +11,9 @@ export class CustomSpeedScroll implements ScrollAcceleration {
   reset(): void {}
 }
 
-export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration {
+export function getScrollAcceleration(
+  tuiConfig?: Pick<TuiConfig.Info, "scroll_acceleration" | "scroll_speed">,
+): ScrollAcceleration {
   if (tuiConfig?.scroll_acceleration?.enabled) {
     return new MacOSScrollAccel()
   }

+ 41 - 1
packages/opencode/src/cli/cmd/tui/util/selection.ts

@@ -5,9 +5,21 @@ type Toast = {
   error: (err: unknown) => void
 }
 
+type FocusableSelectionTarget = {
+  hasSelection: () => boolean
+}
+
 type Renderer = {
-  getSelection: () => { getSelectedText: () => string } | null
+  getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
   clearSelection: () => void
+  currentFocusedRenderable?: FocusableSelectionTarget | null
+}
+
+type SelectionKeyEvent = {
+  ctrl?: boolean
+  name: string
+  preventDefault: () => void
+  stopPropagation: () => void
 }
 
 export function copy(renderer: Renderer, toast: Toast): boolean {
@@ -22,4 +34,32 @@ export function copy(renderer: Renderer, toast: Toast): boolean {
   return true
 }
 
+export function handleSelectionKey(renderer: Renderer, toast: Toast, event: SelectionKeyEvent) {
+  const selection = renderer.getSelection()
+  if (!selection) return
+
+  if (event.ctrl && event.name === "c") {
+    if (!copy(renderer, toast)) {
+      renderer.clearSelection()
+      return
+    }
+
+    event.preventDefault()
+    event.stopPropagation()
+    return
+  }
+
+  if (event.name === "escape") {
+    renderer.clearSelection()
+    event.preventDefault()
+    event.stopPropagation()
+    return
+  }
+
+  const focus = renderer.currentFocusedRenderable
+  if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return
+
+  renderer.clearSelection()
+}
+
 export * as Selection from "./selection"

+ 17 - 1
packages/opencode/src/config/keybinds.ts

@@ -21,7 +21,6 @@ const KeybindsSchema = Schema.Struct({
   theme_list: keybind("<leader>t", "List available themes"),
   sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
   scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
-  username_toggle: keybind("none", "Toggle username visibility"),
   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"),
@@ -59,6 +58,22 @@ const KeybindsSchema = Schema.Struct({
   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"),
@@ -101,6 +116,7 @@ const KeybindsSchema = Schema.Struct({
   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"),

+ 0 - 103
packages/opencode/src/util/keybind.ts

@@ -1,103 +0,0 @@
-import { isDeepEqual } from "remeda"
-import type { ParsedKey } from "@opentui/core"
-
-/**
- * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
- * This ensures type compatibility and catches missing fields at compile time.
- */
-export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
-  leader: boolean // our custom field
-}
-
-export function match(a: Info | undefined, b: Info): boolean {
-  if (!a) return false
-  const normalizedA = { ...a, super: a.super ?? false }
-  const normalizedB = { ...b, super: b.super ?? false }
-  return isDeepEqual(normalizedA, normalizedB)
-}
-
-/**
- * Convert OpenTUI's ParsedKey to our Keybind.Info format.
- * This helper ensures all required fields are present and avoids manual object creation.
- */
-export function fromParsedKey(key: ParsedKey, leader = false): Info {
-  return {
-    name: key.name === " " ? "space" : key.name,
-    ctrl: key.ctrl,
-    meta: key.meta,
-    shift: key.shift,
-    super: key.super ?? false,
-    leader,
-  }
-}
-
-export function toString(info: Info | undefined): string {
-  if (!info) return ""
-  const parts: string[] = []
-
-  if (info.ctrl) parts.push("ctrl")
-  if (info.meta) parts.push("alt")
-  if (info.super) parts.push("super")
-  if (info.shift) parts.push("shift")
-  if (info.name) {
-    if (info.name === "delete") parts.push("del")
-    else parts.push(info.name)
-  }
-
-  let result = parts.join("+")
-
-  if (info.leader) {
-    result = result ? `<leader> ${result}` : `<leader>`
-  }
-
-  return result
-}
-
-export function parse(key: string): Info[] {
-  if (key === "none") return []
-
-  return key.split(",").map((combo) => {
-    // Handle <leader> syntax by replacing with leader+
-    const normalized = combo.replace(/<leader>/g, "leader+")
-    const parts = normalized.toLowerCase().split("+")
-    const info: Info = {
-      ctrl: false,
-      meta: false,
-      shift: false,
-      leader: false,
-      name: "",
-    }
-
-    for (const part of parts) {
-      switch (part) {
-        case "ctrl":
-          info.ctrl = true
-          break
-        case "alt":
-        case "meta":
-        case "option":
-          info.meta = true
-          break
-        case "super":
-          info.super = true
-          break
-        case "shift":
-          info.shift = true
-          break
-        case "leader":
-          info.leader = true
-          break
-        case "esc":
-          info.name = "escape"
-          break
-        default:
-          info.name = part
-          break
-      }
-    }
-
-    return info
-  })
-}
-
-export * as Keybind from "./keybind"

+ 0 - 90
packages/opencode/test/cli/tui/keybind-plugin.test.ts

@@ -1,90 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import type { ParsedKey } from "@opentui/core"
-import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
-
-describe("createPluginKeybind", () => {
-  const defaults = {
-    open: "ctrl+o",
-    close: "escape",
-  }
-
-  test("uses defaults when overrides are missing", () => {
-    const api = {
-      match: () => false,
-      print: (key: string) => key,
-    }
-    const bind = createPluginKeybind(api, defaults)
-
-    expect(bind.all).toEqual(defaults)
-    expect(bind.get("open")).toBe("ctrl+o")
-    expect(bind.get("close")).toBe("escape")
-  })
-
-  test("applies valid overrides", () => {
-    const api = {
-      match: () => false,
-      print: (key: string) => key,
-    }
-    const bind = createPluginKeybind(api, defaults, {
-      open: "ctrl+alt+o",
-      close: "q",
-    })
-
-    expect(bind.all).toEqual({
-      open: "ctrl+alt+o",
-      close: "q",
-    })
-  })
-
-  test("ignores invalid overrides", () => {
-    const api = {
-      match: () => false,
-      print: (key: string) => key,
-    }
-    const bind = createPluginKeybind(api, defaults, {
-      open: "   ",
-      close: 1,
-      extra: "ctrl+x",
-    })
-
-    expect(bind.all).toEqual(defaults)
-    expect(bind.get("extra")).toBe("extra")
-  })
-
-  test("resolves names for match", () => {
-    const list: string[] = []
-    const api = {
-      match: (key: string) => {
-        list.push(key)
-        return true
-      },
-      print: (key: string) => key,
-    }
-    const bind = createPluginKeybind(api, defaults, {
-      open: "ctrl+shift+o",
-    })
-
-    bind.match("open", { name: "x" } as ParsedKey)
-    bind.match("ctrl+k", { name: "x" } as ParsedKey)
-
-    expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
-  })
-
-  test("resolves names for print", () => {
-    const list: string[] = []
-    const api = {
-      match: () => false,
-      print: (key: string) => {
-        list.push(key)
-        return `print:${key}`
-      },
-    }
-    const bind = createPluginKeybind(api, defaults, {
-      close: "q",
-    })
-
-    expect(bind.print("close")).toBe("print:q")
-    expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
-    expect(list).toEqual(["q", "ctrl+p"])
-  })
-})

+ 5 - 6
packages/opencode/test/cli/tui/plugin-add.test.ts

@@ -4,6 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -31,10 +32,9 @@ test("adds tui plugin at runtime from spec", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [],
-    plugin_origins: undefined,
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
@@ -74,10 +74,9 @@ test("retries runtime add for file plugins after dependency wait", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [],
-    plugin_origins: undefined,
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
     await Bun.write(
       path.join(tmp.extra.mod, "index.ts"),

+ 3 - 3
packages/opencode/test/cli/tui/plugin-install.test.ts

@@ -4,6 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -50,10 +51,9 @@ test("installs plugin without loading it", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [],
-    plugin_origins: undefined,
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi({

+ 17 - 16
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -4,6 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 import { Npm } from "@opencode-ai/core/npm"
 
@@ -44,7 +45,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -53,7 +54,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -105,7 +106,7 @@ test("does not use npm package exports dot for tui entry", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -114,7 +115,7 @@ test("does not use npm package exports dot for tui entry", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -167,7 +168,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -176,7 +177,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -229,7 +230,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -238,7 +239,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -287,7 +288,7 @@ test("does not use npm package main for tui entry", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -296,7 +297,7 @@ test("does not use npm package main for tui entry", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -352,7 +353,7 @@ test("does not use directory package main for tui entry", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -361,7 +362,7 @@ test("does not use directory package main for tui entry", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
@@ -399,7 +400,7 @@ test("uses directory index fallback for tui when package.json is missing", async
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [tmp.extra.spec],
     plugin_origins: [
       {
@@ -408,7 +409,7 @@ test("uses directory index fallback for tui when package.json is missing", async
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
@@ -456,7 +457,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -465,7 +466,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })

+ 3 - 2
packages/opencode/test/cli/tui/plugin-loader-pure.test.ts

@@ -4,6 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -37,7 +38,7 @@ test("skips external tui plugins in pure mode", async () => {
   process.env.OPENCODE_PURE = "1"
   process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
 
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_origins: [
       {
@@ -46,7 +47,7 @@ test("skips external tui plugins in pure mode", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 

+ 278 - 30
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -2,8 +2,10 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
 import { pathToFileURL } from "url"
+import { createTestKeymap } from "@opentui/keymap/testing"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { Global } from "@opencode-ai/core/global"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 import { Filesystem } from "@/util/filesystem"
@@ -79,7 +81,10 @@ async function load(): Promise<Data> {
 
       await Bun.write(
         localPluginPath,
-        `export const ignored = async (_input, options) => {
+        `import { resolveBindingSections } from "@opentui/keymap/extras"
+import { useBindings } from "@opentui/keymap/solid"
+
+export const ignored = async (_input, options) => {
   if (!options?.fn_marker) return
   await Bun.write(options.fn_marker, "called")
 }
@@ -93,10 +98,21 @@ export default {
     const cfg_speed = api.tuiConfig.scroll_speed
     const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
     const cfg_submit = api.tuiConfig.keybinds?.input_submit
-    const key = api.keybind.create(
-      { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
-      options.keybinds,
-    )
+    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 key_unknown = "ctrl+k"
+    const off = api.keymap.registerLayer({
+      commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
+      bindings: keymap.main,
+    })
+    off()
     const kv_before = api.kv.get(options.kv_key, "missing")
     api.kv.set(options.kv_key, "stored")
     const kv_after = api.kv.get(options.kv_key, "missing")
@@ -132,10 +148,13 @@ export default {
         set_installed,
         selected: api.theme.selected,
         same: first === second,
-        key_modal: key.get("modal"),
-        key_close: key.get("close"),
-        key_unknown: key.get("ctrl+k"),
-        key_print: key.print("modal"),
+        key_modal,
+        key_close,
+        key_unknown,
+        has_keys,
+        has_keymap: typeof api.keymap.registerLayer === "function",
+        has_resolve_binding_sections: typeof resolveBindingSections === "function",
+        has_keymap_solid: typeof useBindings === "function",
         kv_before,
         kv_after,
         kv_ready: api.kv.ready,
@@ -337,7 +356,14 @@ export default {
       theme_name: tmp.extra.localThemeName,
       kv_key: "plugin_state_key",
       session_id: "ses_test",
-      keybinds: { modal: "ctrl+alt+m", close: "q" },
+      keymap: {
+        sections: {
+          main: {
+            "plugin.loader.local": "ctrl+alt+m",
+            "plugin.loader.close": "q",
+          },
+        },
+      },
     }
     const invalidOpts = {
       marker: tmp.extra.invalidMarker,
@@ -356,7 +382,7 @@ export default {
       theme_name: tmp.extra.globalThemeName,
     }
 
-    const config: TuiConfig.Info = {
+    const config = createTuiResolvedConfig({
       plugin: [
         [tmp.extra.localSpec, localOpts],
         [tmp.extra.invalidSpec, invalidOpts],
@@ -373,7 +399,7 @@ export default {
           source: path.join(Global.Path.config, "tui.json"),
         },
       ],
-    }
+    })
 
     await TuiPluginRuntime.init({
       api: createTuiPluginApi({
@@ -386,9 +412,6 @@ export default {
             input_submit: "ctrl+enter",
           },
         },
-        keybind: {
-          print: (key) => `print:${key}`,
-        },
         state: {
           session: {
             diff(sessionID) {
@@ -507,7 +530,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [
       [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
       [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
@@ -525,7 +548,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
 
@@ -606,13 +629,13 @@ export default {
     const b = path.join(tmp.path, "order-b.ts")
     const aSpec = pathToFileURL(a).href
     const bSpec = pathToFileURL(b).href
-    const config: TuiConfig.Info = {
+    const config = createTuiResolvedConfig({
       plugin: [aSpec, bSpec],
       plugin_origins: [
         { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
         { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
       ],
-    }
+    })
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
     const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
     expect(lines).toEqual(["a-start", "a-end", "b"])
@@ -645,7 +668,10 @@ describe("tui.plugin.loader", () => {
     expect(data.local.key_modal).toBe("ctrl+alt+m")
     expect(data.local.key_close).toBe("q")
     expect(data.local.key_unknown).toBe("ctrl+k")
-    expect(data.local.key_print).toBe("print:ctrl+alt+m")
+    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_keymap_solid).toBe(true)
     expect(data.local.kv_before).toBe("missing")
     expect(data.local.kv_after).toBe("stored")
     expect(data.local.kv_ready).toBe(true)
@@ -703,6 +729,227 @@ describe("tui.plugin.loader", () => {
   })
 })
 
+test("auto-disposes plugin keymap layers", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "keymap-cleanup-plugin.ts")
+      const spec = pathToFileURL(file).href
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.keymap.cleanup",
+  tui: async (api) => {
+    api.keymap.registerLayer({
+      commands: [{ name: "demo.keymap.cleanup", run() {} }],
+      bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }],
+    })
+  },
+}
+`,
+      )
+
+      return { spec }
+    },
+  })
+
+  let command_add = 0
+  let command_drop = 0
+  const keymap = {
+    registerLayer(layer: { commands?: Array<{ name: string }> }) {
+      const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false
+      if (tracked) command_add += 1
+      return () => {
+        if (!tracked) return
+        command_drop += 1
+      }
+    },
+  } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init({
+      api: createTuiPluginApi({ keymap }),
+      config: createTuiResolvedConfig({
+        plugin: [tmp.extra.spec],
+        plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
+      }),
+    })
+
+    expect(command_add).toBe(1)
+    expect(command_drop).toBe(0)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    expect(command_drop).toBe(1)
+    cwd.mockRestore()
+    wait.mockRestore()
+  }
+})
+
+test("plugin keymap proxy preserves real keymap receiver", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "keymap-receiver-plugin.ts")
+      const spec = pathToFileURL(file).href
+      const marker = path.join(dir, "keymap-receiver.txt")
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.keymap.receiver",
+  tui: async (api) => {
+    api.keymap.setData("demo.receiver", "ok")
+    await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver")))
+  },
+}
+`,
+      )
+
+      return { spec, marker }
+    },
+  })
+
+  const harness = createTestKeymap({ defaultKeys: true })
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init({
+      api: createTuiPluginApi({
+        keymap: harness.keymap as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"],
+      }),
+      config: createTuiResolvedConfig({
+        plugin: [tmp.extra.spec],
+        plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
+      }),
+    })
+
+    await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok")
+    expect(harness.keymap.getData("demo.receiver")).toBe("ok")
+  } finally {
+    await TuiPluginRuntime.dispose()
+    harness.cleanup()
+    cwd.mockRestore()
+    wait.mockRestore()
+  }
+})
+
+test("auto-disposes plugin keymap transformers", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts")
+      const spec = pathToFileURL(file).href
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.keymap.transformer.cleanup",
+  tui: async (api) => {
+    api.keymap.prependLayerBindingsTransformer((bindings) => bindings)
+    api.keymap.appendLayerBindingsTransformer((bindings) => bindings)
+    api.keymap.prependCommandTransformer(() => {})
+    api.keymap.appendCommandTransformer(() => {})
+  },
+}
+`,
+      )
+
+      return { spec }
+    },
+  })
+
+  let add = 0
+  let drop = 0
+  const track = () => {
+    add += 1
+    return () => {
+      drop += 1
+    }
+  }
+  const keymap = {
+    registerLayer: () => () => {},
+    prependLayerBindingsTransformer: track,
+    appendLayerBindingsTransformer: track,
+    prependCommandTransformer: track,
+    appendCommandTransformer: track,
+  } as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init({
+      api: createTuiPluginApi({ keymap }),
+      config: createTuiResolvedConfig({
+        plugin: [tmp.extra.spec],
+        plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
+      }),
+    })
+
+    expect(add).toBe(4)
+    expect(drop).toBe(0)
+  } finally {
+    await TuiPluginRuntime.dispose()
+    expect(drop).toBe(4)
+    cwd.mockRestore()
+    wait.mockRestore()
+  }
+})
+
+test("manual onDispose for plugin keymap layers stays idempotent", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const file = path.join(dir, "keymap-cleanup-manual-plugin.ts")
+      const spec = pathToFileURL(file).href
+
+      await Bun.write(
+        file,
+        `export default {
+  id: "demo.keymap.cleanup.manual",
+  tui: async (api) => {
+    const off = api.keymap.registerLayer({
+      commands: [{ name: "demo.keymap.cleanup.manual", run() {} }],
+      bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }],
+    })
+    api.lifecycle.onDispose(off)
+  },
+}
+`,
+      )
+
+      return { spec }
+    },
+  })
+
+  let command_drop = 0
+  const keymap = {
+    registerLayer(layer: { commands?: Array<{ name: string }> }) {
+      const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false
+      return () => {
+        if (!tracked) return
+        command_drop += 1
+      }
+    },
+  } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
+  const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
+  const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
+
+  try {
+    await TuiPluginRuntime.init({
+      api: createTuiPluginApi({ keymap }),
+      config: createTuiResolvedConfig({
+        plugin: [tmp.extra.spec],
+        plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
+      }),
+    })
+  } finally {
+    await TuiPluginRuntime.dispose()
+    expect(command_drop).toBe(1)
+    cwd.mockRestore()
+    wait.mockRestore()
+  }
+})
+
 test("updates installed theme when plugin metadata changes", async () => {
   await using tmp = await tmpdir<{
     spec: string
@@ -766,16 +1013,17 @@ test("updates installed theme when plugin metadata changes", async () => {
       },
     })
 
-  const mkConfig = (): TuiConfig.Info => ({
-    plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
-    plugin_origins: [
-      {
-        spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
-        scope: "local",
-        source: path.join(tmp.path, "tui.json"),
-      },
-    ],
-  })
+  const mkConfig = () =>
+    createTuiResolvedConfig({
+      plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
+      plugin_origins: [
+        {
+          spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
+          scope: "local",
+          source: path.join(tmp.path, "tui.json"),
+        },
+      ],
+    })
 
   try {
     await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })

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

@@ -4,6 +4,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
 
 const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -39,7 +40,7 @@ test("toggles plugin runtime state by exported id", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_enabled: {
       "demo.toggle": false,
@@ -51,7 +52,7 @@ test("toggles plugin runtime state by exported id", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi()
@@ -116,7 +117,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
     plugin_enabled: {
       "demo.startup": false,
@@ -128,7 +129,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
         source: path.join(tmp.path, "tui.json"),
       },
     ],
-  }
+  })
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const api = createTuiPluginApi()

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

@@ -30,6 +30,19 @@ const getTuiConfig = async (directory: string) =>
     ),
   )
 
+async function withPlatform<Value>(platform: typeof process.platform, fn: () => Promise<Value>) {
+  const original = Object.getOwnPropertyDescriptor(process, "platform")
+  Object.defineProperty(process, "platform", {
+    ...original,
+    value: platform,
+  })
+  try {
+    return await fn()
+  } finally {
+    if (original) Object.defineProperty(process, "platform", original)
+  }
+}
+
 afterEach(async () => {
   delete process.env.OPENCODE_CONFIG
   delete process.env.OPENCODE_TUI_CONFIG
@@ -389,6 +402,98 @@ test("merges keybind overrides across precedence layers", async () => {
   expect(config.keybinds?.theme_list).toBe("ctrl+k")
 })
 
+test("resolves semantic keymap sections", 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" },
+              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" },
+            },
+          },
+        }),
+      )
+    },
+  })
+
+  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.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.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 () => {
+  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",
+            plugin_manager: "ctrl+shift+p",
+          },
+        }),
+      )
+    },
+  })
+
+  const config = await getTuiConfig(tmp.path)
+  expect(Object.keys(config.keymap.sections)).toEqual([
+    "global",
+    "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.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",
+  ])
+})
+
 wintest("defaults Ctrl+Z to input undo on Windows", async () => {
   await using tmp = await tmpdir()
   const config = await getTuiConfig(tmp.path)
@@ -419,6 +524,62 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
   expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
 })
 
+test("applies Windows keymap 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",
+    )
+  })
+})
+
+test("keeps explicit configured keymap 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" },
+              },
+            },
+          }),
+        )
+      },
+    })
+
+    const config = await getTuiConfig(tmp.path)
+    expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z")
+  })
+})
+
+test("keeps explicit configured keymap 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" },
+              },
+            },
+          }),
+        )
+      },
+    })
+
+    const config = await getTuiConfig(tmp.path)
+    expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y")
+  })
+})
+
 test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

+ 40 - 27
packages/opencode/test/fixture/tui-plugin.ts

@@ -1,7 +1,9 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { RGBA, type CliRenderer } from "@opentui/core"
-import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds"
 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"
 
 type Count = {
   event_add: number
@@ -84,8 +86,8 @@ type Opts = {
   client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
   renderer?: HostPluginApi["renderer"]
   count?: Count
-  keybind?: Partial<HostPluginApi["keybind"]>
-  tuiConfig?: HostPluginApi["tuiConfig"]
+  keymap?: HostPluginApi["keymap"]
+  tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
   app?: Partial<HostPluginApi["app"]>
   state?: {
     ready?: HostPluginApi["state"]["ready"]
@@ -109,6 +111,15 @@ type Opts = {
   }
 }
 
+function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
+  const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {})
+  return {
+    ...input,
+    keybinds,
+    keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})),
+  }
+}
+
 export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
   const kv: Record<string, unknown> = {}
   const count = opts.count
@@ -128,10 +139,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
   let size: "medium" | "large" | "xlarge" = "medium"
   const has = opts.theme?.has ?? (() => false)
   let selected = opts.theme?.selected ?? "opencode"
-  const key = {
-    match: opts.keybind?.match ?? (() => false),
-    print: opts.keybind?.print ?? ((name: string) => name),
-  }
   const set =
     opts.theme?.set ??
     ((name: string) => {
@@ -145,6 +152,26 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
       return this
     },
   }
+  const keymap =
+    opts.keymap ??
+    ({
+      acquireResource(_key: symbol, setup: () => () => void) {
+        const dispose = setup()
+        return () => {
+          dispose()
+        }
+      },
+      registerLayer() {
+        if (count) count.command_add += 1
+        return () => {
+          if (!count) return
+          count.command_drop += 1
+        }
+      },
+      runCommand() {
+        return { ok: true } as const
+      },
+    } as unknown as HostPluginApi["keymap"])
 
   function kvGet(name: string): unknown
   function kvGet<Value>(name: string, fallback: Value): Value
@@ -160,6 +187,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
         return opts.app?.version ?? "0.0.0-test"
       },
     },
+    keys: {
+      formatSequence: () => "",
+      formatBindings: () => undefined,
+    },
     get client() {
       return client()
     },
@@ -192,17 +223,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
         return () => {}
       },
     },
-    command: {
-      register: () => {
-        if (count) count.command_add += 1
-        return () => {
-          if (!count) return
-          count.command_drop += 1
-        }
-      },
-      trigger: () => {},
-      show: () => {},
-    },
+    keymap,
     route: {
       register: () => {
         if (count) count.route_add += 1
@@ -247,15 +268,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
         },
       },
     },
-    keybind: {
-      ...key,
-      create:
-        opts.keybind?.create ??
-        ((defaults, over) => {
-          return createPluginKeybind(key, defaults, over)
-        }),
-    },
-    tuiConfig: opts.tuiConfig ?? {},
+    tuiConfig: tuiConfig(opts.tuiConfig),
     kv: {
       get: kvGet,
       set(name, value) {

+ 41 - 2
packages/opencode/test/fixture/tui-runtime.ts

@@ -1,8 +1,47 @@
 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 { 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"
 
 type PluginSpec = string | [string, Record<string, unknown>]
+type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "keymap"> & {
+  keybinds?: TuiConfig.Resolved["keybinds"]
+  keymap?: TuiConfig.Resolved["keymap"]
+}
+
+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 createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved {
+  const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({})
+  return {
+    ...input,
+    keybinds,
+    keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})),
+  }
+}
 
 export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
@@ -14,11 +53,11 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugi
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
 
-  const config: TuiConfig.Info = {
+  const config = createTuiResolvedConfig({
     plugin,
     plugin_origins,
     ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
-  }
+  })
 
   return {
     config,

+ 0 - 421
packages/opencode/test/keybind.test.ts

@@ -1,421 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import { Keybind } from "@/util/keybind"
-
-describe("Keybind.toString", () => {
-  test("should convert simple key to string", () => {
-    const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
-    expect(Keybind.toString(info)).toBe("f")
-  })
-
-  test("should convert ctrl modifier to string", () => {
-    const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
-    expect(Keybind.toString(info)).toBe("ctrl+x")
-  })
-
-  test("should convert leader key to string", () => {
-    const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
-    expect(Keybind.toString(info)).toBe("<leader> f")
-  })
-
-  test("should convert multiple modifiers to string", () => {
-    const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
-    expect(Keybind.toString(info)).toBe("ctrl+alt+g")
-  })
-
-  test("should convert all modifiers to string", () => {
-    const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" }
-    expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift+h")
-  })
-
-  test("should convert shift modifier to string", () => {
-    const info: Keybind.Info = {
-      ctrl: false,
-      meta: false,
-      shift: true,
-      leader: false,
-      name: "return",
-    }
-    expect(Keybind.toString(info)).toBe("shift+return")
-  })
-
-  test("should convert function key to string", () => {
-    const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" }
-    expect(Keybind.toString(info)).toBe("f2")
-  })
-
-  test("should convert special key to string", () => {
-    const info: Keybind.Info = {
-      ctrl: false,
-      meta: false,
-      shift: false,
-      leader: false,
-      name: "pgup",
-    }
-    expect(Keybind.toString(info)).toBe("pgup")
-  })
-
-  test("should handle empty name", () => {
-    const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" }
-    expect(Keybind.toString(info)).toBe("ctrl")
-  })
-
-  test("should handle only modifiers", () => {
-    const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" }
-    expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift")
-  })
-
-  test("should handle only leader with no other parts", () => {
-    const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" }
-    expect(Keybind.toString(info)).toBe("<leader>")
-  })
-
-  test("should convert super modifier to string", () => {
-    const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
-    expect(Keybind.toString(info)).toBe("super+z")
-  })
-
-  test("should convert super+shift modifier to string", () => {
-    const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
-    expect(Keybind.toString(info)).toBe("super+shift+z")
-  })
-
-  test("should handle super with ctrl modifier", () => {
-    const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" }
-    expect(Keybind.toString(info)).toBe("ctrl+super+a")
-  })
-
-  test("should handle super with all modifiers", () => {
-    const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" }
-    expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x")
-  })
-
-  test("should handle undefined super field (omitted)", () => {
-    const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
-    expect(Keybind.toString(info)).toBe("ctrl+c")
-  })
-})
-
-describe("Keybind.match", () => {
-  test("should match identical keybinds", () => {
-    const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
-    const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
-    expect(Keybind.match(a, b)).toBe(true)
-  })
-
-  test("should not match different key names", () => {
-    const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
-    const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" }
-    expect(Keybind.match(a, b)).toBe(false)
-  })
-
-  test("should not match different modifiers", () => {
-    const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
-    const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" }
-    expect(Keybind.match(a, b)).toBe(false)
-  })
-
-  test("should match leader keybinds", () => {
-    const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
-    const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
-    expect(Keybind.match(a, b)).toBe(true)
-  })
-
-  test("should not match leader vs non-leader", () => {
-    const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
-    const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
-    expect(Keybind.match(a, b)).toBe(false)
-  })
-
-  test("should match complex keybinds", () => {
-    const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
-    const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
-    expect(Keybind.match(a, b)).toBe(true)
-  })
-
-  test("should not match with one modifier different", () => {
-    const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
-    const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" }
-    expect(Keybind.match(a, b)).toBe(false)
-  })
-
-  test("should match simple key without modifiers", () => {
-    const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
-    const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
-    expect(Keybind.match(a, b)).toBe(true)
-  })
-
-  test("should match super modifier keybinds", () => {
-    const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
-    const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
-    expect(Keybind.match(a, b)).toBe(true)
-  })
-
-  test("should not match super vs non-super", () => {
-    const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
-    const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: false, leader: false, name: "z" }
-    expect(Keybind.match(a, b)).toBe(false)
-  })
-
-  test("should match undefined super with false super", () => {
-    const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
-    const b: Keybind.Info = { ctrl: true, meta: false, shift: false, super: false, leader: false, name: "c" }
-    expect(Keybind.match(a, b)).toBe(true)
-  })
-
-  test("should match super+shift combination", () => {
-    const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
-    const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
-    expect(Keybind.match(a, b)).toBe(true)
-  })
-
-  test("should not match when only super differs", () => {
-    const a: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "a" }
-    const b: Keybind.Info = { ctrl: true, meta: true, shift: true, super: false, leader: false, name: "a" }
-    expect(Keybind.match(a, b)).toBe(false)
-  })
-})
-
-describe("Keybind.parse", () => {
-  test("should parse simple key", () => {
-    const result = Keybind.parse("f")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "f",
-      },
-    ])
-  })
-
-  test("should parse leader key syntax", () => {
-    const result = Keybind.parse("<leader>f")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        leader: true,
-        name: "f",
-      },
-    ])
-  })
-
-  test("should parse ctrl modifier", () => {
-    const result = Keybind.parse("ctrl+x")
-    expect(result).toEqual([
-      {
-        ctrl: true,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "x",
-      },
-    ])
-  })
-
-  test("should parse multiple modifiers", () => {
-    const result = Keybind.parse("ctrl+alt+u")
-    expect(result).toEqual([
-      {
-        ctrl: true,
-        meta: true,
-        shift: false,
-        leader: false,
-        name: "u",
-      },
-    ])
-  })
-
-  test("should parse shift modifier", () => {
-    const result = Keybind.parse("shift+f2")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: true,
-        leader: false,
-        name: "f2",
-      },
-    ])
-  })
-
-  test("should parse meta/alt modifier", () => {
-    const result = Keybind.parse("meta+g")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: true,
-        shift: false,
-        leader: false,
-        name: "g",
-      },
-    ])
-  })
-
-  test("should parse leader with modifier", () => {
-    const result = Keybind.parse("<leader>h")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        leader: true,
-        name: "h",
-      },
-    ])
-  })
-
-  test("should parse multiple keybinds separated by comma", () => {
-    const result = Keybind.parse("ctrl+c,<leader>q")
-    expect(result).toEqual([
-      {
-        ctrl: true,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "c",
-      },
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        leader: true,
-        name: "q",
-      },
-    ])
-  })
-
-  test("should parse shift+return combination", () => {
-    const result = Keybind.parse("shift+return")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: true,
-        leader: false,
-        name: "return",
-      },
-    ])
-  })
-
-  test("should parse ctrl+j combination", () => {
-    const result = Keybind.parse("ctrl+j")
-    expect(result).toEqual([
-      {
-        ctrl: true,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "j",
-      },
-    ])
-  })
-
-  test("should handle 'none' value", () => {
-    const result = Keybind.parse("none")
-    expect(result).toEqual([])
-  })
-
-  test("should handle special keys", () => {
-    const result = Keybind.parse("pgup")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "pgup",
-      },
-    ])
-  })
-
-  test("should handle function keys", () => {
-    const result = Keybind.parse("f2")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "f2",
-      },
-    ])
-  })
-
-  test("should handle complex multi-modifier combination", () => {
-    const result = Keybind.parse("ctrl+alt+g")
-    expect(result).toEqual([
-      {
-        ctrl: true,
-        meta: true,
-        shift: false,
-        leader: false,
-        name: "g",
-      },
-    ])
-  })
-
-  test("should be case insensitive", () => {
-    const result = Keybind.parse("CTRL+X")
-    expect(result).toEqual([
-      {
-        ctrl: true,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "x",
-      },
-    ])
-  })
-
-  test("should parse super modifier", () => {
-    const result = Keybind.parse("super+z")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        super: true,
-        leader: false,
-        name: "z",
-      },
-    ])
-  })
-
-  test("should parse super with shift modifier", () => {
-    const result = Keybind.parse("super+shift+z")
-    expect(result).toEqual([
-      {
-        ctrl: false,
-        meta: false,
-        shift: true,
-        super: true,
-        leader: false,
-        name: "z",
-      },
-    ])
-  })
-
-  test("should parse multiple keybinds with super", () => {
-    const result = Keybind.parse("ctrl+-,super+z")
-    expect(result).toEqual([
-      {
-        ctrl: true,
-        meta: false,
-        shift: false,
-        leader: false,
-        name: "-",
-      },
-      {
-        ctrl: false,
-        meta: false,
-        shift: false,
-        super: true,
-        leader: false,
-        name: "z",
-      },
-    ])
-  })
-})

+ 7 - 2
packages/plugin/package.json

@@ -22,19 +22,24 @@
     "zod": "catalog:"
   },
   "peerDependencies": {
-    "@opentui/core": ">=0.2.2",
-    "@opentui/solid": ">=0.2.2"
+    "@opentui/core": ">=0.2.4",
+    "@opentui/keymap": ">=0.2.4",
+    "@opentui/solid": ">=0.2.4"
   },
   "peerDependenciesMeta": {
     "@opentui/core": {
       "optional": true
     },
+    "@opentui/keymap": {
+      "optional": true
+    },
     "@opentui/solid": {
       "optional": true
     }
   },
   "devDependencies": {
     "@opentui/core": "catalog:",
+    "@opentui/keymap": "catalog:",
     "@opentui/solid": "catalog:",
     "@tsconfig/node22": "catalog:",
     "@types/node": "catalog:",

+ 44 - 43
packages/plugin/src/tui.ts

@@ -15,11 +15,39 @@ import type {
   TextPart,
   Config as SdkConfig,
 } from "@opencode-ai/sdk/v2"
-import type { CliRenderer, ParsedKey, RGBA, SlotMode } from "@opentui/core"
+import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
+import type { Binding, Keymap } from "@opentui/keymap"
+import {
+  resolveBindingSections as resolveKeymapBindingSections,
+  type BindingSectionsConfig,
+  type KeySequenceFormatPart,
+  type SequenceBindingLike,
+} from "@opentui/keymap/extras"
 import type { JSX, SolidPlugin } from "@opentui/solid"
 import type { Config as PluginConfig, PluginOptions } from "./index.js"
 
-export type { CliRenderer, SlotMode } from "@opentui/core"
+export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core"
+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,
+  BindingValue,
+  FormatCommandBindingsOptions,
+  FormatKeySequenceOptions,
+  KeySequenceFormatPart,
+  SequenceBindingLike,
+} from "@opentui/keymap/extras"
+
+export function resolveBindingSections<Section extends string>(
+  config: BindingSectionsConfig<Renderable, KeyEvent> | undefined,
+  options: { sections: readonly Section[] },
+) {
+  return resolveKeymapBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, Section>(
+    config ?? {},
+    options,
+  )
+}
 
 export type TuiRouteCurrent =
   | {
@@ -42,39 +70,12 @@ export type TuiRouteDefinition = {
   render: (input: { params?: Record<string, unknown> }) => JSX.Element
 }
 
-export type TuiCommand = {
-  title: string
-  value: string
-  description?: string
-  category?: string
-  keybind?: string
-  suggested?: boolean
-  hidden?: boolean
-  enabled?: boolean
-  slash?: {
-    name: string
-    aliases?: string[]
-  }
-  onSelect?: () => void
-}
-
-export type TuiKeybind = {
-  name: string
-  ctrl: boolean
-  meta: boolean
-  shift: boolean
-  super?: boolean
-  leader: boolean
+export type TuiKeys = {
+  formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string
+  formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined
 }
 
-export type TuiKeybindMap = Record<string, string>
-
-export type TuiKeybindSet = {
-  readonly all: TuiKeybindMap
-  get: (name: string) => string
-  match: (name: string, evt: ParsedKey) => boolean
-  print: (name: string) => string
-}
+export type TuiKeymap = Keymap<Renderable, KeyEvent>
 
 export type TuiDialogProps = {
   size?: "medium" | "large" | "xlarge"
@@ -288,6 +289,14 @@ export type TuiState = {
 type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
   NonNullable<PluginConfig["tui"]> & {
     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>[]
+    }
   }
 
 export type TuiApp = {
@@ -448,11 +457,8 @@ export type TuiWorkspace = {
 
 export type TuiPluginApi = {
   app: TuiApp
-  command: {
-    register: (cb: () => TuiCommand[]) => () => void
-    trigger: (value: string) => void
-    show: () => void
-  }
+  keys: TuiKeys
+  keymap: TuiKeymap
   route: {
     register: (routes: TuiRouteDefinition[]) => () => void
     navigate: (name: string, params?: Record<string, unknown>) => void
@@ -469,11 +475,6 @@ export type TuiPluginApi = {
     toast: (input: TuiToast) => void
     dialog: TuiDialogStack
   }
-  keybind: {
-    match: (key: string, evt: ParsedKey) => boolean
-    print: (key: string) => string
-    create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
-  }
   readonly tuiConfig: Frozen<TuiConfigView>
   kv: TuiKV
   state: TuiState

+ 13 - 3
packages/web/src/content/docs/config.mdx

@@ -525,17 +525,27 @@ You can also define commands using markdown files in `~/.config/opencode/command
 
 ---
 
-### Keybinds
+### Keymap
 
-Customize keybinds in `tui.json`.
+Customize TUI keyboard shortcuts in `tui.json` with `keymap`.
 
 ```json title="tui.json"
 {
   "$schema": "https://opencode.ai/tui.json",
-  "keybinds": {}
+  "keymap": {
+    "sections": {
+      "global": {
+        "command.palette.show": "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.
+
 [Learn more here](/docs/keybinds).
 
 ---

+ 280 - 107
packages/web/src/content/docs/keybinds.mdx

@@ -1,144 +1,317 @@
 ---
 title: Keybinds
-description: Customize your keybinds.
+description: Customize your keyboard shortcuts.
 ---
 
-OpenCode has a list of keybinds that you can customize through `tui.json`.
+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
 
 ```json title="tui.json"
 {
   "$schema": "https://opencode.ai/tui.json",
-  "keybinds": {
+  "keymap": {
     "leader": "ctrl+x",
-    "app_exit": "ctrl+c,ctrl+d,<leader>q",
-    "editor_open": "<leader>e",
-    "theme_list": "<leader>t",
-    "sidebar_toggle": "<leader>b",
-    "scrollbar_toggle": "none",
-    "username_toggle": "none",
-    "status_view": "<leader>s",
-    "tool_details": "none",
-    "session_export": "<leader>x",
-    "session_new": "<leader>n",
-    "session_list": "<leader>l",
-    "session_timeline": "<leader>g",
-    "session_fork": "none",
-    "session_rename": "ctrl+r",
-    "session_share": "none",
-    "session_unshare": "none",
-    "session_interrupt": "escape",
-    "session_compact": "<leader>c",
-    "session_child_first": "<leader>down",
-    "session_child_cycle": "right",
-    "session_child_cycle_reverse": "left",
-    "session_parent": "up",
-    "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_copy": "<leader>y",
-    "messages_undo": "<leader>u",
-    "messages_redo": "<leader>r",
-    "messages_last_user": "none",
-    "messages_toggle_conceal": "<leader>h",
-    "model_list": "<leader>m",
-    "model_cycle_recent": "f2",
-    "model_cycle_recent_reverse": "shift+f2",
-    "model_cycle_favorite": "none",
-    "model_cycle_favorite_reverse": "none",
-    "variant_cycle": "ctrl+t",
-    "variant_list": "none",
-    "command_list": "ctrl+p",
-    "agent_list": "<leader>a",
-    "agent_cycle": "tab",
-    "agent_cycle_reverse": "shift+tab",
-    "input_clear": "ctrl+c",
-    "input_paste": "ctrl+v",
-    "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",
-    "history_previous": "up",
-    "history_next": "down",
-    "terminal_suspend": "ctrl+z",
-    "terminal_title_toggle": "none",
-    "tips_toggle": "<leader>h",
-    "display_thinking": "none"
+    "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"]
+      }
+    }
   }
 }
 ```
 
-:::note
-On Windows, the defaults for `input_undo` and `terminal_suspend` are different:
+---
+
+## Keymap structure
+
+`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them.
 
-- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (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.
-  :::
+| 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. |
 
 ---
 
-## Leader key
+## Binding values
+
+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.
+
+```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"]
+      }
+    }
+  }
+}
+```
 
-OpenCode uses a `leader` key for most keybinds. This avoids conflicts in your terminal.
+For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
 
-By default, `ctrl+x` is the leader key and most 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`.
+```json title="tui.json"
+{
+  "$schema": "https://opencode.ai/tui.json",
+  "keymap": {
+    "sections": {
+      "prompt": {
+        "prompt.paste": {
+          "key": "ctrl+v",
+          "preventDefault": false
+        }
+      }
+    }
+  }
+}
+```
+
+---
 
-You don't need to use a leader key for your keybinds but we recommend doing so.
+## Complete keymap reference
 
-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`.
+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.
+
+```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"
+      }
+    }
+  }
+}
+```
 
 ---
 
-## Disable keybind
+## Legacy keybinds
 
-You can disable a keybind by adding the key to `tui.json` with a value of "none".
+`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`.
+
+Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution.
 
 ```json title="tui.json"
 {
   "$schema": "https://opencode.ai/tui.json",
   "keybinds": {
-    "session_compact": "none"
+    "command_list": "ctrl+p",
+    "session_new": "<leader>n",
+    "session_compact": "<leader>c"
   }
 }
 ```
 
+:::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

+ 13 - 4
packages/web/src/content/docs/tui.mdx

@@ -63,7 +63,7 @@ When using the OpenCode TUI, you can type `/` followed by a command name to quic
 /help
 ```
 
-Most commands also have keybind using `ctrl+x` as the leader key, where `ctrl+x` is the default leader key. [Learn more](/docs/keybinds).
+Most commands also have keyboard shortcuts using `ctrl+x` as the default leader key. [Learn more](/docs/keybinds).
 
 Here are all available slash commands:
 
@@ -353,8 +353,14 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
 {
   "$schema": "https://opencode.ai/tui.json",
   "theme": "opencode",
-  "keybinds": {
-    "leader": "ctrl+x"
+  "keymap": {
+    "leader": "ctrl+x",
+    "leader_timeout": 2000,
+    "sections": {
+      "global": {
+        "command.palette.show": "ctrl+p"
+      }
+    }
   },
   "scroll_speed": 3,
   "scroll_acceleration": {
@@ -367,10 +373,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.
+
 ### Options
 
 - `theme` - Sets your UI theme. [Learn more](/docs/themes).
-- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
+- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
+- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present.
 - `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.

+ 25 - 6
packages/opencode/script/upgrade-opentui.ts → script/upgrade-opentui.ts

@@ -9,29 +9,30 @@ if (!raw) {
 }
 
 const ver = raw.replace(/^v/, "")
-const root = path.resolve(import.meta.dir, "../../..")
+const root = path.resolve(import.meta.dir, "..")
 const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
-const keys = ["@opentui/core", "@opentui/solid"] as const
+const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const
 
 const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter(
   (file) => !file.split("/").some((part) => skip.has(part)),
 )
 
-const set = (cur: string) => {
+const setVersion = (cur: string) => {
+  if (cur === "catalog:" || cur.startsWith("workspace:")) return cur
   if (cur.startsWith(">=")) return `>=${ver}`
   if (cur.startsWith("^")) return `^${ver}`
   if (cur.startsWith("~")) return `~${ver}`
   return ver
 }
 
-const edit = (obj: unknown) => {
+const editDeps = (obj: unknown) => {
   if (!obj || typeof obj !== "object") return false
   const map = obj as Record<string, unknown>
   return keys
     .map((key) => {
       const cur = map[key]
       if (typeof cur !== "string") return false
-      const next = set(cur)
+      const next = setVersion(cur)
       if (next === cur) return false
       map[key] = next
       return true
@@ -39,13 +40,31 @@ const edit = (obj: unknown) => {
     .some(Boolean)
 }
 
+const editCatalog = (obj: unknown) => {
+  if (!obj || typeof obj !== "object") return false
+  const map = obj as Record<string, unknown>
+  return keys
+    .map((key) => {
+      const cur = map[key]
+      if (typeof cur !== "string" || cur === ver) return false
+      map[key] = ver
+      return true
+    })
+    .some(Boolean)
+}
+
 const out = (
   await Promise.all(
     files.map(async (rel) => {
       const file = path.join(root, rel)
       const txt = await Bun.file(file).text()
       const json = JSON.parse(txt)
-      const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean)
+      const hit = [
+        editCatalog(json.workspaces?.catalog),
+        editDeps(json.dependencies),
+        editDeps(json.devDependencies),
+        editDeps(json.peerDependencies),
+      ].some(Boolean)
       if (!hit) return null
       await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
       return rel