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..d9db7b329 100644 --- a/docs/TUI-keymap-eval.md +++ b/docs/TUI-keymap-eval.md @@ -1,39 +1,27 @@ # 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 +`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. -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. +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. -`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. +## What stays a command body -## What stays +Widget `keyBindings` cannot express policy that spans the shell: -- `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. +- **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. -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.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 526739f59..362d4b801 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -1,9 +1,8 @@ /** - * 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. + * 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. */ export interface ShellShortcut { @@ -11,69 +10,17 @@ export interface ShellShortcut { readonly description: 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", - }, - { - keys: "Up / Down / Enter / Ctrl+X", - description: - "on held items above the prompt: select, send now, drop (Esc backs out)", - }, - { - 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", - }, - { - keys: "Ctrl+G", - description: - "pop the most recently queued or steered message back into the prompt for editing", - }, - { - keys: "Alt+C", - description: - "copy mode: pick a message, tool output, or diff; press again to close it", - }, - { - 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", - }, - { - keys: "Alt+E", - description: - "expand or collapse every collapsible row (tool call, diff, skill, reasoning)", - }, - { - keys: "Alt+T", - description: - "show or hide the task list above the prompt (hidden by default)", - }, - { - keys: "Alt+O", - description: - "observe a live subagent session; a system row says so when there is none", - }, - { - keys: "Tab", - description: "move focus between the prompt and the transcript", - }, - { - keys: "Shift+Tab", - description: "cycle reasoning effort for the current model", - }, - { - keys: "Esc", - description: "close the open overlay, or leave subagent observe", - }, { keys: "Ctrl+B / Ctrl+F", description: "move the cursor back / forward one character", @@ -126,12 +73,37 @@ 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; +]; + +let appRows: readonly ShellShortcut[] = []; +let readAppHelp: () => readonly string[] = () => + appRows.map((row) => `${row.keys} — ${row.description}`); + +export function setAppShortcutRows(rows: readonly ShellShortcut[]): void { + appRows = rows; +} + +export function bindAppHelp(read: () => readonly string[]): () => void { + const previous = readAppHelp; + readAppHelp = read; + return () => { + readAppHelp = previous; + }; +} + +export function shellShortcuts(): readonly ShellShortcut[] { + return [...appRows, ...WIDGET_DEFAULT_SHORTCUTS]; +} + +function formatShortcut(row: ShellShortcut): string { + return `${row.keys} — ${row.description}`; +} -/** Help rows from the shell's own catalog, so they cannot drift from it. */ +/** Keymap command lines, then widget-default rows. */ export function helpItems(): readonly string[] { return [ - ...SHELL_SHORTCUTS.map((s) => `${s.keys} — ${s.description}`), + ...readAppHelp(), + ...WIDGET_DEFAULT_SHORTCUTS.map(formatShortcut), "Close help", ]; } 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/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/index.ts b/src/tui/shell/index.ts index 3d97cf5d1..4c9d5197c 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); @@ -632,7 +636,11 @@ export function createAppShell( 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; } diff --git a/src/tui/shell/keymap.ts b/src/tui/shell/keymap.ts new file mode 100644 index 000000000..912eddcd1 --- /dev/null +++ b/src/tui/shell/keymap.ts @@ -0,0 +1,146 @@ +/** + * Registers `APP_CHORDS` on `@opentui/keymap`. Lookup belongs to the package. + * + * 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 { 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"; +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(); +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 keymap = new Keymap(guardHost(renderer)); + registerDefaultKeys(keymap); + registerEnabledFields(keymap); + registerMetadataFields(keymap); + keymaps.set(renderer, keymap); + return keymap; +} + +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[] { + 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 drops that layer. */ +export function attachShellKeymap( + renderer: CliRenderer, + shell: AppShell, +): () => void { + const keymap = keymapFor(renderer); + 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: 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 cc7c6e0e5..cad2eb264 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -14,46 +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, - 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, @@ -71,10 +52,7 @@ 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"; +import { focusOwner } from "../focus/index.js"; import { beginYank, breakKillSequence, @@ -91,7 +69,6 @@ import { stepSentHistoryDown, stepSentHistoryUp, } from "../sent-message-history.js"; -import { EXPAND_KEY } from "../stream.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -118,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; @@ -310,32 +248,53 @@ interface ShellKeyHandlers { onPaste: (event: { bytes: Uint8Array; preventDefault: () => void }) => void; } -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, - ); +/** + * 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. + */ +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"); } /** @@ -346,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; @@ -371,55 +331,54 @@ export function createShellKeyHandlers( clearPendingSelection(shell); }; + 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; + }; + const onKey = (key: KeyEvent): void => { if (opts.isDisposed()) return; if (shellInternals(shell)?.inputSuspended === true) return; - if (key.name === "escape") { - if (exitOverlayAnswerMode(shell)) { - key.preventDefault(); - return; - } - 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; - } - // 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; - } - } - // Landing starters; only while the prompt is untouched, so the key is a // plain character once typing starts. if ( @@ -435,21 +394,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 +511,13 @@ 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, Ctrl+G, and + // Esc are keymap commands and must still see the selection. ↑/↓ stay. if (pendingSelectionActive(shell)) { if ( (keyName === "return" || keyName === "kpenter") && @@ -626,21 +529,19 @@ 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") { + 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 && @@ -657,36 +558,31 @@ 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; + // 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") { @@ -797,109 +693,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 }; } 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();