Skip to content

Commit a4149eb

Browse files
committed
feat(tui): route shell shortcuts through opentui keymap
1 parent c63783c commit a4149eb

7 files changed

Lines changed: 444 additions & 281 deletions

File tree

‎bun.lock‎

Lines changed: 21 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/TUI-keymap-eval.md‎

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,25 @@
11
# Keymap evaluation
22

3-
Decision record for a global keymap on the imperative `@opentui/core` 0.6.4 surface. This record changes no code.
3+
Decision record for application key bindings on `@opentui/core` 0.6.6. The engine is `@opentui/keymap`.
44

55
## Recommendation
66

7-
**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.
7+
**Adopt** `@opentui/keymap` for application commands. Do not move widget-local editing, the scroll lease, or the kill ring into it.
88

9-
## What 0.6.4 actually provides
9+
`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()`).
1010

11-
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.
11+
## What stays a command body
1212

13-
`mergeKeyBindings(defaults, custom)` in `lib/keybinding.internal.d.ts` is that merge: two `KeyBinding<Action>[]` 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.
13+
Widget `keyBindings` cannot express policy that spans the shell:
1414

15-
## What stays
15+
- **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.
16+
- **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.
17+
- **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.
1618

17-
- `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.
18-
- `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.
19-
20-
Neither file is a stand-in for `mergeKeyBindings`. Replacing them with widget tables would drop behavior the tables cannot express.
21-
22-
## Deleted if a global keymap were adopted
23-
24-
It is not adopted. If it were, the deletion this record refuses is:
25-
26-
- `src/tui/shell/keys.ts` would be deleted or reduced to widget binding tables.
27-
- `src/tui/keybindings.ts` would be deleted in favor of `mergeKeyBindings` defaults.
28-
29-
Those files stay. Reducing them would drop three policies that are not widget bindings:
30-
31-
- **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.
32-
- **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.
33-
- **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.
19+
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.
3420

3521
## Checks
3622

37-
- [x] Decline a global keymap. `mergeKeyBindings` and per-widget `keyBindings` are the only real key APIs, and they are not an application map.
38-
- [ ] 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.
39-
- [x] File no follow-up. This record does not adopt a path that needs one.
23+
- [x] Use `@opentui/keymap` for application commands. Help rows for those chords come from the keymap catalog.
24+
- [x] Leave the scroll lease, the kill ring, and Ctrl+C policy as command bodies. Widget bindings cannot express them.
25+
- [ ] Delete `shell/keys.ts` and `keybindings.ts`. Cannot check. The dispatcher and the prompt-local catalog stay.

‎package.json‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@
106106
"@intx/types": "workspace:*",
107107
"@intx/workflow-host": "0.3.0",
108108
"@modelcontextprotocol/sdk": "^1.32.1",
109-
"@opentui/core": "0.6.4",
109+
"@opentui/core": "0.6.6",
110+
"@opentui/keymap": "0.6.6",
110111
"arktype": "catalog:",
111112
"isomorphic-git": "catalog:",
112113
"@corbits/code-agent-designer": "workspace:*",
@@ -125,13 +126,13 @@
125126
"typescript-language-server": "^4.3.4"
126127
},
127128
"optionalDependencies": {
128-
"@opentui/core-darwin-arm64": "0.6.4",
129-
"@opentui/core-darwin-x64": "0.6.4",
130-
"@opentui/core-linux-arm64": "0.6.4",
131-
"@opentui/core-linux-arm64-musl": "0.6.4",
132-
"@opentui/core-linux-x64": "0.6.4",
133-
"@opentui/core-linux-x64-musl": "0.6.4",
134-
"@opentui/core-win32-arm64": "0.6.4",
135-
"@opentui/core-win32-x64": "0.6.4"
129+
"@opentui/core-darwin-arm64": "0.6.6",
130+
"@opentui/core-darwin-x64": "0.6.6",
131+
"@opentui/core-linux-arm64": "0.6.6",
132+
"@opentui/core-linux-arm64-musl": "0.6.6",
133+
"@opentui/core-linux-x64": "0.6.6",
134+
"@opentui/core-linux-x64-musl": "0.6.6",
135+
"@opentui/core-win32-arm64": "0.6.6",
136+
"@opentui/core-win32-x64": "0.6.6"
136137
}
137138
}

