mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 23:52:06 +00:00
restore managed textarea keymap handling (#26771)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) {
|
||||
...input.traits,
|
||||
...computePromptTraits({
|
||||
mode: store.mode,
|
||||
disabled: !!props.disabled,
|
||||
autocompleteVisible: !!auto()?.visible,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user