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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <ShellFrame title="Inventory" footer={<KeyHints />} />;
};

await runShell(
<KeymapProvider>
<App />
</KeymapProvider>,
);
```

See [the keybinding guide](./docs/keyboard.md) for key specs, layers, and text input.

## Development

```sh
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
52 changes: 52 additions & 0 deletions docs/keyboard.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 32 additions & 14 deletions src/demo/DemoApp.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<DemoAppProps, 'interactive'>) => {
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 (
<ShellFrame
title="Relay Shell"
subtitle="demo"
theme={theme}
theme={colors}
{...(width === undefined ? {} : {width})}
{...(height === undefined ? {} : {height})}
footer={<Text color={theme.muted}>q quit</Text>}
footer={<KeyHints theme={colors} />}
>
<Box flexGrow={1} borderStyle="round" borderColor={theme.rule} paddingX={1}>
<Text color={theme.text}>
The frame fills the terminal and follows it when the window is resized.
</Text>
<Box flexGrow={1} borderStyle="round" borderColor={colors.rule} paddingX={1}>
<Box flexDirection="column">
<Text color={colors.text}>
The frame fills the terminal and follows it when the window is resized.
</Text>
<Text color={colors.muted}>Space pressed {count} times.</Text>
</Box>
</Box>
{help ? (
<Box position="absolute" top={2} left={4}>
<HelpOverlay theme={colors} onClose={() => setHelp(false)} />
</Box>
) : null}
</ShellFrame>
);
};

export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => (
<KeymapProvider active={interactive}>
<DemoScreen {...props} />
</KeymapProvider>
);
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
65 changes: 65 additions & 0 deletions src/keys/HelpOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ListedBinding[]> => {
const groups = new Map<string, ListedBinding[]>();
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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.focus}
backgroundColor={theme.surface}
paddingX={1}
>
<Text bold color={theme.text}>
{title}
</Text>
{[...groupBindings(hints)].map(([group, bindings]) => (
<Box key={group} flexDirection="column" marginTop={1}>
<Text color={theme.accent}>{group}</Text>
{bindings.map((binding) => (
<Text key={binding.keys} wrap="truncate-end">
<Text color={theme.focus}>{binding.keys.padEnd(width)}</Text>
<Text color={theme.text}> {binding.description}</Text>
</Text>
))}
</Box>
))}
<Box marginTop={1}>
<Text color={theme.muted}>Esc close</Text>
</Box>
</Box>
);
};
26 changes: 26 additions & 0 deletions src/keys/KeyHints.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text wrap="truncate-end">
{hints.map((hint, index) => (
<Text key={hint.keys}>
{index === 0 ? '' : <Text color={theme.rule}> · </Text>}
<Text color={theme.focus}>{hint.keys}</Text>
<Text color={theme.muted}> {hint.description.toLowerCase()}</Text>
</Text>
))}
</Text>
);
};
Loading
Loading