restore managed textarea keymap handling (#26771)
This commit is contained in:
parent
64dde0cb15
commit
5654dd2aad
5 changed files with 53 additions and 37 deletions
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue