From a4149eb27c44d560402d49ad0a5d6159978ba57c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 10:29:24 -0700 Subject: [PATCH 1/5] feat(tui): route shell shortcuts through opentui keymap --- bun.lock | 39 ++-- docs/TUI-keymap-eval.md | 38 ++-- package.json | 19 +- src/tui/keybindings.ts | 66 +++++- src/tui/shell/index.ts | 7 +- src/tui/shell/keymap.ts | 96 +++++++++ src/tui/shell/keys.ts | 460 +++++++++++++++++++++------------------- 7 files changed, 444 insertions(+), 281 deletions(-) create mode 100644 src/tui/shell/keymap.ts diff --git a/bun.lock b/bun.lock index 5aaf53c60..f0d8b2afe 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,8 @@ "@intx/types": "workspace:*", "@intx/workflow-host": "0.3.0", "@modelcontextprotocol/sdk": "^1.32.1", - "@opentui/core": "0.6.4", + "@opentui/core": "0.6.6", + "@opentui/keymap": "0.6.6", "arktype": "catalog:", "isomorphic-git": "catalog:", }, @@ -44,14 +45,14 @@ "typescript-language-server": "^4.3.4", }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.6.4", - "@opentui/core-darwin-x64": "0.6.4", - "@opentui/core-linux-arm64": "0.6.4", - "@opentui/core-linux-arm64-musl": "0.6.4", - "@opentui/core-linux-x64": "0.6.4", - "@opentui/core-linux-x64-musl": "0.6.4", - "@opentui/core-win32-arm64": "0.6.4", - "@opentui/core-win32-x64": "0.6.4", + "@opentui/core-darwin-arm64": "0.6.6", + "@opentui/core-darwin-x64": "0.6.6", + "@opentui/core-linux-arm64": "0.6.6", + "@opentui/core-linux-arm64-musl": "0.6.6", + "@opentui/core-linux-x64": "0.6.6", + "@opentui/core-linux-x64-musl": "0.6.6", + "@opentui/core-win32-arm64": "0.6.6", + "@opentui/core-win32-x64": "0.6.6", }, }, "agents/artist": { @@ -408,23 +409,25 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@opentui/core": ["@opentui/core@0.6.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.6.4", "@opentui/core-darwin-x64": "0.6.4", "@opentui/core-linux-arm64": "0.6.4", "@opentui/core-linux-arm64-musl": "0.6.4", "@opentui/core-linux-x64": "0.6.4", "@opentui/core-linux-x64-musl": "0.6.4", "@opentui/core-win32-arm64": "0.6.4", "@opentui/core-win32-x64": "0.6.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ZCywvSi9A8H5IoDyrw3sKpPkzimsJ+CoUuQWg0bSKLVLh/tYJDmOsFFOnlRzUZPnhGRV8rtLlR613NoEIytig=="], + "@opentui/core": ["@opentui/core@0.6.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.6.6", "@opentui/core-darwin-x64": "0.6.6", "@opentui/core-linux-arm64": "0.6.6", "@opentui/core-linux-arm64-musl": "0.6.6", "@opentui/core-linux-x64": "0.6.6", "@opentui/core-linux-x64-musl": "0.6.6", "@opentui/core-win32-arm64": "0.6.6", "@opentui/core-win32-x64": "0.6.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GhlGB8iG4YDzL6hIaxAYUZpHXDy76sQKuI6mQiCFCKXjfv6xH53pEcPX5gn+qYYYPjfjSaUevAVZBaS5Srtl/g=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mZRj5dwR9+wvota0WDgGwYe3xiNzr4ZTpXcSDB2/vPCjuT5npvO9Y1KQWnNBtI85aOtszBHKirXtVqMk0/kjPQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.6.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pETue+Rm0lVUzJQfdd4ra0zqdqtzObxC4aBsPsRnAg/wc8RCSisT7oxudZyGaTq39Lashnp5R6i7QWPK4HvxLQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.6.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-9Oa2QPLwg0AFy5EAXCQnIxOphPyCo/g476PBfyl32hUKbSxscKJeq1fvepZBdD3kAzSZFGzIroUZ2FhLg2b2kw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.6.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-nexiM1l4rrUEz68t9XgdpaU5RcBH2ebHCsxw9UAGHBSlE477x6vjlaO8bvfdvx1k8vBbGir8y8zDWgkraE0cdA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pstmXbojEysbDiP6SSfDxk7nD6Sy/xFy+AhEfZstV8SKUHGZAbrLep4GnggL5Q5/soy5gQtQTcaaFhK4LtQNxw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.6.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-EXKW3Rmhnv3zkca2sqNBmVXm43TWCHqC/UI4fSDml9WfjZcvdbw+mJ8TEBMe818hwkLOn16bJ/behb3GIVZiZQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-eKRQ13r146JPCuL2NeHcj9i86arZOk9XGJkFc63x7y1iH7YFxgA5sx/KevsqmLNl3F8LE3j+RhX3HPAlmR/KrQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.6.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FU10EOCQT0+B6JU6oo/RmFCZqKJ2XNB8diLXCsAJzspMrtRngXV84MfyOiNcR6o033O7SdHdep+6PoXfU6RZQw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-QVEzBYhjq0T2kzrH2qpvn4trUA+XPJyp5ASEsDbcnihyMFtStdj6wggJ8Ufp+EIeEuf1bi7STv7Ui9MtvgQ2+w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.6.6", "", { "os": "linux", "cpu": "x64" }, "sha512-MNAAsU2+ywpYfJkZ+EYr02nh0yc9egBWRCFAcKvZiYiCbxCoSTmO1c03LbUMlZ/zUlYOi5ZtwzSNi6rhrQZNkw=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1DW/t/7hJc9HpDzmkrZ6pKbXH2uEjOY3Uwo1FlHGZSRy6XMjireF2iU+ucJKO+Krsu07c8hVAOxFQb+sqkLscQ=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.6.6", "", { "os": "linux", "cpu": "x64" }, "sha512-NtRUvk2j7O88JYdVy6aIZyEN31wuHg2cDTsQ13UAoXaGkTc/o3cz8Z7Z1PAA8y5lduZkHWmUd9jhItsfWU8uQw=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.6.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmFGqYoQ+ZbDbZrx7GNXxu/mt06zyBOwIDXKdsiUFgC6sZODz69QGN4xpLhF+popDVCjDyLtFxKW0vMfIOXSXA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.6.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z4bJDA04uCDvXFCquDnbCihof5hMVDP7cGBME4fv/zrKuVMQlQlLGwyExQiG+8vmSZeSeNU0ks+p1g3ewL+3ew=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.6.4", "", { "os": "win32", "cpu": "x64" }, "sha512-NzgXKe+AIrQGXMESc+ScyL6T55h4m7IEA09r8BpxgjwfzMSRzGvI9//0Ps8bQNnGXIDURH8zuS11SWneKhbL3A=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.6.6", "", { "os": "win32", "cpu": "x64" }, "sha512-i3IMd1uviUlJICRjzxPf8LJaxQXkZGHHm/ExqWkymvbUI+FjP/2XWC/JMqO7pD39GIb+lkv5Wte3bxQGvXx8Hg=="], + + "@opentui/keymap": ["@opentui/keymap@0.6.6", "", { "dependencies": { "@opentui/core": "0.6.6" }, "peerDependencies": { "@opentui/react": "0.6.6", "@opentui/solid": "0.6.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-rurBRKBjKydE5GJQEhTm6gPimBzuX+c2Pww0cd+eKH2sxuh0mOh0jhDA5Mr57QLFh2y8EH9a+g01wzfc/j/YFA=="], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.153.0", "", { "os": "android", "cpu": "arm" }, "sha512-BcWu25dMwMjHpaDCEidmpAi7rGg1qZoihJeUUH9bD55pVZ0z4Wa5CQgqSTo0R41H81oozisQKW3WqvAN2m77IQ=="], diff --git a/docs/TUI-keymap-eval.md b/docs/TUI-keymap-eval.md index 633590936..2c42ceb01 100644 --- a/docs/TUI-keymap-eval.md +++ b/docs/TUI-keymap-eval.md @@ -1,39 +1,25 @@ # Keymap evaluation -Decision record for a global keymap on the imperative `@opentui/core` 0.6.4 surface. This record changes no code. +Decision record for application key bindings on `@opentui/core` 0.6.6. The engine is `@opentui/keymap`. ## Recommendation -**Decline** a global keymap. Do not adopt one later under another name, and file no follow-up. The library does not ship an application keymap, and the shell behaviors that would have to move into one are not widget bindings. +**Adopt** `@opentui/keymap` for application commands. Do not move widget-local editing, the scroll lease, or the kill ring into it. -## What 0.6.4 actually provides +`createDefaultOpenTuiKeymap(renderer)` installs the default parser, the event matcher, and the enabled and metadata fields. The shell registers one layer: `commands` (`name`, `run`) and `bindings` (`key`, `cmd`). Each `run` calls the existing handler in `src/tui/shell/keys.ts`. The help overlay reads those rows from the same catalog as the registration (`keymapShortcuts()` plus `promptLocalShortcuts()`). -Bindings are per widget. `TextareaRenderable` takes `keyBindings` of `TextareaAction` (caret, selection, kill-to-line, undo, submit, and the rest of the editor action set). `SelectRenderable` takes `keyBindings` of `SelectAction`: `move-up`, `move-down`, `move-up-fast`, `move-down-fast`, `select-current`. The same shape exists on `TabSelect` and the terminal console; `Input` reuses the textarea actions. Each widget merges its defaults with the caller's list and looks up one event. +## What stays a command body -`mergeKeyBindings(defaults, custom)` in `lib/keybinding.internal.d.ts` is that merge: two `KeyBinding[]` lists become one list for one widget. It does not route across the shell, it does not know which surface holds focus, and it does not encode policy that spans widgets. There is no keymap engine package on this core. The repo depends on `@opentui/core` only. +Widget `keyBindings` cannot express policy that spans the shell: -## What stays +- **Scroll lease.** One surface holds the lease. The wheel must not move the transcript while a modal is open. Arrow keys never scroll the transcript. +- **Kill ring.** Ctrl+K/U/W and Alt+D kill, Ctrl+Y yanks, Alt+Y yank-pops. Those stay Textarea-local in `prompt-kill-ring.ts`. The keymap does not bind them. +- **Ctrl+C.** Pause, stop sub-agents, and quit stay `handleCtrlC`. The keymap command calls that handler; it does not reimplement the arming window. A decision gate still declines before the arming handler runs. -- `src/tui/shell/keys.ts` is the shell dispatcher: paste guard, overlay keys, prompt wheel forwarding, kill/yank orchestration, and Ctrl+C arming. It is not a binding table. -- `src/tui/keybindings.ts` is the shortcut catalog the help overlay reads. `keybindings.test.ts` drives each row through a live shell. The catalog describes chords; it does not implement them. - -Neither file is a stand-in for `mergeKeyBindings`. Replacing them with widget tables would drop behavior the tables cannot express. - -## Deleted if a global keymap were adopted - -It is not adopted. If it were, the deletion this record refuses is: - -- `src/tui/shell/keys.ts` would be deleted or reduced to widget binding tables. -- `src/tui/keybindings.ts` would be deleted in favor of `mergeKeyBindings` defaults. - -Those files stay. Reducing them would drop three policies that are not widget bindings: - -- **Scroll lease.** One surface holds the lease. The wheel must not move the transcript while a modal is open on top of it. Prompt wheel events are forwarded to the transcript only because the prompt itself does not scroll. Arrow keys never scroll anything: in the prompt they are caret motion or history recall, and in an overlay list they move the selection. Page keys move only the surface that holds the lease. -- **Kill ring.** The prompt kill ring (kill chords and yank / yank-pop) is shell state. Textarea delete actions discard or edit a buffer; they are not that ring. -- **Ctrl+C.** The first press pauses a busy run or clears an idle prompt. A later press inside the arming window quits, and live sub-agents insert a stop press before quit. That is session policy, not a textarea `submit` or select `select-current` binding. +Paste, overlay accept, and prompt newline chords stay where they were. Enter, Up, and Down stay in `keys.ts` because the same chord sends, steers, recalls history, or moves the caret. Ctrl+X is the held-item drop and is a keymap command. ## Checks -- [x] Decline a global keymap. `mergeKeyBindings` and per-widget `keyBindings` are the only real key APIs, and they are not an application map. -- [ ] Adopt a global keymap and delete `shell/keys.ts` and `keybindings.ts`. Cannot check. No keymap engine exists on this core, and the adopt path would drop the scroll lease, the kill ring, and Ctrl+C policy. Leave this unchecked. -- [x] File no follow-up. This record does not adopt a path that needs one. +- [x] Use `@opentui/keymap` for application commands. Help rows for those chords come from the keymap catalog. +- [x] Leave the scroll lease, the kill ring, and Ctrl+C policy as command bodies. Widget bindings cannot express them. +- [ ] Delete `shell/keys.ts` and `keybindings.ts`. Cannot check. The dispatcher and the prompt-local catalog stay. diff --git a/package.json b/package.json index f6e8313ee..fc21edeeb 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,8 @@ "@intx/types": "workspace:*", "@intx/workflow-host": "0.3.0", "@modelcontextprotocol/sdk": "^1.32.1", - "@opentui/core": "0.6.4", + "@opentui/core": "0.6.6", + "@opentui/keymap": "0.6.6", "arktype": "catalog:", "isomorphic-git": "catalog:", "@corbits/code-agent-designer": "workspace:*", @@ -125,13 +126,13 @@ "typescript-language-server": "^4.3.4" }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.6.4", - "@opentui/core-darwin-x64": "0.6.4", - "@opentui/core-linux-arm64": "0.6.4", - "@opentui/core-linux-arm64-musl": "0.6.4", - "@opentui/core-linux-x64": "0.6.4", - "@opentui/core-linux-x64-musl": "0.6.4", - "@opentui/core-win32-arm64": "0.6.4", - "@opentui/core-win32-x64": "0.6.4" + "@opentui/core-darwin-arm64": "0.6.6", + "@opentui/core-darwin-x64": "0.6.6", + "@opentui/core-linux-arm64": "0.6.6", + "@opentui/core-linux-arm64-musl": "0.6.6", + "@opentui/core-linux-x64": "0.6.6", + "@opentui/core-linux-x64-musl": "0.6.6", + "@opentui/core-win32-arm64": "0.6.6", + "@opentui/core-win32-x64": "0.6.6" } } diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 526739f59..e8044a99f 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -1,14 +1,18 @@ /** * OpenTUI shell keybinding catalog (pure data) — the help overlay's source - * of truth. Every row must be a chord that really works: a handler in - * `src/tui/shell.ts`, or a default prompt binding from @opentui/core. Do - * not hand-transcribe from docs; `keybindings.test.ts` drives every row's - * chord through a live shell and asserts its effect. + * of truth. Rows with `command` are application chords registered on + * `@opentui/keymap`. Rows without one stay prompt-local: textarea defaults, + * the kill ring, or a chord whose meaning depends on context keys.ts still + * owns. `keybindings.test.ts` drives every row through a live shell. */ export interface ShellShortcut { readonly keys: string; readonly description: string; + /** `@opentui/keymap` command name. Absent for prompt-local chords. */ + readonly command?: string; + /** Binding strings for `command`. Absent for prompt-local chords. */ + readonly bindings?: readonly string[]; } export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ @@ -21,58 +25,84 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ keys: "Alt+Enter", description: "queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy", + command: "shell.queue", + bindings: ["alt+return"], }, { keys: "Up / Down / Enter / Ctrl+X", description: "on held items above the prompt: select, send now, drop (Esc backs out)", + // Up, Down, and Enter also move the caret, recall history, or send. + // Only Ctrl+X is unambiguous; the others stay in keys.ts. + command: "shell.held.drop", + bindings: ["ctrl+x"], }, { keys: "Ctrl+C", description: "pause a busy run and hold the queue, or clear the prompt and attachments when idle; Ctrl+C again to exit — with live sub-agents, a 2nd press stops them, a 3rd quits", + command: "shell.interrupt", + bindings: ["ctrl+c"], }, { keys: "Ctrl+G", description: "pop the most recently queued or steered message back into the prompt for editing", + command: "shell.cancel", + bindings: ["ctrl+g"], }, { keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it", + command: "shell.copy", + bindings: ["alt+c"], }, { keys: "Alt+M", description: "toggle DEC mouse capture (on by default: wheel scroll, click-to-expand, drag-to-copy); off restores native terminal drag-select", + command: "shell.mouse", + bindings: ["alt+m"], }, { keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)", + command: "shell.expand", + bindings: ["alt+e"], }, { keys: "Alt+T", description: "show or hide the task list above the prompt (hidden by default)", + command: "shell.tasks", + bindings: ["alt+t"], }, { keys: "Alt+O", description: "observe a live subagent session; a system row says so when there is none", + command: "shell.observe", + bindings: ["alt+o"], }, { keys: "Tab", description: "move focus between the prompt and the transcript", + command: "shell.focus", + bindings: ["tab"], }, { keys: "Shift+Tab", description: "cycle reasoning effort for the current model", + command: "shell.effort", + bindings: ["shift+tab"], }, { keys: "Esc", description: "close the open overlay, or leave subagent observe", + command: "shell.dismiss", + bindings: ["escape"], }, { keys: "Ctrl+B / Ctrl+F", @@ -128,10 +158,34 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ }, ] as const; -/** Help rows from the shell's own catalog, so they cannot drift from it. */ +/** Application chords the keymap registers. */ +export function keymapShortcuts(): readonly ShellShortcut[] { + return SHELL_SHORTCUTS.filter((row) => row.command !== undefined); +} + +/** + * Prompt-local rows: textarea defaults, the kill ring, paste, and chords + * whose meaning depends on context the keymap does not own. + */ +export function promptLocalShortcuts(): readonly ShellShortcut[] { + return SHELL_SHORTCUTS.filter((row) => row.command === undefined); +} + +function formatShortcut(row: ShellShortcut): string { + return `${row.keys} — ${row.description}`; +} + +/** + * Help rows from the keymap catalog plus prompt-local rows, in catalog + * order, so the overlay cannot drift from either list. + */ export function helpItems(): readonly string[] { + const owned = new Set([ + ...keymapShortcuts().map((row) => row.keys), + ...promptLocalShortcuts().map((row) => row.keys), + ]); return [ - ...SHELL_SHORTCUTS.map((s) => `${s.keys} — ${s.description}`), + ...SHELL_SHORTCUTS.filter((row) => owned.has(row.keys)).map(formatShortcut), "Close help", ]; } diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 3d97cf5d1..78ccb6d99 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -83,6 +83,7 @@ import { createShellKeyHandlers, routePromptWheelToTranscript, } from "./keys.js"; +import { attachShellKeymap } from "./keymap.js"; const DEFAULT_TITLE = "corbits"; @@ -364,6 +365,7 @@ export function createAppShell( } let disposed = false; + let detachKeymap: (() => void) | undefined; let session = createSessionQueue(run); const seedPending = Math.max(0, Math.floor(options?.pendingQueue ?? 0)); for (let i = 0; i < seedPending; i++) { @@ -518,6 +520,8 @@ export function createAppShell( shell.disposed = true; // Quit paths that skip idle Ctrl+C still drop Corbits-created files. clearPendingAttachments(shell); + detachKeymap?.(); + detachKeymap = undefined; if (wireKeys) { renderer.keyInput.off("keypress", onKey); renderer.keyInput.off("paste", onPaste); @@ -628,13 +632,14 @@ export function createAppShell( if (onObserveRequestOpt) { setPaletteOnObserveRequest(shell, onObserveRequestOpt); } - const { onKey, onPaste } = createShellKeyHandlers(shell, { + const { onKey, onPaste, keymap } = createShellKeyHandlers(shell, { isDisposed: () => disposed, }); if (wireKeys) { renderer.keyInput.on("keypress", onKey); renderer.keyInput.on("paste", onPaste); prompt.onSubmit = onEnter; + detachKeymap = attachShellKeymap(renderer as CliRenderer, keymap); } applyLayout(shell, layout); diff --git a/src/tui/shell/keymap.ts b/src/tui/shell/keymap.ts new file mode 100644 index 000000000..1966a387b --- /dev/null +++ b/src/tui/shell/keymap.ts @@ -0,0 +1,96 @@ +/** + * Application chords on `@opentui/keymap`. The package looks the chord up; + * `ShellKeymapDispatch` in keys.ts is the command body. Prompt kill, newline, + * and contextual Enter / Up / Down stay out of this layer. + */ +import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"; +import type { Keymap } from "@opentui/keymap"; +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"; + +import { keymapShortcuts } from "../keybindings.js"; +import type { ShellKeymapDispatch } from "./keys.js"; + +type ShellKeymap = Keymap; + +const keymaps = new WeakMap(); + +/** + * Terminals that cannot report Shift+Tab send `backtab`. It is not a named + * key in the default parser, so a string binding would become a letter sequence. + */ +const EFFORT_BACKTAB = { name: "backtab" } as const; + +function keymapFor(renderer: CliRenderer): ShellKeymap { + const existing = keymaps.get(renderer); + if (existing !== undefined) return existing; + const created = createDefaultOpenTuiKeymap(renderer); + keymaps.set(renderer, created); + return created; +} + +function runCommand( + command: string, + dispatch: ShellKeymapDispatch, + key: KeyEvent, +): boolean { + switch (command) { + case "shell.dismiss": + return dispatch.escape(key); + case "shell.interrupt": + return dispatch.ctrlC(key); + case "shell.copy": + return dispatch.copy(key); + case "shell.mouse": + return dispatch.mouse(key); + case "shell.expand": + return dispatch.expand(key); + case "shell.tasks": + return dispatch.tasks(key); + case "shell.observe": + return dispatch.observe(key); + case "shell.focus": + return dispatch.focus(key); + case "shell.effort": + return dispatch.effort(key); + case "shell.cancel": + return dispatch.cancel(key); + case "shell.queue": + return dispatch.queue(key); + case "shell.held.drop": + return dispatch.heldDrop(key); + default: + return false; + } +} + +/** Register the shell's application layer. Dispose unregisters that layer. */ +export function attachShellKeymap( + renderer: CliRenderer, + dispatch: ShellKeymapDispatch, +): () => void { + const keymap = keymapFor(renderer); + const rows = keymapShortcuts(); + return keymap.registerLayer({ + commands: rows.map((row) => ({ + name: row.command ?? "", + desc: row.description, + run(ctx: { event: KeyEvent }): boolean { + const command = row.command; + if (command === undefined) return false; + return runCommand(command, dispatch, ctx.event); + }, + })), + bindings: [ + ...rows.flatMap((row) => + (row.bindings ?? []).map((key) => ({ + key, + cmd: row.command ?? "", + // Esc must not stopPropagation. The engine pairs that with + // preventDefault, and the list modal's own listener still has to run. + ...(row.command === "shell.dismiss" ? { preventDefault: false } : {}), + })), + ), + { key: EFFORT_BACKTAB, cmd: "shell.effort" }, + ], + }); +} diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index cc7c6e0e5..b8c0a1e9a 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -36,6 +36,7 @@ import { applyFocus, applyLandingSuggestion, setStatusFlash, + toggleCollapsedRow, toggleShellFocus, toggleTasksPanel, } from "./chrome.js"; @@ -71,7 +72,6 @@ import { toggleOverlayExpand, } from "./overlay-list.js"; import { OVERLAY_EXPAND_KEY } from "./transcript.js"; -import { toggleCollapsedRow } from "./chrome.js"; import { leaveSubagentObserve, observeActiveSubagent } from "./observe.js"; import { enterCopyMode, toggleMouseCapture } from "./copy.js"; import { canPopFocus, focusOwner, popFocus } from "../focus/index.js"; @@ -91,7 +91,6 @@ import { stepSentHistoryDown, stepSentHistoryUp, } from "../sent-message-history.js"; -import { EXPAND_KEY } from "../stream.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -308,6 +307,28 @@ export function routePromptWheelToTranscript( interface ShellKeyHandlers { onKey: (key: KeyEvent) => void; onPaste: (event: { bytes: Uint8Array; preventDefault: () => void }) => void; + /** Application chords the OpenTUI keymap dispatches into. */ + keymap: ShellKeymapDispatch; +} + +/** + * Command bodies for `@opentui/keymap`. The keymap owns chord lookup; these + * closures own the policy, including the paste-burst clock that has to move + * with a chord the keymap consumes before `onKey`. + */ +export interface ShellKeymapDispatch { + escape(key: KeyEvent): boolean; + ctrlC(key: KeyEvent): boolean; + copy(key: KeyEvent): boolean; + mouse(key: KeyEvent): boolean; + expand(key: KeyEvent): boolean; + tasks(key: KeyEvent): boolean; + observe(key: KeyEvent): boolean; + focus(key: KeyEvent): boolean; + effort(key: KeyEvent): boolean; + cancel(key: KeyEvent): boolean; + queue(key: KeyEvent): boolean; + heldDrop(key: KeyEvent): boolean; } function killThenRecord( @@ -371,54 +392,219 @@ export function createShellKeyHandlers( clearPendingSelection(shell); }; - const onKey = (key: KeyEvent): void => { - if (opts.isDisposed()) return; - if (shellInternals(shell)?.inputSuspended === true) return; + const accepts = (): boolean => + !opts.isDisposed() && shellInternals(shell)?.inputSuspended !== true; + + const token = (key: KeyEvent): string => + typeof key.name === "string" ? key.name.toLowerCase() : ""; + + /** + * Un-bracketed paste replay. Returns true when this key was consumed + * (suppressed LF, or a burst Enter turned into a newline). Callers + * preventDefault. + */ + const trackPasteBurst = (key: KeyEvent): boolean => { + if (sawBracketedPaste) return false; + const keyName = token(key); + const suppressLinefeed = suppressNextLinefeed; + suppressNextLinefeed = false; + if ( + suppressLinefeed && + keyName === "linefeed" && + !key.ctrl && + !key.meta && + !key.option + ) { + return true; + } + const now = Date.now(); + const sincePreviousKey = now - lastKeyAt; + const previousKeyWasPrintable = lastKeyWasPrintable; + lastKeyAt = now; + lastKeyWasPrintable = isPrintableInsertKey(key); + const isBareReturn = + !key.ctrl && + !key.meta && + !key.option && + (keyName === "return" || keyName === "kpenter"); + if ( + isBareReturn && + previousKeyWasPrintable && + sincePreviousKey < PASTE_BURST_MS + ) { + shell.prompt.insertText("\n"); + suppressNextLinefeed = true; + return true; + } + return false; + }; - if (key.name === "escape") { - if (exitOverlayAnswerMode(shell)) { - key.preventDefault(); - return; - } + /** + * Side effects a non-kill key used to pick up in onKey after paste tracking + * and after the kill handlers declined it: drop a held-item selection, + * break the yank sequence, and leave history browse on an edit. + */ + const prepareNonKillKey = (key: KeyEvent): void => { + const keyName = token(key); + if ( + pendingSelectionActive(shell) && + keyName !== "up" && + keyName !== "down" + ) { + clearPendingSelection(shell); + } + const isCtrlKillYank = + key.ctrl && + !key.meta && + !key.option && + (keyName === "k" || + keyName === "u" || + keyName === "w" || + keyName === "y"); + const isAltKillYank = + (key.meta || key.option) && + !key.ctrl && + (keyName === "d" || keyName === "y"); + if (!isCtrlKillYank && !isAltKillYank) { + shell.promptKillRing = breakKillSequence(shell.promptKillRing); + } + const promptUpDown = + !key.ctrl && + !key.meta && + !key.option && + (key.name === "up" || key.name === "down") && + focusOwner(shell.focus) === "prompt"; + if (!promptUpDown && !MOTION_KEYS.has(keyName)) { + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); + } + }; + + /** Chords that only apply once the overlay has not claimed the key. */ + const beginLateChord = (key: KeyEvent): boolean => { + if (!accepts() || shell.overlayList !== null) return false; + if (trackPasteBurst(key)) return true; + prepareNonKillKey(key); + return true; + }; + + const dismissShell = (): boolean => { + if (exitOverlayAnswerMode(shell)) return true; + if (shell.overlayList) { + abortOverlayHostReservations(shell); + const list = shell.overlayList; + closeSlashPopup(shell); + if (list !== null && shell.overlayList === list) closeInsetOverlay(shell); + return true; + } + if (shellInternals(shell)?.overlayHostReservations) { + abortOverlayHostReservations(shell); + queueMicrotask(() => notifyOverlayClosed(shell)); + return true; + } + if (shell.observe) { + leaveSubagentObserve(shell); + return true; + } + if (clearPendingSelection(shell)) return true; + if (canPopFocus(shell.focus)) { + shell.focus = popFocus(shell.focus); + applyFocus(shell); + return true; + } + return false; + }; + + const keymap: ShellKeymapDispatch = { + escape: (key) => { + if (!accepts()) return false; + const handled = dismissShell(); + // preventDefault without stopPropagation: the keymap binding opts out of + // the engine's paired stop so a later global listener (the list modal) + // still sees Esc. Renderables do not. + if (handled) key.preventDefault(); + return handled; + }, + ctrlC: (key) => { + if (!accepts()) return false; if (shell.overlayList) { - key.preventDefault(); - abortOverlayHostReservations(shell); - // A slash popup's close already closes the inset; closing again - // would idle-notify twice and kill a gate the first notify drains. - // Other overlays have no entry, so the shared close handles them. - const list = shell.overlayList; - closeSlashPopup(shell); - if (list !== null && shell.overlayList === list) - closeInsetOverlay(shell); - return; - } - if (shellInternals(shell)?.overlayHostReservations) { - abortOverlayHostReservations(shell); - key.preventDefault(); - // Next tick so the same Esc cannot also dismiss a gate this abort drains. - queueMicrotask(() => notifyOverlayClosed(shell)); - return; - } - if (shell.observe) { - key.preventDefault(); - leaveSubagentObserve(shell); - return; - } - // The pending-column selection is the shallowest frame; back out of - // it before touching transcript focus. - if (clearPendingSelection(shell)) { - key.preventDefault(); - return; + if (isDecisionGate(shell)) { + stopDecisionGate(shell); + return true; + } + return false; } - // Transcript browse (Tab) is the last poppable frame: Esc returns - // typing to the prompt. - if (canPopFocus(shell.focus)) { - key.preventDefault(); - shell.focus = popFocus(shell.focus); - applyFocus(shell); - return; + if (!beginLateChord(key)) return false; + handleCtrlC(shell); + return true; + }, + copy: (key) => { + if (!accepts()) return false; + if (shell.overlayList) return toggleCloseOpenSurface(shell, key); + if (!beginLateChord(key)) return false; + enterCopyMode(shell); + return true; + }, + mouse: (key) => { + if (!beginLateChord(key)) return false; + toggleMouseCapture(shell); + return true; + }, + expand: (key) => { + if (!accepts() || shell.overlayList !== null) return false; + if (!toggleCollapsedRow(shell)) return false; + if (trackPasteBurst(key)) return true; + prepareNonKillKey(key); + return true; + }, + tasks: (key) => { + if (!beginLateChord(key)) return false; + toggleTasksPanel(shell); + return true; + }, + observe: (key) => { + if (!beginLateChord(key)) return false; + observeActiveSubagent(shell); + return true; + }, + focus: (key) => { + if (!beginLateChord(key)) return false; + toggleShellFocus(shell); + return true; + }, + effort: (key) => { + if (!beginLateChord(key)) return false; + effortCycleHandlers.get(shell)?.(); + return true; + }, + cancel: (key) => { + if (!accepts() || shell.overlayList !== null) return false; + if (trackPasteBurst(key)) return true; + if (pendingSelectionActive(shell)) { + applyPendingCancelSelected(shell); + return true; } - } + prepareNonKillKey(key); + applyShellCancelLast(shell); + return true; + }, + queue: (key) => { + if (!beginLateChord(key)) return false; + if (shell.session.run !== "busy") return true; + submitPrompt(shell, "queue"); + return true; + }, + heldDrop: (key) => { + if (!accepts() || shell.overlayList !== null) return false; + if (!pendingSelectionActive(shell)) return false; + if (trackPasteBurst(key)) return true; + applyPendingDrop(shell); + return true; + }, + }; + + const onKey = (key: KeyEvent): void => { + if (opts.isDisposed()) return; + if (shellInternals(shell)?.inputSuspended === true) return; // Landing starters; only while the prompt is untouched, so the key is a // plain character once typing starts. @@ -435,21 +621,6 @@ export function createShellKeyHandlers( } if (shell.overlayList) { - // A decision gate keeps every key until it is answered, and a run that - // keeps raising gates (each rejection re-infers into more calls) would - // then leave the stop key unreachable. Ctrl+C declines the open gate the - // way Esc does, then interrupts the turn, which drops queued gates too. - if (key.ctrl && key.name === "c" && isDecisionGate(shell)) { - key.preventDefault(); - stopDecisionGate(shell); - return; - } - // Checked before the filter handlers: an opener pressed again closes, - // it is not a filter character. - if (toggleCloseOpenSurface(shell, key)) { - key.preventDefault(); - return; - } // The `/` popup filters as you type, claiming printable keys ahead of // j/k navigation. if (handleSlashPopupKey(shell, key)) { @@ -567,54 +738,14 @@ export function createShellKeyHandlers( // Ctrl+Y/Alt+Y need a place to yank it back from — the kill ring below. const keyName = typeof key.name === "string" ? key.name.toLowerCase() : ""; - // The un-bracketed-paste fallback: a terminal that fired a real `paste` - // event never needs it again. - if (!sawBracketedPaste) { - // The LF of a CRLF pair the block below converted; without this, - // "line one\r\nline two" would insert two newlines. - const suppressLinefeed = suppressNextLinefeed; - suppressNextLinefeed = false; - if ( - suppressLinefeed && - keyName === "linefeed" && - !key.ctrl && - !key.meta && - !key.option - ) { - key.preventDefault(); - return; - } - - // A bare CR is the same "return" that submits; pasting three lines - // would otherwise send three messages. The signal — printable char - // then Enter, both within one keystroke burst — is unique to a paste - // replay, and gating on both keeps Ctrl+J-then-Enter safe (Ctrl+J is - // not printable). - const now = Date.now(); - const sincePreviousKey = now - lastKeyAt; - const previousKeyWasPrintable = lastKeyWasPrintable; - lastKeyAt = now; - lastKeyWasPrintable = isPrintableInsertKey(key); - const isBareReturn = - !key.ctrl && - !key.meta && - !key.option && - (keyName === "return" || keyName === "kpenter"); - if ( - isBareReturn && - previousKeyWasPrintable && - sincePreviousKey < PASTE_BURST_MS - ) { - key.preventDefault(); - shell.prompt.insertText("\n"); - suppressNextLinefeed = true; - return; - } + if (trackPasteBurst(key)) { + key.preventDefault(); + return; } - // A pending-column selection owns Enter (send now), ^X (drop) and ^G - // (back to editing); any other key ends the selection. ↑/↓ stay with - // the column (nav block below). + // A pending-column selection owns Enter (send now). Ctrl+X and Ctrl+G are + // keymap commands. Any other key ends the selection. ↑/↓ stay with the + // column (nav block below). if (pendingSelectionActive(shell)) { if ( (keyName === "return" || keyName === "kpenter") && @@ -626,16 +757,6 @@ export function createShellKeyHandlers( applyPendingForcePush(shell); return; } - if (key.ctrl && !key.meta && !key.option && keyName === "x") { - key.preventDefault(); - applyPendingDrop(shell); - return; - } - if (key.ctrl && !key.meta && !key.option && keyName === "g") { - key.preventDefault(); - applyPendingCancelSelected(shell); - return; - } if (keyName !== "up" && keyName !== "down") { clearPendingSelection(shell); } @@ -797,109 +918,6 @@ export function createShellKeyHandlers( } else if (!MOTION_KEYS.has(keyName)) { shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); } - - if ( - ((key.name === "tab" && key.shift) || key.name === "backtab") && - !key.ctrl && - !key.meta && - !key.option - ) { - key.preventDefault(); - effortCycleHandlers.get(shell)?.(); - return; - } - - if ( - key.name === "tab" && - !key.ctrl && - !key.meta && - !key.option && - !key.shift - ) { - key.preventDefault(); - toggleShellFocus(shell); - return; - } - - // Alt+E, never bare: with the prompt focused a bare `e` would just type - // a letter instead of expanding a row. - if ((key.meta || key.option) && !key.ctrl && key.name === EXPAND_KEY) { - if (toggleCollapsedRow(shell)) { - key.preventDefault(); - return; - } - } - - if ( - (key.meta || key.option) && - (key.name === "c" || key.name === "C") && - !key.ctrl - ) { - // Alt+C: keyboard copy path (no mouse drag-select). - key.preventDefault(); - enterCopyMode(shell); - return; - } - - if ( - (key.meta || key.option) && - (key.name === "m" || key.name === "M") && - !key.ctrl - ) { - // Alt+M: release mouse reporting so the terminal can drag-select. - key.preventDefault(); - toggleMouseCapture(shell); - return; - } - - if ( - (key.meta || key.option) && - (key.name === "t" || key.name === "T") && - !key.ctrl - ) { - // Alt+T: the task panel toggle (palette no longer owns it). - key.preventDefault(); - toggleTasksPanel(shell); - return; - } - - if ( - (key.meta || key.option) && - (key.name === "o" || key.name === "O") && - !key.ctrl - ) { - // Alt+O: observe a live subagent, same rationale as Alt+T. - key.preventDefault(); - observeActiveSubagent(shell); - return; - } - - if (key.ctrl && key.name === "c") { - key.preventDefault(); - handleCtrlC(shell); - return; - } - - if (key.ctrl && key.name === "g") { - // Readline/Emacs "abort" chord, unclaimed elsewhere; muscle memory - // already reads it as "cancel the pending thing". - key.preventDefault(); - applyShellCancelLast(shell); - return; - } - - if ( - (key.name === "return" || key.name === "enter") && - (key.meta || key.option) && - !key.ctrl - ) { - // Alt+Enter: enqueue a follow-up, delivered only when the run goes - // idle; does not interrupt or reinject. Idle/empty is a no-op. - key.preventDefault(); - if (shell.session.run !== "busy") return; - submitPrompt(shell, "queue"); - return; - } }; - return { onKey, onPaste }; + return { onKey, onPaste, keymap }; } From 108b6f0db1ff030efeb308ec9810bd02ae2f7f26 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 10:47:09 -0700 Subject: [PATCH 2/5] fix(tui): drop the keymap chord switch and textarea rebinds --- docs/TUI-keymap-eval.md | 8 +- src/tui/keybindings.test.ts | 12 +- src/tui/keybindings.ts | 145 +++---------- src/tui/shell/index.ts | 9 +- src/tui/shell/keymap.ts | 111 ++++------ src/tui/shell/keys.ts | 372 ++++++---------------------------- src/tui/shell/shell-chords.ts | 254 +++++++++++++++++++++++ src/tui/wave7.test.ts | 4 +- 8 files changed, 410 insertions(+), 505 deletions(-) create mode 100644 src/tui/shell/shell-chords.ts diff --git a/docs/TUI-keymap-eval.md b/docs/TUI-keymap-eval.md index 2c42ceb01..482217d9b 100644 --- a/docs/TUI-keymap-eval.md +++ b/docs/TUI-keymap-eval.md @@ -6,14 +6,16 @@ Decision record for application key bindings on `@opentui/core` 0.6.6. The engin **Adopt** `@opentui/keymap` for application commands. Do not move widget-local editing, the scroll lease, or the kill ring into it. -`createDefaultOpenTuiKeymap(renderer)` installs the default parser, the event matcher, and the enabled and metadata fields. The shell registers one layer: `commands` (`name`, `run`) and `bindings` (`key`, `cmd`). Each `run` calls the existing handler in `src/tui/shell/keys.ts`. The help overlay reads those rows from the same catalog as the registration (`keymapShortcuts()` plus `promptLocalShortcuts()`). +`createDefaultOpenTuiKeymap(renderer)` installs the default parser, the event matcher, and the enabled and metadata fields. The shell registers one layer. Each command `run` calls one existing function. There is no parallel key switch. Help lines are the keymap's registered `desc` values plus the widget-default rows. + +Textarea defaults are not registered again. `renderables/Textarea.d.ts` `TextareaAction` and the shipped `defaultTextareaKeyBindings` already cover cursor motion, word motion, Ctrl+D, Ctrl+K/U/W, and Alt+D. `PROMPT_KEY_BINDINGS` already maps Enter to submit and Ctrl+Enter / Ctrl+J / Shift+Enter to newline. The kill ring only diffs those deletes and yanks. ## What stays a command body Widget `keyBindings` cannot express policy that spans the shell: -- **Scroll lease.** One surface holds the lease. The wheel must not move the transcript while a modal is open. Arrow keys never scroll the transcript. -- **Kill ring.** Ctrl+K/U/W and Alt+D kill, Ctrl+Y yanks, Alt+Y yank-pops. Those stay Textarea-local in `prompt-kill-ring.ts`. The keymap does not bind them. +- **Scroll lease.** One surface holds the lease. The wheel must not move the transcript while a modal is open. Arrow keys never scroll the transcript. `ScrollBoxRenderable.handleKeyPress` forwards arrows to `ScrollBarRenderable`, which scrolls on up/down. That focused arrow-scroll is not adopted. +- **Kill ring.** Textarea delete actions discard the text (`delete-to-line-end`, `delete-to-line-start`, `delete-word-backward`, `delete-word-forward` in `Textarea.d.ts`). `prompt-kill-ring.ts` stays for the diff and for yank / yank-pop. The keymap does not bind those chords. - **Ctrl+C.** Pause, stop sub-agents, and quit stay `handleCtrlC`. The keymap command calls that handler; it does not reimplement the arming window. A decision gate still declines before the arming handler runs. Paste, overlay accept, and prompt newline chords stay where they were. Enter, Up, and Down stay in `keys.ts` because the same chord sends, steers, recalls history, or moves the caret. Ctrl+X is the held-item drop and is a keymap command. diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index e3f06c6a2..4d855c567 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -17,7 +17,7 @@ import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; import { isPaused } from "./delivery-queue.js"; import { PROMPT_KEY_BINDINGS } from "./prompt-input.js"; -import { SHELL_SHORTCUTS } from "./keybindings.js"; +import { shellShortcuts } from "./keybindings.js"; import { createHarness, type Harness } from "./harness.js"; import { mountRunnerHost } from "./runner/host.js"; import { openCommandSurface } from "./command-surfaces.js"; @@ -733,7 +733,7 @@ function recordSubmits( } function rowsIn(group: Group): readonly { keys: string; probe: Probe }[] { - return SHELL_SHORTCUTS.flatMap((row) => { + return shellShortcuts().flatMap((row) => { const entry = PROBES[row.keys]; return entry !== undefined && entry.group === group ? [{ keys: row.keys, probe: entry.probe }] @@ -760,14 +760,14 @@ async function runGroup(group: Group): Promise { describe("every catalog row is driven against real behavior", () => { test("no row is left without a probe", () => { - const missing = SHELL_SHORTCUTS.filter( - (row) => PROBES[row.keys] === undefined, - ).map((row) => row.keys); + const missing = shellShortcuts() + .filter((row) => PROBES[row.keys] === undefined) + .map((row) => row.keys); expect(missing).toEqual([]); }); test("no probe describes a chord the catalog no longer lists", () => { - const listed = new Set(SHELL_SHORTCUTS.map((row) => row.keys)); + const listed = new Set(shellShortcuts().map((row) => row.keys)); expect(Object.keys(PROBES).filter((keys) => !listed.has(keys))).toEqual([]); }); diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index e8044a99f..d5bc89db7 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -1,109 +1,27 @@ /** - * OpenTUI shell keybinding catalog (pure data) — the help overlay's source - * of truth. Rows with `command` are application chords registered on - * `@opentui/keymap`. Rows without one stay prompt-local: textarea defaults, - * the kill ring, or a chord whose meaning depends on context keys.ts still - * owns. `keybindings.test.ts` drives every row through a live shell. + * Help rows. Application lines come from the live `@opentui/keymap` + * registration (`bindAppHelp`). Widget-default rows are the Textarea chords + * `defaultTextareaKeyBindings` / `PROMPT_KEY_BINDINGS` already perform, plus + * the kill-ring yank chords the textarea does not implement. + * There is no second copy of the application chord table here. */ export interface ShellShortcut { readonly keys: string; readonly description: string; - /** `@opentui/keymap` command name. Absent for prompt-local chords. */ - readonly command?: string; - /** Binding strings for `command`. Absent for prompt-local chords. */ - readonly bindings?: readonly string[]; } -export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ +/** + * Prompt-local rows. Cursor, delete, word motion, and newline/submit are + * Textarea defaults (`renderables/Textarea.d.ts` `TextareaAction`). Yank is + * not a Textarea action; it stays listed because the kill ring records it. + */ +export const WIDGET_DEFAULT_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Enter", description: "soft-steer at the next tool boundary while busy (held above the prompt); send straight through when idle", }, - { - keys: "Alt+Enter", - description: - "queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy", - command: "shell.queue", - bindings: ["alt+return"], - }, - { - keys: "Up / Down / Enter / Ctrl+X", - description: - "on held items above the prompt: select, send now, drop (Esc backs out)", - // Up, Down, and Enter also move the caret, recall history, or send. - // Only Ctrl+X is unambiguous; the others stay in keys.ts. - command: "shell.held.drop", - bindings: ["ctrl+x"], - }, - { - keys: "Ctrl+C", - description: - "pause a busy run and hold the queue, or clear the prompt and attachments when idle; Ctrl+C again to exit — with live sub-agents, a 2nd press stops them, a 3rd quits", - command: "shell.interrupt", - bindings: ["ctrl+c"], - }, - { - keys: "Ctrl+G", - description: - "pop the most recently queued or steered message back into the prompt for editing", - command: "shell.cancel", - bindings: ["ctrl+g"], - }, - { - keys: "Alt+C", - description: - "copy mode: pick a message, tool output, or diff; press again to close it", - command: "shell.copy", - bindings: ["alt+c"], - }, - { - keys: "Alt+M", - description: - "toggle DEC mouse capture (on by default: wheel scroll, click-to-expand, drag-to-copy); off restores native terminal drag-select", - command: "shell.mouse", - bindings: ["alt+m"], - }, - { - keys: "Alt+E", - description: - "expand or collapse every collapsible row (tool call, diff, skill, reasoning)", - command: "shell.expand", - bindings: ["alt+e"], - }, - { - keys: "Alt+T", - description: - "show or hide the task list above the prompt (hidden by default)", - command: "shell.tasks", - bindings: ["alt+t"], - }, - { - keys: "Alt+O", - description: - "observe a live subagent session; a system row says so when there is none", - command: "shell.observe", - bindings: ["alt+o"], - }, - { - keys: "Tab", - description: "move focus between the prompt and the transcript", - command: "shell.focus", - bindings: ["tab"], - }, - { - keys: "Shift+Tab", - description: "cycle reasoning effort for the current model", - command: "shell.effort", - bindings: ["shift+tab"], - }, - { - keys: "Esc", - description: "close the open overlay, or leave subagent observe", - command: "shell.dismiss", - bindings: ["escape"], - }, { keys: "Ctrl+B / Ctrl+F", description: "move the cursor back / forward one character", @@ -156,36 +74,39 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ description: "insert a newline instead of sending (Shift+Enter also works on terminals that report the modifier)", }, -] as const; +]; -/** Application chords the keymap registers. */ -export function keymapShortcuts(): readonly ShellShortcut[] { - return SHELL_SHORTCUTS.filter((row) => row.command !== undefined); +let appRows: readonly ShellShortcut[] = []; +let readAppHelp: () => readonly string[] = () => + appRows.map((row) => `${row.keys} — ${row.description}`); + +/** Called when the keymap module loads, and again from the live keymap. */ +export function setAppShortcutRows(rows: readonly ShellShortcut[]): void { + appRows = rows; } -/** - * Prompt-local rows: textarea defaults, the kill ring, paste, and chords - * whose meaning depends on context the keymap does not own. - */ -export function promptLocalShortcuts(): readonly ShellShortcut[] { - return SHELL_SHORTCUTS.filter((row) => row.command === undefined); +export function bindAppHelp(read: () => readonly string[]): () => void { + const previous = readAppHelp; + readAppHelp = read; + return () => { + readAppHelp = previous; + }; +} + +/** Catalog the live tests drive. Application rows are whatever the keymap registered. */ +export function shellShortcuts(): readonly ShellShortcut[] { + return [...appRows, ...WIDGET_DEFAULT_SHORTCUTS]; } function formatShortcut(row: ShellShortcut): string { return `${row.keys} — ${row.description}`; } -/** - * Help rows from the keymap catalog plus prompt-local rows, in catalog - * order, so the overlay cannot drift from either list. - */ +/** Keymap command lines, then widget-default rows. */ export function helpItems(): readonly string[] { - const owned = new Set([ - ...keymapShortcuts().map((row) => row.keys), - ...promptLocalShortcuts().map((row) => row.keys), - ]); return [ - ...SHELL_SHORTCUTS.filter((row) => owned.has(row.keys)).map(formatShortcut), + ...readAppHelp(), + ...WIDGET_DEFAULT_SHORTCUTS.map(formatShortcut), "Close help", ]; } diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 78ccb6d99..4c9d5197c 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -632,14 +632,17 @@ export function createAppShell( if (onObserveRequestOpt) { setPaletteOnObserveRequest(shell, onObserveRequestOpt); } - const { onKey, onPaste, keymap } = createShellKeyHandlers(shell, { + const { onKey, onPaste } = createShellKeyHandlers(shell, { isDisposed: () => disposed, }); if (wireKeys) { - renderer.keyInput.on("keypress", onKey); + detachKeymap = attachShellKeymap(renderer as CliRenderer, shell); + // onKey runs before the keymap listener (prepended above) so paste-burst + // bookkeeping and the kill-ring break happen, then the keymap may + // stopPropagation. Textarea defaults run last. + renderer.keyInput.prependListener("keypress", onKey); renderer.keyInput.on("paste", onPaste); prompt.onSubmit = onEnter; - detachKeymap = attachShellKeymap(renderer as CliRenderer, keymap); } applyLayout(shell, layout); diff --git a/src/tui/shell/keymap.ts b/src/tui/shell/keymap.ts index 1966a387b..ade7cce15 100644 --- a/src/tui/shell/keymap.ts +++ b/src/tui/shell/keymap.ts @@ -1,25 +1,27 @@ /** - * Application chords on `@opentui/keymap`. The package looks the chord up; - * `ShellKeymapDispatch` in keys.ts is the command body. Prompt kill, newline, - * and contextual Enter / Up / Down stay out of this layer. + * Registers `APP_CHORDS` on `@opentui/keymap`. Lookup belongs to the package. + * There is no second switch from key name to handler. */ import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"; import type { Keymap } from "@opentui/keymap"; import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"; -import { keymapShortcuts } from "../keybindings.js"; -import type { ShellKeymapDispatch } from "./keys.js"; +import { bindAppHelp, setAppShortcutRows } from "../keybindings.js"; +import type { AppShell } from "./internals.js"; +import { APP_CHORDS, appHelpLines, shellIsLive } from "./shell-chords.js"; + +setAppShortcutRows( + APP_CHORDS.map((row) => ({ + keys: row.keys, + description: row.description, + })), +); +bindAppHelp(appHelpLines); type ShellKeymap = Keymap; const keymaps = new WeakMap(); -/** - * Terminals that cannot report Shift+Tab send `backtab`. It is not a named - * key in the default parser, so a string binding would become a letter sequence. - */ -const EFFORT_BACKTAB = { name: "backtab" } as const; - function keymapFor(renderer: CliRenderer): ShellKeymap { const existing = keymaps.get(renderer); if (existing !== undefined) return existing; @@ -28,69 +30,44 @@ function keymapFor(renderer: CliRenderer): ShellKeymap { return created; } -function runCommand( - command: string, - dispatch: ShellKeymapDispatch, - key: KeyEvent, -): boolean { - switch (command) { - case "shell.dismiss": - return dispatch.escape(key); - case "shell.interrupt": - return dispatch.ctrlC(key); - case "shell.copy": - return dispatch.copy(key); - case "shell.mouse": - return dispatch.mouse(key); - case "shell.expand": - return dispatch.expand(key); - case "shell.tasks": - return dispatch.tasks(key); - case "shell.observe": - return dispatch.observe(key); - case "shell.focus": - return dispatch.focus(key); - case "shell.effort": - return dispatch.effort(key); - case "shell.cancel": - return dispatch.cancel(key); - case "shell.queue": - return dispatch.queue(key); - case "shell.held.drop": - return dispatch.heldDrop(key); - default: - return false; - } +function helpFromKeymap(keymap: ShellKeymap): readonly string[] { + return keymap.getCommands({ visibility: "registered" }).flatMap((command) => { + const desc = command.desc; + return typeof desc === "string" && desc.length > 0 ? [desc] : []; + }); } -/** Register the shell's application layer. Dispose unregisters that layer. */ +/** Register the shell's application layer. Dispose drops that layer. */ export function attachShellKeymap( renderer: CliRenderer, - dispatch: ShellKeymapDispatch, + shell: AppShell, ): () => void { const keymap = keymapFor(renderer); - const rows = keymapShortcuts(); - return keymap.registerLayer({ - commands: rows.map((row) => ({ - name: row.command ?? "", - desc: row.description, - run(ctx: { event: KeyEvent }): boolean { - const command = row.command; - if (command === undefined) return false; - return runCommand(command, dispatch, ctx.event); + const disposeLayer = keymap.registerLayer({ + commands: APP_CHORDS.map((chord) => ({ + name: chord.name, + desc: `${chord.keys} — ${chord.description}`, + enabled: () => shellIsLive(shell) && (chord.active?.(shell) ?? true), + run: (ctx: { event: KeyEvent }): boolean => { + // onKey already claimed this chord (slash Tab, overlay Enter, yank). + if (ctx.event.defaultPrevented) return false; + const handled = chord.run(shell); + if (handled && chord.preventDefault === false) + ctx.event.preventDefault(); + return handled; }, })), - bindings: [ - ...rows.flatMap((row) => - (row.bindings ?? []).map((key) => ({ - key, - cmd: row.command ?? "", - // Esc must not stopPropagation. The engine pairs that with - // preventDefault, and the list modal's own listener still has to run. - ...(row.command === "shell.dismiss" ? { preventDefault: false } : {}), - })), - ), - { key: EFFORT_BACKTAB, cmd: "shell.effort" }, - ], + bindings: APP_CHORDS.flatMap((chord) => + chord.bindings.map((key) => ({ + key, + cmd: chord.name, + ...(chord.preventDefault === false ? { preventDefault: false } : {}), + })), + ), }); + const disposeHelp = bindAppHelp(() => helpFromKeymap(keymap)); + return () => { + disposeHelp(); + disposeLayer(); + }; } diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index b8c0a1e9a..a1c615f1e 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -14,47 +14,27 @@ import { badgeCount, pause } from "../delivery-queue.js"; import { type AppShell, type FlashOptions, - effortCycleHandlers, - isSlashPopupOpen, - type PrimaryOverlayKind, getShellStopAffordance, shellExitHandlers, shellInternals, } from "./internals.js"; import { acceptOverlaySelection, - abortOverlayHostReservations, - closeInsetOverlay, confirmCopySelection, copyAllTargets, - exitOverlayAnswerMode, handleOverlayAnswerKey, - notifyOverlayClosed, - stopDecisionGate, } from "./overlay-host.js"; +import { applyLandingSuggestion, setStatusFlash } from "./chrome.js"; import { - applyFocus, - applyLandingSuggestion, - setStatusFlash, - toggleCollapsedRow, - toggleShellFocus, - toggleTasksPanel, -} from "./chrome.js"; -import { - applyPendingCancelSelected, - applyPendingDrop, applyPendingForcePush, applyPendingNav, - applyShellCancelLast, attachClipboardImage, clearPendingAttachments, clearPendingSelection, interruptShell, pendingSelectionActive, - submitPrompt, } from "./prompt.js"; import { - closeSlashPopup, handleListFilterKey, handleMentionPopupKey, handlePaletteFilterKey, @@ -72,9 +52,7 @@ import { toggleOverlayExpand, } from "./overlay-list.js"; import { OVERLAY_EXPAND_KEY } from "./transcript.js"; -import { leaveSubagentObserve, observeActiveSubagent } from "./observe.js"; -import { enterCopyMode, toggleMouseCapture } from "./copy.js"; -import { canPopFocus, focusOwner, popFocus } from "../focus/index.js"; +import { focusOwner } from "../focus/index.js"; import { beginYank, breakKillSequence, @@ -117,45 +95,6 @@ function isPrintableInsertKey(key: KeyEvent): boolean { ); } -/** - * The picker a re-pressed chord closes, or null when the chord must not - * toggle: action openers (Ctrl+P, Ctrl+C, expand) have nothing to close, - * decision surfaces must not read a re-press as an answer, and `@`/`/` - * re-press as typed characters, not toggles. - */ -function toggledSurfaceFor(key: KeyEvent): PrimaryOverlayKind | null { - if ( - (key.meta || key.option) && - !key.ctrl && - (key.name === "c" || key.name === "C") - ) { - return "copy"; - } - return null; -} - -/** - * Re-pressing a picker's opener closes it via the same path Esc uses, so - * key claims and focus unwind identically. - */ -function toggleCloseOpenSurface(shell: AppShell, key: KeyEvent): boolean { - if (shell.overlayList === null) return false; - const kind = toggledSurfaceFor(key); - if (kind === null || kind !== shell.overlayKind) return false; - // The `/` popup borrows the palette overlay; there the chord is still - // filter input. - if (kind === "palette" && isSlashPopupOpen(shell)) return false; - closeInsetOverlay(shell); - return true; -} - -/** Permission and operator gates: the overlays that hold every key. */ -function isDecisionGate(shell: AppShell): boolean { - return ( - shell.overlayKind === "permissions" || shell.overlayKind === "operator" - ); -} - /** Window in which a second Ctrl+C is read as "yes, quit". */ export const CTRL_C_EXIT_WINDOW_MS = 2000; @@ -307,56 +246,55 @@ export function routePromptWheelToTranscript( interface ShellKeyHandlers { onKey: (key: KeyEvent) => void; onPaste: (event: { bytes: Uint8Array; preventDefault: () => void }) => void; - /** Application chords the OpenTUI keymap dispatches into. */ - keymap: ShellKeymapDispatch; } /** - * Command bodies for `@opentui/keymap`. The keymap owns chord lookup; these - * closures own the policy, including the paste-burst clock that has to move - * with a chord the keymap consumes before `onKey`. + * Textarea already deletes on Ctrl+K/U/W and Alt+D (`Textarea.d.ts` + * `TextareaAction`, shipped `defaultTextareaKeyBindings`). Record the diff + * those methods remove so yank has a ring. Do not re-bind the chords. */ -export interface ShellKeymapDispatch { - escape(key: KeyEvent): boolean; - ctrlC(key: KeyEvent): boolean; - copy(key: KeyEvent): boolean; - mouse(key: KeyEvent): boolean; - expand(key: KeyEvent): boolean; - tasks(key: KeyEvent): boolean; - observe(key: KeyEvent): boolean; - focus(key: KeyEvent): boolean; - effort(key: KeyEvent): boolean; - cancel(key: KeyEvent): boolean; - queue(key: KeyEvent): boolean; - heldDrop(key: KeyEvent): boolean; -} - -function killThenRecord( - shell: AppShell, - direction: "forward" | "backward", - remove: (prompt: AppShell["prompt"]) => void, -): void { - const before = shell.prompt.value; - remove(shell.prompt); - const after = shell.prompt.value; - const measure = (prefix: string): number => - shell.prompt.editBuffer.measureText(prefix); - const removal = removedByDelete(before, after, { - caret: shell.prompt.cursorOffset, - measure, - }); - // A snapped wide cell leaves the caret off the grapheme it dropped. Yank - // inserts at the caret, so park it where the lost span splices back in. - // When the native caret already sits on that hole, this is a no-op and - // does not slide it onto the other copy of a repeated prefix. - if (removal.text.length > 0) { - shell.prompt.cursorOffset = measure(after.slice(0, removal.at)); - } - shell.promptKillRing = recordKill( - shell.promptKillRing, - removal.text, - direction, - ); +function capturePromptKills(shell: AppShell): void { + const prompt = shell.prompt; + const wrap = ( + method: + | "deleteToLineEnd" + | "deleteToLineStart" + | "deleteWordBackward" + | "deleteWordForward", + direction: "forward" | "backward", + ): void => { + const original = prompt[method].bind(prompt) as () => boolean; + const wrapped = (): boolean => { + const before = prompt.value; + const result = original(); + const after = prompt.value; + const measure = (prefix: string): number => + prompt.editBuffer.measureText(prefix); + const removal = removedByDelete(before, after, { + caret: prompt.cursorOffset, + measure, + }); + // A snapped wide cell leaves the caret off the grapheme it dropped. Yank + // inserts at the caret, so park it where the lost span splices back in. + // When the native caret already sits on that hole, this is a no-op and + // does not slide it onto the other copy of a repeated prefix. + if (removal.text.length > 0) { + prompt.cursorOffset = measure(after.slice(0, removal.at)); + } + shell.promptKillRing = recordKill( + shell.promptKillRing, + removal.text, + direction, + ); + return result; + }; + (prompt as unknown as Record boolean>)[method] = + wrapped; + }; + wrap("deleteToLineEnd", "forward"); + wrap("deleteToLineStart", "backward"); + wrap("deleteWordBackward", "backward"); + wrap("deleteWordForward", "forward"); } /** @@ -367,6 +305,7 @@ export function createShellKeyHandlers( shell: AppShell, opts: { isDisposed: () => boolean }, ): ShellKeyHandlers { + capturePromptKills(shell); // A real bracketed paste proves DEC 2004; the CRLF-submit fallback below // then disables itself for the session. let sawBracketedPaste = false; @@ -392,9 +331,6 @@ export function createShellKeyHandlers( clearPendingSelection(shell); }; - const accepts = (): boolean => - !opts.isDisposed() && shellInternals(shell)?.inputSuspended !== true; - const token = (key: KeyEvent): string => typeof key.name === "string" ? key.name.toLowerCase() : ""; @@ -439,169 +375,6 @@ export function createShellKeyHandlers( return false; }; - /** - * Side effects a non-kill key used to pick up in onKey after paste tracking - * and after the kill handlers declined it: drop a held-item selection, - * break the yank sequence, and leave history browse on an edit. - */ - const prepareNonKillKey = (key: KeyEvent): void => { - const keyName = token(key); - if ( - pendingSelectionActive(shell) && - keyName !== "up" && - keyName !== "down" - ) { - clearPendingSelection(shell); - } - const isCtrlKillYank = - key.ctrl && - !key.meta && - !key.option && - (keyName === "k" || - keyName === "u" || - keyName === "w" || - keyName === "y"); - const isAltKillYank = - (key.meta || key.option) && - !key.ctrl && - (keyName === "d" || keyName === "y"); - if (!isCtrlKillYank && !isAltKillYank) { - shell.promptKillRing = breakKillSequence(shell.promptKillRing); - } - const promptUpDown = - !key.ctrl && - !key.meta && - !key.option && - (key.name === "up" || key.name === "down") && - focusOwner(shell.focus) === "prompt"; - if (!promptUpDown && !MOTION_KEYS.has(keyName)) { - shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); - } - }; - - /** Chords that only apply once the overlay has not claimed the key. */ - const beginLateChord = (key: KeyEvent): boolean => { - if (!accepts() || shell.overlayList !== null) return false; - if (trackPasteBurst(key)) return true; - prepareNonKillKey(key); - return true; - }; - - const dismissShell = (): boolean => { - if (exitOverlayAnswerMode(shell)) return true; - if (shell.overlayList) { - abortOverlayHostReservations(shell); - const list = shell.overlayList; - closeSlashPopup(shell); - if (list !== null && shell.overlayList === list) closeInsetOverlay(shell); - return true; - } - if (shellInternals(shell)?.overlayHostReservations) { - abortOverlayHostReservations(shell); - queueMicrotask(() => notifyOverlayClosed(shell)); - return true; - } - if (shell.observe) { - leaveSubagentObserve(shell); - return true; - } - if (clearPendingSelection(shell)) return true; - if (canPopFocus(shell.focus)) { - shell.focus = popFocus(shell.focus); - applyFocus(shell); - return true; - } - return false; - }; - - const keymap: ShellKeymapDispatch = { - escape: (key) => { - if (!accepts()) return false; - const handled = dismissShell(); - // preventDefault without stopPropagation: the keymap binding opts out of - // the engine's paired stop so a later global listener (the list modal) - // still sees Esc. Renderables do not. - if (handled) key.preventDefault(); - return handled; - }, - ctrlC: (key) => { - if (!accepts()) return false; - if (shell.overlayList) { - if (isDecisionGate(shell)) { - stopDecisionGate(shell); - return true; - } - return false; - } - if (!beginLateChord(key)) return false; - handleCtrlC(shell); - return true; - }, - copy: (key) => { - if (!accepts()) return false; - if (shell.overlayList) return toggleCloseOpenSurface(shell, key); - if (!beginLateChord(key)) return false; - enterCopyMode(shell); - return true; - }, - mouse: (key) => { - if (!beginLateChord(key)) return false; - toggleMouseCapture(shell); - return true; - }, - expand: (key) => { - if (!accepts() || shell.overlayList !== null) return false; - if (!toggleCollapsedRow(shell)) return false; - if (trackPasteBurst(key)) return true; - prepareNonKillKey(key); - return true; - }, - tasks: (key) => { - if (!beginLateChord(key)) return false; - toggleTasksPanel(shell); - return true; - }, - observe: (key) => { - if (!beginLateChord(key)) return false; - observeActiveSubagent(shell); - return true; - }, - focus: (key) => { - if (!beginLateChord(key)) return false; - toggleShellFocus(shell); - return true; - }, - effort: (key) => { - if (!beginLateChord(key)) return false; - effortCycleHandlers.get(shell)?.(); - return true; - }, - cancel: (key) => { - if (!accepts() || shell.overlayList !== null) return false; - if (trackPasteBurst(key)) return true; - if (pendingSelectionActive(shell)) { - applyPendingCancelSelected(shell); - return true; - } - prepareNonKillKey(key); - applyShellCancelLast(shell); - return true; - }, - queue: (key) => { - if (!beginLateChord(key)) return false; - if (shell.session.run !== "busy") return true; - submitPrompt(shell, "queue"); - return true; - }, - heldDrop: (key) => { - if (!accepts() || shell.overlayList !== null) return false; - if (!pendingSelectionActive(shell)) return false; - if (trackPasteBurst(key)) return true; - applyPendingDrop(shell); - return true; - }, - }; - const onKey = (key: KeyEvent): void => { if (opts.isDisposed()) return; if (shellInternals(shell)?.inputSuspended === true) return; @@ -743,9 +516,8 @@ export function createShellKeyHandlers( return; } - // A pending-column selection owns Enter (send now). Ctrl+X and Ctrl+G are - // keymap commands. Any other key ends the selection. ↑/↓ stay with the - // column (nav block below). + // A pending-column selection owns Enter (send now). Ctrl+X, Ctrl+G, and + // Esc are keymap commands and must still see the selection. ↑/↓ stay. if (pendingSelectionActive(shell)) { if ( (keyName === "return" || keyName === "kpenter") && @@ -757,11 +529,19 @@ export function createShellKeyHandlers( applyPendingForcePush(shell); return; } - if (keyName !== "up" && keyName !== "down") { + const keymapOwnsKey = + keyName === "escape" || + (key.ctrl && + !key.meta && + !key.option && + (keyName === "x" || keyName === "g")); + if (keyName !== "up" && keyName !== "down" && !keymapOwnsKey) { clearPendingSelection(shell); } } + // Ctrl+K/U/W and Alt+D are Textarea defaults (`defaultTextareaKeyBindings`). + // The wrapped delete methods record the kill. Only yank is still ours. const isCtrlKillYank = key.ctrl && !key.meta && @@ -778,38 +558,6 @@ export function createShellKeyHandlers( shell.promptKillRing = breakKillSequence(shell.promptKillRing); } - if (key.ctrl && !key.meta && !key.option && keyName === "k") { - key.preventDefault(); - killThenRecord(shell, "forward", (prompt) => { - prompt.deleteToLineEnd(); - }); - return; - } - - if (key.ctrl && !key.meta && !key.option && keyName === "u") { - key.preventDefault(); - killThenRecord(shell, "backward", (prompt) => { - prompt.deleteToLineStart(); - }); - return; - } - - if (key.ctrl && !key.meta && !key.option && keyName === "w") { - key.preventDefault(); - killThenRecord(shell, "backward", (prompt) => { - prompt.deleteWordBackward(); - }); - return; - } - - if ((key.meta || key.option) && !key.ctrl && keyName === "d") { - key.preventDefault(); - killThenRecord(shell, "forward", (prompt) => { - prompt.deleteWordForward(); - }); - return; - } - if (key.ctrl && !key.meta && !key.option && keyName === "y") { key.preventDefault(); const yank = beginYank(shell.promptKillRing, shell.prompt.cursorOffset); @@ -919,5 +667,5 @@ export function createShellKeyHandlers( shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); } }; - return { onKey, onPaste, keymap }; + return { onKey, onPaste }; } diff --git a/src/tui/shell/shell-chords.ts b/src/tui/shell/shell-chords.ts new file mode 100644 index 000000000..c9eca5f60 --- /dev/null +++ b/src/tui/shell/shell-chords.ts @@ -0,0 +1,254 @@ +/** + * Application chords registered on `@opentui/keymap`. Each `run` calls one + * existing function. Textarea defaults (cursor, delete, newline, submit) are + * not in this list: `Textarea.d.ts` `TextareaAction` and the shipped + * `defaultTextareaKeyBindings` already own them, and `PROMPT_KEY_BINDINGS` + * already flips Enter to submit. + */ +import { + type AppShell, + effortCycleHandlers, + shellInternals, +} from "./internals.js"; +import { handleCtrlC } from "./keys.js"; +import { + applyFocus, + toggleCollapsedRow, + toggleShellFocus, + toggleTasksPanel, +} from "./chrome.js"; +import { + abortOverlayHostReservations, + closeInsetOverlay, + exitOverlayAnswerMode, + notifyOverlayClosed, + stopDecisionGate, +} from "./overlay-host.js"; +import { closeSlashPopup } from "./palette.js"; +import { enterCopyMode, toggleMouseCapture } from "./copy.js"; +import { leaveSubagentObserve, observeActiveSubagent } from "./observe.js"; +import { canPopFocus, popFocus } from "../focus/index.js"; +import { + applyPendingCancelSelected, + applyPendingDrop, + applyShellCancelLast, + clearPendingSelection, + pendingSelectionActive, + submitPrompt, +} from "./prompt.js"; + +export interface AppChord { + readonly name: string; + readonly keys: string; + readonly description: string; + readonly bindings: readonly (string | { readonly name: string })[]; + /** Inactive while an overlay holds the key, unless the chord must still run. */ + readonly active?: (shell: AppShell) => boolean; + /** + * Esc opts out. `@opentui/keymap` `Binding.preventDefault` also calls + * `stopPropagation` (`types.d.ts`), which would hide Esc from the list modal. + */ + readonly preventDefault?: boolean; + readonly run: (shell: AppShell) => boolean; +} + +const promptSurface = (shell: AppShell): boolean => shell.overlayList === null; + +function isDecisionGate(shell: AppShell): boolean { + return ( + shell.overlayKind === "permissions" || shell.overlayKind === "operator" + ); +} + +/** Ctrl+C. TextareaAction has no interrupt; arming stays here. */ +function runShellCtrlC(shell: AppShell): boolean { + if (shell.overlayList) { + if (isDecisionGate(shell)) { + stopDecisionGate(shell); + return true; + } + return false; + } + handleCtrlC(shell); + return true; +} + +/** Esc. Not a ScrollBox binding; arrow-scroll on a focused transcript stays off. */ +function runShellDismiss(shell: AppShell): boolean { + if (exitOverlayAnswerMode(shell)) return true; + if (shell.overlayList) { + abortOverlayHostReservations(shell); + const list = shell.overlayList; + closeSlashPopup(shell); + if (list !== null && shell.overlayList === list) closeInsetOverlay(shell); + return true; + } + if (shellInternals(shell)?.overlayHostReservations) { + abortOverlayHostReservations(shell); + queueMicrotask(() => notifyOverlayClosed(shell)); + return true; + } + if (shell.observe) { + leaveSubagentObserve(shell); + return true; + } + if (clearPendingSelection(shell)) return true; + if (canPopFocus(shell.focus)) { + shell.focus = popFocus(shell.focus); + applyFocus(shell); + return true; + } + return false; +} + +export const APP_CHORDS: readonly AppChord[] = [ + { + name: "shell.queue", + keys: "Alt+Enter", + description: + "queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy", + bindings: ["alt+return"], + active: promptSurface, + run: (shell) => { + if (shell.session.run !== "busy") return true; + submitPrompt(shell, "queue"); + return true; + }, + }, + { + name: "shell.held.drop", + keys: "Up / Down / Enter / Ctrl+X", + description: + "on held items above the prompt: select, send now, drop (Esc backs out)", + bindings: ["ctrl+x"], + active: (shell) => promptSurface(shell) && pendingSelectionActive(shell), + run: (shell) => { + applyPendingDrop(shell); + return true; + }, + }, + { + name: "shell.interrupt", + keys: "Ctrl+C", + description: + "pause a busy run and hold the queue, or clear the prompt and attachments when idle; Ctrl+C again to exit — with live sub-agents, a 2nd press stops them, a 3rd quits", + bindings: ["ctrl+c"], + run: runShellCtrlC, + }, + { + name: "shell.cancel", + keys: "Ctrl+G", + description: + "pop the most recently queued or steered message back into the prompt for editing", + bindings: ["ctrl+g"], + active: promptSurface, + run: (shell) => { + if (pendingSelectionActive(shell)) { + applyPendingCancelSelected(shell); + return true; + } + applyShellCancelLast(shell); + return true; + }, + }, + { + name: "shell.copy", + keys: "Alt+C", + description: + "copy mode: pick a message, tool output, or diff; press again to close it", + bindings: ["alt+c"], + active: (shell) => + shell.overlayList === null || shell.overlayKind === "copy", + run: (shell) => { + if (shell.overlayKind === "copy" && shell.overlayList !== null) { + closeInsetOverlay(shell); + return true; + } + enterCopyMode(shell); + return true; + }, + }, + { + name: "shell.mouse", + keys: "Alt+M", + description: + "toggle DEC mouse capture (on by default: wheel scroll, click-to-expand, drag-to-copy); off restores native terminal drag-select", + bindings: ["alt+m"], + active: promptSurface, + run: (shell) => { + toggleMouseCapture(shell); + return true; + }, + }, + { + name: "shell.expand", + keys: "Alt+E", + description: + "expand or collapse every collapsible row (tool call, diff, skill, reasoning)", + bindings: ["alt+e"], + active: promptSurface, + run: (shell) => toggleCollapsedRow(shell), + }, + { + name: "shell.tasks", + keys: "Alt+T", + description: + "show or hide the task list above the prompt (hidden by default)", + bindings: ["alt+t"], + active: promptSurface, + run: (shell) => { + toggleTasksPanel(shell); + return true; + }, + }, + { + name: "shell.observe", + keys: "Alt+O", + description: + "observe a live subagent session; a system row says so when there is none", + bindings: ["alt+o"], + active: promptSurface, + run: (shell) => { + observeActiveSubagent(shell); + return true; + }, + }, + { + name: "shell.focus", + keys: "Tab", + description: "move focus between the prompt and the transcript", + bindings: ["tab"], + active: promptSurface, + run: (shell) => { + toggleShellFocus(shell); + return true; + }, + }, + { + name: "shell.effort", + keys: "Shift+Tab", + description: "cycle reasoning effort for the current model", + bindings: ["shift+tab", { name: "backtab" }], + active: promptSurface, + run: (shell) => { + effortCycleHandlers.get(shell)?.(); + return true; + }, + }, + { + name: "shell.dismiss", + keys: "Esc", + description: "close the open overlay, or leave subagent observe", + bindings: ["escape"], + preventDefault: false, + run: runShellDismiss, + }, +]; + +export function appHelpLines(): readonly string[] { + return APP_CHORDS.map((row) => `${row.keys} — ${row.description}`); +} + +export function shellIsLive(shell: AppShell): boolean { + return !shell.disposed && shellInternals(shell)?.inputSuspended !== true; +} diff --git a/src/tui/wave7.test.ts b/src/tui/wave7.test.ts index d37ca0abb..c0d829bf6 100644 --- a/src/tui/wave7.test.ts +++ b/src/tui/wave7.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test"; import { focusOwner } from "./focus/index.js"; import { withTestRenderer } from "./harness.js"; -import { SHELL_SHORTCUTS } from "./keybindings.js"; +import { shellShortcuts } from "./keybindings.js"; import { residualIdFromSelection, residualListFromCatalog, @@ -86,7 +86,7 @@ describe("Wave 7: residual list surfaces", () => { try { openHelpOverlay(shell); expect(shell.overlayKind).toBe("help"); - expect(shell.overlayItems).toHaveLength(SHELL_SHORTCUTS.length + 1); + expect(shell.overlayItems).toHaveLength(shellShortcuts().length + 1); expect(focusOwner(shell.focus)).toBe("overlay"); closeInsetOverlay(shell); expect(shell.overlayList).toBeNull(); From b0b8cd6d057c5a561b85cd78515fb6f8ce5cc0b3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 11:03:33 -0700 Subject: [PATCH 3/5] fix(tui): kill prompt text while the transcript is focused --- src/tui/prompt-kill-ring.test.ts | 18 ++++++++++++++++++ src/tui/shell/keys.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/tui/prompt-kill-ring.test.ts b/src/tui/prompt-kill-ring.test.ts index 27d5bc523..deb61e6f9 100644 --- a/src/tui/prompt-kill-ring.test.ts +++ b/src/tui/prompt-kill-ring.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; import { withTestRenderer } from "./harness"; +import { shellFocusTranscript } from "./shell/chrome"; import { createAppShell } from "./shell/index"; import { beginYank, @@ -322,4 +323,21 @@ describe("prompt kill ring chords", () => { expect(shell.prompt.value).toBe("ab你cd"); }); }); + + test("ctrl+k still kills prompt text while the transcript is focused", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "hello"; + shell.prompt.cursorOffset = 0; + shellFocusTranscript(shell); + expect(shell.prompt.focused).toBe(false); + h.pressKey("k", { ctrl: true }); + expect(shell.prompt.value).toBe(""); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("hello"); + }); + }); }); diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index a1c615f1e..cad2eb264 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -558,6 +558,33 @@ export function createShellKeyHandlers( shell.promptKillRing = breakKillSequence(shell.promptKillRing); } + // Textarea defaults only run while the prompt is focused. After Tab the + // transcript holds focus, so call the same delete methods the default + // bindings use. capturePromptKills records the diff. Do not keymap-bind + // these: that would steal them from the focused textarea. + if (!shell.prompt.focused) { + if (key.ctrl && !key.meta && !key.option && keyName === "k") { + key.preventDefault(); + shell.prompt.deleteToLineEnd(); + return; + } + if (key.ctrl && !key.meta && !key.option && keyName === "u") { + key.preventDefault(); + shell.prompt.deleteToLineStart(); + return; + } + if (key.ctrl && !key.meta && !key.option && keyName === "w") { + key.preventDefault(); + shell.prompt.deleteWordBackward(); + return; + } + if ((key.meta || key.option) && !key.ctrl && keyName === "d") { + key.preventDefault(); + shell.prompt.deleteWordForward(); + return; + } + } + if (key.ctrl && !key.meta && !key.option && keyName === "y") { key.preventDefault(); const yank = beginYank(shell.promptKillRing, shell.prompt.cursorOffset); From d324276ae7f24e5b2aeb7558dd0cb4afd059346f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 12:20:00 -0700 Subject: [PATCH 4/5] fix(tui): drop the keymap listener when the renderer is destroyed --- src/tui/list-modal.test.ts | 24 +++++++++++ src/tui/shell/keymap.ts | 85 +++++++++++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/tui/list-modal.test.ts b/src/tui/list-modal.test.ts index ef6db8258..744fa6cfd 100644 --- a/src/tui/list-modal.test.ts +++ b/src/tui/list-modal.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; import { createHarness, type Harness } from "./harness.js"; import { runListModal, type ListModalConfig } from "./list-modal.js"; +import { createAppShell } from "./shell/index.js"; +import { pokeShellKeymapPress, shellKeymapListening } from "./shell/keymap.js"; let harness: Harness | undefined; @@ -30,6 +32,28 @@ async function mountModal(overrides: Partial = {}): Promise<{ } describe("runListModal", () => { + test("renderer destroy detaches the keymap before a later keypress", async () => { + const h = await createHarness({ width: 40, height: 12 }); + try { + const shell = createAppShell(h.renderer, { + title: "keymap", + run: "idle", + }); + expect(shellKeymapListening(h.renderer)).toBe(true); + const before = h.renderer.keyInput.listenerCount("keypress"); + expect(before).toBeGreaterThan(0); + shell.dispose(); + h.renderer.destroy(); + expect(shellKeymapListening(h.renderer)).toBe(false); + expect(h.renderer.keyInput.listenerCount("keypress")).toBeLessThan( + before, + ); + expect(() => pokeShellKeymapPress(h.renderer)).not.toThrow(); + } finally { + h.destroy(); + } + }); + test("resolves the id of the accepted row", async () => { const { choice, harness } = await mountModal(); harness.pressKey("Enter"); diff --git a/src/tui/shell/keymap.ts b/src/tui/shell/keymap.ts index ade7cce15..c99708c8b 100644 --- a/src/tui/shell/keymap.ts +++ b/src/tui/shell/keymap.ts @@ -1,10 +1,20 @@ /** * Registers `APP_CHORDS` on `@opentui/keymap`. Lookup belongs to the package. * There is no second switch from key name to handler. + * + * The default host keeps its keypress listener until the destroy event, but + * `isDestroyed` flips first and leftover stdin then throws inside + * `getLiveHost`. This host unsubscribes inside `destroy()` and ignores any + * keypress already queued for this turn. */ import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"; -import type { Keymap } from "@opentui/keymap"; -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"; +import { Keymap, type KeymapHost } from "@opentui/keymap"; +import { + registerDefaultKeys, + registerEnabledFields, + registerMetadataFields, +} from "@opentui/keymap/addons"; +import { createOpenTuiKeymapHost } from "@opentui/keymap/opentui"; import { bindAppHelp, setAppShortcutRows } from "../keybindings.js"; import type { AppShell } from "./internals.js"; @@ -21,13 +31,78 @@ bindAppHelp(appHelpLines); type ShellKeymap = Keymap; const keymaps = new WeakMap(); +const detached = new WeakSet(); +const pressListeners = new WeakMap< + CliRenderer, + ((event: KeyEvent) => void)[] +>(); + +function guardHost(renderer: CliRenderer): KeymapHost { + const host = createOpenTuiKeymapHost(renderer); + const onKeyPress = host.onKeyPress.bind(host); + const onKeyRelease = host.onKeyRelease.bind(host); + const offs: (() => void)[] = []; + const ignore = (): boolean => detached.has(renderer) || renderer.isDestroyed; + + const wrap = ( + subscribe: (listener: (event: KeyEvent) => void) => () => void, + listener: (event: KeyEvent) => void, + record: boolean, + ): (() => void) => { + const wrapped = (event: KeyEvent): void => { + if (ignore()) return; + listener(event); + }; + if (record) { + const list = pressListeners.get(renderer) ?? []; + list.push(wrapped); + pressListeners.set(renderer, list); + } + const off = subscribe(wrapped); + offs.push(off); + return off; + }; + + host.onKeyPress = (listener) => wrap(onKeyPress, listener, true); + host.onKeyRelease = (listener) => wrap(onKeyRelease, listener, false); + + const originalDestroy = renderer.destroy.bind(renderer); + renderer.destroy = () => { + if (!detached.has(renderer)) { + detached.add(renderer); + for (const off of offs) off(); + offs.length = 0; + } + originalDestroy(); + }; + + return host; +} function keymapFor(renderer: CliRenderer): ShellKeymap { const existing = keymaps.get(renderer); if (existing !== undefined) return existing; - const created = createDefaultOpenTuiKeymap(renderer); - keymaps.set(renderer, created); - return created; + const keymap = new Keymap(guardHost(renderer)); + registerDefaultKeys(keymap); + registerEnabledFields(keymap); + registerMetadataFields(keymap); + keymaps.set(renderer, keymap); + return keymap; +} + +/** True while this renderer's keymap is still subscribed to keypress. */ +export function shellKeymapListening(renderer: CliRenderer): boolean { + return keymaps.has(renderer) && !detached.has(renderer); +} + +/** + * Re-enter the keymap press listeners. After destroy they must return + * without calling into a destroyed host. + */ +export function pokeShellKeymapPress(renderer: CliRenderer): void { + for (const listener of pressListeners.get(renderer) ?? []) { + listener({ name: "c" } as KeyEvent); + } } function helpFromKeymap(keymap: ShellKeymap): readonly string[] { From 004ae6b1af2e443642d128261ffeeeaa51953969 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 17:53:02 -0700 Subject: [PATCH 5/5] refactor(tui): drop keymap narration --- docs/TUI-keymap-eval.md | 2 +- src/tui/keybindings.ts | 3 --- src/tui/shell/keymap.ts | 2 -- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/TUI-keymap-eval.md b/docs/TUI-keymap-eval.md index 482217d9b..d9db7b329 100644 --- a/docs/TUI-keymap-eval.md +++ b/docs/TUI-keymap-eval.md @@ -6,7 +6,7 @@ Decision record for application key bindings on `@opentui/core` 0.6.6. The engin **Adopt** `@opentui/keymap` for application commands. Do not move widget-local editing, the scroll lease, or the kill ring into it. -`createDefaultOpenTuiKeymap(renderer)` installs the default parser, the event matcher, and the enabled and metadata fields. The shell registers one layer. Each command `run` calls one existing function. There is no parallel key switch. Help lines are the keymap's registered `desc` values plus the widget-default rows. +`keymapFor` builds `new Keymap(guardHost(renderer))`, then registers the default parser, the enabled fields, and the metadata fields. The shell registers one layer. Each command `run` calls one existing function. There is no parallel key switch. Help lines are the keymap's registered `desc` values plus the widget-default rows. Textarea defaults are not registered again. `renderables/Textarea.d.ts` `TextareaAction` and the shipped `defaultTextareaKeyBindings` already cover cursor motion, word motion, Ctrl+D, Ctrl+K/U/W, and Alt+D. `PROMPT_KEY_BINDINGS` already maps Enter to submit and Ctrl+Enter / Ctrl+J / Shift+Enter to newline. The kill ring only diffs those deletes and yanks. diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index d5bc89db7..362d4b801 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -3,7 +3,6 @@ * registration (`bindAppHelp`). Widget-default rows are the Textarea chords * `defaultTextareaKeyBindings` / `PROMPT_KEY_BINDINGS` already perform, plus * the kill-ring yank chords the textarea does not implement. - * There is no second copy of the application chord table here. */ export interface ShellShortcut { @@ -80,7 +79,6 @@ let appRows: readonly ShellShortcut[] = []; let readAppHelp: () => readonly string[] = () => appRows.map((row) => `${row.keys} — ${row.description}`); -/** Called when the keymap module loads, and again from the live keymap. */ export function setAppShortcutRows(rows: readonly ShellShortcut[]): void { appRows = rows; } @@ -93,7 +91,6 @@ export function bindAppHelp(read: () => readonly string[]): () => void { }; } -/** Catalog the live tests drive. Application rows are whatever the keymap registered. */ export function shellShortcuts(): readonly ShellShortcut[] { return [...appRows, ...WIDGET_DEFAULT_SHORTCUTS]; } diff --git a/src/tui/shell/keymap.ts b/src/tui/shell/keymap.ts index c99708c8b..912eddcd1 100644 --- a/src/tui/shell/keymap.ts +++ b/src/tui/shell/keymap.ts @@ -1,6 +1,5 @@ /** * Registers `APP_CHORDS` on `@opentui/keymap`. Lookup belongs to the package. - * There is no second switch from key name to handler. * * The default host keeps its keypress listener until the destroy event, but * `isDestroyed` flips first and leftover stdin then throws inside @@ -90,7 +89,6 @@ function keymapFor(renderer: CliRenderer): ShellKeymap { return keymap; } -/** True while this renderer's keymap is still subscribed to keypress. */ export function shellKeymapListening(renderer: CliRenderer): boolean { return keymaps.has(renderer) && !detached.has(renderer); }