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

cellstate

v0.1.1

Published

React terminal renderer with cell-level diffing, double-buffered rendering, and native scrollback. No alternate screen.

Readme

CellState

A React terminal renderer for building modern interactive UIs.

CSS-style layout, component-based UI, and native terminal behavior. Scrolling, text selection, Cmd+F, and copy/paste all work exactly like the rest of your terminal. No alternate screen.

CellState uses double-buffered rendering with cell-level diffing, SGR state tracking, row-level damage detection, wide character support, and synchronized output. Tested with 3,600+ property-based test iterations against xterm.js.

Architecture

CellState uses a custom React reconciler that renders directly to a cell grid with no intermediate ANSI string building. It runs in inline mode rather than alternate screen, so native terminal features like scrolling, text selection, Cmd+F, and copy/paste work exactly as expected.

Every frame renders the full content tree into an offscreen buffer. The viewport is extracted from that buffer based on how far the content has scrolled, compared cell by cell against the previous frame. Only the differences are written to the terminal.

We use damage tracking to skip unchanged rows entirely and erase blank rows with a single command instead of overwriting every column. Within changed rows, individual cells are compared and only the differences are written. SGR state tracking is used to keep style changes minimal: switching from bold red to bold blue emits one color change, not a full reset.

Every frame is wrapped in DEC 2026 synchronized output sequences so terminals that support it paint atomically with zero tearing. Terminals without support silently ignore the sequences. Supported terminals: https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md

Install

npm install cellstate react

Usage

import React, { useState } from 'react';
import { render, Box, Text, useInput, useApp } from 'cellstate';

function App() {
  const { exit } = useApp();
  const [count, setCount] = useState(0);

  useInput((key) => {
    if (key.type === 'ctrl' && key.ctrlKey === 'c') exit();
    if (key.type === 'up') setCount(c => c + 1);
    if (key.type === 'down') setCount(c => c - 1);
  });

  return (
    <Box flexDirection="column">
      <Text bold>Count: {count}</Text>
      <Text dim>↑/↓ to change, Ctrl+C to exit</Text>
    </Box>
  );
}

const app = render(<App />);
await app.waitUntilExit();

Contents

Getting Started

CellState uses its own Flexbox layout engine for the terminal, allowing you to build user interfaces for your CLIs using familiar CSS-like properties. <Box> is your layout container (like <div> with display: flex), <Text> renders styled text. State changes via hooks trigger re-renders automatically.

All visible text must be inside a <Text> component. You can use plain string children or structured segments for mixed styles. The built-in markdown renderer (powered by remark with syntax-highlighted code blocks via Shiki) produces <Box> and <Text> trees for you.

App Lifecycle

An app using CellState stays alive until you call unmount(). You don't need to keep the event loop busy; the renderer handles that internally. Signal handling is your responsibility, so you decide when and how your app exits.

What render() does

A single call sets up the full terminal pipeline:

  1. Creates the frame loop (reconciler → layout → rasterize → diff → stdout)
  2. Hides the cursor
  3. Puts stdin in raw mode for keypress handling
  4. Redirects console.log/info/warn/error/debug to stderr (enabled by default, disable with patchConsole: false)
  5. Listens for terminal resize (triggers automatic re-layout and full redraw)
  6. Wraps your component in an error boundary that restores terminal state on crash

Console patching is enabled by default. To disable it:

const app = render(<App />, { patchConsole: false });

Custom stdout/stdin streams (defaults to process.stdout and process.stdin):

const app = render(<App />, { stdout: myStream, stdin: myInputStream });

What render() returns

interface RenderInstance {
  unmount: () => void;                     // Stop rendering, restore terminal state
  waitUntilExit: () => Promise<unknown>;   // Resolves when unmount() is called
  dumpFrameLog: (path: string) => void;    // Write last 20 frames to file (debugging)
}

