From a921bee4607970b9885280e21f4db3380cedd340 Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:22:42 -0300 Subject: [PATCH] feat: add a status bar with segments and notices --- CHANGELOG.md | 1 + README.md | 17 ++++++ docs/architecture.md | 3 +- docs/keyboard.md | 2 +- docs/status-bar.md | 49 ++++++++++++++++ src/demo/DemoApp.tsx | 90 ++++++++++++++++++++++------- src/index.ts | 1 + src/keys/KeyHints.tsx | 6 +- src/keys/registry.ts | 6 ++ src/panes/focus.tsx | 1 + src/status/StatusBar.tsx | 75 ++++++++++++++++++++++++ src/status/index.ts | 17 ++++++ src/status/react.tsx | 49 ++++++++++++++++ src/status/store.ts | 115 +++++++++++++++++++++++++++++++++++++ tests/demo.test.tsx | 13 +++++ tests/registry.test.ts | 6 ++ tests/status.test.tsx | 120 +++++++++++++++++++++++++++++++++++++++ 17 files changed, 547 insertions(+), 24 deletions(-) create mode 100644 docs/status-bar.md create mode 100644 src/status/StatusBar.tsx create mode 100644 src/status/index.ts create mode 100644 src/status/react.tsx create mode 100644 src/status/store.ts create mode 100644 tests/status.test.tsx 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'); + }); +});