diff --git a/CHANGELOG.md b/CHANGELOG.md index 7728926..9232d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 86ccc70..e235f32 100644 --- a/README.md +++ b/README.md @@ -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 + + + + + + + + + + + + + + + +``` + +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. diff --git a/docs/architecture.md b/docs/architecture.md index 67a7132..5d37aff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/panes.md b/docs/panes.md new file mode 100644 index 0000000..3f770ad --- /dev/null +++ b/docs/panes.md @@ -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 + + + + … + + + + … + + + … + + + + +``` + +## 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)}, +]); +``` diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx index ab8fa9b..eac090a 100644 --- a/src/demo/DemoApp.tsx +++ b/src/demo/DemoApp.tsx @@ -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; @@ -13,36 +16,116 @@ export interface DemoAppProps { interactive?: boolean; } -const DemoScreen = ({theme, width, height}: Omit) => { +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 ( + + + {current ? '› ' : ' '} + + ● + {host.name} + + ); + })} + + ); +}; + +const HostDetail = ({host, theme}: {host: DemoHost; theme: ShellTheme}) => ( + <> + + {host.name} + + {host.status} + + + region {host.region} + + + cpu {host.cpu}% + + + uptime {host.uptime} + + + +); + +const DemoScreen = ({theme = relayTheme, width, height}: Omit) => { 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 ( } + footer={} > - - - - The frame fills the terminal and follows it when the window is resized. - - Space pressed {count} times. - - + + + + + + + + {host === undefined ? null : } + + + {(host?.events ?? []).map((event) => ( + + {event} + + ))} + + + + {help ? ( - setHelp(false)} /> + setHelp(false)} /> ) : null} diff --git a/src/demo/hosts.ts b/src/demo/hosts.ts new file mode 100644 index 0000000..fa00302 --- /dev/null +++ b/src/demo/hosts.ts @@ -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'], + }, +]; diff --git a/src/index.ts b/src/index.ts index e7c8750..97eadfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/panes/Pane.tsx b/src/panes/Pane.tsx new file mode 100644 index 0000000..559d434 --- /dev/null +++ b/src/panes/Pane.tsx @@ -0,0 +1,103 @@ +import {createContext, useContext} from 'react'; +import type {ReactNode} from 'react'; +import {Box, Text} from 'ink'; +import {keyLayers, useKeybindings} from '../keys/index.js'; +import type {KeyBinding, UseKeybindingsOptions} from '../keys/index.js'; +import {relayTheme} from '../theme.js'; +import type {ShellTheme} from '../theme.js'; +import {usePaneRegistration} from './focus.js'; +import {sizeStyle} from './size.js'; +import type {PaneSize} from './size.js'; +import {useSplitDirection} from './Split.js'; + +export interface PaneState { + id: string; + title?: string; + focused: boolean; + focus: () => void; +} + +const PaneContext = createContext(undefined); + +/** The pane a component is rendered in, and whether that pane has focus. */ +export const usePane = (): PaneState => { + const pane = useContext(PaneContext); + if (pane === undefined) throw new Error('usePane must be used inside .'); + return pane; +}; + +/** Bindings that are active only while the enclosing pane has focus. */ +export const usePaneKeybindings = ( + bindings: readonly KeyBinding[], + options: Omit = {}, +): void => { + const pane = usePane(); + useKeybindings(bindings, { + ...options, + layer: keyLayers.pane, + active: pane.focused && (options.active ?? true), + }); +}; + +export interface PaneProps { + /** Stable identifier used for focus. */ + id: string; + title?: string; + /** Short text shown at the right of the title row, such as a count. */ + detail?: string; + size?: PaneSize; + minSize?: number; + /** Panes that cannot take focus are skipped by `Tab`. */ + focusable?: boolean; + /** Position in the focus order. Panes with equal order follow mount order. */ + order?: number; + border?: boolean; + theme?: ShellTheme; + children?: ReactNode; +} + +/** A bordered region of a split that can take focus and own keybindings. */ +export const Pane = ({ + id, + title, + detail, + size, + minSize, + focusable = true, + order, + border = true, + theme = relayTheme, + children, +}: PaneProps) => { + const direction = useSplitDirection(); + const {focused, focus} = usePaneRegistration(id, { + focusable, + ...(order === undefined ? {} : {order}), + }); + const style = + direction === undefined ? {flexGrow: 1, flexShrink: 1} : sizeStyle(size, direction, minSize); + const state: PaneState = {id, focused, focus, ...(title === undefined ? {} : {title})}; + return ( + + {title === undefined && detail === undefined ? null : ( + + + {title ?? ''} + + {detail === undefined ? null : {detail}} + + )} + + {children} + + + ); +}; diff --git a/src/panes/Split.tsx b/src/panes/Split.tsx new file mode 100644 index 0000000..af0c930 --- /dev/null +++ b/src/panes/Split.tsx @@ -0,0 +1,33 @@ +import {createContext, useContext} from 'react'; +import type {ReactNode} from 'react'; +import {Box} from 'ink'; +import {sizeStyle} from './size.js'; +import type {PaneSize, SplitDirection} from './size.js'; + +const SplitContext = createContext(undefined); + +/** The direction of the nearest enclosing split, if any. */ +export const useSplitDirection = (): SplitDirection | undefined => useContext(SplitContext); + +export interface SplitProps { + /** `row` places children side by side; `column` stacks them. */ + direction?: SplitDirection; + /** This split's own size when it is nested inside another split. */ + size?: PaneSize; + minSize?: number; + /** Empty cells between children. */ + gap?: number; + children?: ReactNode; +} + +/** Divides its space between panes or nested splits according to their `size`. */ +export const Split = ({direction = 'row', size, minSize, gap = 0, children}: SplitProps) => { + const parent = useSplitDirection(); + const style = + parent === undefined ? {flexGrow: 1, flexShrink: 1} : sizeStyle(size, parent, minSize); + return ( + + {children} + + ); +}; diff --git a/src/panes/focus.tsx b/src/panes/focus.tsx new file mode 100644 index 0000000..44b5360 --- /dev/null +++ b/src/panes/focus.tsx @@ -0,0 +1,168 @@ +import {createContext, useContext, useEffect, useMemo, useState, useSyncExternalStore} from 'react'; +import type {ReactNode} from 'react'; +import {useKeybindings} from '../keys/index.js'; + +interface FocusEntry { + id: string; + order: number; + sequence: number; +} + +/** + * Tracks which panes can take focus and which one has it. Panes are visited + * in `order`, then in the order they mounted. + */ +export class PaneFocus { + #entries = new Map(); + #sequence = 0; + #focused: string | undefined; + #preferred: string | undefined; + #listeners = new Set<() => void>(); + + constructor(initial?: string) { + this.#preferred = initial; + } + + get focused(): string | undefined { + return this.#focused; + } + + /** Pane ids in focus order. */ + get ids(): readonly string[] { + return [...this.#entries.values()] + .sort((left, right) => left.order - right.order || left.sequence - right.sequence) + .map((entry) => entry.id); + } + + register(id: string, order = 0): () => void { + if (this.#entries.has(id)) throw new Error(`A pane with id "${id}" is already mounted.`); + this.#sequence += 1; + this.#entries.set(id, {id, order, sequence: this.#sequence}); + if (id === this.#preferred) { + this.#preferred = undefined; + this.#focused = id; + } else if (this.#focused === undefined) { + this.#focused = id; + } + this.#emit(); + return () => { + const ids = this.ids; + this.#entries.delete(id); + if (this.#focused === id) { + const index = ids.indexOf(id); + const remaining = this.ids; + this.#focused = remaining[Math.min(index, remaining.length - 1)]; + } + this.#emit(); + }; + } + + /** Focus a pane. A pane that has not mounted yet takes focus when it does. */ + focus(id: string): void { + if (!this.#entries.has(id)) { + this.#preferred = id; + return; + } + this.#preferred = undefined; + if (this.#focused === id) return; + this.#focused = id; + this.#emit(); + } + + next(): void { + this.#move(1); + } + + previous(): void { + this.#move(-1); + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + #move(step: number): void { + const ids = this.ids; + if (ids.length === 0) return; + const index = this.#focused === undefined ? -1 : ids.indexOf(this.#focused); + const target = ids[(index + step + ids.length) % ids.length]; + if (target !== undefined) this.focus(target); + } + + #emit(): void { + for (const listener of this.#listeners) listener(); + } +} + +const PaneFocusContext = createContext(undefined); + +export interface PaneFocusProviderProps { + /** The pane focused first. Defaults to the first pane that mounts. */ + initialFocus?: string; + /** Keys that move focus forward and backward. Pass `false` to bind them yourself. */ + keys?: {next: string | readonly string[]; previous: string | readonly string[]} | false; + /** Called whenever another pane takes focus. */ + onFocusChange?: (id: string | undefined) => void; + children?: ReactNode; +} + +const defaultFocusKeys = {next: 'tab', previous: 'shift+tab'}; + +/** Owns pane focus and binds `Tab` and `Shift+Tab` to move it. */ +export const PaneFocusProvider = ({ + initialFocus, + keys = defaultFocusKeys, + onFocusChange, + children, +}: PaneFocusProviderProps) => { + const [focus] = useState(() => new PaneFocus(initialFocus)); + const focused = useSyncExternalStore( + (listener) => focus.subscribe(listener), + () => focus.focused, + ); + useEffect(() => { + onFocusChange?.(focused); + }, [focused, onFocusChange]); + useKeybindings( + keys === false + ? [] + : [ + {keys: keys.next, description: 'Next pane', group: 'Panes', run: () => focus.next()}, + { + keys: keys.previous, + description: 'Previous pane', + group: 'Panes', + run: () => focus.previous(), + }, + ], + ); + return {children}; +}; + +export const usePaneFocus = (): PaneFocus => { + const focus = useContext(PaneFocusContext); + if (focus === undefined) throw new Error('Panes must be used inside .'); + return focus; +}; + +/** The id of the focused pane, updated as focus moves. */ +export const useFocusedPane = (): string | undefined => { + const focus = usePaneFocus(); + return useSyncExternalStore( + (listener) => focus.subscribe(listener), + () => focus.focused, + ); +}; + +/** Register a focusable pane and report whether it has focus. */ +export const usePaneRegistration = (id: string, options: {order?: number; focusable?: boolean}) => { + const focus = usePaneFocus(); + const {order = 0, focusable = true} = options; + useEffect( + () => (focusable ? focus.register(id, order) : undefined), + [focus, id, order, focusable], + ); + const focused = useFocusedPane() === id; + return useMemo(() => ({focused, focus: () => focus.focus(id)}), [focus, focused, id]); +}; diff --git a/src/panes/index.ts b/src/panes/index.ts new file mode 100644 index 0000000..c34d628 --- /dev/null +++ b/src/panes/index.ts @@ -0,0 +1,11 @@ +export { + PaneFocus, + PaneFocusProvider, + useFocusedPane, + usePaneFocus, + usePaneRegistration, + type PaneFocusProviderProps, +} from './focus.js'; +export {Pane, usePane, usePaneKeybindings, type PaneProps, type PaneState} from './Pane.js'; +export {sizeStyle, type PaneSize, type SizeStyle, type SplitDirection} from './size.js'; +export {Split, useSplitDirection, type SplitProps} from './Split.js'; diff --git a/src/panes/size.ts b/src/panes/size.ts new file mode 100644 index 0000000..3d0decc --- /dev/null +++ b/src/panes/size.ts @@ -0,0 +1,42 @@ +/** + * How much of a split a pane or nested split takes along the split's axis: + * a fixed number of cells, a percentage, or a share of the remaining space. + */ +export type PaneSize = number | `${number}%` | `${number}fr`; + +export type SplitDirection = 'row' | 'column'; + +export interface SizeStyle { + width?: number | string; + height?: number | string; + flexGrow: number; + flexShrink: number; + flexBasis?: number | string; + minWidth?: number; + minHeight?: number; +} + +const fraction = /^(\d+(?:\.\d+)?)fr$/; +const percent = /^(\d+(?:\.\d+)?)%$/; + +/** Translate a pane size into flexbox style for a child of a split. */ +export const sizeStyle = ( + size: PaneSize | undefined, + direction: SplitDirection, + minimum?: number, +): SizeStyle => { + const axis = direction === 'row' ? 'width' : 'height'; + const minAxis = direction === 'row' ? 'minWidth' : 'minHeight'; + const min = minimum === undefined ? {} : {[minAxis]: minimum}; + if (typeof size === 'number') { + if (!Number.isFinite(size) || size < 0) throw new Error(`Invalid pane size: ${size}`); + return {[axis]: Math.floor(size), flexGrow: 0, flexShrink: 0, ...min}; + } + const value = size ?? '1fr'; + const share = fraction.exec(value); + if (share?.[1] !== undefined) { + return {flexGrow: Number(share[1]), flexShrink: 1, flexBasis: 0, ...min}; + } + if (percent.test(value)) return {[axis]: value, flexGrow: 0, flexShrink: 1, ...min}; + throw new Error(`Invalid pane size: ${value}`); +}; diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx index fac694e..868f74a 100644 --- a/tests/demo.test.tsx +++ b/tests/demo.test.tsx @@ -6,12 +6,25 @@ import {tick} from './helpers.js'; describe('demonstration', () => { it('opens the keyboard reference over the frame', async () => { - const view = render(); + const view = render(); await tick(); - expect(view.lastFrame()).toContain('q / Ctrl+C quit'); view.stdin.write('?'); await tick(); expect(view.lastFrame()).toContain('Keyboard'); expect(view.lastFrame()).toContain('Application'); }); + + it('moves through hosts only while the host list has focus', async () => { + const view = render(); + await tick(); + view.stdin.write('j'); + await tick(); + expect(view.lastFrame()).toContain('› ● api-2'); + view.stdin.write('\t'); + await tick(); + view.stdin.write('j'); + await tick(); + expect(view.lastFrame()).toContain('› ● api-2'); + expect(view.lastFrame()).not.toContain('next host'); + }); }); diff --git a/tests/panes.test.tsx b/tests/panes.test.tsx new file mode 100644 index 0000000..b7ea956 --- /dev/null +++ b/tests/panes.test.tsx @@ -0,0 +1,152 @@ +import React, {useState} from 'react'; +import {Text} from 'ink'; +import {render} from 'ink-testing-library'; +import {describe, expect, it, vi} from 'vitest'; +import {KeymapProvider} from '../src/keys/index.js'; +import { + Pane, + PaneFocus, + PaneFocusProvider, + Split, + sizeStyle, + usePane, + usePaneKeybindings, +} from '../src/panes/index.js'; +import type {PaneFocusProviderProps} from '../src/panes/index.js'; +import {tick} from './helpers.js'; + +describe('pane sizes', () => { + it('maps cells, percentages, and shares onto the split axis', () => { + expect(sizeStyle(20, 'row')).toMatchObject({width: 20, flexGrow: 0, flexShrink: 0}); + expect(sizeStyle('30%', 'column')).toMatchObject({height: '30%'}); + expect(sizeStyle('2fr', 'row', 10)).toMatchObject({flexGrow: 2, flexBasis: 0, minWidth: 10}); + expect(sizeStyle(undefined, 'row')).toMatchObject({flexGrow: 1}); + }); + + it('rejects sizes it cannot place', () => { + expect(() => sizeStyle(-1, 'row')).toThrow('Invalid pane size'); + expect(() => sizeStyle('wide' as never, 'row')).toThrow('Invalid pane size'); + }); +}); + +describe('PaneFocus', () => { + it('cycles in order and wraps around', () => { + const focus = new PaneFocus(); + focus.register('b', 1); + focus.register('a', 0); + focus.register('c', 1); + expect(focus.ids).toEqual(['a', 'b', 'c']); + expect(focus.focused).toBe('b'); + focus.next(); + expect(focus.focused).toBe('c'); + focus.next(); + expect(focus.focused).toBe('a'); + focus.previous(); + expect(focus.focused).toBe('c'); + }); + + it('honors an initial pane that mounts after others', () => { + const focus = new PaneFocus('log'); + focus.register('list'); + focus.register('log'); + expect(focus.focused).toBe('log'); + }); + + it('moves focus to a neighbour when the focused pane unmounts', () => { + const focus = new PaneFocus(); + focus.register('a'); + const removeB = focus.register('b'); + focus.register('c'); + focus.focus('b'); + removeB(); + expect(focus.focused).toBe('c'); + }); + + it('refuses duplicate ids', () => { + const focus = new PaneFocus(); + focus.register('a'); + expect(() => focus.register('a')).toThrow('already mounted'); + }); +}); + +const Label = () => { + const pane = usePane(); + return {pane.focused ? `${pane.id} has focus` : `${pane.id} idle`}; +}; + +const Layout = (props: PaneFocusProviderProps) => ( + + + + + + + + + + + + + + +); + +describe('panes', () => { + it('lays out nested splits within the frame', async () => { + const view = render(); + await tick(); + const lines = (view.lastFrame() ?? '').split('\n'); + expect(lines[0]).toMatch(/^╭─{22}╮╭/); + expect(lines.some((line) => line.includes('Hosts') && line.includes('Detail'))).toBe(true); + }); + + it('moves focus with Tab and Shift+Tab', async () => { + const onFocusChange = vi.fn(); + const view = render(); + await tick(); + expect(view.lastFrame()).toContain('list has focus'); + view.stdin.write('\t'); + await tick(); + expect(view.lastFrame()).toContain('detail has focus'); + expect(view.lastFrame()).toContain('list idle'); + view.stdin.write('\u001B[Z'); + await tick(); + expect(view.lastFrame()).toContain('list has focus'); + expect(onFocusChange).toHaveBeenLastCalledWith('list'); + }); + + it('sends pane keys only to the focused pane', async () => { + const Counter = () => { + const pane = usePane(); + const [count, setCount] = useState(0); + usePaneKeybindings([{keys: 'j', description: 'Down', run: () => setCount(count + 1)}]); + return ( + + {pane.id}={count} + + ); + }; + const view = render( + + + + + + + + + + + + , + ); + await tick(); + view.stdin.write('j'); + await tick(); + expect(view.lastFrame()).toContain('left=0'); + expect(view.lastFrame()).toContain('right=1'); + }); +});