runeframe
v0.5.1
Published
Runeframe 0.5 is a reusable Ink/React framework for building keyboard-first terminal applications: screen navigation, a hierarchical focus tree, scoped keyboard handling and actions, composable widgets, and async process sessions.
Readme
Runeframe
Runeframe 0.5 is a reusable Ink/React framework for building keyboard-first terminal applications: screen navigation, a hierarchical focus tree, scoped keyboard handling and actions, composable widgets, and async process sessions.
- ESM-only. Runeframe ships ECMAScript modules and nothing else. Use
import(or dynamicawait import(...)). There is no CommonJS entry point, sorequire('runeframe')does not work. - Node.js
>= 22.0.0. - Peer dependencies:
ink ^7.0.2andreact ^19.2.5.
Install
npm install runeframeInstall the peer dependencies in your application if your package manager does not install them automatically:
npm install ink reactTwo entry points are published:
import { FrameworkProvider, ScreenRegistry } from 'runeframe'
import { KeyboardRegistry, ScreenTransition } from 'runeframe/experimental'Both entries are import-only ESM. The package exports map exposes only types and import conditions.
Quickstart
import React from 'react'
import { Text, render } from 'ink'
import {
AppShell,
FrameworkProvider,
ScreenOutlet,
ScreenRegistry,
TopBar,
useNavigation,
} from 'runeframe'
const registry = new ScreenRegistry()
registry.register({
id: 'home',
title: 'Home',
sidebar: true,
category: 'main',
component: () => <Text>Hello from Runeframe</Text>,
})
function Shell() {
const { currentScreen } = useNavigation()
return (
<AppShell topBar={<TopBar appName="My App" screenTitle={currentScreen.title} />}>
<ScreenOutlet />
</AppShell>
)
}
export function App() {
return (
<FrameworkProvider registry={registry} defaultScreen="home">
<Shell />
</FrameworkProvider>
)
}
render(<App />)FrameworkProvider
FrameworkProvider is the single composition root for an application. Every capability is always enabled; there are no opt-in composition flags.
Provider order (outermost → innermost):
ThemeProvider— design tokens (themeMode: 'dark' | 'light', default'dark').KeyboardScopeProvider— scope stack for keyboard dispatch.FocusTreeProvider— hierarchical focus zones/groups/focusables.ScopedActionRegistryProvider— action registration for hint bars and collision checks.NavigationProvider— screen registry, route history, and the modal stack.MouseProvider— mouse area hit-testing registry.ToastProvider— toast host.ModalProvider— modal host (onModalClosecallback).
<FrameworkProvider
registry={registry}
defaultScreen="home"
themeMode="dark"
onModalClose={() => {}}
>
<App />
</FrameworkProvider>FrameworkProviderProps: registry, defaultScreen, children, themeMode?, onModalClose?.
The lower-level providers are exported individually for manual composition (ThemeProvider, KeyboardScopeProvider, FocusTreeProvider, ScopedActionRegistryProvider, NavigationProvider, ModalProvider, ToastProvider). MouseProvider is internal and not a root export, so only FrameworkProvider assembles the complete supported stack, including mouse hit-testing.
Screens and navigation
Screen definitions
ScreenRegistry is a plain class (not a React component). It is passed to FrameworkProvider / NavigationProvider.
const registry = new ScreenRegistry()
registry.register({
id: 'home',
title: 'Home',
shortcut: 'h',
component: ({ params }) => <HomeScreen params={params} />,
sidebar: true,
category: 'main',
})ScreenDefinition: id, title, component, shortcut?, sidebar?, category?. ScreenCategory is 'main' | 'learning' | 'system'. The component receives { params, modalProps, closeModal? }.
ScreenRegistry methods: register(def), get(id), has(id), getAll(), getAllByCategory(category), unregister(id).
ScreenOutlet renders the component of the screen registered under the current route. It must be rendered inside NavigationProvider (normally via FrameworkProvider).
useNavigation
useNavigation() returns the combined navigation state and actions:
| Group | Members |
| --- | --- |
| State | currentScreenId, currentScreen, params, registry, canGoBack, breadcrumbs |
| Actions | push(screenId, params?), pop(), popToRoot(), replace(screenId, params?) |
| Modal state | modalStack, isModalOpen, currentModal, currentModalProps |
| Modal actions | pushModal(screenId, props?), popModal(), popAllModals() |
function Home() {
const { push, canGoBack, pop, currentScreen } = useNavigation()
useKeyBinding('s', () => push('settings', { tab: 'general' }), 'navigation')
useKeyBinding('escape', () => canGoBack && pop(), 'navigation')
return <Text>{currentScreen.title}</Text>
}Modal props reach a rendered modal screen through the screen component's modalProps argument. useModal() returns { openModal, closeModal, isOpen, currentModal } for controlling the modal host directly.
Keyboard handling
Scopes
Keyboard events are dispatched through a scope stack, deepest first. Built-in scopes:
'navigation' | 'list' | 'command' | 'modal' | 'textinput' | 'process'
FocusScope accepts those built-ins plus any custom string scope.
useKeyHandler and useKeyBinding
import { InputConsumptionResult, useKeyHandler, useKeyBinding } from 'runeframe'
function Screen() {
useKeyHandler(
(event) => {
if (!event.enter) return false
openItem()
return true // consumed
},
'navigation',
{ priority: 70 },
)
useKeyBinding('p', () => openPalette(), 'navigation', {
modifiers: { ctrl: true },
})
return null
}useKeyHandler(handler, scope, options?)— receives aNormalizedKeyEvent; returntrue,InputConsumptionResult.Consumed, orInputConsumptionResult.ConsumedAndTrappedto consume. Options:priority?,enabled?,deps?.useKeyBinding(key, handler, scope, options?)— fires when the normalized key equalskey('b','enter','escape','space','up', ...).options.modifiers(ctrl?,alt?,shift?,meta?) enables modifier matching; withoutmodifiers, events carrying ctrl/alt/meta are ignored.useKeyboardScope()returns the scope-stack API:activeScope,activeScopes,activateScope,pushScope,popScope,isScopeActive,registerHandler,suspendShell,restoreShell.useShellSuspension()returns{ suspend, restore, isSuspended }so a widget can silence shell-level ('navigation') shortcuts while it owns input.
NormalizedKeyEvent fields: text, key, code, isPrintable, backspace, enter, escape, tab, space, up, down, left, right, ctrl, shift, alt, meta, rawInput. normalizeKey(input, key) converts Ink's raw (input, key) tuple, and KEY_ENTER, KEY_ESCAPE, KEY_TAB, KEY_BACKSPACE, KEY_DELETE, KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_SPACE are exported key constants.
Consumption results
| Value | Meaning |
| --- | --- |
| InputConsumptionResult.NotConsumed (0) | Continue to the next handler. |
| InputConsumptionResult.Consumed (1) | Stop propagation to siblings; unrelated scopes may still handle the event. |
| InputConsumptionResult.ConsumedAndTrapped (2) | Stop all further propagation. |
Focus tree
FocusTreeProvider is composed by FrameworkProvider. useFocusZone groups a region of the UI, useFocusGroup manages list-like navigation inside a zone, and useFocusable marks an individual item.
function Panel() {
const { ZoneProvider } = useFocusZone('content', { orientation: 'vertical' })
return (
<ZoneProvider>
<Items />
</ZoneProvider>
)
}
function Items() {
const { GroupProvider, focusedId, focusNext, focusPrev } = useFocusGroup('items', {
autoFocus: true,
})
return (
<GroupProvider>
{/* useFocusable() inside each row */}
</GroupProvider>
)
}
function Row() {
const { focused, onActivate } = useFocusable()
return <Text>{focused ? '> ' : ' '}row</Text>
}useFocusZone(id, options?)→{ zoneId, isActive, activate, ZoneProvider }. Options:autoFocus?,scope?,orientation?,order?,navigable?.useFocusGroup(id, options?)→{ groupId, isActive, focusedId, focusNext, focusPrev, activate, GroupProvider }. Options:autoFocus?,scope?.useFocusable(options?)→{ id, focused, onActivate, isFirst, isLast }.FocusTreeProviderprops:children,defaultScope?.
Scoped actions
Actions are declarative metadata for the UI: id, label, category, handler, and optional keys, scope, enabled, visible, and group. Use them to power hint bars and collision checks; wire the actual key handling with useKeyHandler / useKeyBinding.
import {
HotkeyHintBar,
useKeyBinding,
useRegisterActions,
} from 'runeframe'
function ScreenActions() {
useRegisterActions([
{
id: 'open-palette',
label: 'Open palette',
category: 'navigation',
keys: ['ctrl+p'],
scope: 'navigation',
handler: () => openPalette(),
},
{
id: 'run',
label: 'Run',
category: 'context',
keys: ['r'],
scope: 'list',
enabled: () => canRun(),
handler: () => run(),
},
])
useKeyBinding('p', () => openPalette(), 'navigation', {
modifiers: { ctrl: true },
})
useKeyBinding('r', () => run(), 'list')
return <HotkeyHintBar scope="navigation" maxHints={8} />
}useRegisterActions(actions)— registers the action array once at mount. Action arrays are captured at mount, so remount the owning component (for example withkey) when definitions change.useActiveActions()— currently enabled, visible actions.useScopedActionRegistry()—{ getVisibleActions, getActionsByScope, registerActions, isActionAvailable, version }.ScopedActionRegistryProviderprops:children,registry?(ActionRegistry).ActionRegistryis the standalone registry class:register,get,getAll,has,unregister,search(query),getVisibleActions,getActionsByScope(scope),isActionAvailable(id).CommandPaletteprops:registry,onClose— search overlay over anActionRegistry.detectCollisions(actions)returnsCollisionWarning[]for actions that route the same key in overlapping scopes.HotkeyHintBarprops:scope?,maxHints?(default 8).
Async sessions and process output
ProcessRunner
ProcessRunner is the spawn abstraction; NodeProcessRunner is the default implementation (shell-enabled unless new NodeProcessRunner({ shell: false })).
interface ProcessRunner {
spawn(command: string, args?: string[]): RunningProcess
}
interface RunningProcess {
sendStdin(data: string): void
kill(): void
onStdout(cb: (data: string) => void): () => void
onStderr(cb: (data: string) => void): () => void
onExit(cb: (code: number | null) => void): () => void
}When args is omitted, command is treated as a shell command line. Passing an explicit array (including []) spawns command with exactly those arguments.
useAsyncSession
useAsyncSession({ runner, autoCleanup?, maxOutputLines? }) owns one AsyncSessionRunner for the component's lifetime and mirrors its bounded event stream into React state.
const session = useAsyncSession({ runner })
session.start('npm test')
session.sendInput('y\n')
session.cancel()Result:
| Member | Meaning |
| --- | --- |
| status | 'idle' \| 'starting' \| 'running' \| 'complete' \| 'error' |
| events | Bounded event history including status/exit markers |
| output | events filtered to stdout / stderr |
| exitCode | Exit code of the last finished process, or null |
| start(command, args?) | Spawn a process, replacing any running one |
| sendInput(data) | Forward data to the running process's stdin |
| cancel() | Stop the running process and return to idle |
| cleanup() | Tear down the session and reset hook state to idle |
| isRunning, isComplete, isError | Derived status booleans |
| lastEvent | Most recent SessionEvent, or null |
SessionEvent is { type: 'stdout' | 'stderr' | 'status' | 'error' | 'exit', data, timestamp, exitCode? }. DEFAULT_MAX_OUTPUT_LINES is 500.
AsyncSessionRunner is also exported for non-React use: new AsyncSessionRunner({ runner, maxOutputLines? }) with start(command, args?, lifecycle?), sendInput, cancel, cleanup, isRunning(), events, output, status, and exitCode.
ProcessOutputPanel
ProcessOutputPanel renders a session's event stream with status, active command, and a visible-line bound.
<ProcessOutputPanel
events={session.events}
status={session.status}
activeCommand={activeCommand}
maxVisibleLines={500}
/>Props: events, status, activeCommand?, maxVisibleLines? (default 500).
Components
Layout and structure
| Component | Props | Purpose |
| --- | --- | --- |
| AppShell | topBar?, sidebar?, statusBar?, children, columns?, sidebarPosition? ('flow' \| 'fixed'), scrollContent? | Top-level layout; hides the sidebar below 80 columns. scrollContent requires sidebarPosition="fixed". |
| TopBar | appName, screenTitle?, columns? | Application header. |
| StatusBar | mode?, columns?, registry? | Bottom bar; can derive hints from an ActionRegistry. |
| HotkeyHintBar | scope?, maxHints? | Auto-generated hints from registered actions. |
| Breadcrumbs | onSelect?, maxItems?, separator? | Route trail from navigation state. |
| Tabs | tabs, activeTabId, onChange, scope? | Keyboard tab strip. |
| Panel | title?, children | Titled content panel. |
| Card | children, variant? ('default' \| 'elevated') | Bordered content card. |
| Section | label?, children | Labeled section. |
| Divider | label? | Divider line. |
| Spacer | size? ('sm' \| 'md' \| 'lg') | Vertical spacing. |
| Badge | variant, children, compact? | Status badge. |
| Table | columns, rows, title?, width? | Data table. |
| EmptyState | title, description, hint?, action? | Empty placeholder. |
| LoadingState | label, detail? | Loading indicator. |
Button, List, RadioList, ListSelect, Sidebar
Button — keyboard-activatable action button.
| Prop | Type | Notes |
| --- | --- | --- |
| children | ReactNode | Label content. |
| variant? | 'default' \| 'primary' \| 'danger' \| 'ghost' | Default 'default'. |
| disabled? | boolean | Default false. |
| focused? | boolean | Renders the focus ring. |
| onActivate? | () => void | Fires on activation. |
| mouseBounds? | MouseBounds | Optional absolute bounds for mouse activation. |
List — selectable vertical list.
| Prop | Type | Notes |
| --- | --- | --- |
| items | { id, label, description? }[] | Rows. |
| selectedId? | string | Currently highlighted row. |
| onSelect? | (id: string) => void | Highlight change. |
| onActivate? | (id: string) => void | Activation (Enter). |
| maxVisible? | number | Visible window size. |
| mouseBoundsForItem? | (item, index) => MouseBounds \| undefined | Per-row mouse bounds. |
| renderItem? | (item, { focused, selected }) => ReactElement | Custom row renderer. |
RadioList — radio-style single selection.
| Prop | Type |
| --- | --- |
| options | { value, label, disabled? }[] |
| selected | string \| null |
| onSelect | (value: string) => void |
| mouseBoundsForItem? | (option, index) => MouseBounds \| undefined |
ListSelect — list with immediate selection callback and initial focus.
| Prop | Type |
| --- | --- |
| items | { value, label, disabled? }[] |
| onSelect | (value: T) => void |
| initialFocus? | number (default 0) |
| mouseBoundsForItem? | (item, index) => MouseBounds \| undefined |
Sidebar — screen navigation sidebar, grouped by category.
| Prop | Type |
| --- | --- |
| items | { id, label, description?, category? }[] |
| sectionTitles? | Record<string, string> |
| columns? | number |
| categoryOrder? | string[] |
| screenOrderByCategory? | Record<string, string[]> |
| footer? | ReactNode |
| mouseBoundsForItem? | (item, index) => MouseBounds \| undefined |
Input and flow widgets
| Component | Props | Purpose |
| --- | --- | --- |
| TextInput | value?, onChange?, placeholder?, maxLength?, onSubmit?, onCancel?, validate? | Text entry. |
| NumberInput | value?, onChange?, min?, max?, step?, defaultValue?, label?, onSubmit? | Clamped numeric entry. |
| SearchInput | value?, onChange?, placeholder?, scope? | Filter input. |
| CommandInput | mode ('navigation' \| 'command' \| 'process'), value, onChange, onSubmit, onCancel?, placeholder?, prompt? | Mode-aware command line. |
| ChoicePrompt | items, onSelect, onCancel?, label? | Letter-key + arrow choice prompt. |
| SelectableList | List props except items, plus filterQuery?, filterFn? | Filterable list. |
| OptionGrid | options, onSelect, columns? | Directional grid selector. |
| StepFlow | steps, initialData?, onComplete?, onCancel? | Multi-step wizard with shared data. |
Modals and toasts
| Component | Props | Purpose |
| --- | --- | --- |
| ModalProvider / useModal | children, onClose? | Modal host and context API. |
| ModalDialog | title, children, onClose, footer?, trapFocus?, width? | Focus-trapped modal overlay. |
| ConfirmCancel | title, message, onConfirm, onCancel, confirmLabel?, cancelLabel?, danger? | Inline confirm/cancel. |
| ConfirmModal | message, onConfirm, onCancel, title?, confirmLabel?, cancelLabel?, danger? | Modal confirm. |
| ConfirmDialog / useConfirmDialog | isOpen, onClose, message, onConfirm, title?, confirmLabel?, cancelLabel?, danger? | Controlled confirm dialog; useConfirmDialog(options) returns { isOpen, open, close, confirm, setMessage, message }. |
| InfoModal | message, onDismiss, title?, details?, dismissLabel? | Informational modal. |
| ToastProvider / useToast | children | Toast host and context API. |
Mouse interaction and scrolling
FrameworkProvider includes Runeframe's mouse input handling. Built-in controls can measure their own mouse targets when they render inside the opt-in MouseLayout tree. MouseLayout is a Box-equivalent adapter; it uses Ink's public layout metrics and adds no wrapper around the Box it represents.
import React from 'react'
import { Text } from 'ink'
import {
AppShell,
FrameworkProvider,
List,
MouseLayout,
ScreenOutlet,
ScreenRegistry,
} from 'runeframe'
// Use this only when the live root is actually at zero-based cell (0, 0).
const liveRootOrigin = { x: 0, y: 0 }
const items = Array.from({ length: 24 }, (_, index) => ({
id: `item-${index + 1}`,
label: `Item ${index + 1}`,
}))
const registry = new ScreenRegistry()
registry.register({
id: 'home',
title: 'Home',
sidebar: true,
category: 'main',
component: () => <List items={items} maxVisible={8} />,
})
function App() {
return (
<FrameworkProvider registry={registry} defaultScreen="home">
{/* Use as the root layout node; replace an existing root Box when possible. */}
<MouseLayout origin={liveRootOrigin} flexDirection="column">
<AppShell
sidebar={<Text>Navigation</Text>}
sidebarPosition="fixed"
scrollContent
>
<ScreenOutlet />
</AppShell>
</MouseLayout>
</FrameworkProvider>
)
}The example only has valid automatic coordinates if the supplied origin is correct. For nested application-owned Box ancestors between this root and a target, replace each with a nested MouseLayout using the same Box props; Runeframe measures its own built-in layout nodes. An ordinary Box in that path breaks the geometry chain and cannot be detected through Ink's public API. Use MouseLayout in place of an existing layout node when possible: introducing a new Box-equivalent node can change layout.
- Origin and output limits. The root origin is an assertion, not something Runeframe can discover. Normal-screen scrollback,
<Static>output before the live tree, and uncoordinated stdout/stderr writes can move the live frame; automatic coordinates are only valid if the application keeps the origin accurate. Alternate-screen output at(0, 0)is common, not guaranteed. Without a valid measured root, keyboard behavior remains available and automatic targets stay inactive. - Built-in targets. Measurable controls such as buttons, navigation rows, list rows, tabs, inputs, and modal actions can use automatic hit areas inside the measured tree. No per-control rectangles are needed.
List/SelectableListscroll their visible row window with the wheel and keep keyboard focus visible.AppShellwheel scrolling is enabled only withscrollContentand a visible fixed sidebar (sidebarPosition="fixed"); a nested list gets the first chance and passes wheel input outward at its boundary. Wheel input changes viewport position only; it does not select or activate a row. - Clipping limits. Runeframe models its own measured viewport clips and scroll offsets. Arbitrary consumer clipping, transforms, and scroll containers are not inferred and are outside the automatic-geometry guarantee.
- Interactive output only. Mouse input uses SGR reports in an interactive TTY. The test suite exercises synthetic input; PTY and named terminal-emulator compatibility have not been validated, so no named-terminal support is claimed.
Explicit MouseArea
MouseArea remains the headless, explicit-bounds primitive. It renders children unchanged and registers a caller-supplied rectangle with the surrounding mouse registry. Without a MouseProvider it renders children and does nothing.
<MouseArea
bounds={{ x: 0, y: 0, width: 24, height: 1 }}
onClick={({ x, y }) => selectAt(x, y)}
>
<Text>Click target</Text>
</MouseArea>MouseBounds uses zero-based terminal cells and half-open rectangles: [x, x + width) × [y, y + height). Explicit bounds remain caller-owned and are not inferred from Ink/Yoga layout. MouseArea handles clicks only: a matching left-button press and release must land on the area. scope, priority (default 0), disabled, modal eligibility, and overlap behavior are unchanged. Types: MouseAreaProps, MouseBounds, MouseClickEvent.
Theme
ThemeProvider supplies the ThemeTokens (colors, spacing, typography, border styles) and accepts mode: 'dark' | 'light' (default 'dark'). useTheme() returns the active tokens.
const { colors } = useTheme()
<Text color={colors.focus.ring}>focused</Text>Diagnostics
EventTracer— bounded keyboard trace buffer:new EventTracer(maxEntries?),enable(),disable(),clear(),getTrace(),trace(...).KeyboardDebugInspector— live overlay:tracer,getActiveScopeStack?,getActiveFocusPath?.detectCollisions(actions)— reports actions that route the same key in overlapping scopes.
Experimental
runeframe/experimental currently exports KeyboardRegistry (plus the Keybinding type) and ScreenTransition (plus ScreenTransitionProps, TransitionType). The rest of this document describes the stable runeframe root entry point.
Development
Prerequisites: Node.js >= 22 and npm.
npm ci
npm run typecheck
npm test
npm run test:integration:smoke # examples/__tests__ full-stack smoke
npm run build
npm run pack:check # build + packed-consumer ESM check
npm run test-app # install + smoke-test the showcase app, then launch it
npm run test-app:check # install + typecheck + tests for the showcase app (CI)examples/test-app is a standalone consumer project pinned to the published [email protected] package from the npm registry; it never imports the repository's src/, dist/, or a local tarball. npm run test-app installs its own dependencies on first run (later launches reuse the installed copy while its lockfile stamp is current), runs the automated showcase smoke test, and then starts the interactive Ink app. npm run test-app:check is the non-interactive variant used by CI.
See REPOSITORY_SETUP.md for repository configuration and release flow.
