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 @@ -6,3 +6,4 @@
- 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.
67 changes: 33 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,39 +17,51 @@ 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).

## Panes

`Split` and `Pane` divide the screen into regions. Each pane can take focus, and `Tab` moves focus between them.
`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
<PaneFocusProvider>
<Split direction="row">
<Pane id="hosts" title="Hosts" size={28}>
<HostList />
<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 direction="column">
<Pane id="detail" title="Detail">
<HostDetail />
</Pane>
<Pane id="events" title="Events" size="30%">
<Events />
</Pane>
</Split>
</Split>
</PaneFocusProvider>
</Split>
```

See [the pane guide](./docs/panes.md) for sizes, focus order, and keys that belong to one pane.
Expand All @@ -73,23 +85,10 @@ 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.
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
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>,
);
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` | |
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ Relay Shell is the frame a full-screen terminal application lives in. It does no
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).

Each provider can be used on its own; an application that only needs keybindings does not have to adopt panes.
69 changes: 16 additions & 53 deletions src/demo/DemoApp.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
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 {
Pane,
PaneFocusProvider,
Split,
useFocusedPane,
usePane,
usePaneKeybindings,
} from '../panes/index.js';
import {StatusBar, StatusProvider, useNotify, useStatusSegment} from '../status/index.js';
import {Box, Text} from 'ink';
import {useKeybindings} from '../keys/index.js';
import {Pane, Split, usePane, usePaneKeybindings} from '../panes/index.js';
import {Shell} from '../shell/index.js';
import {useNotify, useStatusSegment} from '../status/index.js';
import {relayTheme} from '../theme.js';
import type {ShellTheme} from '../theme.js';
import {demoHosts} from './hosts.js';
Expand Down Expand Up @@ -95,14 +88,8 @@ const HostDetail = ({host, theme}: {host: DemoHost; theme: ShellTheme}) => (
</>
);

const paneLabels = new Map([
['hosts', 'HOSTS'],
['detail', 'DETAIL'],
['events', 'EVENTS'],
]);

const DemoStatus = ({host, theme}: {host: DemoHost | undefined; theme: ShellTheme}) => {
const focused = useFocusedPane();
const DemoStatus = ({host}: {host: DemoHost | undefined}) => {
const notify = useNotify();
const down = demoHosts.filter((candidate) => candidate.status === 'down').length;
useStatusSegment(
host === undefined
Expand All @@ -119,39 +106,30 @@ const DemoStatus = ({host, theme}: {host: DemoHost | undefined; theme: ShellThem
);
useStatusSegment({text: `${demoHosts.length} hosts`, side: 'right', order: 1});
useStatusSegment({text: `${down} down`, side: 'right', tone: down > 0 ? 'danger' : 'neutral'});
return (
<>
<StatusBar label={paneLabels.get(focused ?? '') ?? 'SHELL'} theme={theme} />
<KeyHints theme={theme} />
</>
);
};

const DemoScreen = ({theme = relayTheme, width, height}: Omit<DemoAppProps, 'interactive'>) => {
const {exit} = useApp();
const notify = useNotify();
const [help, setHelp] = useState(false);
const [selected, setSelected] = useState(0);
useKeybindings([
{keys: ['q', 'ctrl+c'], description: 'Quit', group: 'Application', run: () => exit()},
{keys: '?', description: 'Help', group: 'Application', run: () => setHelp(true)},
{
keys: 'r',
description: 'Refresh',
group: 'Application',
run: () => notify(`Refreshed ${demoHosts.length} hosts`, {tone: 'success'}),
},
]);
return null;
};

export const DemoApp = ({theme = relayTheme, width, height, interactive = true}: DemoAppProps) => {
const [selected, setSelected] = useState(0);
const host = demoHosts[selected] ?? demoHosts[0];
return (
<ShellFrame
<Shell
title="Relay Shell"
subtitle="demo"
theme={theme}
interactive={interactive}
{...(width === undefined ? {} : {width})}
{...(height === undefined ? {} : {height})}
footer={<DemoStatus host={host} theme={theme} />}
>
<DemoStatus host={host} />
<Split direction="row">
<Pane id="hosts" title="Hosts" detail={`${demoHosts.length}`} size={28} theme={theme}>
<HostList selected={selected} onSelect={setSelected} theme={theme} />
Expand All @@ -169,21 +147,6 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit<DemoAppProps, 'int
</Pane>
</Split>
</Split>
{help ? (
<Box position="absolute" top={2} left={4}>
<HelpOverlay theme={theme} onClose={() => setHelp(false)} />
</Box>
) : null}
</ShellFrame>
</Shell>
);
};

export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => (
<KeymapProvider active={interactive}>
<StatusProvider>
<PaneFocusProvider>
<DemoScreen {...props} />
</PaneFocusProvider>
</StatusProvider>
</KeymapProvider>
);
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from './keys/index.js';
export * from './panes/index.js';
export * from './status/index.js';
export {runShell, type RunShellOptions} from './run.js';
export * from './shell/index.js';
export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js';
export {relayShellVersion} from './version.js';
120 changes: 120 additions & 0 deletions src/shell/Shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {useState} from 'react';
import type {ReactNode} from 'react';
import {Box, useApp} from 'ink';
import {ShellFrame} from '../frame/index.js';
import {HelpOverlay, KeyHints, KeymapProvider, useKeybindings} from '../keys/index.js';
import type {KeyBinding} from '../keys/index.js';
import {PaneFocusProvider, useFocusedPane} from '../panes/index.js';
import {StatusBar, StatusProvider} from '../status/index.js';
import {relayTheme} from '../theme.js';
import type {ShellTheme} from '../theme.js';

export interface ShellProps {
title: string;
subtitle?: string;
theme?: ShellTheme;
/** Fixed size, mainly for tests and snapshots. Defaults to the terminal size. */
width?: number;
height?: number;
/**
* The label at the left of the status bar. Defaults to the focused pane's
* id in capitals. Pass a function to derive it from the focused pane.
*/
statusLabel?: string | ((focusedPane: string | undefined) => string | undefined);
/** Content at the right end of the status bar, such as a clock. */
statusRight?: ReactNode;
/** The pane focused first. */
initialFocus?: string;
/** Keys that quit. Pass `false` to bind quitting yourself. */
quitKeys?: string | readonly string[] | false;
/** Called on a quit key. Defaults to exiting the Ink application. */
onQuit?: () => void;
/** The key that opens the keyboard reference. Pass `false` to disable it. */
helpKey?: string | false;
/** Show the one-line key hints under the status bar. */
hints?: boolean;
/** Read the keyboard. Disable it to print a single frame. */
interactive?: boolean;
children?: ReactNode;
}

type ChromeProps = Omit<ShellProps, 'initialFocus' | 'interactive'>;

const defaultLabel = (focused: string | undefined): string | undefined => focused?.toUpperCase();

const ShellChrome = ({
title,
subtitle,
theme = relayTheme,
width,
height,
statusLabel = defaultLabel,
statusRight,
quitKeys = ['q', 'ctrl+c'],
onQuit,
helpKey = '?',
hints = true,
children,
}: ChromeProps) => {
const {exit} = useApp();
const focused = useFocusedPane();
const [help, setHelp] = useState(false);
const bindings: KeyBinding[] = [];
if (quitKeys !== false) {
bindings.push({
keys: quitKeys,
description: 'Quit',
group: 'Application',
run: () => (onQuit ?? exit)(),
});
}
if (helpKey !== false) {
bindings.push({
keys: helpKey,
description: 'Help',
group: 'Application',
run: () => setHelp(true),
});
}
useKeybindings(bindings);
const label = typeof statusLabel === 'function' ? statusLabel(focused) : statusLabel;
return (
<ShellFrame
title={title}
theme={theme}
{...(subtitle === undefined ? {} : {subtitle})}
{...(width === undefined ? {} : {width})}
{...(height === undefined ? {} : {height})}
footer={
<>
<StatusBar theme={theme} {...(label === undefined ? {} : {label})}>
{statusRight}
</StatusBar>
{hints ? <KeyHints theme={theme} /> : null}
</>
}
>
{children}
{help ? (
<Box position="absolute" top={1} left={2}>
<HelpOverlay theme={theme} onClose={() => setHelp(false)} />
</Box>
) : null}
</ShellFrame>
);
};

/**
* A complete application frame: keybindings, pane focus, a status bar, key
* hints, a keyboard reference on `?`, and quitting on `q` or `Ctrl+C`.
* Children are usually a `Split` of `Pane`s.
*/
export const Shell = ({initialFocus, interactive = true, ...props}: ShellProps) => (
<KeymapProvider active={interactive}>
<StatusProvider>
<PaneFocusProvider {...(initialFocus === undefined ? {} : {initialFocus})}>
<ShellChrome {...props} />
</PaneFocusProvider>
</StatusProvider>
</KeymapProvider>
);
1 change: 1 addition & 0 deletions src/shell/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {Shell, type ShellProps} from './Shell.js';
Loading
Loading