Skip to content

Commit 108b6f0

Browse files
committed
fix(tui): drop the keymap chord switch and textarea rebinds
1 parent a4149eb commit 108b6f0

8 files changed

Lines changed: 410 additions & 505 deletions

File tree

‎docs/TUI-keymap-eval.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ Decision record for application key bindings on `@opentui/core` 0.6.6. The engin
66

77
**Adopt** `@opentui/keymap` for application commands. Do not move widget-local editing, the scroll lease, or the kill ring into it.
88

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()`).
9+
`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.
10+
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.
1012

1113
## What stays a command body
1214

1315
Widget `keyBindings` cannot express policy that spans the shell:
1416

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+
- **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.
1719
- **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.
1820

1921
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.

‎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: 33 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,27 @@
11
/**
2-
* OpenTUI shell keybinding catalog (pure data) — the help overlay's source
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.
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.
6+
* There is no second copy of the application chord table here.
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[];
1612
}
1713

18-
export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
14+
/**
15+
* Prompt-local rows. Cursor, delete, word motion, and newline/submit are
16+
* Textarea defaults (`renderables/Textarea.d.ts` `TextareaAction`). Yank is
17+
* not a Textarea action; it stays listed because the kill ring records it.
18+
*/
19+
export const WIDGET_DEFAULT_SHORTCUTS: readonly ShellShortcut[] = [
1920
{
2021
keys: "Enter",
2122
description:
2223
"soft-steer at the next tool boundary while busy (held above the prompt); send straight through when idle",
2324
},
24-
{
25-
keys: "Alt+Enter",
26-
description:
27-
"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"],
30-
},
31-
{
32-
keys: "Up / Down / Enter / Ctrl+X",
33-
description:
34-
"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"],
39-
},
40-
{
41-
keys: "Ctrl+C",
42-
description:
43-
"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"],
46-
},
47-
{
48-
keys: "Ctrl+G",
49-
description:
50-
"pop the most recently queued or steered message back into the prompt for editing",
51-
command: "shell.cancel",
52-
bindings: ["ctrl+g"],
53-
},
54-
{
55-
keys: "Alt+C",
56-
description:
57-
"copy mode: pick a message, tool output, or diff; press again to close it",
58-
command: "shell.copy",
59-
bindings: ["alt+c"],
60-
},
61-
{
62-
keys: "Alt+M",
63-
description:
64-
"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"],
67-
},
68-
{
69-
keys: "Alt+E",
70-
description:
71-
"expand or collapse every collapsible row (tool call, diff, skill, reasoning)",
72-
command: "shell.expand",
73-
bindings: ["alt+e"],
74-
},
75-
{
76-
keys: "Alt+T",
77-
description:
78-
"show or hide the task list above the prompt (hidden by default)",
79-
command: "shell.tasks",
80-
bindings: ["alt+t"],
81-
},
82-
{
83-
keys: "Alt+O",
84-
description:
85-
"observe a live subagent session; a system row says so when there is none",
86-
command: "shell.observe",
87-
bindings: ["alt+o"],
88-
},
89-
{
90-
keys: "Tab",
91-
description: "move focus between the prompt and the transcript",
92-
command: "shell.focus",
93-
bindings: ["tab"],
94-
},
95-
{
96-
keys: "Shift+Tab",
97-
description: "cycle reasoning effort for the current model",
98-
command: "shell.effort",
99-
bindings: ["shift+tab"],
100-
},
101-
{
102-
keys: "Esc",
103-
description: "close the open overlay, or leave subagent observe",
104-
command: "shell.dismiss",
105-
bindings: ["escape"],
106-
},
10725
{
10826
keys: "Ctrl+B / Ctrl+F",
10927
description: "move the cursor back / forward one character",
@@ -156,36 +74,39 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
15674
description:
15775
"insert a newline instead of sending (Shift+Enter also works on terminals that report the modifier)",
15876
},
159-
] as const;
77+
];
16078

