React terminal renderer with cell-level diffing. No alternate screen.
Built on the architecture behind Claude Code's rendering rewrite.
CellState rendering a coding agent UI via OpenCode's serve API
React reconciler → layout → rasterize to cell grid → extract viewport → cell-level diff → minimal ANSI escape sequences.
Every frame renders the full content tree into an offscreen buffer. The viewport is extracted based on scroll position, then compared cell by cell against the previous frame. Only differences are written. Unchanged rows are skipped entirely. Within changed rows, SGR state tracking minimizes style changes: switching from bold red to bold blue emits one color change, not a full reset.
CJK characters, emoji, and fullwidth Unicode are handled natively. The layout engine, rasterizer, and diff engine all operate on display width (terminal columns), not string length. Wide characters occupy two cells with proper continuation markers. Variation Selector 16 sequences (e.g. ☀️) are detected and widened correctly. Width tables are generated from Unicode 17.0 emoji data.
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
Tested with 4,300+ property-based test iterations against xterm.js. 1,004 tests across the full rendering pipeline, verified against 6 terminals: Windows Terminal, kitty, Ghostty, iTerm2, macOS Terminal.app, and xterm.js.
Benchmarked against Ink and raw escape codes. Full methodology and code: tui-benchmarks
| Messages | Content | CellState Pipeline | Raw | Ink |
|---|---|---|---|---|
| 100 | 13.3 KB | 1.10ms | 1.10ms | 26.53ms |
| 250 | 33.1 KB | 2.54ms | 2.44ms | 36.93ms |
| 500 | 66.0 KB | 5.10ms | 4.81ms | 63.05ms |
npm install cellstate react
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();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.
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.
A single call sets up the full terminal pipeline:
- Creates the frame loop (reconciler → layout → rasterize → diff → stdout)
- Hides the cursor
- Puts stdin in raw mode for keypress handling
- Redirects
console.log/info/warn/error/debugto stderr (enabled by default, disable withpatchConsole: false) - Listens for terminal resize (triggers automatic re-layout and full redraw)
- 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 });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.
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);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.
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');
});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>| 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.tsxtruncate 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.
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>| 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>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="·" />| 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 |
───────────────────── (default)
═════════════════════ char="═"
····················· char="·"
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ char="─ "
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').
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.
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.
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.
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.
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.
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.
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.
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.
Measure and slice strings by terminal display width rather than string length. CJK characters and emoji occupy 2 columns, combining marks occupy 0. These are the same functions the layout engine uses internally.
import { stringDisplayWidth, charDisplayWidth, sliceToWidth, sliceFromEndToWidth } from 'cellstate';
stringDisplayWidth('hello'); // 5
stringDisplayWidth('你好'); // 4 (2 columns each)
stringDisplayWidth('😀'); // 2
stringDisplayWidth('☀\uFE0F'); // 2 (VS16 upgrades to emoji presentation)
stringDisplayWidth('e\u0301'); // 1 (combining mark is zero-width)
charDisplayWidth(0x4E00); // 2 (CJK ideograph)
charDisplayWidth(0x1F680); // 2 (rocket emoji)
charDisplayWidth(0x0301); // 0 (combining acute accent)
charDisplayWidth(0x61); // 1 (ASCII 'a')
sliceToWidth('你好世界', 5); // '你好' (4 cols — next char would exceed 5)
sliceFromEndToWidth('你好世界', 5); // '世界' (4 cols from the end)| Function | Description |
|---|---|
stringDisplayWidth(str) |
Total display width of a string in terminal columns |
charDisplayWidth(codePoint) |
Display width of a single Unicode code point (0, 1, or 2) |
sliceToWidth(text, maxCols) |
Slice from the start to fit within maxCols columns. Never splits wide characters or surrogate pairs |
sliceFromEndToWidth(text, maxCols) |
Slice from the end to fit within maxCols columns |
