npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 dynamic await import(...)). There is no CommonJS entry point, so require('runeframe') does not work.
  • Node.js >= 22.0.0.
  • Peer dependencies: ink ^7.0.2 and react ^19.2.5.

Install

npm install runeframe

Install the peer dependencies in your application if your package manager does not install them automatically:

npm install ink react

Two 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):

  1. ThemeProvider — design tokens (themeMode: 'dark' | 'light', default 'dark').
  2. KeyboardScopeProvider — scope stack for keyboard dispatch.
  3. FocusTreeProvider — hierarchical focus zones/groups/focusables.
  4. ScopedActionRegistryProvider — action registration for hint bars and collision checks.
  5. NavigationProvider — screen registry, route history, and the modal stack.
  6. MouseProvider — mouse area hit-testing registry.
  7. ToastProvider — toast host.
  8. ModalProvider — modal host (onModalClose callback).
<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 a NormalizedKeyEvent; return true, InputConsumptionResult.Consumed, or InputConsumptionResult.ConsumedAndTrapped to consume. Options: priority?, enabled?, deps?.
  • useKeyBinding(key, handler, scope, options?) — fires when the normalized key equals key ('b', 'enter', 'escape', 'space', 'up', ...). options.modifiers (ctrl?, alt?, shift?, meta?) enables modifier matching; without modifiers, 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 }.
  • FocusTreeProvider props: 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 with key) when definitions change.
  • useActiveActions() — currently enabled, visible actions.
  • useScopedActionRegistry() — { getVisibleActions, getActionsByScope, registerActions, isActionAvailable, version }.
  • ScopedActionRegistryProvider props: children, registry? (ActionRegistry).
  • ActionRegistry is the standalone registry class: register, get, getAll, has, unregister, search(query), getVisibleActions, getActionsByScope(scope), isActionAvailable(id).
  • CommandPalette props: registry, onClose — search overlay over an ActionRegistry.
  • detectCollisions(actions) returns CollisionWarning[] for actions that route the same key in overlapping scopes.
  • HotkeyHintBar props: 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/SelectableList scroll their visible row window with the wheel and keep keyboard focus visible. AppShell wheel scrolling is enabled only with scrollContent and 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.