161-
/** Application chords the keymap registers. */
162-
export function keymapShortcuts(): readonly ShellShortcut[] {
163-
return SHELL_SHORTCUTS.filter((row) => row.command !== undefined);
79+
let appRows: readonly ShellShortcut[] = [];
80+
let readAppHelp: () => readonly string[] = () =>
81+
appRows.map((row) => `${row.keys} — ${row.description}`);
82+
83+
/** Called when the keymap module loads, and again from the live keymap. */
84+
export function setAppShortcutRows(rows: readonly ShellShortcut[]): void {
85+
appRows = rows;
16486
}
16587

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);
88+
export function bindAppHelp(read: () => readonly string[]): () => void {
89+
const previous = readAppHelp;
90+
readAppHelp = read;
91+
return () => {
92+
readAppHelp = previous;
93+
};
94+
}
95+
96+
/** Catalog the live tests drive. Application rows are whatever the keymap registered. */
97+
export function shellShortcuts(): readonly ShellShortcut[] {
98+
return [...appRows, ...WIDGET_DEFAULT_SHORTCUTS];
17299
}
173100

174101
function formatShortcut(row: ShellShortcut): string {
175102
return `${row.keys} — ${row.description}`;
176103
}
177104

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-
*/
105+
/** Keymap command lines, then widget-default rows. */
182106
export function helpItems(): readonly string[] {
183-
const owned = new Set<string>([
184-
...keymapShortcuts().map((row) => row.keys),
185-
...promptLocalShortcuts().map((row) => row.keys),
186-
]);
187107
return [
188-
...SHELL_SHORTCUTS.filter((row) => owned.has(row.keys)).map(formatShortcut),
108+
...readAppHelp(),
109+
...WIDGET_DEFAULT_SHORTCUTS.map(formatShortcut),
189110
"Close help",
190111
];
191112
}

