diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7387e9..7e5984c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 9d3b9ce..5bd9dcf 100644
--- a/README.md
+++ b/README.md
@@ -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 api-1;
+};
await runShell(
-
- Hello from a full-screen application.
- ,
+
+
+
+
+
+
+ Select a host.
+
+
+ ,
);
```
-`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
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
```
See [the pane guide](./docs/panes.md) for sizes, focus order, and keys that belong to one pane.
@@ -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 } />;
-};
-
-await runShell(
-
-
- ,
-);
+useKeybindings([{keys: 'ctrl+s', description: 'Save', run: save}]);
```
See [the keybinding guide](./docs/keyboard.md) for key specs, layers, and text input.
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..67dc2e3
--- /dev/null
+++ b/docs/api.md
@@ -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` | |
diff --git a/docs/architecture.md b/docs/architecture.md
index 0126c47..0cdfc85 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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.
diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx
index 81cd6a5..f811458 100644
--- a/src/demo/DemoApp.tsx
+++ b/src/demo/DemoApp.tsx
@@ -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';
@@ -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
@@ -119,22 +106,7 @@ 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 (
- <>
-
-
- >
- );
-};
-
-const DemoScreen = ({theme = relayTheme, width, height}: Omit) => {
- 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',
@@ -142,16 +114,22 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit 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 (
- }
>
+
@@ -169,21 +147,6 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit
- {help ? (
-
- setHelp(false)} />
-
- ) : null}
-
+
);
};
-
-export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => (
-
-
-
-
-
-
-
-);
diff --git a/src/index.ts b/src/index.ts
index a6727fc..1ea7903 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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';
diff --git a/src/shell/Shell.tsx b/src/shell/Shell.tsx
new file mode 100644
index 0000000..b871c9a
--- /dev/null
+++ b/src/shell/Shell.tsx
@@ -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;
+
+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 (
+
+
+ {statusRight}
+
+ {hints ? : null}
+ >
+ }
+ >
+ {children}
+ {help ? (
+
+ setHelp(false)} />
+
+ ) : null}
+
+ );
+};
+
+/**
+ * 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) => (
+
+
+
+
+
+
+
+);
diff --git a/src/shell/index.ts b/src/shell/index.ts
new file mode 100644
index 0000000..67c7ffb
--- /dev/null
+++ b/src/shell/index.ts
@@ -0,0 +1 @@
+export {Shell, type ShellProps} from './Shell.js';
diff --git a/tests/shell.test.tsx b/tests/shell.test.tsx
new file mode 100644
index 0000000..3b13506
--- /dev/null
+++ b/tests/shell.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import {Text} from 'ink';
+import {render} from 'ink-testing-library';
+import {describe, expect, it, vi} from 'vitest';
+import {Pane, Shell, Split} from '../src/index.js';
+import {tick} from './helpers.js';
+
+const panes = (
+
+
+ left body
+
+
+ right body
+
+
+);
+
+describe('Shell', () => {
+ it('assembles the frame, panes, status bar, and hints', async () => {
+ const view = render(
+
+ {panes}
+ ,
+ );
+ await tick();
+ const lines = (view.lastFrame() ?? '').split('\n');
+ expect(lines).toHaveLength(12);
+ expect(lines[0]).toContain('Inventory');
+ expect(lines.at(-2)).toMatch(/^ RIGHT/);
+ expect(lines.at(-1)).toContain('Tab next pane');
+ expect(lines.at(-1)).toContain('q / Ctrl+C quit');
+ });
+
+ it('calls onQuit on a custom quit key', async () => {
+ const onQuit = vi.fn();
+ const view = render(
+
+ {panes}
+ ,
+ );
+ await tick();
+ view.stdin.write('q');
+ await tick();
+ expect(onQuit).not.toHaveBeenCalled();
+ view.stdin.write('x');
+ await tick();
+ expect(onQuit).toHaveBeenCalledOnce();
+ });
+
+ it('derives the status label from the focused pane', async () => {
+ const view = render(
+ (pane === 'left' ? 'Browsing' : 'Reading')}
+ >
+ {panes}
+ ,
+ );
+ await tick();
+ expect(view.lastFrame()).toContain(' Browsing');
+ view.stdin.write('\t');
+ await tick();
+ expect(view.lastFrame()).toContain(' Reading');
+ });
+
+ it('can leave out help and hints', async () => {
+ const view = render(
+
+ {panes}
+ ,
+ );
+ await tick();
+ view.stdin.write('?');
+ await tick();
+ expect(view.lastFrame()).not.toContain('Keyboard');
+ expect(view.lastFrame()).not.toContain('Tab next pane');
+ });
+});