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 @@ -5,3 +5,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.
- Added `StatusBar` with segments published by components, transient notices, and `StatusStore` for status from outside React.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ await runShell(

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;
};
```

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.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ Relay Shell is the frame a full-screen terminal application lives in. It does no
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).

The status bar is added as a separate module. Every module can be used on its own.
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.
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,
);
```
90 changes: 70 additions & 20 deletions src/demo/DemoApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ 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 {
Pane,
PaneFocusProvider,
Split,
useFocusedPane,
usePane,
usePaneKeybindings,
} from '../panes/index.js';
import {StatusBar, StatusProvider, 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 @@ -45,6 +53,7 @@ const HostList = ({
keys: ['k', 'up'],
description: 'Previous host',
group: 'Hosts',
hint: false,
run: () => onSelect(Math.max(0, selected - 1)),
},
]);
Expand Down Expand Up @@ -86,13 +95,52 @@ 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 down = demoHosts.filter((candidate) => candidate.status === 'down').length;
useStatusSegment(
host === undefined
? undefined
: {
text: `${host.name} ${host.status}`,
tone:
host.status === 'healthy'
? 'success'
: host.status === 'degraded'
? 'warning'
: 'danger',
},
);
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'}),
},
]);
const host = demoHosts[selected] ?? demoHosts[0];
return (
Expand All @@ -102,27 +150,25 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit<DemoAppProps, 'int
theme={theme}
{...(width === undefined ? {} : {width})}
{...(height === undefined ? {} : {height})}
footer={<KeyHints theme={theme} />}
footer={<DemoStatus host={host} theme={theme} />}
>
<PaneFocusProvider>
<Split direction="row">
<Pane id="hosts" title="Hosts" detail={`${demoHosts.length}`} size={28} theme={theme}>
<HostList selected={selected} onSelect={setSelected} theme={theme} />
<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 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>
</Split>
{help ? (
<Box position="absolute" top={2} left={4}>
<HelpOverlay theme={theme} onClose={() => setHelp(false)} />
Expand All @@ -134,6 +180,10 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit<DemoAppProps, 'int

export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => (
<KeymapProvider active={interactive}>
<DemoScreen {...props} />
<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
@@ -1,6 +1,7 @@
export * from './frame/index.js';
export * from './keys/index.js';
export * from './panes/index.js';
export * from './status/index.js';
export {runShell, type RunShellOptions} from './run.js';
export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js';
export {relayShellVersion} from './version.js';
6 changes: 4 additions & 2 deletions src/keys/KeyHints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import type {ShellTheme} from '../theme.js';
import {useKeyHints} from './react.js';

export interface KeyHintsProps {
/** Show at most this many hints, highest layer first. */
/** Show at most this many hints, highest layer first. Bindings with `hint: false` are skipped. */
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);
const hints = useKeyHints()
.filter((hint) => hint.hint)
.slice(0, limit);
return (
<Text wrap="truncate-end">
{hints.map((hint, index) => (
Expand Down
6 changes: 6 additions & 0 deletions src/keys/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface KeyBinding {
description?: string;
/** Heading the binding is listed under in help. */
group?: string;
/** Set to `false` to list the binding in help but not in one-line hints. */
hint?: boolean;
/** Return `false` to let the key continue to lower layers. */
run: () => unknown;
}
Expand All @@ -39,13 +41,15 @@ export interface ResolvedBinding {
labels: readonly string[];
description?: string;
group?: string;
hint: boolean;
run: () => unknown;
}

export interface ListedBinding {
keys: string;
description: string;
group?: string;
hint: boolean;
layer: number;
}

Expand All @@ -64,6 +68,7 @@ export const resolveBinding = (binding: KeyBinding): ResolvedBinding => {
chords,
labels: chords.map(formatChord),
run: binding.run,
hint: binding.hint ?? true,
...(binding.description === undefined ? {} : {description: binding.description}),
...(binding.group === undefined ? {} : {group: binding.group}),
};
Expand Down Expand Up @@ -158,6 +163,7 @@ export class KeyRegistry {
listed.push({
keys: labels.join(' / '),
description: binding.description,
hint: binding.hint,
layer: entry.layer,
...(binding.group === undefined ? {} : {group: binding.group}),
});
Expand Down
1 change: 1 addition & 0 deletions src/panes/focus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export const PaneFocusProvider = ({
keys: keys.previous,
description: 'Previous pane',
group: 'Panes',
hint: false,
run: () => focus.previous(),
},
],
Expand Down
75 changes: 75 additions & 0 deletions src/status/StatusBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type {ReactNode} from 'react';
import {Box, Text} from 'ink';
import {relayTheme} from '../theme.js';
import type {ShellTheme} from '../theme.js';
import {useStatusSnapshot} from './react.js';
import type {StatusSegment, StatusTone} from './store.js';

export interface StatusBarProps {
/** A short label at the far left, such as the current view or mode. */
label?: string;
/** Extra content after the right-hand segments, such as a clock. */
children?: ReactNode;
theme?: ShellTheme;
}

export const toneColor = (tone: StatusTone | undefined, theme: ShellTheme): string => {
switch (tone) {
case 'info':
return theme.focus;
case 'success':
return theme.success;
case 'warning':
return theme.warning;
case 'danger':
return theme.danger;
default:
return theme.muted;
}
};

const Segments = ({segments, theme}: {segments: readonly StatusSegment[]; theme: ShellTheme}) => (
<>
{segments.map((segment, index) => (
<Text key={`${index}:${segment.text}`}>
{index === 0 ? '' : <Text color={theme.rule}> · </Text>}
<Text color={toneColor(segment.tone, theme)}>{segment.text}</Text>
</Text>
))}
</>
);

/**
* One row with a label, the segments components publish, and the latest
* notice, which replaces the left-hand segments until it expires.
*/
export const StatusBar = ({label, children, theme = relayTheme}: StatusBarProps) => {
const {left, right, notice} = useStatusSnapshot();
return (
<Box flexShrink={0} backgroundColor={theme.surface} justifyContent="space-between">
<Box flexShrink={1} overflow="hidden">
<Text wrap="truncate-end">
{label === undefined ? null : (
<Text bold color={theme.background} backgroundColor={theme.accent}>
{` ${label} `}
</Text>
)}
<Text> </Text>
{notice === undefined ? (
<Segments segments={left} theme={theme} />
) : (
<Text color={toneColor(notice.tone, theme)}>{notice.message}</Text>
)}
</Text>
</Box>
<Box flexShrink={0}>
<Text>
<Segments segments={right} theme={theme} />
{children === undefined ? null : <Text color={theme.rule}> · </Text>}
{children}
<Text> </Text>
</Text>
</Box>
</Box>
);
};
17 changes: 17 additions & 0 deletions src/status/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export {StatusBar, toneColor, type StatusBarProps} from './StatusBar.js';
export {
StatusProvider,
useNotify,
useStatusSegment,
useStatusSnapshot,
useStatusStore,
type StatusProviderProps,
} from './react.js';
export {
StatusStore,
type NotifyOptions,
type StatusNotice,
type StatusSegment,
type StatusSnapshot,
type StatusTone,
} from './store.js';
Loading
Loading