‎src/tui/shell/index.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -632,14 +632,17 @@ export function createAppShell(
632632
if (onObserveRequestOpt) {
633633
setPaletteOnObserveRequest(shell, onObserveRequestOpt);
634634
}
635-
const { onKey, onPaste, keymap } = createShellKeyHandlers(shell, {
635+
const { onKey, onPaste } = createShellKeyHandlers(shell, {
636636
isDisposed: () => disposed,
637637
});
638638
if (wireKeys) {
639-
renderer.keyInput.on("keypress", onKey);
639+
detachKeymap = attachShellKeymap(renderer as CliRenderer, shell);
640+
// onKey runs before the keymap listener (prepended above) so paste-burst
641+
// bookkeeping and the kill-ring break happen, then the keymap may
642+
// stopPropagation. Textarea defaults run last.
643+
renderer.keyInput.prependListener("keypress", onKey);
640644
renderer.keyInput.on("paste", onPaste);
641645
prompt.onSubmit = onEnter;
642-
detachKeymap = attachShellKeymap(renderer as CliRenderer, keymap);
643646
}
644647

645648
applyLayout(shell, layout);

‎src/tui/shell/keymap.ts‎

Lines changed: 44 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
11
/**
2-
* Application chords on `@opentui/keymap`. The package looks the chord up;
3-
* `ShellKeymapDispatch` in keys.ts is the command body. Prompt kill, newline,
4-
* and contextual Enter / Up / Down stay out of this layer.
2+
* Registers `APP_CHORDS` on `@opentui/keymap`. Lookup belongs to the package.
3+
* There is no second switch from key name to handler.
54
*/
65
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core";
76
import type { Keymap } from "@opentui/keymap";
87
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui";
98

10-
import { keymapShortcuts } from "../keybindings.js";
11-
import type { ShellKeymapDispatch } from "./keys.js";
9+
import { bindAppHelp, setAppShortcutRows } from "../keybindings.js";
10+
import type { AppShell } from "./internals.js";
11+
import { APP_CHORDS, appHelpLines, shellIsLive } from "./shell-chords.js";
12+
13+
setAppShortcutRows(
14+
APP_CHORDS.map((row) => ({
15+
keys: row.keys,
16+
description: row.description,
17+
})),
18+
);
19+
bindAppHelp(appHelpLines);
1220

1321
type ShellKeymap = Keymap<Renderable, KeyEvent>;
1422

1523
const keymaps = new WeakMap<CliRenderer, ShellKeymap>();
1624

17-
/**
18-
* Terminals that cannot report Shift+Tab send `backtab`. It is not a named
19-
* key in the default parser, so a string binding would become a letter sequence.
20-
*/
21-
const EFFORT_BACKTAB = { name: "backtab" } as const;
22-
2325
function keymapFor(renderer: CliRenderer): ShellKeymap {
2426
const existing = keymaps.get(renderer);
2527
if (existing !== undefined) return existing;
@@ -28,69 +30,44 @@ function keymapFor(renderer: CliRenderer): ShellKeymap {
2830
return created;
2931
}
3032

31-
function runCommand(
32-
command: string,
33-
dispatch: ShellKeymapDispatch,
34-
key: KeyEvent,
35-
): boolean {
36-
switch (command) {
37-
case "shell.dismiss":
38-
return dispatch.escape(key);
39-
case "shell.interrupt":
40-
return dispatch.ctrlC(key);
41-
case "shell.copy":
42-
return dispatch.copy(key);
43-
case "shell.mouse":
44-
return dispatch.mouse(key);
45-
case "shell.expand":
46-
return dispatch.expand(key);
47-
case "shell.tasks":
48-
return dispatch.tasks(key);
49-
case "shell.observe":
50-
return dispatch.observe(key);
51-
case "shell.focus":
52-
return dispatch.focus(key);
53-
case "shell.effort":
54-
return dispatch.effort(key);
55-
case "shell.cancel":
56-
return dispatch.cancel(key);
57-
case "shell.queue":
58-
return dispatch.queue(key);
59-
case "shell.held.drop":
60-
return dispatch.heldDrop(key);
61-
default:
62-
return false;
63-
}
33+
function helpFromKeymap(keymap: ShellKeymap): readonly string[] {
34+
return keymap.getCommands({ visibility: "registered" }).flatMap((command) => {
35+
const desc = command.desc;
36+
return typeof desc === "string" && desc.length > 0 ? [desc] : [];
37+
});
6438
}
6539

66-
/** Register the shell's application layer. Dispose unregisters that layer. */
40+
/** Register the shell's application layer. Dispose drops that layer. */
6741
export function attachShellKeymap(
6842
renderer: CliRenderer,
69-
dispatch: ShellKeymapDispatch,
43+
shell: AppShell,
7044
): () => void {
7145
const keymap = keymapFor(renderer);
72-
const rows = keymapShortcuts();
73-
return keymap.registerLayer({
74-
commands: rows.map((row) => ({
75-
name: row.command ?? "",
76-
desc: row.description,
77-
run(ctx: { event: KeyEvent }): boolean {
78-
const command = row.command;
79-
if (command === undefined) return false;
80-
return runCommand(command, dispatch, ctx.event);
46+
const disposeLayer = keymap.registerLayer({
47+
commands: APP_CHORDS.map((chord) => ({
48+
name: chord.name,
49+
desc: `${chord.keys} — ${chord.description}`,
50+
enabled: () => shellIsLive(shell) && (chord.active?.(shell) ?? true),
51+
run: (ctx: { event: KeyEvent }): boolean => {
52+
// onKey already claimed this chord (slash Tab, overlay Enter, yank).
53+
if (ctx.event.defaultPrevented) return false;
54+
const handled = chord.run(shell);
55+
if (handled && chord.preventDefault === false)
56+
ctx.event.preventDefault();
57+
return handled;
8158
},
8259
})),
83-
bindings: [
84-
...rows.flatMap((row) =>
85-
(row.bindings ?? []).map((key) => ({
86-
key,
87-
cmd: row.command ?? "",
88-
// Esc must not stopPropagation. The engine pairs that with
89-
// preventDefault, and the list modal's own listener still has to run.
90-
...(row.command === "shell.dismiss" ? { preventDefault: false } : {}),
91-
})),
92-
),
93-
{ key: EFFORT_BACKTAB, cmd: "shell.effort" },
94-
],
60+
bindings: APP_CHORDS.flatMap((chord) =>
61+
chord.bindings.map((key) => ({
62+
key,
63+
cmd: chord.name,
64+
...(chord.preventDefault === false ? { preventDefault: false } : {}),
65+
})),
66+
),
9567
});
68+
const disposeHelp = bindAppHelp(() => helpFromKeymap(keymap));
69+
return () => {
70+
disposeHelp();
71+
disposeLayer();
72+
};
9673
}

0 commit comments

Comments
 (0)