Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 13 additions & 25 deletions docs/TUI-keymap-eval.md
Original file line number Diff line number Diff line change
@@ -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<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.
## 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.
19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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"
}
}
12 changes: 6 additions & 6 deletions src/tui/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }]
Expand All @@ -760,14 +760,14 @@ async function runGroup(group: Group): Promise<void> {

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([]);
});

Expand Down
104 changes: 38 additions & 66 deletions src/tui/keybindings.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,26 @@
/**
* 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 {
readonly keys: string;
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",
Expand Down Expand Up @@ -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",
];
}
Loading
Loading