unmount() is idempotent and safe to call multiple times. It restores raw mode, re-shows the cursor, stops the frame loop, restores original console methods, and cleans up all listeners. If your component tree throws during a render, the error boundary calls unmount() automatically and prints the error to stderr.

dumpFrameLog(path) writes the last 20 frames to a JSON file for debugging rendering issues. Each entry includes frame type, content height, scrollback state, and viewport dimensions.

Waiting for Exit

Use waitUntilExit() to run code after the app is unmounted:

const app = render(<App />);

process.on('SIGINT', () => app.unmount());

await app.waitUntilExit();
console.log('App exited');
process.exit(0);

Low-Level API

For custom pipelines or when you need direct control over the frame loop:

import { createFrameLoop } from 'cellstate';

const loop = createFrameLoop(process.stdout);
loop.start(<App />);
loop.update(<App newProps={...} />);
loop.getGrid();  // Current rendered grid (for testing or screenshots)
loop.stop();

This is what render() uses internally. You get full control over the lifecycle but are responsible for raw mode, cursor visibility, and cleanup yourself.

Static Rendering

renderOnce runs the full rendering pipeline (reconciler, layout, rasterize, serialize) and returns a styled ANSI string. No frame loop, no raw mode, no cursor management, no stdin. The caller decides where to write the output.

import { renderOnce, Box, Text } from 'cellstate';

const output = await renderOnce(
  <Box gap={1}>
    <Text segments={[{ text: 'Error: ', style: { bold: true, color: '#ff0000' } }]} />
    <Text>File not found</Text>
  </Box>
);

process.stdout.write(output + '\n');

Render markdown to the terminal:

import { renderOnce, markdownToElements } from 'cellstate';

const markdown = '# Hello\n\nSome **bold** text and `inline code`.';
const output = await renderOnce(<>{markdownToElements(markdown)}</>);
process.stdout.write(output + '\n');

Custom column width (defaults to terminal width, or 80 if unavailable):

const output = await renderOnce(<MyComponent />, { columns: 60 });

Testing component output:

test('renders greeting', async () => {
  const output = await renderOnce(<Greeting name="World" />);
  expect(output).toContain('Hello, World');
});

Components

<Text>

Renders styled text with automatic line wrapping.

<Text>Plain text</Text>
<Text bold>Bold text</Text>
<Text color="#00ff00">Green text</Text>
<Text bold italic color="#ff0000" backgroundColor="#333333">Styled text</Text>
<Text dim>Muted text</Text>
<Text inverse>Inverted text (fg/bg swapped)</Text>

Props

