diff --git a/CHANGELOG.md b/CHANGELOG.md
index c463f84..7728926 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,3 +3,4 @@
## Unreleased
- Added the full-screen `ShellFrame`, the `runShell` renderer, Relay themes, and the `relay-shell` demonstration command.
+- Added layered keybindings with `KeymapProvider`, `useKeybindings`, key specs, `HelpOverlay`, and `KeyHints`.
diff --git a/README.md b/README.md
index 5452332..86ccc70 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ npm install
npm run demo
```
-Press `q` to quit. To print one frame without opening the interactive screen:
+Press `?` for the keyboard reference and `q` to quit. To print one frame without opening the interactive screen:
```sh
npx tsx src/cli.tsx --demo --snapshot
@@ -30,6 +30,29 @@ await runShell(
`runShell` renders on the alternate screen, so the terminal's scrollback is restored when the application exits.
+## Keybindings
+
+Components declare the keys they handle, and Relay Shell routes each key press to the right one. Overlays sit above panes, and panes sit above application-wide keys.
+
+```tsx
+import {useApp} from 'ink';
+import {KeyHints, KeymapProvider, ShellFrame, runShell, useKeybindings} from '@relay-cli/shell';
+
+const App = () => {
+ const {exit} = useApp();
+ useKeybindings([{keys: ['q', 'ctrl+c'], description: 'Quit', run: () => exit()}]);
+ return } />;
+};
+
+await runShell(
+
+
+ ,
+);
+```
+
+See [the keybinding guide](./docs/keyboard.md) for key specs, layers, and text input.
+
## Development
```sh
diff --git a/docs/architecture.md b/docs/architecture.md
index aaf1f3a..67a7132 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -4,5 +4,6 @@ Relay Shell is the frame a full-screen terminal application lives in. It does no
1. `runShell` renders an application on the alternate screen and resolves when it exits.
2. `ShellFrame` sizes the application to the terminal and reserves a title row and a footer.
+3. `KeymapProvider` reads the keyboard once and asks a `KeyRegistry` which binding should receive each key. See [keybindings](./keyboard.md).
-Layout, focus, keybindings, and the status bar are added as separate modules so an application can use only the parts it needs.
+Layout, focus, and the status bar are added as separate modules so an application can use only the parts it needs.
diff --git a/docs/keyboard.md b/docs/keyboard.md
new file mode 100644
index 0000000..d425508
--- /dev/null
+++ b/docs/keyboard.md
@@ -0,0 +1,52 @@
+# Keybindings
+
+Relay Shell reads the keyboard once, in `KeymapProvider`, and routes each key press through a registry of bindings. Components declare the keys they handle with `useKeybindings`; they never call Ink's `useInput` directly, so two components cannot both react to the same key by accident.
+
+```tsx
+useKeybindings([
+ {keys: ['q', 'ctrl+c'], description: 'Quit', group: 'Application', run: () => exit()},
+ {keys: 'ctrl+s', description: 'Save', run: save},
+]);
+```
+
+## Key specs
+
+A spec is a key name with optional modifiers joined by `+`.
+
+| Spec | Meaning |
+| -------------------------------------------------------- | ---------------------------------------------------------- |
+| `q`, `?`, `/` | A printable character. Letters are case-sensitive. |
+| `Q`, `shift+q` | A capital letter |
+| `ctrl+s`, `alt+x` | A modified key. `meta` and `option` are aliases for `alt`. |
+| `shift+tab` | A modified named key |
+| `up`, `down`, `left`, `right` | Arrow keys |
+| `pageup`, `pagedown`, `home`, `end` | Navigation keys (`pgup` and `pgdn` also work) |
+| `enter`, `escape`, `tab`, `space`, `backspace`, `delete` | Named keys (`esc` and `return` also work) |
+
+## Layers
+
+Every set of bindings belongs to a layer. Higher layers are asked first:
+
+| Layer | Value | Typical use |
+| --------- | ----- | --------------------------- |
+| `global` | 0 | Quit, help, switching views |
+| `pane` | 10 | Keys for the focused pane |
+| `overlay` | 20 | Dialogs and the help screen |
+
+Within a layer the most recently mounted set wins. A handler that returns `false` passes the key on to the next set.
+
+An `exclusive` set stops keys it does not handle from reaching lower layers. A `fallback` receives keys that none of its bindings matched, which is how a text field accepts typing while the application still owns `Escape` and `Enter`:
+
+```tsx
+useKeybindings([{keys: 'enter', run: submit}], {
+ layer: keyLayers.pane,
+ fallback: (input) => {
+ setDraft((draft) => draft + input);
+ return true;
+ },
+});
+```
+
+## Help and hints
+
+Bindings with a `description` are listed by `HelpOverlay`, grouped by `group`, and summarized on one line by `KeyHints`. A key that a higher layer already handles is listed only once, under the binding that will receive it.
diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx
index 66b39bb..ab8fa9b 100644
--- a/src/demo/DemoApp.tsx
+++ b/src/demo/DemoApp.tsx
@@ -1,5 +1,7 @@
-import {Box, Text, useApp, useInput} from 'ink';
+import {useState} from 'react';
+import {Box, Text, useApp} from 'ink';
import {ShellFrame} from '../frame/index.js';
+import {HelpOverlay, KeyHints, KeymapProvider, useKeybindings} from '../keys/index.js';
import {relayTheme} from '../theme.js';
import type {ShellTheme} from '../theme.js';
@@ -11,28 +13,44 @@ export interface DemoAppProps {
interactive?: boolean;
}
-export const DemoApp = ({theme = relayTheme, width, height, interactive = true}: DemoAppProps) => {
+const DemoScreen = ({theme, width, height}: Omit) => {
const {exit} = useApp();
- useInput(
- (input, key) => {
- if (input === 'q' || (key.ctrl && input === 'c')) exit();
- },
- {isActive: interactive},
- );
+ const [help, setHelp] = useState(false);
+ const [count, setCount] = useState(0);
+ useKeybindings([
+ {keys: ['q', 'ctrl+c'], description: 'Quit', group: 'Application', run: () => exit()},
+ {keys: '?', description: 'Help', group: 'Application', run: () => setHelp(true)},
+ {keys: 'space', description: 'Count', group: 'Demo', run: () => setCount(count + 1)},
+ ]);
+ const colors = theme ?? relayTheme;
return (
q quit}
+ footer={}
>
-
-
- The frame fills the terminal and follows it when the window is resized.
-
+
+
+
+ The frame fills the terminal and follows it when the window is resized.
+
+ Space pressed {count} times.
+
+ {help ? (
+
+ setHelp(false)} />
+
+ ) : null}
);
};
+
+export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => (
+
+
+
+);
diff --git a/src/index.ts b/src/index.ts
index 28d4cc9..e7c8750 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,5 @@
export * from './frame/index.js';
+export * from './keys/index.js';
export {runShell, type RunShellOptions} from './run.js';
export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js';
export {relayShellVersion} from './version.js';
diff --git a/src/keys/HelpOverlay.tsx b/src/keys/HelpOverlay.tsx
new file mode 100644
index 0000000..315d8a3
--- /dev/null
+++ b/src/keys/HelpOverlay.tsx
@@ -0,0 +1,65 @@
+import {Box, Text} from 'ink';
+import {relayTheme} from '../theme.js';
+import type {ShellTheme} from '../theme.js';
+import {keyLayers} from './registry.js';
+import type {ListedBinding} from './registry.js';
+import {useKeyHints, useKeybindings} from './react.js';
+
+export interface HelpOverlayProps {
+ onClose: () => void;
+ title?: string;
+ theme?: ShellTheme;
+}
+
+const groupBindings = (bindings: readonly ListedBinding[]): Map => {
+ const groups = new Map();
+ for (const binding of bindings) {
+ const group = binding.group ?? 'General';
+ groups.set(group, [...(groups.get(group) ?? []), binding]);
+ }
+ return groups;
+};
+
+/**
+ * Lists every advertised binding, grouped by heading. While it is open it
+ * captures the keyboard; `Esc` or `?` closes it.
+ */
+export const HelpOverlay = ({
+ onClose,
+ title = 'Keyboard',
+ theme = relayTheme,
+}: HelpOverlayProps) => {
+ const hints = useKeyHints();
+ useKeybindings([{keys: ['escape', '?'], run: onClose}], {
+ layer: keyLayers.overlay,
+ exclusive: true,
+ });
+ const width = Math.max(10, ...hints.map((hint) => hint.keys.length));
+ return (
+
+
+ {title}
+
+ {[...groupBindings(hints)].map(([group, bindings]) => (
+
+ {group}
+ {bindings.map((binding) => (
+
+ {binding.keys.padEnd(width)}
+ {binding.description}
+
+ ))}
+
+ ))}
+
+ Esc close
+
+
+ );
+};
diff --git a/src/keys/KeyHints.tsx b/src/keys/KeyHints.tsx
new file mode 100644
index 0000000..6c07726
--- /dev/null
+++ b/src/keys/KeyHints.tsx
@@ -0,0 +1,26 @@
+import {Text} from 'ink';
+import {relayTheme} from '../theme.js';
+import type {ShellTheme} from '../theme.js';
+import {useKeyHints} from './react.js';
+
+export interface KeyHintsProps {
+ /** Show at most this many hints, highest layer first. */
+ limit?: number;
+ theme?: ShellTheme;
+}
+
+/** A single line of the most relevant bindings, such as `Tab next · q quit`. */
+export const KeyHints = ({limit = 6, theme = relayTheme}: KeyHintsProps) => {
+ const hints = useKeyHints().slice(0, limit);
+ return (
+
+ {hints.map((hint, index) => (
+
+ {index === 0 ? '' : · }
+ {hint.keys}
+ {hint.description.toLowerCase()}
+
+ ))}
+
+ );
+};
diff --git a/src/keys/chord.ts b/src/keys/chord.ts
new file mode 100644
index 0000000..8f70229
--- /dev/null
+++ b/src/keys/chord.ts
@@ -0,0 +1,149 @@
+import type {Key} from 'ink';
+
+/** One key press, normalized so that specs and terminal input compare equal. */
+export interface KeyChord {
+ /** A named key such as `tab` or `pageup`, or the printable character itself. */
+ key: string;
+ ctrl: boolean;
+ meta: boolean;
+ shift: boolean;
+}
+
+const namedKeys = new Map([
+ ['up', 'up'],
+ ['down', 'down'],
+ ['left', 'left'],
+ ['right', 'right'],
+ ['pageup', 'pageup'],
+ ['pgup', 'pageup'],
+ ['pagedown', 'pagedown'],
+ ['pgdn', 'pagedown'],
+ ['home', 'home'],
+ ['end', 'end'],
+ ['enter', 'enter'],
+ ['return', 'enter'],
+ ['escape', 'escape'],
+ ['esc', 'escape'],
+ ['tab', 'tab'],
+ ['backspace', 'backspace'],
+ ['delete', 'delete'],
+ ['space', ' '],
+]);
+
+const modifierNames = new Map([
+ ['ctrl', 'ctrl'],
+ ['control', 'ctrl'],
+ ['meta', 'meta'],
+ ['alt', 'meta'],
+ ['option', 'meta'],
+ ['shift', 'shift'],
+]);
+
+const isLetter = (value: string): boolean => /^[a-z]$/i.test(value);
+
+const segmenter = new Intl.Segmenter();
+
+/** One user-perceived character, so an emoji with modifiers still counts as one key. */
+const isSingleCharacter = (value: string): boolean => {
+ const segments = segmenter.segment(value)[Symbol.iterator]();
+ return segments.next().done === false && segments.next().done === true;
+};
+
+/**
+ * Parse a key spec such as `q`, `?`, `ctrl+s`, `shift+tab`, or `pagedown`.
+ *
+ * Printable characters are case-sensitive: `Q` and `shift+q` are the same
+ * chord. `ctrl+` combinations ignore case because terminals cannot report it.
+ */
+export const parseKeySpec = (spec: string): KeyChord => {
+ const trimmed = spec.trim();
+ if (trimmed.length === 0) throw new Error('A key spec cannot be empty.');
+ // A trailing "+" is the plus key itself, as in "ctrl++".
+ const parts = trimmed.endsWith('++')
+ ? [...trimmed.slice(0, -2).split('+'), '+']
+ : trimmed === '+'
+ ? ['+']
+ : trimmed.split('+');
+ const chord: KeyChord = {key: '', ctrl: false, meta: false, shift: false};
+ for (const [index, part] of parts.entries()) {
+ const last = index === parts.length - 1;
+ const lower = part.toLowerCase();
+ if (!last) {
+ const modifier = modifierNames.get(lower);
+ if (modifier === undefined) throw new Error(`Unknown modifier "${part}" in "${spec}".`);
+ chord[modifier] = true;
+ continue;
+ }
+ const named = namedKeys.get(lower);
+ if (named !== undefined && part.length > 1) chord.key = named;
+ else if (isSingleCharacter(part)) chord.key = part;
+ else throw new Error(`Unknown key "${part}" in "${spec}".`);
+ }
+ return normalizeChord(chord);
+};
+
+const normalizeChord = (chord: KeyChord): KeyChord => {
+ // Symbols such as "?" already carry Shift in the character itself.
+ if (isSingleCharacter(chord.key) && !isLetter(chord.key)) return {...chord, shift: false};
+ if (!isLetter(chord.key)) return chord;
+ if (chord.ctrl) return {...chord, key: chord.key.toLowerCase()};
+ if (chord.shift) return {...chord, key: chord.key.toUpperCase(), shift: false};
+ if (chord.key === chord.key.toUpperCase()) return {...chord, shift: false};
+ return chord;
+};
+
+/** Convert Ink's `useInput` arguments into a chord, or `undefined` for pasted text. */
+export const chordFromInput = (input: string, key: Key): KeyChord | undefined => {
+ const base = {ctrl: key.ctrl, meta: key.meta, shift: key.shift};
+ if (key.upArrow) return {...base, key: 'up'};
+ if (key.downArrow) return {...base, key: 'down'};
+ if (key.leftArrow) return {...base, key: 'left'};
+ if (key.rightArrow) return {...base, key: 'right'};
+ if (key.pageUp) return {...base, key: 'pageup'};
+ if (key.pageDown) return {...base, key: 'pagedown'};
+ if (key.home) return {...base, key: 'home'};
+ if (key.end) return {...base, key: 'end'};
+ if (key.return) return {...base, key: 'enter'};
+ if (key.escape) return {...base, key: 'escape'};
+ if (key.tab) return {...base, key: 'tab'};
+ if (key.backspace) return {...base, key: 'backspace'};
+ if (key.delete) return {...base, key: 'delete'};
+ if (!isSingleCharacter(input)) return undefined;
+ return normalizeChord({...base, key: input});
+};
+
+export const chordsEqual = (left: KeyChord, right: KeyChord): boolean =>
+ left.key === right.key &&
+ left.ctrl === right.ctrl &&
+ left.meta === right.meta &&
+ left.shift === right.shift;
+
+const keyLabels = new Map([
+ ['up', '↑'],
+ ['down', '↓'],
+ ['left', '←'],
+ ['right', '→'],
+ ['pageup', 'PgUp'],
+ ['pagedown', 'PgDn'],
+ ['home', 'Home'],
+ ['end', 'End'],
+ ['enter', 'Enter'],
+ ['escape', 'Esc'],
+ ['tab', 'Tab'],
+ ['backspace', 'Backspace'],
+ ['delete', 'Delete'],
+ [' ', 'Space'],
+]);
+
+/** A short label for help screens and status hints, such as `Ctrl+S` or `Shift+Tab`. */
+export const formatChord = (chord: KeyChord): string => {
+ const parts: string[] = [];
+ if (chord.ctrl) parts.push('Ctrl');
+ if (chord.meta) parts.push('Alt');
+ if (chord.shift) parts.push('Shift');
+ const label = keyLabels.get(chord.key) ?? chord.key;
+ parts.push(chord.ctrl && isLetter(chord.key) ? label.toUpperCase() : label);
+ return parts.join('+');
+};
+
+export const formatKeySpec = (spec: string): string => formatChord(parseKeySpec(spec));
diff --git a/src/keys/index.ts b/src/keys/index.ts
new file mode 100644
index 0000000..96d55ba
--- /dev/null
+++ b/src/keys/index.ts
@@ -0,0 +1,27 @@
+export {
+ chordFromInput,
+ chordsEqual,
+ formatChord,
+ formatKeySpec,
+ parseKeySpec,
+ type KeyChord,
+} from './chord.js';
+export {HelpOverlay, type HelpOverlayProps} from './HelpOverlay.js';
+export {KeyHints, type KeyHintsProps} from './KeyHints.js';
+export {
+ KeymapProvider,
+ useKeyHints,
+ useKeyRegistry,
+ useKeybindings,
+ type KeymapProviderProps,
+ type UseKeybindingsOptions,
+} from './react.js';
+export {
+ KeyRegistry,
+ keyLayers,
+ resolveBinding,
+ type KeyBinding,
+ type KeyHandlerOptions,
+ type ListedBinding,
+ type ResolvedBinding,
+} from './registry.js';
diff --git a/src/keys/react.tsx b/src/keys/react.tsx
new file mode 100644
index 0000000..e2551a1
--- /dev/null
+++ b/src/keys/react.tsx
@@ -0,0 +1,76 @@
+import {createContext, useContext, useEffect, useRef, useState, useSyncExternalStore} from 'react';
+import type {ReactNode} from 'react';
+import {useInput} from 'ink';
+import {KeyRegistry} from './registry.js';
+import type {KeyBinding, KeyHandlerOptions, ListedBinding} from './registry.js';
+
+const KeyRegistryContext = createContext(undefined);
+
+export interface KeymapProviderProps {
+ /** Supply a registry to dispatch keys from outside React, for example in tests. */
+ registry?: KeyRegistry;
+ /** Stop reading the keyboard, for example while a child process owns the terminal. */
+ active?: boolean;
+ children?: ReactNode;
+}
+
+/** Reads the keyboard once and routes every key press through the registry. */
+export const KeymapProvider = ({registry, active = true, children}: KeymapProviderProps) => {
+ const [owned] = useState(() => registry ?? new KeyRegistry());
+ useInput(
+ (input, key) => {
+ owned.dispatch(input, key);
+ },
+ {isActive: active},
+ );
+ return {children};
+};
+
+export const useKeyRegistry = (): KeyRegistry => {
+ const registry = useContext(KeyRegistryContext);
+ if (registry === undefined) throw new Error('Key bindings must be used inside .');
+ return registry;
+};
+
+export interface UseKeybindingsOptions extends KeyHandlerOptions {
+ /** Inactive bindings are removed from dispatch and from help. */
+ active?: boolean;
+}
+
+/**
+ * Register bindings for as long as the component is mounted and active.
+ * Handlers always see the latest props, so inline functions are fine.
+ */
+export const useKeybindings = (
+ bindings: readonly KeyBinding[],
+ options: UseKeybindingsOptions = {},
+): void => {
+ const registry = useKeyRegistry();
+ const {active = true, ...handlerOptions} = options;
+ const id = useRef(undefined);
+ const latest = useRef({bindings, handlerOptions});
+ latest.current = {bindings, handlerOptions};
+
+ useEffect(() => {
+ if (!active) return;
+ const registered = registry.register(latest.current.bindings, latest.current.handlerOptions);
+ id.current = registered;
+ return () => {
+ registry.unregister(registered);
+ id.current = undefined;
+ };
+ }, [registry, active]);
+
+ useEffect(() => {
+ if (id.current !== undefined) registry.update(id.current, bindings, handlerOptions);
+ });
+};
+
+/** The bindings a key press can currently reach, for help screens and hints. */
+export const useKeyHints = (): readonly ListedBinding[] => {
+ const registry = useKeyRegistry();
+ return useSyncExternalStore(
+ (listener) => registry.subscribe(listener),
+ () => registry.list(),
+ );
+};
diff --git a/src/keys/registry.ts b/src/keys/registry.ts
new file mode 100644
index 0000000..83f75ec
--- /dev/null
+++ b/src/keys/registry.ts
@@ -0,0 +1,181 @@
+import type {Key} from 'ink';
+import {chordFromInput, chordsEqual, formatChord, parseKeySpec} from './chord.js';
+import type {KeyChord} from './chord.js';
+
+/** How strongly a set of bindings claims keys. Higher layers are asked first. */
+export const keyLayers = {
+ /** Application-wide bindings such as quit and help. */
+ global: 0,
+ /** Bindings that belong to the focused pane. */
+ pane: 10,
+ /** Overlays and dialogs that sit above everything else. */
+ overlay: 20,
+} as const;
+
+export interface KeyBinding {
+ /** One spec or several alternatives, such as `['q', 'ctrl+c']`. */
+ keys: string | readonly string[];
+ /** Shown in help and status hints. Bindings without one are not listed. */
+ description?: string;
+ /** Heading the binding is listed under in help. */
+ group?: string;
+ /** Return `false` to let the key continue to lower layers. */
+ run: () => unknown;
+}
+
+export interface KeyHandlerOptions {
+ layer?: number;
+ /** Stop keys that no binding here handles from reaching lower layers. */
+ exclusive?: boolean;
+ /**
+ * Called with keys that no binding in this set matched, before lower layers.
+ * Return `true` when the key was used, for example by a text field.
+ */
+ fallback?: (input: string, key: Key) => boolean;
+}
+
+export interface ResolvedBinding {
+ chords: readonly KeyChord[];
+ labels: readonly string[];
+ description?: string;
+ group?: string;
+ run: () => unknown;
+}
+
+export interface ListedBinding {
+ keys: string;
+ description: string;
+ group?: string;
+ layer: number;
+}
+
+interface Entry {
+ id: number;
+ layer: number;
+ exclusive: boolean;
+ bindings: readonly ResolvedBinding[];
+ fallback?: (input: string, key: Key) => boolean;
+}
+
+export const resolveBinding = (binding: KeyBinding): ResolvedBinding => {
+ const specs = typeof binding.keys === 'string' ? [binding.keys] : binding.keys;
+ const chords = specs.map(parseKeySpec);
+ return {
+ chords,
+ labels: chords.map(formatChord),
+ run: binding.run,
+ ...(binding.description === undefined ? {} : {description: binding.description}),
+ ...(binding.group === undefined ? {} : {group: binding.group}),
+ };
+};
+
+/**
+ * Holds every active set of bindings and routes each key press to the
+ * highest layer that handles it. Within a layer the most recently registered
+ * set wins, so a pane mounted inside another pane takes precedence.
+ */
+export class KeyRegistry {
+ #entries = new Map();
+ #nextId = 1;
+ #listeners = new Set<() => void>();
+ #version = 0;
+ #listed: readonly ListedBinding[] = [];
+
+ register(bindings: readonly KeyBinding[], options: KeyHandlerOptions = {}): number {
+ const id = this.#nextId;
+ this.#nextId += 1;
+ this.#entries.set(id, this.#entry(id, bindings, options));
+ this.#changed();
+ return id;
+ }
+
+ update(id: number, bindings: readonly KeyBinding[], options: KeyHandlerOptions = {}): void {
+ if (!this.#entries.has(id)) return;
+ const before = this.#signature();
+ this.#entries.set(id, this.#entry(id, bindings, options));
+ if (this.#signature() !== before) this.#changed();
+ }
+
+ unregister(id: number): void {
+ if (this.#entries.delete(id)) this.#changed();
+ }
+
+ /** Route one key press. Returns `true` when a binding or fallback used it. */
+ dispatch(input: string, key: Key): boolean {
+ const chord = chordFromInput(input, key);
+ for (const entry of this.#ordered()) {
+ if (chord !== undefined) {
+ for (const binding of entry.bindings) {
+ if (!binding.chords.some((candidate) => chordsEqual(candidate, chord))) continue;
+ if (binding.run() !== false) return true;
+ }
+ }
+ if (entry.fallback?.(input, key) === true) return true;
+ if (entry.exclusive) return false;
+ }
+ return false;
+ }
+
+ /** Bindings with a description that a key press can reach, highest layer first. */
+ list(): readonly ListedBinding[] {
+ return this.#listed;
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.#listeners.add(listener);
+ return () => this.#listeners.delete(listener);
+ }
+
+ get version(): number {
+ return this.#version;
+ }
+
+ #entry(id: number, bindings: readonly KeyBinding[], options: KeyHandlerOptions): Entry {
+ return {
+ id,
+ layer: options.layer ?? keyLayers.global,
+ exclusive: options.exclusive ?? false,
+ bindings: bindings.map(resolveBinding),
+ ...(options.fallback === undefined ? {} : {fallback: options.fallback}),
+ };
+ }
+
+ #ordered(): Entry[] {
+ return [...this.#entries.values()].sort(
+ (left, right) => right.layer - left.layer || right.id - left.id,
+ );
+ }
+
+ #computeList(): readonly ListedBinding[] {
+ const seen = new Set();
+ const listed: ListedBinding[] = [];
+ for (const entry of this.#ordered()) {
+ const advertises = entry.bindings.some((binding) => binding.description !== undefined);
+ for (const binding of entry.bindings) {
+ const labels = binding.labels.filter((label) => !seen.has(label));
+ for (const label of binding.labels) seen.add(label);
+ if (binding.description === undefined || labels.length === 0) continue;
+ listed.push({
+ keys: labels.join(' / '),
+ description: binding.description,
+ layer: entry.layer,
+ ...(binding.group === undefined ? {} : {group: binding.group}),
+ });
+ }
+ // An exclusive set that lists its own keys, such as a dialog, hides the
+ // keys beneath it. One that lists nothing, such as a help screen, does not.
+ if (entry.exclusive && advertises) break;
+ }
+ return listed;
+ }
+
+ #signature(): string {
+ return JSON.stringify(this.#computeList());
+ }
+
+ #changed(): void {
+ this.#listed = this.#computeList();
+ this.#version += 1;
+ for (const listener of this.#listeners) listener();
+ }
+}
diff --git a/tests/chord.test.ts b/tests/chord.test.ts
new file mode 100644
index 0000000..8eed097
--- /dev/null
+++ b/tests/chord.test.ts
@@ -0,0 +1,52 @@
+import type {Key} from 'ink';
+import {describe, expect, it} from 'vitest';
+import {chordFromInput, chordsEqual, formatKeySpec, parseKeySpec} from '../src/keys/index.js';
+import {keyOf, noKey} from './helpers.js';
+
+const matches = (spec: string, input: string, key: Partial = {}): boolean => {
+ const chord = chordFromInput(input, keyOf(key));
+ return chord !== undefined && chordsEqual(parseKeySpec(spec), chord);
+};
+
+describe('key specs', () => {
+ it('parses modifiers, named keys, and aliases', () => {
+ expect(parseKeySpec('Ctrl+S')).toEqual({key: 's', ctrl: true, meta: false, shift: false});
+ expect(parseKeySpec('alt+pgdn')).toMatchObject({key: 'pagedown', meta: true});
+ expect(parseKeySpec('shift+tab')).toMatchObject({key: 'tab', shift: true});
+ expect(parseKeySpec('space').key).toBe(' ');
+ expect(parseKeySpec('ctrl++')).toMatchObject({key: '+', ctrl: true});
+ });
+
+ it('treats shifted letters and capitals as the same chord', () => {
+ expect(parseKeySpec('shift+q')).toEqual(parseKeySpec('Q'));
+ });
+
+ it('rejects unknown keys and modifiers', () => {
+ expect(() => parseKeySpec('hyper+x')).toThrow('Unknown modifier');
+ expect(() => parseKeySpec('ctrl+banana')).toThrow('Unknown key');
+ expect(() => parseKeySpec('')).toThrow('empty');
+ });
+
+ it('formats specs for help screens', () => {
+ expect(formatKeySpec('ctrl+s')).toBe('Ctrl+S');
+ expect(formatKeySpec('shift+tab')).toBe('Shift+Tab');
+ expect(formatKeySpec('pagedown')).toBe('PgDn');
+ expect(formatKeySpec('?')).toBe('?');
+ });
+});
+
+describe('terminal input', () => {
+ it('matches what Ink reports for common keys', () => {
+ expect(matches('ctrl+s', 's', {ctrl: true})).toBe(true);
+ expect(matches('shift+tab', '', {tab: true, shift: true})).toBe(true);
+ expect(matches('tab', '', {tab: true, shift: true})).toBe(false);
+ expect(matches('Q', 'Q', {shift: true})).toBe(true);
+ expect(matches('q', 'Q', {shift: true})).toBe(false);
+ expect(matches('?', '?', {shift: true})).toBe(true);
+ expect(matches('alt+x', 'x', {meta: true})).toBe(true);
+ });
+
+ it('ignores pasted text', () => {
+ expect(chordFromInput('hello', noKey)).toBeUndefined();
+ });
+});
diff --git a/tests/cli.test.ts b/tests/cli.test.ts
index a329b92..268f202 100644
--- a/tests/cli.test.ts
+++ b/tests/cli.test.ts
@@ -11,7 +11,7 @@ describe('relay-shell command', () => {
it('prints a snapshot of the demonstration without a terminal', async () => {
const {stdout} = await run(process.execPath, [tsx, cli, '--demo', '--snapshot']);
expect(stdout).toContain('Relay Shell');
- expect(stdout).toContain('q quit');
+ expect(stdout).toContain('q / Ctrl+C quit');
}, 30_000);
it('explains how to start when no mode is chosen', async () => {
diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx
new file mode 100644
index 0000000..fac694e
--- /dev/null
+++ b/tests/demo.test.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import {render} from 'ink-testing-library';
+import {describe, expect, it} from 'vitest';
+import {DemoApp} from '../src/demo/index.js';
+import {tick} from './helpers.js';
+
+describe('demonstration', () => {
+ it('opens the keyboard reference over the frame', async () => {
+ const view = render();
+ await tick();
+ expect(view.lastFrame()).toContain('q / Ctrl+C quit');
+ view.stdin.write('?');
+ await tick();
+ expect(view.lastFrame()).toContain('Keyboard');
+ expect(view.lastFrame()).toContain('Application');
+ });
+});
diff --git a/tests/helpers.ts b/tests/helpers.ts
new file mode 100644
index 0000000..8b6d9af
--- /dev/null
+++ b/tests/helpers.ts
@@ -0,0 +1,29 @@
+import type {Key} from 'ink';
+
+export const noKey: Key = {
+ upArrow: false,
+ downArrow: false,
+ leftArrow: false,
+ rightArrow: false,
+ pageDown: false,
+ pageUp: false,
+ home: false,
+ end: false,
+ return: false,
+ escape: false,
+ ctrl: false,
+ shift: false,
+ tab: false,
+ backspace: false,
+ delete: false,
+ meta: false,
+ super: false,
+ hyper: false,
+ capsLock: false,
+ numLock: false,
+};
+
+export const keyOf = (key: Partial = {}): Key => ({...noKey, ...key});
+
+/** Give Ink a moment to read stdin and re-render. */
+export const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 20));
diff --git a/tests/keymap.test.tsx b/tests/keymap.test.tsx
new file mode 100644
index 0000000..439074e
--- /dev/null
+++ b/tests/keymap.test.tsx
@@ -0,0 +1,89 @@
+import React, {useState} from 'react';
+import {Text} from 'ink';
+import {render} from 'ink-testing-library';
+import {describe, expect, it} from 'vitest';
+import {
+ HelpOverlay,
+ KeyHints,
+ KeymapProvider,
+ keyLayers,
+ useKeybindings,
+} from '../src/keys/index.js';
+import {tick} from './helpers.js';
+
+const Counter = () => {
+ const [count, setCount] = useState(0);
+ const [help, setHelp] = useState(false);
+ useKeybindings([
+ {keys: '+', description: 'Increment', group: 'Counter', run: () => setCount(count + 1)},
+ {keys: '?', description: 'Help', run: () => setHelp(true)},
+ ]);
+ return (
+ <>
+ count {count}
+
+ {help ? setHelp(false)} /> : null}
+ >
+ );
+};
+
+describe('keymap components', () => {
+ it('routes keys to handlers that see the latest state', async () => {
+ const view = render(
+
+
+ ,
+ );
+ await tick();
+ view.stdin.write('+');
+ await tick();
+ view.stdin.write('+');
+ await tick();
+ expect(view.lastFrame()).toContain('count 2');
+ expect(view.lastFrame()).toContain('+ increment · ? help');
+ });
+
+ it('opens help, captures keys while open, and closes on Escape', async () => {
+ const view = render(
+
+
+ ,
+ );
+ await tick();
+ view.stdin.write('?');
+ await tick();
+ expect(view.lastFrame()).toContain('Keyboard');
+ expect(view.lastFrame()).toContain('Counter');
+ view.stdin.write('+');
+ await tick();
+ expect(view.lastFrame()).toContain('count 0');
+ view.stdin.write('\u001B');
+ await tick();
+ await tick();
+ expect(view.lastFrame()).not.toContain('Keyboard');
+ });
+
+ it('removes inactive bindings', async () => {
+ const Toggle = ({active}: {active: boolean}) => {
+ useKeybindings([{keys: 'x', description: 'Explode', run: () => undefined}], {
+ active,
+ layer: keyLayers.pane,
+ });
+ return ;
+ };
+ const view = render(
+
+
+ ,
+ );
+ await tick();
+ expect(view.lastFrame()).toContain('explode');
+ view.rerender(
+
+
+ ,
+ );
+ await tick();
+ expect(view.lastFrame()).not.toContain('explode');
+ });
+});
diff --git a/tests/registry.test.ts b/tests/registry.test.ts
new file mode 100644
index 0000000..caeb830
--- /dev/null
+++ b/tests/registry.test.ts
@@ -0,0 +1,116 @@
+import type {Key} from 'ink';
+import {describe, expect, it, vi} from 'vitest';
+import {KeyRegistry, keyLayers} from '../src/keys/index.js';
+import {keyOf} from './helpers.js';
+
+const press = (registry: KeyRegistry, input: string, key: Partial = {}): boolean =>
+ registry.dispatch(input, keyOf(key));
+
+describe('KeyRegistry', () => {
+ it('asks higher layers first and stops at the first handler', () => {
+ const registry = new KeyRegistry();
+ const global = vi.fn();
+ const pane = vi.fn();
+ registry.register([{keys: 'j', run: global}]);
+ registry.register([{keys: 'j', run: pane}], {layer: keyLayers.pane});
+ expect(press(registry, 'j')).toBe(true);
+ expect(pane).toHaveBeenCalledOnce();
+ expect(global).not.toHaveBeenCalled();
+ });
+
+ it('prefers the most recent set within a layer', () => {
+ const registry = new KeyRegistry();
+ const first = vi.fn();
+ const second = vi.fn();
+ registry.register([{keys: 'x', run: first}]);
+ registry.register([{keys: 'x', run: second}]);
+ press(registry, 'x');
+ expect(second).toHaveBeenCalledOnce();
+ expect(first).not.toHaveBeenCalled();
+ });
+
+ it('passes a key on when a handler returns false', () => {
+ const registry = new KeyRegistry();
+ const global = vi.fn();
+ registry.register([{keys: 'enter', run: global}]);
+ registry.register([{keys: 'enter', run: () => false}], {layer: keyLayers.pane});
+ press(registry, '', {return: true});
+ expect(global).toHaveBeenCalledOnce();
+ });
+
+ it('lets exclusive sets block lower layers', () => {
+ const registry = new KeyRegistry();
+ const quit = vi.fn();
+ registry.register([{keys: 'q', run: quit}]);
+ const dialog = registry.register([{keys: 'escape', run: vi.fn()}], {
+ layer: keyLayers.overlay,
+ exclusive: true,
+ });
+ expect(press(registry, 'q')).toBe(false);
+ expect(quit).not.toHaveBeenCalled();
+ registry.unregister(dialog);
+ press(registry, 'q');
+ expect(quit).toHaveBeenCalledOnce();
+ });
+
+ it('offers unmatched keys to a fallback such as a text field', () => {
+ const registry = new KeyRegistry();
+ const typed: string[] = [];
+ const global = vi.fn();
+ registry.register([{keys: 'q', run: global}]);
+ registry.register([{keys: 'enter', run: vi.fn()}], {
+ layer: keyLayers.pane,
+ fallback: (input) => {
+ typed.push(input);
+ return true;
+ },
+ });
+ press(registry, 'q');
+ press(registry, 'hello');
+ expect(typed).toEqual(['q', 'hello']);
+ expect(global).not.toHaveBeenCalled();
+ });
+
+ it('lists reachable bindings without shadowed duplicates', () => {
+ const registry = new KeyRegistry();
+ registry.register([
+ {keys: ['q', 'ctrl+c'], description: 'Quit', group: 'App', run: vi.fn()},
+ {keys: 'j', description: 'Global down', run: vi.fn()},
+ {keys: 'r', run: vi.fn()},
+ ]);
+ registry.register([{keys: ['j', 'down'], description: 'Next row', run: vi.fn()}], {
+ layer: keyLayers.pane,
+ });
+ expect(registry.list().map((hint) => [hint.keys, hint.description])).toEqual([
+ ['j / ↓', 'Next row'],
+ ['q / Ctrl+C', 'Quit'],
+ ]);
+ });
+
+ it('hides keys beneath a dialog that lists its own', () => {
+ const registry = new KeyRegistry();
+ registry.register([{keys: 'q', description: 'Quit', run: vi.fn()}]);
+ registry.register([{keys: 'escape', run: vi.fn()}], {
+ layer: keyLayers.overlay,
+ exclusive: true,
+ });
+ expect(registry.list().map((hint) => hint.description)).toEqual(['Quit']);
+ registry.register([{keys: 'y', description: 'Confirm', run: vi.fn()}], {
+ layer: keyLayers.overlay,
+ exclusive: true,
+ });
+ expect(registry.list().map((hint) => hint.description)).toEqual(['Confirm']);
+ });
+
+ it('notifies subscribers only when the listed bindings change', () => {
+ const registry = new KeyRegistry();
+ const listener = vi.fn();
+ registry.subscribe(listener);
+ const id = registry.register([{keys: 'a', description: 'Add', run: vi.fn()}]);
+ expect(listener).toHaveBeenCalledTimes(1);
+ registry.update(id, [{keys: 'a', description: 'Add', run: vi.fn()}]);
+ expect(listener).toHaveBeenCalledTimes(1);
+ registry.update(id, [{keys: 'a', description: 'Append', run: vi.fn()}]);
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+});