‎src/tui/keybindings.ts‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
/**
22
* OpenTUI shell keybinding catalog (pure data) — the help overlay's source
3-
* of truth. Every row must be a chord that really works: a handler in
4-
* `src/tui/shell.ts`, or a default prompt binding from @opentui/core. Do
5-
* not hand-transcribe from docs; `keybindings.test.ts` drives every row's
6-
* chord through a live shell and asserts its effect.
3+
* of truth. Rows with `command` are application chords registered on
4+
* `@opentui/keymap`. Rows without one stay prompt-local: textarea defaults,
5+
* the kill ring, or a chord whose meaning depends on context keys.ts still
6+
* owns. `keybindings.test.ts` drives every row through a live shell.
77
*/
88

99
export interface ShellShortcut {
1010
readonly keys: string;
1111
readonly description: string;
12+
/** `@opentui/keymap` command name. Absent for prompt-local chords. */
13+
readonly command?: string;
14+
/** Binding strings for `command`. Absent for prompt-local chords. */
15+
readonly bindings?: readonly string[];
1216
}
1317

1418
export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
@@ -21,58 +25,84 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
2125
keys: "Alt+Enter",
2226
description:
2327
"queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy",
28+
command: "shell.queue",
29+
bindings: ["alt+return"],
2430
},
2531
{
2632
keys: "Up / Down / Enter / Ctrl+X",
2733
description:
2834
"on held items above the prompt: select, send now, drop (Esc backs out)",
35+
// Up, Down, and Enter also move the caret, recall history, or send.
36+
// Only Ctrl+X is unambiguous; the others stay in keys.ts.
37+
command: "shell.held.drop",
38+
bindings: ["ctrl+x"],
2939
},
3040
{
3141
keys: "Ctrl+C",
3242
description:
3343
"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",
44+
command: "shell.interrupt",
45+
bindings: ["ctrl+c"],
3446
},
3547
{
3648
keys: "Ctrl+G",
3749
description:
3850
"pop the most recently queued or steered message back into the prompt for editing",
51+
command: "shell.cancel",
52+
bindings: ["ctrl+g"],
3953
},
4054
{
4155
keys: "Alt+C",
4256
description:
4357
"copy mode: pick a message, tool output, or diff; press again to close it",
58+
command: "shell.copy",
59+
bindings: ["alt+c"],
4460
},
4561
{
4662
keys: "Alt+M",
4763
description:
4864
"toggle DEC mouse capture (on by default: wheel scroll, click-to-expand, drag-to-copy); off restores native terminal drag-select",
65+
command: "shell.mouse",
66+
bindings: ["alt+m"],
4967
},
5068
{
5169
keys: "Alt+E",
5270
description:
5371
"expand or collapse every collapsible row (tool call, diff, skill, reasoning)",
72+
command: "shell.expand",
73+
bindings: ["alt+e"],
5474
},
5575
{
5676
keys: "Alt+T",
5777
description:
5878
"show or hide the task list above the prompt (hidden by default)",
79+
command: "shell.tasks",
80+
bindings: ["alt+t"],
5981
},
6082
{
6183
keys: "Alt+O",
6284
description:
6385
"observe a live subagent session; a system row says so when there is none",
86+
command: "shell.observe",
87+
bindings: ["alt+o"],
6488
},
6589
{
6690
keys: "Tab",
6791
description: "move focus between the prompt and the transcript",
92+
command: "shell.focus",
93+
bindings: ["tab"],
6894
},
6995
{
7096
keys: "Shift+Tab",
7197
description: "cycle reasoning effort for the current model",
98+
command: "shell.effort",
99+
bindings: ["shift+tab"],
72100
},
73101
{
74102
keys: "Esc",
75103
description: "close the open overlay, or leave subagent observe",
104+
command: "shell.dismiss",
105+
bindings: ["escape"],
76106
},
77107
{
78108
keys: "Ctrl+B / Ctrl+F",
@@ -128,10 +158,34 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
128158
},
129159
] as const;
130160