| Property | Type | Description | |-------------------|-------------|-----------------------------------------------------------| | bold | boolean | Bold weight | | italic | boolean | Italic style | | underline | boolean | Underline | | strikethrough | boolean | Strikethrough text | | dim | boolean | Dimmed/faint text | | inverse | boolean | Swap foreground and background colors | | color | string | Text color (hex like #ff0000 or named: red, green, blue, yellow, cyan, magenta, white, gray) | | backgroundColor | string | Background color (hex or named color) | | hangingIndent | number | Indent for wrapped continuation lines | | wrap | string | Text overflow: 'wrap' (default), 'truncate', 'truncate-start', 'truncate-middle' | | segments | Segment[] | Multiple styled sections in one text element |

When text exceeds the available width, wrap controls how it's handled:

<Text wrap="truncate">/Users/me/very/long/path/to/file.tsx</Text>
// → /Users/me/very/long/path/to/fi…

<Text wrap="truncate-start">/Users/me/very/long/path/to/file.tsx</Text>
// → …ery/long/path/to/file.tsx

<Text wrap="truncate-middle">/Users/me/very/long/path/to/file.tsx</Text>
// → /Users/me/ver…to/file.tsx

truncate and truncate-end are aliases. truncate-start is useful for file paths where the end matters more.

Known limitation: Truncation with styled segments collapses to plain text, losing per-segment styles. If you need truncated text with mixed styles, apply truncation to the text content before passing it as segments.

For mixed styles in a single element, use segments:

<Text segments={[
  { text: 'Error: ', style: { bold: true, color: '#ff0000' } },
  { text: 'file not found' },
]} />

Segment styles support: bold, italic, underline, strikethrough, dim, inverse, color, and backgroundColor. This is what markdownToElements and syntax highlighting produce internally.

<Box>

Container element for layout. Stack children vertically or horizontally.

<Box flexDirection="column" gap={1}>
  <Text>First</Text>
  <Text>Second</Text>
</Box>

<Box flexDirection="row">
  <Box width={20}><Text>Sidebar</Text></Box>
  <Box flexGrow={1}><Text>Main content</Text></Box>
</Box>

<Box borderStyle="round" borderColor="#888888" padding={1}>
  <Text>Boxed content</Text>
</Box>

Props

| Property | Type | Description | |-------------------|--------------------------------------------------------|------------------------------------------------------| | display | 'flex' \| 'none' | Hide component and children (default: flex) | | flexDirection | 'column' \| 'row' | Stack direction (default: column) | | gap | number | Space between children | | width | number | Fixed width in columns | | height | number | Fixed height in rows. Children are positioned from the top; the box height is fixed regardless of content size | | flexGrow | number | Fill remaining space in row layout | | alignItems | 'stretch' \| 'flex-start' \| 'center' \| 'flex-end' | Cross-axis alignment (default: stretch) | | justifyContent | 'flex-start' \| 'center' \| 'flex-end' \| 'space-between' \| 'space-around' \| 'space-evenly' | Main-axis distribution of children (default: flex-start). Only effective when the container has extra space (e.g. fixed height) | | padding | number | Padding on all sides | | paddingLeft | number | Left padding | | paddingRight | number | Right padding | | paddingTop | number | Top padding | | paddingBottom | number | Bottom padding | | margin | number | Margin on all sides | | marginLeft | number | Left margin | | marginRight | number | Right margin | | marginTop | number | Top margin | | marginBottom | number | Bottom margin | | borderStyle | 'single' \| 'double' \| 'round' \| 'bold' | Box border style | | borderColor | string | Border color (hex or named color) | | backgroundColor | string | Background fill color (hex or named color) |

Use display="none" to hide a component without unmounting it. The component stays in the React tree (state is preserved) but produces no visual output and takes no space in the layout. This is different from {condition && <Component />}, which unmounts the component and destroys its state.

<Box display={showPanel ? 'flex' : 'none'}>
  <Text>This panel preserves state when hidden</Text>
</Box>

<Divider>

Renders a full-width horizontal line that fills its container.

<Divider />

Custom character and color:

<Divider color="#888888" dim />
<Divider char="═" color="#00cccc" />
<Divider char="·" />

Props

| Property | Type | Default | Description | |---|---|---|---| | char | string | '─' | Character to repeat across the line | | color | string | inherited | Line color (hex or named color) | | dim | boolean | false | Dimmed/faint line |

Examples:

─────────────────────    (default)
═════════════════════    char="═"
·····················    char="·"
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    char="─ "

Hooks

useInput

Subscribe to keyboard events inside components. The callback fires for every keypress while active is true (default).

import { useInput } from 'cellstate';

function MyComponent() {
  useInput((key) => {
    if (key.type === 'char') {
      console.log('Typed:', key.char);
    }

    if (key.type === 'enter') {
      console.log('Submitted');
    }

    if (key.type === 'ctrl' && key.ctrlKey === 'c') {
      process.exit(0);
    }
  });

  return null;
}

Disabling input:

useInput(handler, { active: false });

When active is false, the stdin listener is removed entirely. Useful when multiple components use useInput and only one should handle keypresses at a time, like a permission prompt taking focus from the main input field.

Event types:

| Type | Properties | Description | |---|---|---| | char | char: string | Printable character (ASCII, UTF-8, emoji) | | paste | paste: string | Pasted text (via bracketed paste mode) | | ctrl | ctrlKey: string | Ctrl+key (e.g. ctrlKey: 'c' for Ctrl+C) | | enter | | Enter/Return key | | backspace | | Backspace key | | delete | | Delete key | | left | | Left arrow | | right | | Right arrow | | up | | Up arrow | | down | | Down arrow | | home | | Home key | | end | | End key | | tab | | Tab key (consumed by focus system) | | shift-tab | | Shift+Tab (consumed by focus system) |

Bracketed paste:

Bracketed paste mode is enabled automatically when the app starts. When a user pastes text, the terminal wraps it in escape sequences and the renderer delivers the entire pasted string as a single paste event instead of splitting it into individual char and enter events. This prevents multi-line pastes from triggering premature submissions.

useInput((key) => {
  if (key.type === 'paste') {
    // key.paste contains the full pasted string, including newlines
    insertTextAtCursor(key.paste!);
  }
});

Without bracketed paste, pasting hello\nworld would fire: char('h'), char('e'), char('l'), char('l'), char('o'), enter, char('w'), char('o'), char('r'), char('l'), char('d'). With bracketed paste, it fires once: paste('hello\nworld').

useApp

Access the app lifecycle from inside components. Returns an exit function that unmounts the app and resolves (or rejects) the waitUntilExit promise.

import { useApp } from 'cellstate';

function Agent() {
  const { exit } = useApp();

  async function handleTask() {
    try {
      const result = await runAgent();
      exit(result);  // resolves waitUntilExit with result
    } catch (err) {
      exit(err);     // rejects waitUntilExit with error
    }
  }

  return null;
}

exit(errorOrResult?)

| Call | waitUntilExit behavior | |---|---| | exit() | Resolves with undefined | | exit(value) | Resolves with value | | exit(new Error(...)) | Rejects with the error |

This lets the outer process distinguish between success and failure:

const app = render(<Agent />);

try {
  const result = await app.waitUntilExit();
  console.log('Completed:', result);
} catch (err) {
  console.error('Failed:', err);
  process.exit(1);
}

useApp must be called inside a component rendered by render(). Calling it outside that tree throws an error.

useFocus

Makes a component focusable. When the user presses Tab, focus cycles through components in render order. Returns { isFocused } so the component can visually indicate focus and conditionally enable input handling.

import { useFocus, useInput, Text } from 'cellstate';

function Input({ label }: { label: string }) {
  const { isFocused } = useFocus();

  useInput((key) => {
    if (key.type === 'char') {
      // handle input
    }
  }, { active: isFocused });

  return (
    <Text segments={[{
      text: `${isFocused ? '>' : ' '} ${label}`,
      style: { bold: isFocused },
    }]} />
  );
}

Options:

| Property | Type | Default | Description | |---|---|---|---| | id | string | auto-generated | Focus ID for programmatic focus via useFocusManager | | autoFocus | boolean | false | Automatically focus this component if nothing else is focused | | isActive | boolean | true | Whether this component can receive focus. When false, Tab skips it but its position in the focus order is preserved. |

Tab / Shift+Tab:

Tab and Shift+Tab cycling is handled automatically by the renderer. Tab moves focus to the next focusable component, Shift+Tab moves to the previous. Focus wraps around at both ends.

useFocusManager

Programmatic control over the focus system. Use this when focus changes are driven by app logic rather than Tab cycling, like a permission prompt stealing focus from the main input field.

import { useFocusManager } from 'cellstate';

function PermissionPrompt({ onResolve }: { onResolve: () => void }) {
  const { focus } = useFocusManager();

  function handleDone() {
    onResolve();
    focus('input');  // return focus to main input
  }

  return null;
}

Returns:

| Property | Type | Description | |---|---|---| | focus | (id: string) => void | Focus the component with the given ID | | focusNext | () => void | Move focus to the next focusable component | | focusPrevious | () => void | Move focus to the previous focusable component | | enableFocus | () => void | Enable the focus system (enabled by default) | | disableFocus | () => void | Disable the focus system. The focused component loses focus. | | activeId | string \| null | ID of the currently focused component, or null |

Coding agent pattern:

import { useFocus, useFocusManager, useInput, Box, Text } from 'cellstate';

function InputField() {
  const { isFocused } = useFocus({ id: 'input', autoFocus: true });
  useInput(handler, { active: isFocused });
  return <Text segments={[{ text: '> ' }]} />;
}

function PermissionPrompt() {
  const { isFocused } = useFocus({ id: 'permission', autoFocus: true });
  const { focus } = useFocusManager();

  useInput((key) => {
    if (key.type === 'char' && key.char === 'y') {
      approve();
      focus('input');
    }
  }, { active: isFocused });

  return isFocused ? <Text>Allow this action? (y/n)</Text> : null;
}

When PermissionPrompt mounts with autoFocus: true, it takes focus from InputField. When the user responds, focus('input') returns focus to the input. No Tab cycling needed.

useDimensions

Returns the current terminal width and height. Re-renders the component when the terminal is resized.

const { cols, rows } = useDimensions();

Most components don't need this since the layout engine handles sizing automatically. Useful when you want to conditionally render different content based on terminal size, like showing an abbreviated header in narrow terminals.

Utilities

markdownToElements

Render markdown strings directly as terminal UI:

import { markdownToElements } from 'cellstate';

function Response({ content }: { content: string }) {
  return <>{markdownToElements(content)}</>;
}

Supports headings, bold, italic, inline code, fenced code blocks with syntax highlighting (via Shiki), lists (ordered and unordered), blockquotes, links, and thematic breaks.

highlightCode

Syntax-highlight a code string into styled segments (powered by Shiki, Nord theme):

import { highlightCode, Text, Box } from 'cellstate';

function CodeBlock({ code, lang }: { code: string; lang: string }) {
  const lines = highlightCode(code, lang);
  if (!lines) return <Text segments={[{ text: code }]} />;

  return (
    <Box paddingLeft={2}>
      {lines.map((segments, i) => (
        <Text key={i} segments={segments} />
      ))}
    </Box>
  );
}

Returns an array of lines, each containing an array of Segment objects with syntax-highlighted colors. Returns null for unrecognized languages.

Supported languages: TypeScript, TSX, JavaScript, JSX, Bash (sh/shell), JSON, YAML, HTML, CSS, Go, Rust, Python.

measureElement

Returns the rendered dimensions of a component after layout. Use with a ref to measure any <Box> element:

import { useRef, useEffect } from 'react';
import { measureElement, Box, Text } from 'cellstate';

function MeasuredBox() {
  const ref = useRef(null);

  useEffect(() => {
    if (ref.current) {
      const { width, height } = measureElement(ref.current);
      // width and height are in terminal columns/rows
    }
  });

  return (
    <Box ref={ref} borderStyle="round" padding={1}>
      <Text>Content to measure</Text>
    </Box>
  );
}

Dimensions are available after the first layout frame. Since the layout engine recomputes on every frame, measurements are always current. Returns { width: 0, height: 0 } if the node hasn't been laid out yet.

decodeKeypress

Low-level function that decodes raw stdin bytes into structured keypress events. This is what useInput uses internally. Useful if you need keypress decoding outside of a React component tree, like in a custom input loop.

import { decodeKeypress } from 'cellstate';

process.stdin.setRawMode(true);
process.stdin.on('data', (data: Buffer) => {
  const events = decodeKeypress(data);
  for (const event of events) {
    if (event.type === 'char') console.log('Key:', event.char);
    if (event.type === 'ctrl' && event.ctrlKey === 'c') process.exit(0);
  }
});

Handles UTF-8 (multi-byte characters, emoji, CJK), CSI escape sequences (arrows, Home, End, Delete), control bytes (Ctrl+letter), and bracketed paste sequences. SGR mouse sequences are consumed silently.

Inspired by the Claude Code renderer