restore managed textarea keymap handling (#26771)

This commit is contained in:
Sebastian
2026-05-11 01:45:59 +02:00
committed by GitHub
parent 64dde0cb15
commit 5654dd2aad
5 changed files with 53 additions and 37 deletions

View File

@@ -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)
})

View File

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

View File

@@ -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",

View File

@@ -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()
}

View File

@@ -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)
})
})