diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index c7a2cd560f..cc2afd1cdf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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 }) { 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 }) { 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) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d3bfdfbac3..f1ce2b6765 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/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, }), } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts index a701396562..03b0580529 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts +++ b/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", diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 379fa5afdf..289bb901d6 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/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 +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, +) { 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() } diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts index 34a16aedd6..a7b1643357 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts +++ b/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) - }) })