tui(run): use keymap instead of raw key events (#30077)

Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com>
This commit is contained in:
Simon Klee
2026-05-31 10:58:12 +02:00
committed by GitHub
co-authored by Sebastian Herrlinger
parent a291967206
commit e8dd8f7fe0
19 changed files with 760 additions and 891 deletions
@@ -1,20 +1,14 @@
// Pure state machine for the prompt input.
//
// Handles keybind parsing, history ring navigation, and the leader-key
// sequence for variant cycling. All functions are pure -- they take state
// in and return new state out, with no side effects.
// Handles history ring navigation and prompt text helpers. All functions are
// pure -- they take state in and return new state out, with no side effects.
//
// The history ring (PromptHistoryState) stores past prompts and tracks
// the current browse position. When the user arrows up at cursor offset 0,
// the current draft is saved and history begins. Arrowing past the end
// restores the draft.
//
// The leader-key cycle (promptCycle) uses a two-step pattern: first press
// arms the leader, second press within the timeout fires the action.
import type { KeyBinding } from "@opentui/core"
export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
import { formatBinding, parseBindings } from "./keymap.shared"
import type { FooterKeybinds, RunPrompt } from "./types"
import type { RunPrompt } from "./types"
const HISTORY_LIMIT = 200
@@ -24,36 +18,6 @@ export type PromptHistoryState = {
draft: string
}
export function promptInfo(event: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) {
return {
name: event.name === " " ? "space" : event.name,
ctrl: !!event.ctrl,
meta: !!event.meta,
shift: !!event.shift,
super: !!event.super,
leader: false,
}
}
type PromptInfo = ReturnType<typeof promptInfo>
export type PromptKeys = {
leaders: PromptInfo[]
cycles: PromptInfo[]
interrupts: PromptInfo[]
previous: PromptInfo[]
next: PromptInfo[]
clear: PromptInfo[]
bindings: KeyBinding[]
}
export type PromptCycle = {
arm: boolean
clear: boolean
cycle: boolean
consume: boolean
}
export type PromptMove = {
state: PromptHistoryState
text?: string
@@ -73,98 +37,6 @@ export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
return a.mode === b.mode && a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
}
function promptKey(binding: ReturnType<typeof parseBindings>[number]): PromptInfo | undefined {
if (binding.event !== "press") {
return undefined
}
const first = binding.sequence[0]
const second = binding.sequence[1]
if (!first) {
return undefined
}
if (!second) {
return first.patternName || first.tokenName
? undefined
: {
name: first.stroke.name,
ctrl: first.stroke.ctrl,
meta: first.stroke.meta,
shift: first.stroke.shift,
super: first.stroke.super,
leader: false,
}
}
if (binding.sequence.length !== 2 || first.tokenName !== "leader" || second.patternName || second.tokenName) {
return undefined
}
return {
name: second.stroke.name,
ctrl: second.stroke.ctrl,
meta: second.stroke.meta,
shift: second.stroke.shift,
super: second.stroke.super,
leader: true,
}
}
export function promptBindings(bindings: FooterKeybinds["commandList"], leader: string): PromptInfo[] {
return parseBindings(bindings, leader).flatMap((binding) => {
const key = promptKey(binding)
return key ? [key] : []
})
}
function mapInputBindings(
bindings: FooterKeybinds["inputSubmit"],
leader: string,
action: "submit" | "newline",
): KeyBinding[] {
return promptBindings(bindings, leader).flatMap((key) => {
if (key.leader) {
return []
}
return [
{
name: key.name,
ctrl: key.ctrl || undefined,
meta: key.meta || undefined,
shift: key.shift || undefined,
super: key.super || undefined,
action,
},
]
})
}
function textareaBindings(keybinds: FooterKeybinds): KeyBinding[] {
return [
...mapInputBindings(keybinds.inputSubmit, keybinds.leader, "submit"),
...mapInputBindings(keybinds.inputNewline, keybinds.leader, "newline"),
]
}
export function promptKeys(keybinds: FooterKeybinds): PromptKeys {
return {
leaders: promptBindings([{ key: keybinds.leader }], keybinds.leader),
cycles: promptBindings(keybinds.variantCycle, keybinds.leader),
interrupts: promptBindings(keybinds.interrupt, keybinds.leader),
previous: promptBindings(keybinds.historyPrevious, keybinds.leader),
next: promptBindings(keybinds.historyNext, keybinds.leader),
clear: promptBindings(keybinds.inputClear, keybinds.leader),
bindings: textareaBindings(keybinds),
}
}
export function printableBinding(bindings: FooterKeybinds["commandList"], leader: string): string {
return formatBinding(bindings, leader)
}
export function isExitCommand(input: string): boolean {
const text = input.trim().toLowerCase()
return text === "/exit" || text === "/quit" || text === ":q"
@@ -174,59 +46,6 @@ export function isNewCommand(input: string): boolean {
return input.trim().toLowerCase() === "/new"
}
export function promptHit(bindings: PromptInfo[], event: PromptInfo): boolean {
return bindings.some(
(item) =>
item.name === event.name &&
item.ctrl === event.ctrl &&
item.meta === event.meta &&
item.shift === event.shift &&
item.super === event.super &&
item.leader === event.leader,
)
}
export function promptCycle(
armed: boolean,
event: PromptInfo,
leaders: PromptInfo[],
cycles: PromptInfo[],
): PromptCycle {
if (!armed && promptHit(leaders, event)) {
return {
arm: true,
clear: false,
cycle: false,
consume: true,
}
}
if (armed) {
return {
arm: false,
clear: true,
cycle: promptHit(cycles, { ...event, leader: true }),
consume: true,
}
}
if (!promptHit(cycles, event)) {
return {
arm: false,
clear: false,
cycle: false,
consume: false,
}
}
return {
arm: false,
clear: false,
cycle: true,
consume: true,
}
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy)
const next: RunPrompt[] = []