Просмотр исходного кода

tui(run): use keymap instead of raw key events (#30077)

Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
Simon Klee 3 месяцев назад
Родитель
Сommit
e8dd8f7fe0

+ 15 - 15
bun.lock

@@ -611,9 +611,9 @@
         "typescript": "catalog:",
       },
       "peerDependencies": {
-        "@opentui/core": ">=0.2.16",
-        "@opentui/keymap": ">=0.2.16",
-        "@opentui/solid": ">=0.2.16",
+        "@opentui/core": ">=0.3.0",
+        "@opentui/keymap": ">=0.3.0",
+        "@opentui/solid": ">=0.3.0",
       },
       "optionalPeers": [
         "@opentui/core",
@@ -862,9 +862,9 @@
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@opentui/core": "0.2.16",
-    "@opentui/keymap": "0.2.16",
-    "@opentui/solid": "0.2.16",
+    "@opentui/core": "0.3.0",
+    "@opentui/keymap": "0.3.0",
+    "@opentui/solid": "0.3.0",
     "@pierre/diffs": "1.1.0-beta.18",
     "@playwright/test": "1.59.1",
     "@sentry/solid": "10.36.0",
@@ -1758,23 +1758,23 @@
 
     "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
 
-    "@opentui/core": ["@opentui/core@0.2.16", "", { "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.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
+    "@opentui/core": ["@opentui/core@0.3.0", "", { "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.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
+    "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
+    "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
+    "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="],
 
-    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
+    "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
+    "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="],
 
-    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
+    "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="],
 
-    "@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
+    "@opentui/keymap": ["@opentui/keymap@0.3.0", "", { "dependencies": { "@opentui/core": "0.3.0" }, "peerDependencies": { "@opentui/react": "0.3.0", "@opentui/solid": "0.3.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lJN57DanKujy3u0IhfSMCShvXIobRjhprdkrdM3brQoX6wxk7gTFE8fTCCz9z1nINkXNsKHQ6grZO1dsT/0mzA=="],
 
-    "@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "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-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
+    "@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "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-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="],
 
     "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 

+ 3 - 3
package.json

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

+ 3 - 4
packages/opencode/src/cli/cmd/run/footer.command.tsx

@@ -4,9 +4,8 @@ import { useKeyboard, type JSX } from "@opentui/solid"
 import fuzzysort from "fuzzysort"
 import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
 import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
-import { formatBindings } from "./keymap.shared"
 import type { RunFooterTheme } from "./theme"
-import type { FooterKeybinds, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
+import type { FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
 
 type PanelEntry = RunFooterMenuItem & {
   category: string
@@ -296,7 +295,7 @@ export function RunCommandMenuBody(props: {
   commands: Accessor<RunCommand[] | undefined>
   subagents: Accessor<FooterSubagentTab[]>
   variants: Accessor<string[]>
-  keybinds: FooterKeybinds
+  variantCycle: string
   onClose: () => void
   onModel: () => void
   onSubagent: () => void
@@ -334,7 +333,7 @@ export function RunCommandMenuBody(props: {
         action: "variant.cycle",
         category: "Suggested",
         display: "Variant cycle",
-        footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader),
+        footer: props.variantCycle,
         keywords: "variant cycle",
       },
       ...(props.variants().length > 0

+ 3 - 6
packages/opencode/src/cli/cmd/run/footer.permission.tsx

@@ -64,7 +64,8 @@ function buttons(
   )
 }
 
-function RejectField(props: {
+/** @internal Exported to test managed textarea submission without permission navigation. */
+export function RejectField(props: {
   theme: RunFooterTheme
   text: string
   disabled: boolean
@@ -107,6 +108,7 @@ function RejectField(props: {
       focusedBackgroundColor={props.theme.surface}
       cursorColor={props.theme.text}
       focused={!props.disabled}
+      onSubmit={props.onConfirm}
       onContentChange={() => {
         if (!area || area.isDestroyed) {
           return
@@ -119,11 +121,6 @@ function RejectField(props: {
           props.onCancel()
           return
         }
-
-        if (event.name === "return" && !event.meta && !event.ctrl && !event.shift) {
-          event.preventDefault()
-          props.onConfirm()
-        }
       }}
       ref={(item) => {
         area = item

+ 205 - 225
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -1,13 +1,13 @@
 // Prompt textarea component and its state machine for direct interactive mode.
 //
-// createPromptState() wires keybinds, history navigation, leader-key sequences,
-// and `@` autocomplete for files, subagents, and MCP resources.
+// createPromptState() wires keymap command layers, history navigation, and
+// `@` autocomplete for files, subagents, and MCP resources.
 // It produces a PromptState that RunPromptBody renders as an OpenTUI textarea,
 // while the footer view renders the current menu state below it.
 /** @jsxImportSource @opentui/solid */
 import { pathToFileURL } from "bun"
-import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core"
-import { useKeyboard, useRenderer } from "@opentui/solid"
+import { StyledText, bg, fg, type KeyEvent, type TextareaRenderable } from "@opentui/core"
+import { useRenderer } from "@opentui/solid"
 import fuzzysort from "fuzzysort"
 import path from "path"
 import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -20,15 +20,12 @@ import {
   mentionTriggerIndex,
   isNewCommand,
   movePromptHistory,
-  promptCycle,
-  promptHit,
-  promptInfo,
-  promptKeys,
   pushPromptHistory,
 } from "./prompt.shared"
+import { OPENCODE_BASE_MODE, useBindings } from "@/cli/cmd/tui/keymap"
 import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
 import type { RunFooterTheme } from "./theme"
-import type { FooterKeybinds, FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource } from "./types"
+import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
 
 const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
 const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -69,7 +66,7 @@ type PromptInput = {
   subagents: Accessor<number>
   resources: Accessor<RunResource[]>
   commands: Accessor<RunCommand[] | undefined>
-  keybinds: FooterKeybinds
+  tuiConfig: RunTuiConfig
   state: Accessor<FooterState>
   view: Accessor<string>
   prompt: Accessor<boolean>
@@ -89,7 +86,6 @@ type PromptInput = {
 
 export type PromptState = {
   placeholder: Accessor<StyledText | string>
-  bindings: Accessor<KeyBinding[]>
   shell: Accessor<boolean>
   visible: Accessor<boolean>
   options: Accessor<PromptOption[]>
@@ -199,7 +195,6 @@ export function hintFlags(width: number) {
 export function RunPromptBody(props: {
   theme: () => RunFooterTheme
   placeholder: () => StyledText | string
-  bindings: () => KeyBinding[]
   onSubmit: () => void
   onKeyDown: (event: KeyEvent) => void
   onContentChange: () => void
@@ -263,7 +258,6 @@ export function RunPromptBody(props: {
           backgroundColor={props.theme().surface}
           focusedBackgroundColor={props.theme().surface}
           cursorColor={props.theme().text}
-          keyBindings={props.bindings()}
           onSubmit={props.onSubmit}
           onKeyDown={props.onKeyDown}
           onPaste={() => {
@@ -280,8 +274,6 @@ export function RunPromptBody(props: {
 }
 
 export function createPromptState(input: PromptInput): PromptState {
-  const keys = createMemo(() => promptKeys(input.keybinds))
-  const bindings = createMemo(() => keys().bindings)
   const [shell, setShell] = createSignal(false)
   const placeholder = createMemo(() => {
     if (shell()) {
@@ -301,8 +293,6 @@ export function createPromptState(input: PromptInput): PromptState {
   let draft: RunPrompt = { text: "", parts: [] }
   let stash: RunPrompt = { text: "", parts: [] }
   let area: TextareaRenderable | undefined
-  let leader = false
-  let timeout: NodeJS.Timeout | undefined
   let tick = false
   let prev = input.view()
   let type = 0
@@ -461,24 +451,6 @@ export function createPromptState(input: PromptInput): PromptState {
     return visible() ? menu.rows() - 1 + AUTOCOMPLETE_BOTTOM_ROWS : 0
   })
 
-  const clear = () => {
-    leader = false
-    if (!timeout) {
-      return
-    }
-
-    clearTimeout(timeout)
-    timeout = undefined
-  }
-
-  const arm = () => {
-    clear()
-    leader = true
-    timeout = setTimeout(() => {
-      clear()
-    }, input.keybinds.leaderTimeout)
-  }
-
   const hide = () => {
     setMode(false)
     setQuery("")
@@ -742,7 +714,7 @@ export function createPromptState(input: PromptInput): PromptState {
 
   const move = (dir: -1 | 1, event: KeyEvent) => {
     if (!area || area.isDestroyed) {
-      return
+      return false
     }
 
     if (history.index === null && dir === -1) {
@@ -751,7 +723,7 @@ export function createPromptState(input: PromptInput): PromptState {
 
     const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
     if (!next.apply || next.text === undefined || next.cursor === undefined) {
-      return
+      return false
     }
 
     history = next.state
@@ -759,28 +731,27 @@ export function createPromptState(input: PromptInput): PromptState {
       next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
     restore(value, next.cursor)
     event.preventDefault()
+    return true
   }
 
-  const cycle = (event: KeyEvent): boolean => {
-    const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
-    if (!next.consume) {
-      return false
-    }
+  const historyCommand = (dir: -1 | 1, event: KeyEvent) => {
+    if (move(dir, event)) return
+    if (!area || area.isDestroyed) return false
 
-    if (next.clear) {
-      clear()
-    }
-
-    if (next.arm) {
-      arm()
+    const endOffset = Bun.stringWidth(area.plainText)
+    if (dir === -1 && area.visualCursor.visualRow === 0) {
+      area.cursorOffset = 0
     }
 
-    if (next.cycle) {
-      input.onCycle()
+    const end =
+      typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
+        ? area.height - 1
+        : Math.max(0, (area.virtualLineCount ?? 1) - 1)
+    if (dir === 1 && area.visualCursor.visualRow === end) {
+      area.cursorOffset = endOffset
     }
 
-    event.preventDefault()
-    return true
+    return false
   }
 
   const requestExit = () => {
@@ -912,178 +883,190 @@ export function createPromptState(input: PromptInput): PromptState {
     refresh()
   }
 
-  const onKeyDown = (event: KeyEvent) => {
-    const key = promptInfo(event)
-    if (visible()) {
-      const name = event.name.toLowerCase()
-      const ctrl = event.ctrl && !event.meta && !event.shift
-      if (name === "up" || (ctrl && name === "p")) {
-        event.preventDefault()
-        if (options().length > 0) {
-          menu.move(-1)
-        }
-        return
-      }
-
-      if (name === "down" || (ctrl && name === "n")) {
-        event.preventDefault()
-        if (options().length > 0) {
-          menu.move(1)
-        }
-        return
-      }
-
-      if (name === "escape") {
-        event.preventDefault()
-        cancelAutocomplete()
-        return
-      }
-
-      if (name === "return") {
-        if (mode() === "slash" && options().length === 0) {
-          hide()
-          return
-        }
-
-        event.preventDefault()
-        select()
-        return
-      }
-
-      if (name === "tab") {
-        if (mode() === "slash" && options().length === 0) {
-          hide()
-          return
-        }
-
-        event.preventDefault()
-        const item = options()[menu.selected()]
-        if (item?.kind === "mention" && item.directory) {
-          expand()
-          return
-        }
-
-        select()
-        return
-      }
-    }
-
-    if (
-      key.name === "!" &&
-      !shell() &&
-      !event.ctrl &&
-      !event.meta &&
-      !event.super &&
-      area &&
-      !area.isDestroyed &&
-      area.cursorOffset === 0
-    ) {
-      event.preventDefault()
-      setShellMode(true)
-      return
-    }
-
-    if (shell() && !visible()) {
-      if (key.name === "escape") {
-        event.preventDefault()
-        setShellMode(false)
-        return
-      }
-
-      if (key.name === "backspace" && area && !area.isDestroyed && area.cursorOffset === 0) {
-        event.preventDefault()
-        setShellMode(false)
-        return
-      }
-    }
-
-    if (
-      key.name === "down" &&
-      !visible() &&
-      !event.ctrl &&
-      !event.meta &&
-      !event.shift &&
-      !event.super &&
-      area &&
-      !area.isDestroyed &&
-      area.plainText.length === 0 &&
-      input.subagents() > 0
-    ) {
-      event.preventDefault()
-      input.onSubagentMenu?.()
-      return
-    }
-
-    if (promptHit(keys().clear, key)) {
-      const handled = requestExit()
-      if (handled) {
-        event.preventDefault()
-      }
-      return
-    }
-
-    if (promptHit(keys().interrupts, key)) {
-      if (input.onInterrupt()) {
-        event.preventDefault()
-        return
-      }
-    }
-
-    if (cycle(event)) {
-      return
-    }
-
-    const up = promptHit(keys().previous, key)
-    const down = promptHit(keys().next, key)
-    if (!up && !down) {
-      return
-    }
-
-    if (!area || area.isDestroyed) {
-      return
-    }
-
-    const dir = up ? -1 : 1
-    const endOffset = Bun.stringWidth(area.plainText)
-    if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) {
-      move(dir, event)
-      return
-    }
-
-    if (dir === -1 && area.visualCursor.visualRow === 0) {
-      area.cursorOffset = 0
-    }
-
-    const end =
-      typeof area.height === "number" && Number.isFinite(area.height) && area.height > 0
-        ? area.height - 1
-        : Math.max(0, (area.virtualLineCount ?? 1) - 1)
-    if (dir === 1 && area.visualCursor.visualRow === end) {
-      area.cursorOffset = endOffset
-    }
+  const baseBindingsEnabled = () => {
+    const current = input.view()
+    if (current === "command") return false
+    if (current === "model") return false
+    if (current === "variant") return false
+    if (current === "subagent-menu") return false
+    return true
   }
 
-  useKeyboard((event) => {
-    if (input.prompt()) {
-      return
-    }
-
-    if (
-      input.view() === "command" ||
-      input.view() === "model" ||
-      input.view() === "variant" ||
-      input.view() === "subagent-menu"
-    ) {
-      return
-    }
-
-    if (promptHit(keys().clear, promptInfo(event))) {
-      const handled = requestExit()
-      if (handled) {
-        event.preventDefault()
-      }
-    }
-  })
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: baseBindingsEnabled(),
+    commands: [
+      {
+        name: "prompt.clear",
+        title: "Clear prompt or exit",
+        category: "Prompt",
+        run() {
+          if (requestExit()) return
+          return false
+        },
+      },
+    ],
+    bindings: input.tuiConfig.keybinds.get("prompt.clear"),
+  }))
+
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: input.prompt(),
+    commands: [
+      {
+        name: "session.interrupt",
+        title: "Interrupt session",
+        category: "Session",
+        run() {
+          if (input.onInterrupt()) return
+          return false
+        },
+      },
+    ],
+    bindings: input.tuiConfig.keybinds.get("session.interrupt"),
+  }))
+
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: input.prompt() && !visible(),
+    commands: [
+      {
+        name: "prompt.history.previous",
+        title: "Previous prompt history",
+        category: "Prompt",
+        run(ctx: { event: KeyEvent }) {
+          return historyCommand(-1, ctx.event)
+        },
+      },
+      {
+        name: "prompt.history.next",
+        title: "Next prompt history",
+        category: "Prompt",
+        run(ctx: { event: KeyEvent }) {
+          return historyCommand(1, ctx.event)
+        },
+      },
+    ],
+    bindings: [
+      ...input.tuiConfig.keybinds.get("prompt.history.previous"),
+      ...input.tuiConfig.keybinds.get("prompt.history.next"),
+    ],
+  }))
+
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: input.prompt() && !visible(),
+    bindings: [
+      {
+        key: "down",
+        desc: "View subagents",
+        group: "Prompt",
+        cmd() {
+          if (!area || area.isDestroyed) return false
+          if (area.plainText.length !== 0) return false
+          if (input.subagents() === 0) return false
+          input.onSubagentMenu?.()
+        },
+      },
+      {
+        key: "!",
+        desc: "Shell mode",
+        group: "Prompt",
+        cmd() {
+          if (shell()) return false
+          if (!area || area.isDestroyed) return false
+          if (area.cursorOffset !== 0) return false
+          setShellMode(true)
+        },
+      },
+    ],
+  }))
+
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: input.prompt() && shell() && !visible(),
+    bindings: [
+      {
+        key: "escape",
+        desc: "Exit shell mode",
+        group: "Prompt",
+        cmd: () => setShellMode(false),
+      },
+      {
+        key: "backspace",
+        desc: "Exit shell mode",
+        group: "Prompt",
+        cmd() {
+          if (!area || area.isDestroyed) return false
+          if (area.cursorOffset !== 0) return false
+          setShellMode(false)
+        },
+      },
+    ],
+  }))
+
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: input.prompt() && visible(),
+    commands: [
+      {
+        name: "prompt.autocomplete.prev",
+        title: "Previous autocomplete item",
+        category: "Autocomplete",
+        run: () => menu.move(-1),
+      },
+      {
+        name: "prompt.autocomplete.next",
+        title: "Next autocomplete item",
+        category: "Autocomplete",
+        run: () => menu.move(1),
+      },
+      {
+        name: "prompt.autocomplete.hide",
+        title: "Hide autocomplete",
+        category: "Autocomplete",
+        run: cancelAutocomplete,
+      },
+      {
+        name: "prompt.autocomplete.select",
+        title: "Select autocomplete item",
+        category: "Autocomplete",
+        run() {
+          if (mode() === "slash" && options().length === 0) {
+            hide()
+            return
+          }
+          select()
+        },
+      },
+      {
+        name: "prompt.autocomplete.complete",
+        title: "Complete autocomplete item",
+        category: "Autocomplete",
+        run() {
+          if (mode() === "slash" && options().length === 0) {
+            hide()
+            return
+          }
+          const item = options()[menu.selected()]
+          if (item?.kind === "mention" && item.directory) {
+            expand()
+            return
+          }
+          select()
+        },
+      },
+    ],
+    bindings: input.tuiConfig.keybinds.gather("run.prompt.autocomplete", [
+      "prompt.autocomplete.prev",
+      "prompt.autocomplete.next",
+      "prompt.autocomplete.hide",
+      "prompt.autocomplete.select",
+      "prompt.autocomplete.complete",
+    ]),
+  }))
+
+  const onKeyDown = (_event: KeyEvent) => {}
 
   const submitPrompt = (next: RunPrompt) => {
     if (!area || area.isDestroyed) {
@@ -1144,7 +1127,6 @@ export function createPromptState(input: PromptInput): PromptState {
   }
 
   onCleanup(() => {
-    clear()
     if (area && !area.isDestroyed) {
       area.off("line-info-change", scheduleRows)
     }
@@ -1188,7 +1170,6 @@ export function createPromptState(input: PromptInput): PromptState {
       syncDraft()
     }
 
-    clear()
     hide()
     prev = kind
     if (kind !== "prompt") {
@@ -1202,7 +1183,6 @@ export function createPromptState(input: PromptInput): PromptState {
 
   return {
     placeholder,
-    bindings,
     shell,
     visible,
     options,

+ 1 - 4
packages/opencode/src/cli/cmd/run/footer.question.tsx

@@ -177,10 +177,6 @@ export function RunQuestionBody(props: {
         return
       }
 
-      if (event.name === "return" && !event.shift && !event.ctrl && !event.meta) {
-        saveCustom()
-        event.preventDefault()
-      }
       return
     }
 
@@ -496,6 +492,7 @@ export function RunQuestionBody(props: {
                             focusedBackgroundColor={props.theme.surface}
                             cursorColor={props.theme.text}
                             focused={!disabled()}
+                            onSubmit={saveCustom}
                             onContentChange={() => {
                               if (!area || area.isDestroyed || disabled()) {
                                 return

+ 54 - 41
packages/opencode/src/cli/cmd/run/footer.ts

@@ -24,22 +24,22 @@
 // Ctrl-c clears a live prompt draft first; otherwise interrupt and exit use a
 // two-press pattern where the first press shows a hint and the second press
 // within 5 seconds actually fires the action.
-import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
+import { CliRenderEvents, type CliRenderer, type KeyEvent, type Renderable, type TreeSitterClient } from "@opentui/core"
+import type { Keymap } from "@opentui/keymap"
 import { render } from "@opentui/solid"
 import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
+import { OpencodeKeymapProvider, formatKeyBindings } from "@/cli/cmd/tui/keymap"
 import { withRunSpan } from "./otel"
 import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
 import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
 import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
-import { printableBinding } from "./prompt.shared"
 import { RunFooterView } from "./footer.view"
 import { RunScrollbackStream } from "./scrollback.surface"
 import type { RunTheme } from "./theme"
 import type {
   FooterApi,
   FooterEvent,
-  FooterKeybinds,
   FooterPatch,
   FooterPromptRoute,
   FooterState,
@@ -55,6 +55,7 @@ import type {
   RunPrompt,
   RunProvider,
   RunResource,
+  RunTuiConfig,
   StreamCommit,
 } from "./types"
 
@@ -80,7 +81,8 @@ type RunFooterOptions = {
   first: boolean
   history?: RunPrompt[]
   theme: RunTheme
-  keybinds: FooterKeybinds
+  keymap: Keymap<Renderable, KeyEvent>
+  tuiConfig: RunTuiConfig
   diffStyle: RunDiffStyle
   onPermissionReply: (input: PermissionReply) => void | Promise<void>
   onQuestionReply: (input: QuestionReply) => void | Promise<void>
@@ -195,7 +197,6 @@ export class RunFooter implements FooterApi {
   private autocomplete = false
   private interruptTimeout: NodeJS.Timeout | undefined
   private exitTimeout: NodeJS.Timeout | undefined
-  private interruptHint: string
   private requestExitHandler: (() => boolean) | undefined
   private scrollback: RunScrollbackStream
 
@@ -249,7 +250,6 @@ export class RunFooter implements FooterApi {
       setSubagent("questions", reconcile(next.questions, { key: "id" }))
     }
     this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
-    this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
     this.scrollback = new RunScrollbackStream(renderer, options.theme, {
       diffStyle: options.diffStyle,
       wrote: options.wrote,
@@ -259,42 +259,48 @@ export class RunFooter implements FooterApi {
 
     this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
 
+    const footer = this
     void render(
       () =>
-        createComponent(RunFooterView, {
-          directory: options.directory,
-          state: this.state,
-          view: this.view,
-          subagent: this.subagent,
-          findFiles: options.findFiles,
-          agents: this.agents,
-          resources: this.resources,
-          commands: this.commands,
-          providers: this.providers,
-          currentModel: this.currentModel,
-          variants: this.variants,
-          currentVariant: this.currentVariant,
-          theme: options.theme,
-          diffStyle: options.diffStyle,
-          keybinds: options.keybinds,
-          history: options.history,
-          agent: options.agentLabel,
-          onSubmit: this.handlePrompt,
-          onPermissionReply: this.handlePermissionReply,
-          onQuestionReply: this.handleQuestionReply,
-          onQuestionReject: this.handleQuestionReject,
-          onCycle: this.handleCycle,
-          onInterrupt: this.handleInterrupt,
-          onInputClear: this.handleInputClear,
-          onExitRequest: this.handleExit,
-          onRequestExit: this.setRequestExitHandler,
-          onExit: () => this.close(),
-          onModelSelect: this.handleModelSelect,
-          onVariantSelect: this.handleVariantSelect,
-          onRows: this.syncRows,
-          onLayout: this.syncLayout,
-          onStatus: this.setStatus,
-          onSubagentSelect: options.onSubagentSelect,
+        createComponent(OpencodeKeymapProvider, {
+          keymap: options.keymap,
+          get children() {
+            return createComponent(RunFooterView, {
+              directory: options.directory,
+              state: footer.state,
+              view: footer.view,
+              subagent: footer.subagent,
+              findFiles: options.findFiles,
+              agents: footer.agents,
+              resources: footer.resources,
+              commands: footer.commands,
+              providers: footer.providers,
+              currentModel: footer.currentModel,
+              variants: footer.variants,
+              currentVariant: footer.currentVariant,
+              theme: options.theme,
+              diffStyle: options.diffStyle,
+              tuiConfig: options.tuiConfig,
+              history: options.history,
+              agent: options.agentLabel,
+              onSubmit: footer.handlePrompt,
+              onPermissionReply: footer.handlePermissionReply,
+              onQuestionReply: footer.handleQuestionReply,
+              onQuestionReject: footer.handleQuestionReject,
+              onCycle: footer.handleCycle,
+              onInterrupt: footer.handleInterrupt,
+              onInputClear: footer.handleInputClear,
+              onExitRequest: footer.handleExit,
+              onRequestExit: footer.setRequestExitHandler,
+              onExit: () => footer.close(),
+              onModelSelect: footer.handleModelSelect,
+              onVariantSelect: footer.handleVariantSelect,
+              onRows: footer.syncRows,
+              onLayout: footer.syncLayout,
+              onStatus: footer.setStatus,
+              onSubagentSelect: options.onSubagentSelect,
+            })
+          },
         }),
       this.renderer,
     ).catch(() => {
@@ -781,6 +787,13 @@ export class RunFooter implements FooterApi {
     }, 5000)
   }
 
+  private interruptHint(): string {
+    const bindings = this.options.keymap
+      .getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] })
+      .get("session.interrupt")
+    return formatKeyBindings(bindings, this.options.tuiConfig) || "esc"
+  }
+
   private clearExitTimer(): void {
     if (!this.exitTimeout) {
       return
@@ -815,7 +828,7 @@ export class RunFooter implements FooterApi {
 
     if (next < 2) {
       this.armInterruptTimer()
-      this.patch({ status: `${this.interruptHint} again to interrupt` })
+      this.patch({ status: `${this.interruptHint()} again to interrupt` })
       return true
     }
 

+ 52 - 34
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -10,7 +10,7 @@
 // All state comes from the parent RunFooter through SolidJS signals.
 // The view itself is stateless except for derived memos.
 /** @jsxImportSource @opentui/solid */
-import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { useTerminalDimensions } from "@opentui/solid"
 import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import "opentui-spinner/solid"
 import { createColors, createFrames } from "../tui/ui/spinner"
@@ -26,9 +26,14 @@ import { RunFooterSubagentBody } from "./footer.subagent"
 import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
 import { RunPermissionBody } from "./footer.permission"
 import { RunQuestionBody } from "./footer.question"
-import { printableBinding, promptBindings, promptHit, promptInfo } from "./prompt.shared"
+import {
+  OPENCODE_BASE_MODE,
+  formatKeyBindings,
+  useBindings,
+  useKeymapSelector,
+  type OpenTuiKeymap,
+} from "@/cli/cmd/tui/keymap"
 import type {
-  FooterKeybinds,
   FooterPromptRoute,
   FooterState,
   FooterSubagentState,
@@ -43,6 +48,7 @@ import type {
   RunPrompt,
   RunProvider,
   RunResource,
+  RunTuiConfig,
 } from "./types"
 import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
 
@@ -75,7 +81,7 @@ type RunFooterViewProps = {
   subagent?: () => FooterSubagentState
   theme?: RunTheme
   diffStyle?: RunDiffStyle
-  keybinds: FooterKeybinds
+  tuiConfig: RunTuiConfig
   history?: RunPrompt[]
   agent: string
   onSubmit: (input: RunPrompt) => boolean
@@ -149,9 +155,24 @@ export function RunFooterView(props: RunFooterViewProps) {
     const current = route()
     return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
   })
-  const command = createMemo(() => printableBinding(props.keybinds.commandList, props.keybinds.leader))
-  const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
-  const commandKeys = createMemo(() => promptBindings(props.keybinds.commandList, props.keybinds.leader))
+  const command = useKeymapSelector((keymap: OpenTuiKeymap) =>
+    formatKeyBindings(
+      keymap.getCommandBindings({ visibility: "registered", commands: ["command.palette.show"] }).get("command.palette.show"),
+      props.tuiConfig,
+    ) ?? "",
+  )
+  const interrupt = useKeymapSelector((keymap: OpenTuiKeymap) =>
+    formatKeyBindings(
+      keymap.getCommandBindings({ visibility: "registered", commands: ["session.interrupt"] }).get("session.interrupt"),
+      props.tuiConfig,
+    ) ?? "",
+  )
+  const variantCycle = useKeymapSelector((keymap: OpenTuiKeymap) =>
+    formatKeyBindings(
+      keymap.getCommandBindings({ visibility: "registered", commands: ["variant.cycle"] }).get("variant.cycle"),
+      props.tuiConfig,
+    ) ?? "",
+  )
   const hints = createMemo(() => hintFlags(term().width))
   const busy = createMemo(() => props.state().phase === "running")
   const armed = createMemo(() => props.state().interrupt > 0)
@@ -257,7 +278,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     subagents: () => tabs().length,
     resources: props.resources,
     commands: props.commands,
-    keybinds: props.keybinds,
+    tuiConfig: props.tuiConfig,
     state: props.state,
     view: promptView,
     prompt,
@@ -285,30 +306,28 @@ export function RunFooterView(props: RunFooterViewProps) {
     props.onRequestExit?.(undefined)
   })
 
-  useKeyboard((event) => {
-    if (event.defaultPrevented) {
-      return
-    }
-
-    if (active().type !== "prompt") {
-      return
-    }
-
-    if (route().type !== "composer") {
-      return
-    }
-
-    if (composer.visible()) {
-      return
-    }
-
-    if (!promptHit(commandKeys(), promptInfo(event))) {
-      return
-    }
-
-    event.preventDefault()
-    openCommand()
-  })
+  useBindings(() => ({
+    mode: OPENCODE_BASE_MODE,
+    enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
+    commands: [
+      {
+        name: "command.palette.show",
+        title: "Open command palette",
+        category: "Prompt",
+        run: openCommand,
+      },
+      {
+        name: "variant.cycle",
+        title: "Cycle model variant",
+        category: "Model",
+        run: props.onCycle,
+      },
+    ],
+    bindings: [
+      ...props.tuiConfig.keybinds.get("command.palette.show"),
+      ...props.tuiConfig.keybinds.get("variant.cycle"),
+    ],
+  }))
 
   createEffect(() => {
     const current = route()
@@ -407,7 +426,6 @@ export function RunFooterView(props: RunFooterViewProps) {
                       <RunPromptBody
                         theme={theme}
                         placeholder={composer.placeholder}
-                        bindings={composer.bindings}
                         onSubmit={composer.onSubmit}
                         onKeyDown={composer.onKeyDown}
                         onContentChange={composer.onContentChange}
@@ -430,7 +448,7 @@ export function RunFooterView(props: RunFooterViewProps) {
                         commands={props.commands}
                         subagents={tabs}
                         variants={props.variants}
-                        keybinds={props.keybinds}
+                        variantCycle={variantCycle()}
                         onClose={closePanel}
                         onModel={openModel}
                         onSubagent={openSubagentMenu}

+ 0 - 154
packages/opencode/src/cli/cmd/run/keymap.shared.ts

@@ -1,154 +0,0 @@
-import { KeyEvent } from "@opentui/core"
-import { Keymap, type Binding, type KeySequencePart } from "@opentui/keymap"
-import { registerDefaultKeys, registerLeader } from "@opentui/keymap/addons"
-import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
-
-type ParsedBindingInput = Pick<Binding, "key" | "event">
-
-export type ParsedBinding = {
-  sequence: KeySequencePart[]
-  event: "press" | "release"
-}
-
-const keyNameAliases = {
-  delete: "del",
-  enter: "return",
-  escape: "esc",
-  pagedown: "pgdn",
-  pageup: "pgup",
-} as const
-
-const modifierAliases = {
-  meta: "alt",
-} as const
-
-function hostPlatform() {
-  if (process.platform === "darwin") {
-    return "macos" as const
-  }
-
-  if (process.platform === "win32") {
-    return "windows" as const
-  }
-
-  if (process.platform === "linux") {
-    return "linux" as const
-  }
-
-  return "unknown" as const
-}
-
-function createCommandEvent() {
-  return new KeyEvent({
-    name: "command",
-    ctrl: false,
-    meta: false,
-    shift: false,
-    option: false,
-    sequence: "",
-    number: false,
-    raw: "",
-    eventType: "press",
-    source: "raw",
-  })
-}
-
-function createParser(leader: string) {
-  const platform = hostPlatform()
-  const keymap = new Keymap({
-    metadata: {
-      platform,
-      primaryModifier: platform === "macos" ? "super" : platform === "unknown" ? "unknown" : "ctrl",
-      modifiers: {
-        ctrl: "supported",
-        shift: "supported",
-        meta: "supported",
-        super: "unknown",
-        hyper: "unknown",
-      },
-    },
-    rootTarget: {},
-    isDestroyed: false,
-    getFocusedTarget() {
-      return null
-    },
-    getParentTarget(_target) {
-      return null
-    },
-    isTargetDestroyed(_target) {
-      return false
-    },
-    onKeyPress(_listener) {
-      return () => {}
-    },
-    onKeyRelease(_listener) {
-      return () => {}
-    },
-    onFocusChange(_listener) {
-      return () => {}
-    },
-    onTargetDestroy(_target, _listener) {
-      return () => {}
-    },
-    createCommandEvent,
-  })
-
-  const offDefault = registerDefaultKeys(keymap)
-  const offLeader = registerLeader(keymap, { trigger: leader })
-
-  return {
-    keymap,
-    dispose() {
-      offLeader()
-      offDefault()
-    },
-  }
-}
-
-function formatOptions(leader: string) {
-  return {
-    tokenDisplay: {
-      leader,
-    },
-    keyNameAliases,
-    modifierAliases,
-  } as const
-}
-
-function splitBinding(binding: ParsedBindingInput) {
-  if (typeof binding.key !== "string" || !binding.key.includes(",")) {
-    return [binding]
-  }
-
-  return binding.key
-    .split(",")
-    .map((key) => key.trim())
-    .filter(Boolean)
-    .map((key) => ({
-      ...binding,
-      key,
-    }))
-}
-
-export function parseBindings(bindings: readonly ParsedBindingInput[], leader: string): ParsedBinding[] {
-  const parser = createParser(leader)
-
-  try {
-    return bindings.flatMap((binding) =>
-      splitBinding(binding).map((item) => ({
-        sequence: Array.from(parser.keymap.parseKeySequence(item.key)),
-        event: item.event ?? "press",
-      })),
-    )
-  } finally {
-    parser.dispose()
-  }
-}
-
-export function formatBinding(bindings: readonly ParsedBindingInput[], leader: string) {
-  return formatKeySequence(parseBindings(bindings, leader)[0]?.sequence, formatOptions(leader))
-}
-
-export function formatBindings(bindings: readonly ParsedBindingInput[], leader: string) {
-  return formatCommandBindings(parseBindings(bindings, leader), formatOptions(leader))
-}

+ 3 - 184
packages/opencode/src/cli/cmd/run/prompt.shared.ts

@@ -1,20 +1,14 @@
 // Pure state machine for the prompt input.
 //
-// Handles keybind parsing, history ring navigation, and the leader-key
-// sequence for variant cycling. All functions are pure -- they take state
-// in and return new state out, with no side effects.
+// Handles history ring navigation and prompt text helpers. All functions are
+// pure -- they take state in and return new state out, with no side effects.
 //
 // The history ring (PromptHistoryState) stores past prompts and tracks
 // the current browse position. When the user arrows up at cursor offset 0,
 // the current draft is saved and history begins. Arrowing past the end
 // restores the draft.
-//
-// The leader-key cycle (promptCycle) uses a two-step pattern: first press
-// arms the leader, second press within the timeout fires the action.
-import type { KeyBinding } from "@opentui/core"
 export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
-import { formatBinding, parseBindings } from "./keymap.shared"
-import type { FooterKeybinds, RunPrompt } from "./types"
+import type { RunPrompt } from "./types"
 
 const HISTORY_LIMIT = 200
 
@@ -24,36 +18,6 @@ export type PromptHistoryState = {
   draft: string
 }
 
-export function promptInfo(event: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) {
-  return {
-    name: event.name === " " ? "space" : event.name,
-    ctrl: !!event.ctrl,
-    meta: !!event.meta,
-    shift: !!event.shift,
-    super: !!event.super,
-    leader: false,
-  }
-}
-
-type PromptInfo = ReturnType<typeof promptInfo>
-
-export type PromptKeys = {
-  leaders: PromptInfo[]
-  cycles: PromptInfo[]
-  interrupts: PromptInfo[]
-  previous: PromptInfo[]
-  next: PromptInfo[]
-  clear: PromptInfo[]
-  bindings: KeyBinding[]
-}
-
-export type PromptCycle = {
-  arm: boolean
-  clear: boolean
-  cycle: boolean
-  consume: boolean
-}
-
 export type PromptMove = {
   state: PromptHistoryState
   text?: string
@@ -73,98 +37,6 @@ export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
   return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
 }
 
-function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {
-  if (binding.event !== "press") {
-    return undefined
-  }
-
-  const first = binding.sequence[0]
-  const second = binding.sequence[1]
-
-  if (!first) {
-    return undefined
-  }
-
-  if (!second) {
-    return first.patternName || first.tokenName
-      ? undefined
-      : {
-          name: first.stroke.name,
-          ctrl: first.stroke.ctrl,
-          meta: first.stroke.meta,
-          shift: first.stroke.shift,
-          super: first.stroke.super,
-          leader: false,
-        }
-  }
-
-  if (binding.sequence.length !== 2 || first.tokenName !== "leader" || second.patternName || second.tokenName) {
-    return undefined
-  }
-
-  return {
-    name: second.stroke.name,
-    ctrl: second.stroke.ctrl,
-    meta: second.stroke.meta,
-    shift: second.stroke.shift,
-    super: second.stroke.super,
-    leader: true,
-  }
-}
-
-export function promptBindings(bindings: FooterKeybinds["commandList"], leader: string): PromptInfo[] {
-  return parseBindings(bindings, leader).flatMap((binding) => {
-    const key = promptKey(binding)
-    return key ? [key] : []
-  })
-}
-
-function mapInputBindings(
-  bindings: FooterKeybinds["inputSubmit"],
-  leader: string,
-  action: "submit" | "newline",
-): KeyBinding[] {
-  return promptBindings(bindings, leader).flatMap((key) => {
-    if (key.leader) {
-      return []
-    }
-
-    return [
-      {
-        name: key.name,
-        ctrl: key.ctrl || undefined,
-        meta: key.meta || undefined,
-        shift: key.shift || undefined,
-        super: key.super || undefined,
-        action,
-      },
-    ]
-  })
-}
-
-function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
-  return [
-    ...mapInputBindings(keybinds.inputSubmit, keybinds.leader, "submit"),
-    ...mapInputBindings(keybinds.inputNewline, keybinds.leader, "newline"),
-  ]
-}
-
-export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
-  return {
-    leaders: promptBindings([{ key: keybinds.leader }], keybinds.leader),
-    cycles: promptBindings(keybinds.variantCycle, keybinds.leader),
-    interrupts: promptBindings(keybinds.interrupt, keybinds.leader),
-    previous: promptBindings(keybinds.historyPrevious, keybinds.leader),
-    next: promptBindings(keybinds.historyNext, keybinds.leader),
-    clear: promptBindings(keybinds.inputClear, keybinds.leader),
-    bindings: textareaBindings(keybinds),
-  }
-}
-
-export function printableBinding(bindings: FooterKeybinds["commandList"], leader: string): string {
-  return formatBinding(bindings, leader)
-}
-
 export function isExitCommand(input: string): boolean {
   const text = input.trim().toLowerCase()
   return text === "/exit" || text === "/quit" || text === ":q"
@@ -174,59 +46,6 @@ export function isNewCommand(input: string): boolean {
   return input.trim().toLowerCase() === "/new"
 }
 
-export function promptHit(bindings: PromptInfo[], event: PromptInfo): boolean {
-  return bindings.some(
-    (item) =>
-      item.name === event.name &&
-      item.ctrl === event.ctrl &&
-      item.meta === event.meta &&
-      item.shift === event.shift &&
-      item.super === event.super &&
-      item.leader === event.leader,
-  )
-}
-
-export function promptCycle(
-  armed: boolean,
-  event: PromptInfo,
-  leaders: PromptInfo[],
-  cycles: PromptInfo[],
-): PromptCycle {
-  if (!armed && promptHit(leaders, event)) {
-    return {
-      arm: true,
-      clear: false,
-      cycle: false,
-      consume: true,
-    }
-  }
-
-  if (armed) {
-    return {
-      arm: false,
-      clear: true,
-      cycle: promptHit(cycles, { ...event, leader: true }),
-      consume: true,
-    }
-  }
-
-  if (!promptHit(cycles, event)) {
-    return {
-      arm: false,
-      clear: false,
-      cycle: false,
-      consume: false,
-    }
-  }
-
-  return {
-    arm: false,
-    clear: false,
-    cycle: true,
-    consume: true,
-  }
-}
-
 export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
   const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
   const next: RunPrompt[] = []

+ 27 - 39
packages/opencode/src/cli/cmd/run/runtime.boot.ts

@@ -1,32 +1,21 @@
 // Boot-time resolution for direct interactive mode.
 //
 // These functions run concurrently at startup to gather everything the runtime
-// needs before the first frame: keybinds from TUI config, diff display style,
+// needs before the first frame: TUI keymap config, diff display style,
 // model variant list with context limits, and session history for the prompt
 // history ring. All are async because they read config or hit the SDK, but
 // none block each other.
 import { Context, Effect, Layer } from "effect"
-import { stringifyKeyStroke } from "@opentui/keymap"
+import { createBindingLookup } from "@opentui/keymap/extras"
 import { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
 import { makeRuntime } from "@/effect/run-service"
 import { reusePendingTask } from "./runtime.shared"
 import { resolveSession, sessionHistory } from "./session.shared"
-import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } from "./types"
+import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
 import { pickVariant } from "./variant.shared"
 
-const DEFAULT_KEYBINDS: FooterKeybinds = {
-  leader: TuiKeybind.LeaderDefault,
-  leaderTimeout: 2000,
-  commandList: [{ key: "ctrl+p" }],
-  variantCycle: [{ key: "ctrl+t" }],
-  interrupt: [{ key: "escape" }],
-  historyPrevious: [{ key: "up" }],
-  historyNext: [{ key: "down" }],
-  inputClear: [{ key: "ctrl+c" }],
-  inputSubmit: [{ key: "return" }],
-  inputNewline: [{ key: "shift+return,ctrl+return,alt+return,ctrl+j" }],
-}
+const DEFAULT_LEADER_TIMEOUT = 2000
 
 export type ModelInfo = {
   providers: RunProvider[]
@@ -52,7 +41,7 @@ type BootService = {
     sessionID: string,
     model: RunInput["model"],
   ) => Effect.Effect<SessionInfo>
-  readonly resolveFooterKeybinds: () => Effect.Effect<FooterKeybinds>
+  readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
   readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
 }
 
@@ -80,28 +69,27 @@ function emptySessionInfo(): SessionInfo {
   }
 }
 
-function leaderKey(config: Config) {
-  const key = config.keybinds.get("leader")?.[0]?.key
-  if (!key) return TuiKeybind.LeaderDefault
-  return typeof key === "string" ? key : stringifyKeyStroke(key)
+function defaultRunTuiConfig(): RunTuiConfig {
+  const keybinds = TuiKeybind.parse({})
+  return {
+    keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
+      commandMap: TuiKeybind.CommandMap,
+      bindingDefaults: TuiKeybind.bindingDefaults(),
+    }),
+    leader_timeout: DEFAULT_LEADER_TIMEOUT,
+    diff_style: "auto",
+  }
 }
 
-function footerKeybinds(config: Config | undefined): FooterKeybinds {
+function runTuiConfig(config: Config | undefined): RunTuiConfig {
   if (!config) {
-    return DEFAULT_KEYBINDS
+    return defaultRunTuiConfig()
   }
 
   return {
-    leader: leaderKey(config),
-    leaderTimeout: config.leader_timeout,
-    commandList: config.keybinds.get("command.palette.show"),
-    variantCycle: config.keybinds.get("variant.cycle"),
-    interrupt: config.keybinds.get("session.interrupt"),
-    historyPrevious: config.keybinds.get("prompt.history.previous"),
-    historyNext: config.keybinds.get("prompt.history.next"),
-    inputClear: config.keybinds.get("prompt.clear"),
-    inputSubmit: config.keybinds.get("input.submit"),
-    inputNewline: config.keybinds.get("input.newline"),
+    keybinds: config.keybinds,
+    leader_timeout: config.leader_timeout,
+    diff_style: config.diff_style ?? "auto",
   }
 }
 
@@ -175,18 +163,18 @@ const layer = Layer.effect(
       }
     })
 
-    const resolveFooterKeybinds = Effect.fn("RunBoot.resolveFooterKeybinds")(function* () {
-      return footerKeybinds(yield* config())
+    const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () {
+      return runTuiConfig(yield* config())
     })
 
     const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
-      return (yield* config())?.diff_style ?? "auto"
+      return runTuiConfig(yield* config()).diff_style ?? "auto"
     })
 
     return Service.of({
       resolveModelInfo,
       resolveSessionInfo,
-      resolveFooterKeybinds,
+      resolveRunTuiConfig,
       resolveDiffStyle,
     })
   }),
@@ -212,9 +200,9 @@ export async function resolveSessionInfo(
   return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
 }
 
-// Reads keybind overrides from TUI config and merges them with defaults.
-export async function resolveFooterKeybinds(): Promise<FooterKeybinds> {
-  return runtime.runPromise((svc) => svc.resolveFooterKeybinds()).catch(() => DEFAULT_KEYBINDS)
+// Reads TUI config once for direct mode keymap setup and display preferences.
+export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
+  return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
 }
 
 export async function resolveDiffStyle(): Promise<RunDiffStyle> {

+ 12 - 6
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -9,7 +9,9 @@
 // Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
 // back to the usual two-press exit sequence through RunFooter.requestExit().
 import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
+import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
 import { Session as SessionApi } from "@/session/session"
+import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
 import * as Locale from "@/util/locale"
 import { withRunSpan } from "./otel"
 import { resolveInteractiveStdin } from "./runtime.stdin"
@@ -17,15 +19,14 @@ import { entrySplash, exitSplash, splashMeta } from "./splash"
 import { resolveRunTheme } from "./theme"
 import type {
   FooterApi,
-  FooterKeybinds,
   PermissionReply,
   QuestionReject,
   QuestionReply,
   RunAgent,
-  RunDiffStyle,
   RunInput,
   RunPrompt,
   RunResource,
+  RunTuiConfig,
 } from "./types"
 import { formatModelLabel } from "./variant.shared"
 
@@ -61,8 +62,7 @@ export type LifecycleInput = {
   agent: string | undefined
   model: RunInput["model"]
   variant: string | undefined
-  keybinds: FooterKeybinds
-  diffStyle: RunDiffStyle
+  tuiConfig: RunTuiConfig
   onPermissionReply: (input: PermissionReply) => void | Promise<void>
   onQuestionReply: (input: QuestionReply) => void | Promise<void>
   onQuestionReject: (input: QuestionReject) => void | Promise<void>
@@ -169,6 +169,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     },
     async () => {
       const source = resolveInteractiveStdin()
+      let unregisterKeymap: (() => void) | undefined
 
       try {
         const renderer = await createCliRenderer({
@@ -188,6 +189,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
         })
         const theme = await resolveRunTheme(renderer)
         renderer.setBackgroundColor(theme.background)
+        const keymap = createDefaultOpenTuiKeymap(renderer)
+        unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
         const state: SplashState = {
           entry: false,
           exit: false,
@@ -230,8 +233,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
           history: input.history,
           theme,
           wrote,
-          keybinds: input.keybinds,
-          diffStyle: input.diffStyle,
+          keymap,
+          tuiConfig: input.tuiConfig,
+          diffStyle: input.tuiConfig.diff_style ?? "auto",
           onPermissionReply: input.onPermissionReply,
           onQuestionReply: input.onQuestionReply,
           onQuestionReject: input.onQuestionReject,
@@ -293,6 +297,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
                 footer.close()
                 await footer.idle().catch(() => {})
                 footer.destroy()
+                unregisterKeymap?.()
                 shutdown(renderer)
                 source.cleanup?.()
               }
@@ -305,6 +310,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
           close,
         }
       } catch (error) {
+        unregisterKeymap?.()
         source.cleanup?.()
         throw error
       }

+ 6 - 9
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -7,7 +7,7 @@
 //   runInteractiveLocalMode -- used for local in-process mode (no server)
 //
 // Both delegate to runInteractiveRuntime, which:
-//   1. resolves keybinds, diff style, model info, and session history,
+//   1. resolves TUI config, model info, and session history,
 //   2. creates the split-footer lifecycle (renderer + RunFooter),
 //   3. starts the stream transport (SDK event subscription), lazily for fresh
 //      local sessions,
@@ -15,7 +15,7 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { createRunDemo } from "./demo"
-import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
+import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
 import { createRuntimeLifecycle } from "./runtime.lifecycle"
 import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
 import { trace } from "./trace"
@@ -173,8 +173,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
     async (span) => {
       const start = performance.now()
       const log = trace()
-      const keybindTask = resolveFooterKeybinds()
-      const diffTask = resolveDiffStyle()
+      const tuiConfigTask = resolveRunTuiConfig()
       const ctx = await input.boot()
       const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
       const sessionTask =
@@ -186,9 +185,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
               variant: undefined,
             })
       const savedTask = resolveSavedVariant(ctx.model)
-      const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
-        keybindTask,
-        diffTask,
+      const [tuiConfig, session, savedVariant] = await Promise.all([
+        tuiConfigTask,
         sessionTask,
         savedTask,
       ])
@@ -252,8 +250,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
         agent: state.agent,
         model: state.model,
         variant: state.activeVariant,
-        keybinds,
-        diffStyle,
+        tuiConfig,
         onPermissionReply: async (next) => {
           if (state.demo?.permission(next)) {
             return

+ 2 - 16
packages/opencode/src/cli/cmd/run/types.ts

@@ -11,9 +11,8 @@
 //     → stream.ts bridges to footer API
 //       → footer.ts queues commits and patches the footer view
 //         → OpenTUI split-footer renderer writes to terminal
-import type { KeyEvent, Renderable } from "@opentui/core"
-import type { Binding } from "@opentui/keymap"
 import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
 
 export type RunFilePart = {
   type: "file"
@@ -265,20 +264,7 @@ export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
 
 export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
 
-type FooterBinding = Binding<Renderable, KeyEvent>
-
-export type FooterKeybinds = {
-  leader: string
-  leaderTimeout: number
-  commandList: readonly FooterBinding[]
-  variantCycle: readonly FooterBinding[]
-  interrupt: readonly FooterBinding[]
-  historyPrevious: readonly FooterBinding[]
-  historyNext: readonly FooterBinding[]
-  inputClear: readonly FooterBinding[]
-  inputSubmit: readonly FooterBinding[]
-  inputNewline: readonly FooterBinding[]
-}
+export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
 
 // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
 // appends content (coalesced in the footer queue), "final" closes it.

+ 24 - 11
packages/opencode/src/cli/cmd/tui/keymap.tsx

@@ -1,4 +1,4 @@
-import { type CliRenderer } from "@opentui/core"
+import { InputRenderable, TextareaRenderable, type CliRenderer } from "@opentui/core"
 import * as addons from "@opentui/keymap/addons/opentui"
 import { stringifyKeyStroke } from "@opentui/keymap"
 import {
@@ -31,6 +31,7 @@ type CommandSlashEntry = {
   onSelect: () => void
 }
 type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
+type FormatConfig = Pick<TuiConfig.Resolved, "keybinds">
 
 const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
 
@@ -160,13 +161,22 @@ const inputCommands = [
   "input.submit",
 ] as const
 
-function leaderDisplay(config: TuiConfig.Resolved) {
+function hasManagedTextareaFocus(renderer: CliRenderer) {
+  const editor = renderer.currentFocusedEditor
+  return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
+}
+
+function leaderDisplay(config: FormatConfig) {
   const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
   if (!key) return TuiKeybind.LeaderDefault
   return typeof key === "string" ? key : stringifyKeyStroke(key)
 }
 
-function formatOptions(config: TuiConfig.Resolved) {
+function leaderKey(config: FormatConfig) {
+  return config.keybinds.get(LEADER_TOKEN)?.[0]?.key
+}
+
+function formatOptions(config: FormatConfig) {
   return {
     tokenDisplay: {
       [LEADER_TOKEN]: leaderDisplay(config),
@@ -182,13 +192,13 @@ function formatOptions(config: TuiConfig.Resolved) {
   } as const
 }
 
-export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: TuiConfig.Resolved) {
+export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: FormatConfig) {
   return formatKeySequenceExtra(parts, formatOptions(config))
 }
 
 export function formatKeyBindings(
   bindings: Parameters<typeof formatCommandBindingsExtra>[0],
-  config: TuiConfig.Resolved,
+  config: FormatConfig,
 ) {
   return formatCommandBindingsExtra(bindings, formatOptions(config))
 }
@@ -202,15 +212,18 @@ export function registerOpencodeKeymap(
   const offCommaBindings = addons.registerCommaBindings(keymap)
   const offAliasExpander = registerKeyAliases(keymap)
   const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
-  const offLeader = addons.registerTimedLeader(keymap, {
-    trigger: config.keybinds.get(LEADER_TOKEN),
-    name: LEADER_TOKEN,
-    timeoutMs: config.leader_timeout,
-  })
+  const leader = leaderKey(config)
+  const offLeader = leader
+    ? addons.registerTimedLeader(keymap, {
+        trigger: leader,
+        name: LEADER_TOKEN,
+        timeoutMs: config.leader_timeout,
+      })
+    : () => {}
   const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
   const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
   const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
-    enabled: () => renderer.currentFocusedEditor !== null,
+    enabled: () => hasManagedTextareaFocus(renderer),
     bindings: config.keybinds.gather("input", inputCommands),
   })
 

+ 316 - 59
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -1,8 +1,10 @@
 /** @jsxImportSource @opentui/solid */
 import { expect, test } from "bun:test"
-import { testRender } from "@opentui/solid"
+import { testRender, useRenderer } from "@opentui/solid"
 import { createSignal } from "solid-js"
+import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
 import type { QuestionRequest } from "@opencode-ai/sdk/v2"
+import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
 import {
   RUN_COMMAND_PANEL_ROWS,
   RUN_SUBAGENT_PANEL_ROWS,
@@ -15,7 +17,6 @@ import { RunFooterView } from "@/cli/cmd/run/footer.view"
 import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
 import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
 import type {
-  FooterKeybinds,
   FooterState,
   FooterSubagentState,
   FooterSubagentTab,
@@ -23,26 +24,14 @@ import type {
   RunCommand,
   RunInput,
   RunProvider,
+  RunTuiConfig,
   StreamCommit,
 } from "@/cli/cmd/run/types"
 import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
+import { RejectField } from "@/cli/cmd/run/footer.permission"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 
-function bindings(...keys: string[]) {
-  return keys.map((key) => ({ key }))
-}
-
-const keybinds: FooterKeybinds = {
-  leader: "ctrl+x",
-  leaderTimeout: 2000,
-  commandList: bindings("ctrl+p"),
-  variantCycle: bindings("ctrl+t"),
-  interrupt: bindings("escape"),
-  historyPrevious: bindings("up"),
-  historyNext: bindings("down"),
-  inputClear: bindings("ctrl+c"),
-  inputSubmit: bindings("return"),
-  inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
-}
+const tuiConfig = createTuiResolvedConfig()
 
 function command(input: { name: string; description: string; source?: "command" | "mcp" | "skill" }) {
   return {
@@ -143,6 +132,90 @@ function subagent(input: {
   } satisfies FooterSubagentTab
 }
 
+function footerState(input: Partial<FooterState> = {}) {
+  return createSignal<FooterState>({
+    phase: "idle",
+    status: "",
+    queue: 0,
+    model: "gpt-5",
+    duration: "",
+    usage: "",
+    first: false,
+    interrupt: 0,
+    exit: 0,
+    ...input,
+  })[0]
+}
+
+async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => void } = {}) {
+  const [view] = createSignal<FooterView>({ type: "prompt" })
+  const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
+  const state = footerState()
+  const config = input.tuiConfig ?? tuiConfig
+  let offKeymap: (() => void) | undefined
+
+  function Harness() {
+    const renderer = useRenderer()
+    const keymap = createDefaultOpenTuiKeymap(renderer)
+    offKeymap = registerOpencodeKeymap(keymap, renderer, config)
+
+    return (
+      <OpencodeKeymapProvider keymap={keymap}>
+        <RunFooterView
+          directory="/tmp"
+          findFiles={async () => []}
+          agents={() => []}
+          resources={() => []}
+          commands={() => []}
+          providers={() => undefined}
+          currentModel={() => undefined}
+          variants={() => []}
+          currentVariant={() => undefined}
+          state={state}
+          view={view}
+          subagent={subagents}
+          theme={RUN_THEME_FALLBACK}
+          tuiConfig={config}
+          agent="opencode"
+          onSubmit={() => true}
+          onPermissionReply={() => { }}
+          onQuestionReply={() => { }}
+          onQuestionReject={() => { }}
+          onCycle={input.onCycle ?? (() => { })}
+          onInterrupt={() => false}
+          onInputClear={() => { }}
+          onExit={() => { }}
+          onModelSelect={() => { }}
+          onVariantSelect={() => { }}
+          onRows={() => { }}
+          onLayout={() => { }}
+          onStatus={() => { }}
+        />
+      </OpencodeKeymapProvider>
+    )
+  }
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={8}>
+        <Harness />
+      </box>
+    ),
+    { width: 100, height: 8, kittyKeyboard: true },
+  )
+
+  return {
+    ...app,
+    cleanup() {
+      app.renderer.currentFocusedRenderable?.blur()
+      app.renderer.currentFocusedEditor?.blur()
+      offKeymap?.()
+      offKeymap = undefined
+      app.renderer.destroy()
+    },
+  }
+}
+
 test("run entry content updates when live commit text changes", async () => {
   const [commit, setCommit] = createSignal<StreamCommit>({
     kind: "tool",
@@ -204,15 +277,15 @@ test("direct command panel renders grouped command palette", async () => {
           commands={commands}
           subagents={subagents}
           variants={variants}
-          keybinds={keybinds}
-          onClose={() => {}}
-          onModel={() => {}}
-          onSubagent={() => {}}
-          onVariant={() => {}}
-          onVariantCycle={() => {}}
-          onCommand={() => {}}
-          onNew={() => {}}
-          onExit={() => {}}
+          variantCycle="ctrl+t"
+          onClose={() => { }}
+          onModel={() => { }}
+          onSubagent={() => { }}
+          onVariant={() => { }}
+          onVariantCycle={() => { }}
+          onCommand={() => { }}
+          onNew={() => { }}
+          onExit={() => { }}
         />
       </box>
     ),
@@ -262,15 +335,15 @@ test("direct command panel shows subagent entry when available", async () => {
           commands={commands}
           subagents={subagents}
           variants={variants}
-          keybinds={keybinds}
-          onClose={() => {}}
-          onModel={() => {}}
-          onSubagent={() => {}}
-          onVariant={() => {}}
-          onVariantCycle={() => {}}
-          onCommand={() => {}}
-          onNew={() => {}}
-          onExit={() => {}}
+          variantCycle="ctrl+t"
+          onClose={() => { }}
+          onModel={() => { }}
+          onSubagent={() => { }}
+          onVariant={() => { }}
+          onVariantCycle={() => { }}
+          onCommand={() => { }}
+          onNew={() => { }}
+          onExit={() => { }}
         />
       </box>
     ),
@@ -306,8 +379,8 @@ test("direct subagent panel renders active subagents", async () => {
           theme={() => RUN_THEME_FALLBACK.footer}
           tabs={tabs}
           current={current}
-          onClose={() => {}}
-          onSelect={() => {}}
+          onClose={() => { }}
+          onSelect={() => { }}
           onRows={(value) => {
             rows = value
           }}
@@ -334,6 +407,61 @@ test("direct subagent panel renders active subagents", async () => {
   }
 })
 
+// OpenTUI currently segfaults when the full footer view suite creates several
+// keymap-backed test renderers in one process. Re-enable after the runtime fix.
+test.skip("direct footer opens command panel through keymap binding", async () => {
+  const app = await renderFooter()
+
+  try {
+    await app.renderOnce()
+    app.mockInput.pressKey("p", { ctrl: true })
+    await app.renderOnce()
+
+    expect(app.captureCharFrame()).toContain("Commands")
+  } finally {
+    app.cleanup()
+  }
+})
+
+test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => {
+  const calls: string[] = []
+  const app = await renderFooter({
+    tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "<leader>t" } }),
+    onCycle: () => calls.push("cycle"),
+  })
+
+  try {
+    await app.renderOnce()
+    app.mockInput.pressKey("t")
+    expect(calls).toEqual([])
+
+    app.mockInput.pressKey("x", { ctrl: true })
+    app.mockInput.pressKey("t")
+    expect(calls).toEqual(["cycle"])
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer keeps leader variant binding inactive when leader is disabled", async () => {
+  const calls: string[] = []
+  const app = await renderFooter({
+    tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "<leader>t" } }),
+    onCycle: () => calls.push("cycle"),
+  })
+
+  try {
+    await app.renderOnce()
+    app.mockInput.pressKey("t")
+    app.mockInput.pressKey("x", { ctrl: true })
+    app.mockInput.pressKey("t")
+
+    expect(calls).toEqual([])
+  } finally {
+    app.cleanup()
+  }
+})
+
 test("direct footer shows subagent indicator while prompt is running", async () => {
   const [state] = createSignal<FooterState>({
     phase: "running",
@@ -353,10 +481,14 @@ test("direct footer shows subagent indicator while prompt is running", async ()
     permissions: [],
     questions: [],
   })
-
-  const app = await testRender(
-    () => (
-      <box width={100} height={8}>
+  let offKeymap: (() => void) | undefined
+  function Harness() {
+    const renderer = useRenderer()
+    const keymap = createDefaultOpenTuiKeymap(renderer)
+    offKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
+
+    return (
+      <OpencodeKeymapProvider keymap={keymap}>
         <RunFooterView
           directory="/tmp"
           findFiles={async () => []}
@@ -371,22 +503,30 @@ test("direct footer shows subagent indicator while prompt is running", async ()
           view={view}
           subagent={subagents}
           theme={RUN_THEME_FALLBACK}
-          keybinds={keybinds}
+          tuiConfig={tuiConfig}
           agent="opencode"
           onSubmit={() => true}
-          onPermissionReply={() => {}}
-          onQuestionReply={() => {}}
-          onQuestionReject={() => {}}
-          onCycle={() => {}}
+          onPermissionReply={() => { }}
+          onQuestionReply={() => { }}
+          onQuestionReject={() => { }}
+          onCycle={() => { }}
           onInterrupt={() => false}
-          onInputClear={() => {}}
-          onExit={() => {}}
-          onModelSelect={() => {}}
-          onVariantSelect={() => {}}
-          onRows={() => {}}
-          onLayout={() => {}}
-          onStatus={() => {}}
+          onInputClear={() => { }}
+          onExit={() => { }}
+          onModelSelect={() => { }}
+          onVariantSelect={() => { }}
+          onRows={() => { }}
+          onLayout={() => { }}
+          onStatus={() => { }}
         />
+      </OpencodeKeymapProvider>
+    )
+  }
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={8}>
+        <Harness />
       </box>
     ),
     {
@@ -399,6 +539,9 @@ test("direct footer shows subagent indicator while prompt is running", async ()
     await app.renderOnce()
     expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
   } finally {
+    app.renderer.currentFocusedRenderable?.blur()
+    app.renderer.currentFocusedEditor?.blur()
+    offKeymap?.()
     app.renderer.destroy()
   }
 })
@@ -429,7 +572,7 @@ test("direct question body separates single-select checkmark from label", async
           onReply={(input) => {
             replies.push(input)
           }}
-          onReject={() => {}}
+          onReject={() => { }}
         />
       </box>
     ),
@@ -450,6 +593,120 @@ test("direct question body separates single-select checkmark from label", async
   }
 })
 
+test("direct custom answer submits through keymap return binding", async () => {
+  const question = {
+    id: "question-1",
+    sessionID: "session-1",
+    questions: [
+      {
+        question: "Which answer should I use?",
+        header: "Answer",
+        options: [{ label: "Provided", description: "Use the listed answer." }],
+        custom: true,
+      },
+    ],
+  } satisfies QuestionRequest
+  const questions: unknown[] = []
+  let off: (() => void) | undefined
+
+  function Harness() {
+    const renderer = useRenderer()
+    const keymap = createDefaultOpenTuiKeymap(renderer)
+    off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
+
+    return (
+      <OpencodeKeymapProvider keymap={keymap}>
+        <RunQuestionBody
+          request={question}
+          theme={RUN_THEME_FALLBACK.footer}
+          onReply={(input) => {
+            questions.push(input)
+          }}
+          onReject={() => { }}
+        />
+      </OpencodeKeymapProvider>
+    )
+  }
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={18}>
+        <Harness />
+      </box>
+    ),
+    { width: 100, height: 18, kittyKeyboard: true },
+  )
+
+  try {
+    await app.renderOnce()
+    app.mockInput.pressKey("2")
+    await app.renderOnce()
+    "typed".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+    expect(questions).toEqual([{ requestID: "question-1", answers: [["typed"]] }])
+  } finally {
+    app.renderer.currentFocusedRenderable?.blur()
+    app.renderer.currentFocusedEditor?.blur()
+    off?.()
+    app.renderer.destroy()
+  }
+})
+
+test("direct permission rejection submits through keymap return binding", async () => {
+  let text = ""
+  const submits: string[] = []
+  let off: (() => void) | undefined
+
+  function Harness() {
+    const renderer = useRenderer()
+    const keymap = createDefaultOpenTuiKeymap(renderer)
+    off = registerOpencodeKeymap(keymap, renderer, tuiConfig)
+
+    return (
+      <OpencodeKeymapProvider keymap={keymap}>
+        <RejectField
+          theme={RUN_THEME_FALLBACK.footer}
+          text=""
+          disabled={false}
+          onChange={(input) => {
+            text = input
+          }}
+          onConfirm={() => {
+            submits.push(text)
+          }}
+          onCancel={() => { }}
+        />
+      </OpencodeKeymapProvider>
+    )
+  }
+
+  const app = await testRender(
+    () => (
+      <box width={100} height={18}>
+        <Harness />
+      </box>
+    ),
+    { width: 100, height: 18, kittyKeyboard: true },
+  )
+
+  try {
+    await app.renderOnce()
+    "retry".split("").forEach((key) => app.mockInput.pressKey(key))
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("retry")
+    app.mockInput.pressEnter()
+    await app.renderOnce()
+    expect(submits).toEqual(["retry"])
+  } finally {
+    app.renderer.currentFocusedRenderable?.blur()
+    app.renderer.currentFocusedEditor?.blur()
+    off?.()
+    app.renderer.destroy()
+  }
+})
+
 test("direct model panel renders current model selector", async () => {
   const [providers] = createSignal<RunProvider[] | undefined>([provider()])
   const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
@@ -461,8 +718,8 @@ test("direct model panel renders current model selector", async () => {
           theme={() => RUN_THEME_FALLBACK.footer}
           providers={providers}
           current={current}
-          onClose={() => {}}
-          onSelect={() => {}}
+          onClose={() => { }}
+          onSelect={() => { }}
         />
       </box>
     ),
@@ -500,8 +757,8 @@ test("direct variant panel renders current variant selector", async () => {
           theme={() => RUN_THEME_FALLBACK.footer}
           variants={variants}
           current={current}
-          onClose={() => {}}
-          onSelect={() => {}}
+          onClose={() => { }}
+          onSelect={() => { }}
         />
       </box>
     ),

+ 0 - 55
packages/opencode/test/cli/run/prompt.shared.test.ts

@@ -7,32 +7,10 @@ import {
   isNewCommand,
   mentionTriggerIndex,
   movePromptHistory,
-  printableBinding,
-  promptCycle,
-  promptHit,
-  promptInfo,
-  promptKeys,
   pushPromptHistory,
 } from "@/cli/cmd/run/prompt.shared"
 import type { RunPrompt } from "@/cli/cmd/run/types"
 
-function bindings(...keys: string[]) {
-  return keys.map((key) => ({ key }))
-}
-
-const keybinds = {
-  leader: "ctrl+x",
-  leaderTimeout: 2000,
-  commandList: bindings("ctrl+p"),
-  variantCycle: bindings("ctrl+t", "<leader>t"),
-  interrupt: bindings("escape"),
-  historyPrevious: bindings("up"),
-  historyNext: bindings("down"),
-  inputClear: bindings("ctrl+c"),
-  inputSubmit: bindings("return"),
-  inputNewline: bindings("shift+return,ctrl+return,alt+return,ctrl+j"),
-}
-
 function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
   return { text, parts }
 }
@@ -141,39 +119,6 @@ describe("run prompt shared", () => {
     expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
   })
 
-  test("handles direct and leader-based variant cycling", () => {
-    const keys = promptKeys(keybinds)
-
-    expect(promptHit(keys.clear, promptInfo({ name: "c", ctrl: true }))).toBe(true)
-
-    expect(promptCycle(false, promptInfo({ name: "x", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
-      arm: true,
-      clear: false,
-      cycle: false,
-      consume: true,
-    })
-
-    expect(promptCycle(true, promptInfo({ name: "t" }), keys.leaders, keys.cycles)).toEqual({
-      arm: false,
-      clear: true,
-      cycle: true,
-      consume: true,
-    })
-
-    expect(promptCycle(false, promptInfo({ name: "t", ctrl: true }), keys.leaders, keys.cycles)).toEqual({
-      arm: false,
-      clear: false,
-      cycle: true,
-      consume: true,
-    })
-  })
-
-  test("prints bindings with leader substitution and esc normalization", () => {
-    expect(printableBinding(keybinds.variantCycle.slice(1), "ctrl+x")).toBe("ctrl+x t")
-    expect(printableBinding(keybinds.interrupt, "ctrl+x")).toBe("esc")
-    expect(printableBinding([], "ctrl+x")).toBe("")
-  })
-
   test("recognizes exit commands", () => {
     expect(isExitCommand("/exit")).toBe(true)
     expect(isExitCommand(" /Quit ")).toBe(true)

+ 33 - 25
packages/opencode/test/cli/run/runtime.boot.test.ts

@@ -1,8 +1,7 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
 import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
-import { formatBindings } from "@/cli/cmd/run/keymap.shared"
-import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
+import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 
 function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
@@ -111,35 +110,44 @@ describe("run runtime boot", () => {
       }),
     )
 
-    const result = await resolveFooterKeybinds()
+    const result = await resolveRunTuiConfig()
 
-    expect(result.leader).toBe("ctrl+g")
-    expect(result.leaderTimeout).toBe(2000)
-    expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
-    expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t, alt+t")
-    expect(formatBindings(result.interrupt, result.leader)).toBe("ctrl+c")
-    expect(formatBindings(result.historyPrevious, result.leader)).toBe("k")
-    expect(formatBindings(result.historyNext, result.leader)).toBe("j")
-    expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+l")
-    expect(formatBindings(result.inputSubmit, result.leader)).toBe("ctrl+s")
-    expect(formatBindings(result.inputNewline, result.leader)).toBe("alt+return")
+    expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
+    expect(result.leader_timeout).toBe(2000)
+    expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
+    expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
+    expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
+    expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
+    expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
+    expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
+    expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
+    expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
   })
 
-  test("falls back to default keybinds when config load fails", async () => {
+  test("falls back to default tui keymap config when config load fails", async () => {
     spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
 
-    const result = await resolveFooterKeybinds()
+    const result = await resolveRunTuiConfig()
 
-    expect(result.leader).toBe("ctrl+x")
-    expect(result.leaderTimeout).toBe(2000)
-    expect(formatBindings(result.commandList, result.leader)).toBe("ctrl+p")
-    expect(formatBindings(result.variantCycle, result.leader)).toBe("ctrl+t")
-    expect(formatBindings(result.interrupt, result.leader)).toBe("esc")
-    expect(formatBindings(result.historyPrevious, result.leader)).toBe("up")
-    expect(formatBindings(result.historyNext, result.leader)).toBe("down")
-    expect(formatBindings(result.inputClear, result.leader)).toBe("ctrl+c")
-    expect(formatBindings(result.inputSubmit, result.leader)).toBe("return")
-    expect(formatBindings(result.inputNewline, result.leader)).toBe("shift+return, ctrl+return, alt+return, ctrl+j")
+    expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
+    expect(result.leader_timeout).toBe(2000)
+    expect(result.diff_style).toBe("auto")
+    expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
+    expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
+    expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
+    expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
+    expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
+    expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
+    expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
+    expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
+  })
+
+  test("preserves disabled leader from resolved tui config", async () => {
+    spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
+
+    const result = await resolveRunTuiConfig()
+
+    expect(result.keybinds.get("leader")).toEqual([])
   })
 
   test("reads diff style and falls back to auto", async () => {

+ 3 - 3
packages/plugin/package.json

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