131-
/** Help rows from the shell's own catalog, so they cannot drift from it. */
161+
/** Application chords the keymap registers. */
162+
export function keymapShortcuts(): readonly ShellShortcut[] {
163+
return SHELL_SHORTCUTS.filter((row) => row.command !== undefined);
164+
}
165+
166+
/**
167+
* Prompt-local rows: textarea defaults, the kill ring, paste, and chords
168+
* whose meaning depends on context the keymap does not own.
169+
*/
170+
export function promptLocalShortcuts(): readonly ShellShortcut[] {
171+
return SHELL_SHORTCUTS.filter((row) => row.command === undefined);
172+
}
173+
174+
function formatShortcut(row: ShellShortcut): string {
175+
return `${row.keys} — ${row.description}`;
176+
}
177+
178+
/**
179+
* Help rows from the keymap catalog plus prompt-local rows, in catalog
180+
* order, so the overlay cannot drift from either list.
181+
*/
132182
export function helpItems(): readonly string[] {
183+
const owned = new Set<string>([
184+
...keymapShortcuts().map((row) => row.keys),
185+
...promptLocalShortcuts().map((row) => row.keys),
186+
]);
133187
return [
134-
...SHELL_SHORTCUTS.map((s) => `${s.keys} — ${s.description}`),
188+
...SHELL_SHORTCUTS.filter((row) => owned.has(row.keys)).map(formatShortcut),
135189
"Close help",
136190
];
137191
}

‎src/tui/shell/index.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
createShellKeyHandlers,
8484
routePromptWheelToTranscript,
8585
} from "./keys.js";
86+
import { attachShellKeymap } from "./keymap.js";
8687

8788
const DEFAULT_TITLE = "corbits";
8889

@@ -364,6 +365,7 @@ export function createAppShell(
364365
}
365366

366367
let disposed = false;
368+
let detachKeymap: (() => void) | undefined;
367369
let session = createSessionQueue(run);
368370
const seedPending = Math.max(0, Math.floor(options?.pendingQueue ?? 0));
369371
for (let i = 0; i < seedPending; i++) {
@@ -518,6 +520,8 @@ export function createAppShell(
518520
shell.disposed = true;
519521
// Quit paths that skip idle Ctrl+C still drop Corbits-created files.
520522
clearPendingAttachments(shell);
523+
detachKeymap?.();
524+
detachKeymap = undefined;
521525
if (wireKeys) {
522526
renderer.keyInput.off("keypress", onKey);
523527
renderer.keyInput.off("paste", onPaste);
@@ -628,13 +632,14 @@ export function createAppShell(
628632
if (onObserveRequestOpt) {
629633
setPaletteOnObserveRequest(shell, onObserveRequestOpt);
630634
}
631-
const { onKey, onPaste } = createShellKeyHandlers(shell, {
635+
const { onKey, onPaste, keymap } = createShellKeyHandlers(shell, {
632636
isDisposed: () => disposed,
633637
});
634638
if (wireKeys) {
635639
renderer.keyInput.on("keypress", onKey);
636640
renderer.keyInput.on("paste", onPaste);
637641
prompt.onSubmit = onEnter;
642+
detachKeymap = attachShellKeymap(renderer as CliRenderer, keymap);
638643
}
639644

640645
applyLayout(shell, layout);

0 commit comments

Comments
 (0)