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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@

- 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`.
- Added `Split` and `Pane` layouts with fixed, percentage, and shared sizes, `Tab` focus movement, and keys that follow the focused pane.
- Added `StatusBar` with segments published by components, transient notices, and `StatusStore` for status from outside React.
- Added `Shell`, which assembles the frame, panes, status bar, key hints, help, and quitting, and rebuilt the demonstration on it.
76 changes: 58 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,78 @@ npx tsx src/cli.tsx --demo --snapshot

## Use it in an application

`Shell` assembles the whole frame: panes with focus, a status bar, key hints, a keyboard reference on `?`, and quitting on `q` or `Ctrl+C`.

```tsx
import {Text} from 'ink';
import {ShellFrame, runShell} from '@relay-cli/shell';
import {Pane, Shell, Split, runShell, usePaneKeybindings} from '@relay-cli/shell';

const HostList = () => {
usePaneKeybindings([{keys: ['j', 'down'], description: 'Next host', run: selectNext}]);
return <Text>api-1</Text>;
};

await runShell(
<ShellFrame title="Inventory" subtitle="3 hosts">
<Text>Hello from a full-screen application.</Text>
</ShellFrame>,
<Shell title="Inventory" subtitle="3 hosts">
<Split direction="row">
<Pane id="hosts" title="Hosts" size={28}>
<HostList />
</Pane>
<Pane id="detail" title="Detail">
<Text>Select a host.</Text>
</Pane>
</Split>
</Shell>,
);
```

`runShell` renders on the alternate screen, so the terminal's scrollback is restored when the application exits.
`runShell` renders on the alternate screen, so the terminal's scrollback is restored when the application exits. Every part of `Shell` is also exported on its own; see [the API reference](./docs/api.md).

## Keybindings
## Panes

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.
`Split` and `Pane` divide the screen into regions. A size can be a number of cells, a percentage, or a share of the remaining space, and splits nest. Each pane can take focus, and `Tab` moves focus between them.

```tsx
import {useApp} from 'ink';
import {KeyHints, KeymapProvider, ShellFrame, runShell, useKeybindings} from '@relay-cli/shell';
<Split direction="row">
<Pane id="hosts" title="Hosts" size={28}>
<HostList />
</Pane>
<Split direction="column">
<Pane id="detail" title="Detail">
<HostDetail />
</Pane>
<Pane id="events" title="Events" size="30%">
<Events />
</Pane>
</Split>
</Split>
```

const App = () => {
const {exit} = useApp();
useKeybindings([{keys: ['q', 'ctrl+c'], description: 'Quit', run: () => exit()}]);
return <ShellFrame title="Inventory" footer={<KeyHints />} />;
See [the pane guide](./docs/panes.md) for sizes, focus order, and keys that belong to one pane.

## Status bar

Components publish short segments to the status bar, and any component can show a notice that briefly replaces them.

```tsx
const HostSummary = ({hosts}: {hosts: Host[]}) => {
const notify = useNotify();
useStatusSegment({text: `${hosts.length} hosts`, side: 'right'});
useKeybindings([
{keys: 'r', description: 'Refresh', run: () => notify('Refreshed', {tone: 'success'})},
]);
return null;
};
```

await runShell(
<KeymapProvider>
<App />
</KeymapProvider>,
);
See [the status bar guide](./docs/status-bar.md).

## 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, so the same key can mean different things in different panes.

```tsx
useKeybindings([{keys: 'ctrl+s', description: 'Save', run: save}]);
```

See [the keybinding guide](./docs/keyboard.md) for key specs, layers, and text input.
Expand Down
39 changes: 39 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# API

Everything is exported from `@relay-cli/shell`.

## Application

| Export | Purpose |
| ---------- | ---------------------------------------------------------------------------------------- |
| `Shell` | The complete frame: keybindings, pane focus, status bar, hints, help on `?`, quit on `q` |
| `runShell` | Render on the alternate screen and resolve when the application exits |

### `Shell` props

| Prop | Default | Meaning |
| ----------------- | ------------------ | ----------------------------------------------------- |
| `title` | required | Shown at the top left |
| `subtitle` | | Shown after the title |
| `theme` | `relayTheme` | Colors for every part of the shell |
| `statusLabel` | focused pane id | A string, or a function of the focused pane id |
| `statusRight` | | Content at the right end of the status bar |
| `initialFocus` | first pane mounted | The pane focused first |
| `quitKeys` | `['q', 'ctrl+c']` | Keys that quit, or `false` |
| `onQuit` | exit Ink | Called on a quit key |
| `helpKey` | `'?'` | Opens the keyboard reference, or `false` |
| `hints` | `true` | Show one line of key hints under the status bar |
| `interactive` | `true` | Read the keyboard; disable it to print a single frame |
| `width`, `height` | terminal size | Fixed size, mainly for tests |

## Building blocks

`Shell` is assembled from parts that can also be used on their own.

| Area | Exports | Guide |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| Frame | `ShellFrame` | [architecture](./architecture.md) |
| Keybindings | `KeymapProvider`, `useKeybindings`, `useKeyHints`, `KeyHints`, `HelpOverlay`, `KeyRegistry`, `keyLayers`, `parseKeySpec`, `formatKeySpec` | [keybindings](./keyboard.md) |
| Panes | `PaneFocusProvider`, `Split`, `Pane`, `usePane`, `usePaneKeybindings`, `useFocusedPane`, `usePaneFocus` | [panes](./panes.md) |
| Status bar | `StatusProvider`, `StatusBar`, `useStatusSegment`, `useNotify`, `StatusStore` | [status bar](./status-bar.md) |
| Themes | `relayTheme`, `monoTheme`, `shellThemes`, `ShellTheme` | |
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ 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).
4. `Split` and `Pane` divide the body into regions, and `PaneFocusProvider` decides which pane receives pane-layer keys. See [panes](./panes.md).
5. `StatusProvider` collects segments and notices from any component, and `StatusBar` draws them in the footer. See [the status bar](./status-bar.md).
6. `Shell` nests those providers in the right order and draws the frame, status bar, hints, and help. See [the API reference](./api.md).

Layout, focus, and the status bar are added as separate modules so an application can use only the parts it needs.
Each provider can be used on its own; an application that only needs keybindings does not have to adopt panes.
2 changes: 1 addition & 1 deletion docs/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ useKeybindings([{keys: 'enter', run: submit}], {

## 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.
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. Set `hint: false` on a binding to keep it in help but out of the one-line hints.
54 changes: 54 additions & 0 deletions docs/panes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Panes

A layout is a tree of `Split` elements with `Pane` leaves. A split divides its space between its children along one axis; each child says how much it wants with `size`.

```tsx
<PaneFocusProvider>
<Split direction="row">
<Pane id="hosts" title="Hosts" size={28}>
…
</Pane>
<Split direction="column">
<Pane id="detail" title="Detail">
…
</Pane>
<Pane id="events" title="Events" size="30%">
…
</Pane>
</Split>
</Split>
</PaneFocusProvider>
```

## Sizes

| Size | Meaning |
| ------- | ---------------------------------------------- |
| `28` | Exactly 28 cells, including the border |
| `'30%'` | A percentage of the split |
| `'2fr'` | Two shares of the space left after fixed sizes |
| omitted | One share (`'1fr'`) |

`minSize` keeps a shared or percentage pane from shrinking below a number of cells. The outermost split fills the space it is given, normally the body of a `ShellFrame`.

## Focus

`PaneFocusProvider` tracks which pane has focus and binds `Tab` and `Shift+Tab` to move it. Panes are visited in `order`, then in the order they mounted. Pass `focusable={false}` to skip a pane, `initialFocus` to choose the first one, or `keys={false}` to bind focus movement yourself through `usePaneFocus()`.

The focused pane draws its border and title in the theme's focus color. Components inside a pane read its state with `usePane()`:

```tsx
const pane = usePane();
pane.focused; // true while this pane has focus
pane.focus(); // take focus
```

## Pane keys

`usePaneKeybindings` registers bindings on the pane layer that are active only while the enclosing pane has focus. The same key can mean different things in different panes, and help lists only the keys that the focused pane will receive.

```tsx
usePaneKeybindings([
{keys: ['j', 'down'], description: 'Next host', run: () => select(selected + 1)},
]);
```
49 changes: 49 additions & 0 deletions docs/status-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Status bar

`StatusBar` is one row at the bottom of the screen. Components anywhere in the application publish short segments to it, and any component can show a notice that temporarily replaces the left side.

```tsx
<StatusProvider>
<ShellFrame title="Inventory" footer={<StatusBar label="HOSTS" />}>
…
</ShellFrame>
</StatusProvider>
```

## Segments

`useStatusSegment` shows a segment for as long as the component is mounted, and updates it when its text or tone changes. Pass `undefined` to hide it without unmounting.

```tsx
useStatusSegment({text: `${host.name} ${host.status}`, tone: 'warning'});
useStatusSegment({text: `${hosts.length} hosts`, side: 'right'});
```

| Field | Meaning |
| ------- | --------------------------------------------------------------------- |
| `text` | The segment's text |
| `side` | `left` (default) or `right` |
| `tone` | `neutral`, `info`, `success`, `warning`, or `danger` |
| `order` | Lower values sit closer to the edge of the bar; ties keep mount order |

## Notices

`useNotify` returns a function that shows a message in place of the left-hand segments. A notice disappears after four seconds by default; a newer notice replaces it immediately. Pass `durationMs: 0` to keep it until `dismiss()` is called on the store.

```tsx
const notify = useNotify();
notify('Saved', {tone: 'success'});
notify('Connection lost', {tone: 'danger', durationMs: 0});
```

## Outside React

Create a `StatusStore` and pass it to `StatusProvider` to publish status from code that is not a component, such as a polling loop:

```ts
const status = new StatusStore();
setInterval(
() => status.set('poll', {text: `checked ${new Date().toLocaleTimeString()}`, side: 'right'}),
5000,
);
```
Loading
Loading