restore managed textarea keymap handling (#26771)

This commit is contained in:
Sebastian 2026-05-11 01:45:59 +02:00 committed by GitHub
commit 5654dd2aad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 53 additions and 37 deletions

View file

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

View file

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

View file

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

View file

@ -8,9 +8,9 @@ import {
import { import {
KeymapProvider, KeymapProvider,
reactiveMatcherFromSignal, reactiveMatcherFromSignal,
useBindings,
useKeymap, useKeymap,
useKeymapSelector, useKeymapSelector,
useBindings,
} from "@opentui/keymap/solid" } from "@opentui/keymap/solid"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { TuiConfig } from "./config/tui" import type { TuiConfig } from "./config/tui"
@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap> 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 = [ const inputCommands = [
"input.move.left", "input.move.left",
"input.move.right", "input.move.right",
@ -98,8 +120,13 @@ export function formatKeyBindings(
return formatCommandBindingsExtra(bindings, formatOptions(config)) 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 offCommaBindings = addons.registerCommaBindings(keymap)
const offAliasExpander = registerKeyAliases(keymap)
const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
const offLeader = addons.registerTimedLeader(keymap, { const offLeader = addons.registerTimedLeader(keymap, {
trigger: config.keybinds.get(LEADER_TOKEN), trigger: config.keybinds.get(LEADER_TOKEN),
@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
}) })
const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
const offInputCommands = addons.registerEditBufferCommands(keymap, renderer) const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
const offInputBindings = keymap.registerLayer({
enabled: () => renderer.currentFocusedEditor !== null, enabled: () => renderer.currentFocusedEditor !== null,
bindings: config.keybinds.gather("input", inputCommands), bindings: config.keybinds.gather("input", inputCommands),
}) })
return () => { return () => {
offInputBindings() offInputBindings()
offInputSuspension()
offInputCommands()
offBackspace() offBackspace()
offEscape() offEscape()
offLeader() offLeader()
offAliasExpander()
offBaseLayout() offBaseLayout()
offCommaBindings() offCommaBindings()
} }

View file

@ -3,36 +3,27 @@ import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/promp
describe("computePromptTraits", () => { describe("computePromptTraits", () => {
test("normal mode without autocomplete only captures tab", () => { 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.capture).toEqual(["tab"])
expect(traits.suspend).toBe(false) expect(traits.suspend).toBeUndefined()
expect(traits.status).toBeUndefined() expect(traits.status).toBeUndefined()
}) })
test("normal mode with autocomplete captures navigation keys", () => { 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.capture).toEqual(["escape", "navigate", "submit", "tab"])
expect(traits.suspend).toBe(false) expect(traits.suspend).toBeUndefined()
expect(traits.status).toBeUndefined() expect(traits.status).toBeUndefined()
}) })
test("shell mode does not suspend the textarea", () => { test("shell mode does not write the keymap-owned suspend trait", () => {
// Suspending the textarea would gate every keybinding action const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false })
// (backspace, delete-word-backward, arrow movement, etc.) — see expect(traits.suspend).toBeUndefined()
// @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 disables capture and labels the prompt", () => { 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.capture).toBeUndefined()
expect(traits.status).toBe("SHELL") 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)
})
}) })