Skip to content

Commit 25998b8

Browse files
Merge pull request #1460 from corbitsdev/cl-10734-adopt-opentuikeymap-for-shell-shortcuts
feat(tui): route shell shortcuts through opentui keymap
2 parents 1aff128 + 004ae6b commit 25998b8

12 files changed

Lines changed: 674 additions & 467 deletions

‎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: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,27 @@
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+
`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.
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+
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.
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+
## What stays a command body
1414

15-
## What stays
15+
Widget `keyBindings` cannot express policy that spans the shell:
1616

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.
17+
- **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.
18+
- **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.
19+
- **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.
1920

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.
21+
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.
3422

3523
## Checks
3624

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.
25+
- [x] Use `@opentui/keymap` for application commands. Help rows for those chords come from the keymap catalog.
26+
- [x] Leave the scroll lease, the kill ring, and Ctrl+C policy as command bodies. Widget bindings cannot express them.
27+
- [ ] 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
@@ -107,7 +107,8 @@
107107
"@intx/types": "workspace:*",
108108
"@intx/workflow-host": "0.3.0",
109109
"@modelcontextprotocol/sdk": "^1.32.1",
110-
"@opentui/core": "0.6.4",
110+
"@opentui/core": "0.6.6",
111+
"@opentui/keymap": "0.6.6",
111112
"arktype": "catalog:",
112113
"isomorphic-git": "catalog:",
113114
"@corbits/code-agent-designer": "workspace:*",
@@ -127,13 +128,13 @@
127128
"typescript-language-server": "^4.3.4"
128129
},
129130
"optionalDependencies": {
130-
"@opentui/core-darwin-arm64": "0.6.4",
131-
"@opentui/core-darwin-x64": "0.6.4",
132-
"@opentui/core-linux-arm64": "0.6.4",
133-
"@opentui/core-linux-arm64-musl": "0.6.4",
134-
"@opentui/core-linux-x64": "0.6.4",
135-
"@opentui/core-linux-x64-musl": "0.6.4",
136-
"@opentui/core-win32-arm64": "0.6.4",
137-
"@opentui/core-win32-x64": "0.6.4"
131+
"@opentui/core-darwin-arm64": "0.6.6",
132+
"@opentui/core-darwin-x64": "0.6.6",
133+
"@opentui/core-linux-arm64": "0.6.6",
134+
"@opentui/core-linux-arm64-musl": "0.6.6",
135+
"@opentui/core-linux-x64": "0.6.6",
136+
"@opentui/core-linux-x64-musl": "0.6.6",
137+
"@opentui/core-win32-arm64": "0.6.6",
138+
"@opentui/core-win32-x64": "0.6.6"
138139
}
139140
}

‎src/tui/keybindings.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { describe, expect, test } from "bun:test";
1717
import { defined } from "../../testkit/defined.js";
1818
import { isPaused } from "./delivery-queue.js";
1919
import { PROMPT_KEY_BINDINGS } from "./prompt-input.js";
20-
import { SHELL_SHORTCUTS } from "./keybindings.js";
20+
import { shellShortcuts } from "./keybindings.js";
2121
import { createHarness, type Harness } from "./harness.js";
2222
import { mountRunnerHost } from "./runner/host.js";
2323
import { openCommandSurface } from "./command-surfaces.js";
@@ -733,7 +733,7 @@ function recordSubmits(
733733
}
734734

735735
function rowsIn(group: Group): readonly { keys: string; probe: Probe }[] {
736-
return SHELL_SHORTCUTS.flatMap((row) => {
736+
return shellShortcuts().flatMap((row) => {
737737
const entry = PROBES[row.keys];
738738
return entry !== undefined && entry.group === group
739739
? [{ keys: row.keys, probe: entry.probe }]
@@ -760,14 +760,14 @@ async function runGroup(group: Group): Promise<void> {
760760

761761
describe("every catalog row is driven against real behavior", () => {
762762
test("no row is left without a probe", () => {
763-
const missing = SHELL_SHORTCUTS.filter(
764-
(row) => PROBES[row.keys] === undefined,
765-
).map((row) => row.keys);
763+
const missing = shellShortcuts()
764+
.filter((row) => PROBES[row.keys] === undefined)
765+
.map((row) => row.keys);
766766
expect(missing).toEqual([]);
767767
});
768768

769769
test("no probe describes a chord the catalog no longer lists", () => {
770-
const listed = new Set(SHELL_SHORTCUTS.map((row) => row.keys));
770+
const listed = new Set(shellShortcuts().map((row) => row.keys));
771771
expect(Object.keys(PROBES).filter((keys) => !listed.has(keys))).toEqual([]);
772772
});
773773

‎src/tui/keybindings.ts‎

Lines changed: 38 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,26 @@
11
/**
2-
* 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.
2+
* Help rows. Application lines come from the live `@opentui/keymap`
3+
* registration (`bindAppHelp`). Widget-default rows are the Textarea chords
4+
* `defaultTextareaKeyBindings` / `PROMPT_KEY_BINDINGS` already perform, plus
5+
* the kill-ring yank chords the textarea does not implement.
76
*/
87

98
export interface ShellShortcut {
109
readonly keys: string;
1110
readonly description: string;
1211
}
1312

14-
export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
13+
/**
14+
* Prompt-local rows. Cursor, delete, word motion, and newline/submit are
15+
* Textarea defaults (`renderables/Textarea.d.ts` `TextareaAction`). Yank is
16+
* not a Textarea action; it stays listed because the kill ring records it.
17+
*/
18+
export const WIDGET_DEFAULT_SHORTCUTS: readonly ShellShortcut[] = [
1519
{
1620
keys: "Enter",
1721
description:
1822
"soft-steer at the next tool boundary while busy (held above the prompt); send straight through when idle",
1923
},
20-
{
21-
keys: "Alt+Enter",
22-
description:
23-
"queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy",
24-
},
25-
{
26-
keys: "Up / Down / Enter / Ctrl+X",
27-
description:
28-
"on held items above the prompt: select, send now, drop (Esc backs out)",
29-
},
30-
{
31-
keys: "Ctrl+C",
32-
description:
33-
"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",
34-
},
35-
{
36-
keys: "Ctrl+G",
37-
description:
38-
"pop the most recently queued or steered message back into the prompt for editing",
39-
},
40-
{
41-
keys: "Alt+C",
42-
description:
43-
"copy mode: pick a message, tool output, or diff; press again to close it",
44-
},
45-
{
46-
keys: "Alt+M",
47-
description:
48-
"toggle DEC mouse capture (on by default: wheel scroll, click-to-expand, drag-to-copy); off restores native terminal drag-select",
49-
},
50-
{
51-
keys: "Alt+E",
52-
description:
53-
"expand or collapse every collapsible row (tool call, diff, skill, reasoning)",
54-
},
55-
{
56-
keys: "Alt+T",
57-
description:
58-
"show or hide the task list above the prompt (hidden by default)",
59-
},
60-
{
61-
keys: "Alt+O",
62-
description:
63-
"observe a live subagent session; a system row says so when there is none",
64-
},
65-
{
66-
keys: "Tab",
67-
description: "move focus between the prompt and the transcript",
68-
},
69-
{
70-
keys: "Shift+Tab",
71-
description: "cycle reasoning effort for the current model",
72-
},
73-
{
74-
keys: "Esc",
75-
description: "close the open overlay, or leave subagent observe",
76-
},
7724
{
7825
keys: "Ctrl+B / Ctrl+F",
7926
description: "move the cursor back / forward one character",
@@ -126,12 +73,37 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
12673
description:
12774
"insert a newline instead of sending (Shift+Enter also works on terminals that report the modifier)",
12875
},
129-
] as const;
76+
];
77+
78+
let appRows: readonly ShellShortcut[] = [];
79+
let readAppHelp: () => readonly string[] = () =>
80+
appRows.map((row) => `${row.keys} — ${row.description}`);
81+
82+
export function setAppShortcutRows(rows: readonly ShellShortcut[]): void {
83+
appRows = rows;
84+
}
85+
86+
export function bindAppHelp(read: () => readonly string[]): () => void {
87+
const previous = readAppHelp;
88+
readAppHelp = read;
89+
return () => {
90+
readAppHelp = previous;
91+
};
92+
}
93+
94+
export function shellShortcuts(): readonly ShellShortcut[] {
95+
return [...appRows, ...WIDGET_DEFAULT_SHORTCUTS];
96+
}
97+
98+
function formatShortcut(row: ShellShortcut): string {
99+
return `${row.keys} — ${row.description}`;
100+
}
130101

131-
/** Help rows from the shell's own catalog, so they cannot drift from it. */
102+
/** Keymap command lines, then widget-default rows. */
132103
export function helpItems(): readonly string[] {
133104
return [
134-
...SHELL_SHORTCUTS.map((s) => `${s.keys} — ${s.description}`),
105+
...readAppHelp(),
106+
...WIDGET_DEFAULT_SHORTCUTS.map(formatShortcut),
135107
"Close help",
136108
];
137109
}

0 commit comments

Comments
 (0)