Pārlūkot izejas kodu

restore managed textarea keymap handling (#26771)

Sebastian 3 mēneši atpakaļ
vecāks
revīzija
5654dd2aad

+ 11 - 6
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -93,7 +93,6 @@ const appBindingCommands = [
   "theme.mode.lock",
   "help.show",
   "docs.open",
-  "app.exit",
   "app.debug",
   "app.console",
   "app.heap_snapshot",
@@ -648,11 +647,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         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",
       },
@@ -785,6 +779,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
   }))
 
+  useBindings(() => ({
+    enabled: () => {
+      const ok = command.matcher.get()
+      if (!ok) return false
+      const current = promptRef.current
+      if (!current?.focused) return true
+      return current.current.input === ""
+    },
+    bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
+  }))
+
   event.on(TuiEvent.CommandExecute.type, (evt) => {
     command.run(evt.properties.command)
   })

+ 0 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) {
       ...input.traits,
       ...computePromptTraits({
         mode: store.mode,
-        disabled: !!props.disabled,
         autocompleteVisible: !!auto()?.visible,
       }),
     }

+ 3 - 6
packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts

@@ -4,7 +4,6 @@ export type PromptMode = "normal" | "shell"
 
 export interface PromptTraitsInput {
   mode: PromptMode
-  disabled: boolean
   autocompleteVisible: boolean
 }
 
@@ -16,10 +15,9 @@ export type PromptTraits = EditorTraits & {
 /**
  * Compute the textarea editor traits for the prompt.
  *
- * `traits.suspend` gates the textarea's keybinding actions (backspace,
- * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
- * editing mode — only `disabled` should suspend the textarea, otherwise
- * users can type in shell mode but cannot delete or move the cursor.
+ * The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits
+ * only expose capture/status metadata so focus changes cannot unsuspend the
+ * keymap-managed editor mappings.
  */
 export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
   const capture =
@@ -30,7 +28,6 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
       : undefined
   return {
     capture,
-    suspend: input.disabled,
     status: input.mode === "shell" ? "SHELL" : undefined,
     owner: "opencode",
     role: "prompt",

+ 31 - 7
packages/opencode/src/cli/cmd/tui/keymap.tsx

@@ -8,9 +8,9 @@ import {
 import {
   KeymapProvider,
   reactiveMatcherFromSignal,
-  useBindings,
   useKeymap,
   useKeymapSelector,
+  useBindings,
 } from "@opentui/keymap/solid"
 import type { Accessor } from "solid-js"
 import type { TuiConfig } from "./config/tui"
@@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
 
 export type OpenTuiKeymap = ReturnType<typeof useKeymap>
 
+const KEY_ALIASES = {
+  enter: "return",
+  esc: "escape",
+} as const
+
+function expandKeyAliases(input: string) {
+  const result = Object.entries(KEY_ALIASES).reduce(
+    (acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
+    input,
+  )
+  if (result === input) return
+  return result
+}
+
+function registerKeyAliases(keymap: OpenTuiKeymap) {
+  return keymap.appendBindingExpander((ctx) => {
+    const key = expandKeyAliases(ctx.input)
+    if (!key) return
+    return [{ key, displays: ctx.displays }]
+  })
+}
+
 const inputCommands = [
   "input.move.left",
   "input.move.right",
@@ -98,8 +120,13 @@ export function formatKeyBindings(
   return formatCommandBindingsExtra(bindings, formatOptions(config))
 }
 
-export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
+export function registerOpencodeKeymap(
+  keymap: OpenTuiKeymap,
+  renderer: CliRenderer,
+  config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
+) {
   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),
@@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
   })
   const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
   const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
-  const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
-  const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
-  const offInputBindings = keymap.registerLayer({
+  const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
     enabled: () => renderer.currentFocusedEditor !== null,
     bindings: config.keybinds.gather("input", inputCommands),
   })
 
   return () => {
     offInputBindings()
-    offInputSuspension()
-    offInputCommands()
     offBackspace()
     offEscape()
     offLeader()
+    offAliasExpander()
     offBaseLayout()
     offCommaBindings()
   }

+ 8 - 17
packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts

@@ -3,36 +3,27 @@ import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/promp
 
 describe("computePromptTraits", () => {
   test("normal mode without autocomplete only captures tab", () => {
-    const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false })
+    const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false })
     expect(traits.capture).toEqual(["tab"])
-    expect(traits.suspend).toBe(false)
+    expect(traits.suspend).toBeUndefined()
     expect(traits.status).toBeUndefined()
   })
 
   test("normal mode with autocomplete captures navigation keys", () => {
-    const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true })
+    const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true })
     expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"])
-    expect(traits.suspend).toBe(false)
+    expect(traits.suspend).toBeUndefined()
     expect(traits.status).toBeUndefined()
   })
 
-  test("shell mode does not suspend the textarea", () => {
-    // Suspending the textarea would gate every keybinding action
-    // (backspace, delete-word-backward, arrow movement, etc.) — see
-    // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is
-    // an active editing mode, so suspend must stay off.
-    const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
-    expect(traits.suspend).toBe(false)
+  test("shell mode does not write the keymap-owned suspend trait", () => {
+    const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false })
+    expect(traits.suspend).toBeUndefined()
   })
 
   test("shell mode disables capture and labels the prompt", () => {
-    const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
+    const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false })
     expect(traits.capture).toBeUndefined()
     expect(traits.status).toBe("SHELL")
   })
-
-  test("disabled suspends regardless of mode", () => {
-    expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
-    expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
-  })
 })