diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9232d19..f7387e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index e235f32..9d3b9ce 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docs/architecture.md b/docs/architecture.md
index 5d37aff..0126c47 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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.
diff --git a/docs/keyboard.md b/docs/keyboard.md
index d425508..8b56e91 100644
--- a/docs/keyboard.md
+++ b/docs/keyboard.md
@@ -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.
diff --git a/docs/status-bar.md b/docs/status-bar.md
new file mode 100644
index 0000000..c01fc81
--- /dev/null
+++ b/docs/status-bar.md
@@ -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
+
+ }>
+ …
+
+
+```
+
+## 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,
+);
+```
diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx
index eac090a..81cd6a5 100644
--- a/src/demo/DemoApp.tsx
+++ b/src/demo/DemoApp.tsx
@@ -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';
@@ -45,6 +53,7 @@ const HostList = ({
keys: ['k', 'up'],
description: 'Previous host',
group: 'Hosts',
+ hint: false,
run: () => onSelect(Math.max(0, selected - 1)),
},
]);
@@ -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 (
+ <>
+
+
+ >
+ );
+};
+
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',
+ group: 'Application',
+ run: () => notify(`Refreshed ${demoHosts.length} hosts`, {tone: 'success'}),
+ },
]);
const host = demoHosts[selected] ?? demoHosts[0];
return (
@@ -102,27 +150,25 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit}
+ footer={}
>
-
-
-
-
+
+
+
+
+
+
+ {host === undefined ? null : }
+
+
+ {(host?.events ?? []).map((event) => (
+
+ {event}
+
+ ))}
-
-
- {host === undefined ? null : }
-
-
- {(host?.events ?? []).map((event) => (
-
- {event}
-
- ))}
-
-
-
+
{help ? (
setHelp(false)} />
@@ -134,6 +180,10 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit (
-
+
+
+
+
+
);
diff --git a/src/index.ts b/src/index.ts
index 97eadfe..a6727fc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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';
diff --git a/src/keys/KeyHints.tsx b/src/keys/KeyHints.tsx
index 6c07726..292e487 100644
--- a/src/keys/KeyHints.tsx
+++ b/src/keys/KeyHints.tsx
@@ -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 (
{hints.map((hint, index) => (
diff --git a/src/keys/registry.ts b/src/keys/registry.ts
index 83f75ec..ff6e561 100644
--- a/src/keys/registry.ts
+++ b/src/keys/registry.ts
@@ -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;
}
@@ -39,6 +41,7 @@ export interface ResolvedBinding {
labels: readonly string[];
description?: string;
group?: string;
+ hint: boolean;
run: () => unknown;
}
@@ -46,6 +49,7 @@ export interface ListedBinding {
keys: string;
description: string;
group?: string;
+ hint: boolean;
layer: number;
}
@@ -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}),
};
@@ -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}),
});
diff --git a/src/panes/focus.tsx b/src/panes/focus.tsx
index 44b5360..744732d 100644
--- a/src/panes/focus.tsx
+++ b/src/panes/focus.tsx
@@ -133,6 +133,7 @@ export const PaneFocusProvider = ({
keys: keys.previous,
description: 'Previous pane',
group: 'Panes',
+ hint: false,
run: () => focus.previous(),
},
],
diff --git a/src/status/StatusBar.tsx b/src/status/StatusBar.tsx
new file mode 100644
index 0000000..68f134d
--- /dev/null
+++ b/src/status/StatusBar.tsx
@@ -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) => (
+
+ {index === 0 ? '' : · }
+ {segment.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 (
+
+
+
+ {label === undefined ? null : (
+
+ {` ${label} `}
+
+ )}
+
+ {notice === undefined ? (
+
+ ) : (
+ {notice.message}
+ )}
+
+
+
+
+
+ {children === undefined ? null : · }
+ {children}
+
+
+
+
+ );
+};
diff --git a/src/status/index.ts b/src/status/index.ts
new file mode 100644
index 0000000..3ebc876
--- /dev/null
+++ b/src/status/index.ts
@@ -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';
diff --git a/src/status/react.tsx b/src/status/react.tsx
new file mode 100644
index 0000000..5115cdb
--- /dev/null
+++ b/src/status/react.tsx
@@ -0,0 +1,49 @@
+import {createContext, useContext, useEffect, useId, useState, useSyncExternalStore} from 'react';
+import type {ReactNode} from 'react';
+import {StatusStore} from './store.js';
+import type {NotifyOptions, StatusSegment, StatusSnapshot} from './store.js';
+
+const StatusContext = createContext(undefined);
+
+export interface StatusProviderProps {
+ /** Supply a store to publish status from outside React. */
+ store?: StatusStore;
+ children?: ReactNode;
+}
+
+export const StatusProvider = ({store, children}: StatusProviderProps) => {
+ const [owned] = useState(() => store ?? new StatusStore());
+ useEffect(() => () => owned.dispose(), [owned]);
+ return {children};
+};
+
+export const useStatusStore = (): StatusStore => {
+ const store = useContext(StatusContext);
+ if (store === undefined) throw new Error('Status must be used inside .');
+ return store;
+};
+
+/** Show a segment in the status bar for as long as the component is mounted. */
+export const useStatusSegment = (segment: StatusSegment | undefined): void => {
+ const store = useStatusStore();
+ const id = useId();
+ useEffect(() => () => store.remove(id), [store, id]);
+ useEffect(() => {
+ if (segment === undefined) store.remove(id);
+ else store.set(id, segment);
+ });
+};
+
+/** Show a short message in the status bar, such as "Saved" or "Connection lost". */
+export const useNotify = (): ((message: string, options?: NotifyOptions) => void) => {
+ const store = useStatusStore();
+ return (message, options) => store.notify(message, options);
+};
+
+export const useStatusSnapshot = (): StatusSnapshot => {
+ const store = useStatusStore();
+ return useSyncExternalStore(
+ (listener) => store.subscribe(listener),
+ () => store.snapshot(),
+ );
+};
diff --git a/src/status/store.ts b/src/status/store.ts
new file mode 100644
index 0000000..b820580
--- /dev/null
+++ b/src/status/store.ts
@@ -0,0 +1,115 @@
+export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
+
+export interface StatusSegment {
+ text: string;
+ /** Which end of the bar the segment sits at. Defaults to `left`. */
+ side?: 'left' | 'right';
+ tone?: StatusTone;
+ /** Lower numbers sit closer to the bar's edge. Equal values keep mount order. */
+ order?: number;
+}
+
+export interface StatusNotice {
+ message: string;
+ tone: StatusTone;
+}
+
+export interface NotifyOptions {
+ tone?: StatusTone;
+ /** How long the notice stays visible. `0` keeps it until the next notice or `dismiss()`. */
+ durationMs?: number;
+}
+
+export interface StatusSnapshot {
+ left: readonly StatusSegment[];
+ right: readonly StatusSegment[];
+ notice?: StatusNotice;
+}
+
+interface StoredSegment {
+ segment: StatusSegment;
+ sequence: number;
+}
+
+const sameSegment = (left: StatusSegment, right: StatusSegment): boolean =>
+ left.text === right.text &&
+ left.side === right.side &&
+ left.tone === right.tone &&
+ left.order === right.order;
+
+/**
+ * Collects the segments components publish to the status bar and the one
+ * transient notice that temporarily replaces the left side.
+ */
+export class StatusStore {
+ #segments = new Map();
+ #sequence = 0;
+ #notice: StatusNotice | undefined;
+ #timer: ReturnType | undefined;
+ #listeners = new Set<() => void>();
+ #snapshot: StatusSnapshot = {left: [], right: []};
+
+ set(id: string, segment: StatusSegment): void {
+ const existing = this.#segments.get(id);
+ if (existing !== undefined && sameSegment(existing.segment, segment)) return;
+ if (existing === undefined) this.#sequence += 1;
+ this.#segments.set(id, {segment, sequence: existing?.sequence ?? this.#sequence});
+ this.#changed();
+ }
+
+ remove(id: string): void {
+ if (this.#segments.delete(id)) this.#changed();
+ }
+
+ notify(message: string, options: NotifyOptions = {}): void {
+ const {tone = 'info', durationMs = 4000} = options;
+ this.#clearTimer();
+ this.#notice = {message, tone};
+ if (durationMs > 0) {
+ this.#timer = setTimeout(() => this.dismiss(), durationMs);
+ this.#timer.unref();
+ }
+ this.#changed();
+ }
+
+ dismiss(): void {
+ this.#clearTimer();
+ if (this.#notice === undefined) return;
+ this.#notice = undefined;
+ this.#changed();
+ }
+
+ snapshot(): StatusSnapshot {
+ return this.#snapshot;
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.#listeners.add(listener);
+ return () => this.#listeners.delete(listener);
+ }
+
+ /** Cancel a pending notice timer, for example when the application exits. */
+ dispose(): void {
+ this.#clearTimer();
+ }
+
+ #clearTimer(): void {
+ if (this.#timer !== undefined) clearTimeout(this.#timer);
+ this.#timer = undefined;
+ }
+
+ #changed(): void {
+ const sorted = [...this.#segments.values()].sort(
+ (left, right) =>
+ (left.segment.order ?? 0) - (right.segment.order ?? 0) || left.sequence - right.sequence,
+ );
+ const segments = sorted.map((stored) => stored.segment);
+ this.#snapshot = {
+ left: segments.filter((segment) => segment.side !== 'right'),
+ // The right side reads from the edge inwards, so the lowest order is last.
+ right: segments.filter((segment) => segment.side === 'right').reverse(),
+ ...(this.#notice === undefined ? {} : {notice: this.#notice}),
+ };
+ for (const listener of this.#listeners) listener();
+ }
+}
diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx
index 868f74a..736c2f3 100644
--- a/tests/demo.test.tsx
+++ b/tests/demo.test.tsx
@@ -27,4 +27,17 @@ describe('demonstration', () => {
expect(view.lastFrame()).toContain('› ● api-2');
expect(view.lastFrame()).not.toContain('next host');
});
+
+ it('shows the focused pane and a refresh notice in the status bar', async () => {
+ const view = render();
+ await tick();
+ expect(view.lastFrame()).toContain(' HOSTS api-1 healthy');
+ expect(view.lastFrame()).toContain('5 hosts · 1 down');
+ view.stdin.write(' ');
+ await tick();
+ expect(view.lastFrame()).toContain(' DETAIL ');
+ view.stdin.write('r');
+ await tick();
+ expect(view.lastFrame()).toContain('Refreshed 5 hosts');
+ });
});
diff --git a/tests/registry.test.ts b/tests/registry.test.ts
index caeb830..9a6a2e7 100644
--- a/tests/registry.test.ts
+++ b/tests/registry.test.ts
@@ -102,6 +102,12 @@ describe('KeyRegistry', () => {
expect(registry.list().map((hint) => hint.description)).toEqual(['Confirm']);
});
+ it('keeps bindings marked hint: false in the list for help', () => {
+ const registry = new KeyRegistry();
+ registry.register([{keys: 'k', description: 'Up', hint: false, run: vi.fn()}]);
+ expect(registry.list()).toEqual([{keys: 'k', description: 'Up', hint: false, layer: 0}]);
+ });
+
it('notifies subscribers only when the listed bindings change', () => {
const registry = new KeyRegistry();
const listener = vi.fn();
diff --git a/tests/status.test.tsx b/tests/status.test.tsx
new file mode 100644
index 0000000..b951f7e
--- /dev/null
+++ b/tests/status.test.tsx
@@ -0,0 +1,120 @@
+import React, {useState} from 'react';
+import {Text} from 'ink';
+import {render} from 'ink-testing-library';
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {KeymapProvider, useKeybindings} from '../src/keys/index.js';
+import {
+ StatusBar,
+ StatusProvider,
+ StatusStore,
+ useNotify,
+ useStatusSegment,
+} from '../src/status/index.js';
+import {tick} from './helpers.js';
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('StatusStore', () => {
+ it('orders segments by side, order, and mount order', () => {
+ const store = new StatusStore();
+ store.set('a', {text: 'first'});
+ store.set('b', {text: 'edge', order: -1});
+ store.set('c', {text: 'right edge', side: 'right'});
+ store.set('d', {text: 'right inner', side: 'right', order: 1});
+ const snapshot = store.snapshot();
+ expect(snapshot.left.map((segment) => segment.text)).toEqual(['edge', 'first']);
+ expect(snapshot.right.map((segment) => segment.text)).toEqual(['right inner', 'right edge']);
+ });
+
+ it('skips notifications when a segment is unchanged', () => {
+ const store = new StatusStore();
+ const listener = vi.fn();
+ store.subscribe(listener);
+ store.set('a', {text: 'same'});
+ store.set('a', {text: 'same'});
+ expect(listener).toHaveBeenCalledOnce();
+ });
+
+ it('expires notices and lets a new notice replace an old one', () => {
+ vi.useFakeTimers();
+ const store = new StatusStore();
+ store.notify('Saved', {durationMs: 1000});
+ vi.advanceTimersByTime(600);
+ store.notify('Saved again', {tone: 'success', durationMs: 1000});
+ vi.advanceTimersByTime(600);
+ expect(store.snapshot().notice).toEqual({message: 'Saved again', tone: 'success'});
+ vi.advanceTimersByTime(500);
+ expect(store.snapshot().notice).toBeUndefined();
+ });
+
+ it('keeps a notice with no duration until it is dismissed', () => {
+ vi.useFakeTimers();
+ const store = new StatusStore();
+ store.notify('Offline', {tone: 'danger', durationMs: 0});
+ vi.advanceTimersByTime(60_000);
+ expect(store.snapshot().notice?.message).toBe('Offline');
+ store.dismiss();
+ expect(store.snapshot().notice).toBeUndefined();
+ });
+});
+
+describe('StatusBar', () => {
+ it('shows published segments and removes them on unmount', async () => {
+ const Publisher = ({text}: {text: string}) => {
+ useStatusSegment({text});
+ return null;
+ };
+ const view = render(
+
+
+
+ 12:00
+
+ ,
+ );
+ await tick();
+ expect(view.lastFrame()).toContain(' HOSTS 5 hosts');
+ expect(view.lastFrame()).toContain('12:00');
+ view.rerender(
+
+
+ ,
+ );
+ await tick();
+ expect(view.lastFrame()).not.toContain('5 hosts');
+ });
+
+ it('replaces the left side with a notice', async () => {
+ const Saver = () => {
+ const notify = useNotify();
+ const [saves, setSaves] = useState(0);
+ useStatusSegment({text: `${saves} saves`});
+ useKeybindings([
+ {
+ keys: 'ctrl+s',
+ run: () => {
+ setSaves(saves + 1);
+ notify('Saved', {tone: 'success'});
+ },
+ },
+ ]);
+ return null;
+ };
+ const view = render(
+
+
+
+
+
+ ,
+ );
+ await tick();
+ expect(view.lastFrame()).toContain('0 saves');
+ view.stdin.write('\u0013');
+ await tick();
+ expect(view.lastFrame()).toContain('Saved');
+ expect(view.lastFrame()).not.toContain('1 saves');
+ });
+});