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 @@ -4,3 +4,4 @@

- 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.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,30 @@ await runShell(

`runShell` renders on the alternate screen, so the terminal's scrollback is restored when the application exits.

## Panes

`Split` and `Pane` divide the screen into regions. Each pane can take focus, and `Tab` moves focus between them.

```tsx
<PaneFocusProvider>
<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>
</PaneFocusProvider>
```

See [the pane guide](./docs/panes.md) for sizes, focus order, and keys that belong to one pane.

## 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.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,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).
4. `Split` and `Pane` divide the body into regions, and `PaneFocusProvider` decides which pane receives pane-layer keys. See [panes](./panes.md).

Layout, focus, and the status bar are added as separate modules so an application can use only the parts it needs.
The status bar is added as a separate module. Every module can be used on its own.
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)},
]);
```
113 changes: 98 additions & 15 deletions src/demo/DemoApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ 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, usePane, usePaneKeybindings} from '../panes/index.js';
import {relayTheme} from '../theme.js';
import type {ShellTheme} from '../theme.js';
import {demoHosts} from './hosts.js';
import type {DemoHost} from './hosts.js';

export interface DemoAppProps {
theme?: ShellTheme;
Expand All @@ -13,36 +16,116 @@ export interface DemoAppProps {
interactive?: boolean;
}

const DemoScreen = ({theme, width, height}: Omit<DemoAppProps, 'interactive'>) => {
const statusColor = (host: DemoHost, theme: ShellTheme): string =>
host.status === 'healthy'
? theme.success
: host.status === 'degraded'
? theme.warning
: theme.danger;

const HostList = ({
selected,
onSelect,
theme,
}: {
selected: number;
onSelect: (index: number) => void;
theme: ShellTheme;
}) => {
const pane = usePane();
const last = demoHosts.length - 1;
usePaneKeybindings([
{
keys: ['j', 'down'],
description: 'Next host',
group: 'Hosts',
run: () => onSelect(Math.min(last, selected + 1)),
},
{
keys: ['k', 'up'],
description: 'Previous host',
group: 'Hosts',
run: () => onSelect(Math.max(0, selected - 1)),
},
]);
return (
<>
{demoHosts.map((host, index) => {
const current = index === selected;
return (
<Text key={host.name} wrap="truncate-end">
<Text color={current && pane.focused ? theme.focus : theme.rule}>
{current ? '› ' : ' '}
</Text>
<Text color={statusColor(host, theme)}>● </Text>
<Text color={current ? theme.text : theme.muted}>{host.name}</Text>
</Text>
);
})}
</>
);
};

const HostDetail = ({host, theme}: {host: DemoHost; theme: ShellTheme}) => (
<>
<Text bold color={theme.text}>
{host.name}
</Text>
<Text color={statusColor(host, theme)}>{host.status}</Text>
<Box marginTop={1} flexDirection="column">
<Text color={theme.muted}>
region <Text color={theme.text}>{host.region}</Text>
</Text>
<Text color={theme.muted}>
cpu <Text color={theme.text}>{host.cpu}%</Text>
</Text>
<Text color={theme.muted}>
uptime <Text color={theme.text}>{host.uptime}</Text>
</Text>
</Box>
</>
);

const DemoScreen = ({theme = relayTheme, width, height}: Omit<DemoAppProps, 'interactive'>) => {
const {exit} = useApp();
const [help, setHelp] = useState(false);
const [count, setCount] = useState(0);
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: 'space', description: 'Count', group: 'Demo', run: () => setCount(count + 1)},
]);
const colors = theme ?? relayTheme;
const host = demoHosts[selected] ?? demoHosts[0];
return (
<ShellFrame
title="Relay Shell"
subtitle="demo"
theme={colors}
theme={theme}
{...(width === undefined ? {} : {width})}
{...(height === undefined ? {} : {height})}
footer={<KeyHints theme={colors} />}
footer={<KeyHints theme={theme} />}
>
<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>
<PaneFocusProvider>
<Split direction="row">
<Pane id="hosts" title="Hosts" detail={`${demoHosts.length}`} size={28} theme={theme}>
<HostList selected={selected} onSelect={setSelected} theme={theme} />
</Pane>
<Split direction="column">
<Pane id="detail" title="Detail" theme={theme}>
{host === undefined ? null : <HostDetail host={host} theme={theme} />}
</Pane>
<Pane id="events" title="Events" size={7} theme={theme}>
{(host?.events ?? []).map((event) => (
<Text key={event} color={theme.muted} wrap="truncate-end">
{event}
</Text>
))}
</Pane>
</Split>
</Split>
</PaneFocusProvider>
{help ? (
<Box position="absolute" top={2} left={4}>
<HelpOverlay theme={colors} onClose={() => setHelp(false)} />
<HelpOverlay theme={theme} onClose={() => setHelp(false)} />
</Box>
) : null}
</ShellFrame>
Expand Down
52 changes: 52 additions & 0 deletions src/demo/hosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export interface DemoHost {
name: string;
region: string;
status: 'healthy' | 'degraded' | 'down';
cpu: number;
uptime: string;
events: readonly string[];
}

/** Fixed sample data so the demonstration looks the same on every run. */
export const demoHosts: readonly DemoHost[] = [
{
name: 'api-1',
region: 'eu-west',
status: 'healthy',
cpu: 23,
uptime: '41d 6h',
events: ['09:12 deploy 2.14.0 finished', '09:10 deploy 2.14.0 started'],
},
{
name: 'api-2',
region: 'eu-west',
status: 'degraded',
cpu: 91,
uptime: '41d 6h',
events: ['09:31 cpu above 90% for 5m', '09:12 deploy 2.14.0 finished'],
},
{
name: 'worker-1',
region: 'us-east',
status: 'healthy',
cpu: 47,
uptime: '12d 2h',
events: ['08:55 queue drained', '08:40 queue depth 1,204'],
},
{
name: 'worker-2',
region: 'us-east',
status: 'down',
cpu: 0,
uptime: '—',
events: ['09:27 health check failed 3 times', '09:26 connection refused on :8080'],
},
{
name: 'cache-1',
region: 'eu-west',
status: 'healthy',
cpu: 8,
uptime: '90d 1h',
events: ['07:00 snapshot written'],
},
];
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './frame/index.js';
export * from './keys/index.js';
export * from './panes/index.js';
export {runShell, type RunShellOptions} from './run.js';
export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js';
export {relayShellVersion} from './version.js';
Loading
Loading