termdom-react
v0.1.0
Published
React 19 renderer for TermDOM: React components rendered to the terminal through a real DOM.
Maintainers
Readme
termdom-react
React 19 in the terminal, by way of TermDOM.
There is no custom reconciler here, and no <Box>/<Text> vocabulary to learn. TermDOM implements a real DOM with real CSS, so react-dom renders into it the way it renders into a browser's — divs, spans, stylesheets, onClick, onChange, refs, portals. What this package adds is the wiring around that: the globals installed in the right order, onChange taught to fire for terminal edits, and hooks for the things a terminal has that a browser does not.
npm install termdom-react react react-domimport {render, useExit, useKeydown} from "termdom-react";
function Hello() {
const exit = useExit();
useKeydown((event) => event.key === "q" && exit());
return <div style={{color: "#5fafff"}}>hello — press q to quit</div>;
}
await render(<Hello />);React and react-dom are peer dependencies: this package brings only TermDOM with it.
Running the examples
The examples live in the repository, not in the package — clone it, then:
npm install
npm run hello # a card, a stylesheet, one key binding
npm run counter # state, keyboard input, a bar drawn with flexbox
npm run todo # controlled input, checkboxes, list navigation
npm run dashboard # full screen, live data, a media query that reflows
npm run agent # a coding agent on the Agent SDK: your local claude, gated
npm test # the suite, including the examples, headlessNode 24 and Bun both run these directly; the examples are TypeScript and go through tsx under Node.
API
render(element, options?): Promise<TermReactRoot>
Mounts a React tree into a terminal and resolves once it is on screen — mounted, subscribed, painted.
| option | default | what it does |
| --- | --- | --- |
| transport | the process's terminal | Where to render. Tests and embeds pass their own; see Testing. |
| globals | "react" | "react" publishes document and window; "dom" adds the constructors and helpers third-party libraries reach for (Element, HTMLElement, getComputedStyle, matchMedia, …); "none" publishes nothing. |
| fullscreen | false | Take the alternate screen, so the shell's scrollback comes back untouched on exit. |
| css | — | A stylesheet appended to <head> before the first render. |
| onRecoverableError | — | Forwarded to createRoot. |
The returned root carries the terminal (term, document, window, columns, rows, exit()), React's root, the container element, a closed promise, and two teardowns: unmount() (React goes, terminal stays) and dispose() (both go, process stays alive).
Hooks
useTerminal()— theTerminalfor this tree:term,document,window,columns,rows,exit(). Throws outside arender()tree rather than silently reaching for a global.useTerminalSize()—{columns, rows}, re-read on every resize. Layout usually belongs in CSS — TermDOM runs@mediaqueries against the real terminal — so reach for this when the decision is not expressible as style: how many rows to fetch, how to truncate a path, whether to draw a chart at all.useKeydown(handler, options?)— a keydown listener that never goes stale and never re-subscribes. Options:target(an element or ref; the document by default),capture,enabled.useExit()— closes the window: last frame flushed, terminal restored, process exited. The same thing an unhandled Ctrl-C does.
Also exported: observeTerminalSize, installGlobals, installChangeEventFix, TerminalContext.
How it works
Globals go up before react-dom loads. react-dom feature-detects the DOM as its module evaluates — it reads document.documentMode and probes "TextEvent" in window, and at runtime reads window.event to pick an update priority. A static import anywhere in the module graph would evaluate it against a bare Node process. So render() installs the globals and then imports react-dom/client dynamically. Keep that import dynamic if you rewrite this.
onChange needs a nudge (src/compat.ts). React does not trust input events to mean the value changed: it wraps each field's value accessor with a tracker and fires onChange only when what it reads back differs from what it last saw. In a browser, typing writes the field's internal value slot and never touches the JS setter, so a keystroke always looks like a change. TermDOM's user-agent code edits fields with field.value = next — an ordinary DOM write, which React attributes to itself and then swallows. The fix is three capture-phase listeners on the document that overwrite the tracker's remembered value before the event reaches React. Uncontrolled fields work without it; controlled ones look frozen without it.
Resizes come from a probe element (src/size.ts). TermDOM has no window.onresize — SIGWINCH lands as a media-query re-evaluation and a fresh layout, which is all a stylesheet needs and nothing a script can subscribe to. A ResizeObserver on <html> measures content height, so a terminal that gets shorter without getting narrower looks unchanged; matchMedia only fires when an answer flips, and re-arming a query per resize leaks (TermDOM keeps every media query list it hands out). So one hidden 100vw × 100vh element, pinned with position: fixed, gets a single ResizeObserver — it changes size exactly when the terminal does, in both axes, and one probe serves every subscriber.
The first render is flushed synchronously. root.render() only schedules work, so an awaited render() would otherwise hand back a document React had not written to yet.
Writing terminal CSS
The layout engine is real CSS, but the canvas is a character grid, and a few habits transfer badly. These were all found by rendering the examples, and hold for TermDOM 0.1.1:
- Lengths need units. A column is
1ch, a row is1px.padding: 1 2is silently dropped;padding: 1px 2chis a row of vertical padding and two columns of horizontal. - Percentage widths resolve against the viewport, not the containing block:
width: 50%inside a20chbox comes out as half the terminal. Build proportional bars out of flex children that grow against each other (flexGrow: valuenext toflexGrow: max - value), which is what the counter and dashboard examples do. flex: 1sizes items by their content and lets them overflow a narrow terminal.flex: 1 1 0pxwithmin-width: 0splits a row evenly. Note the0px—flex: 1 1 0hits the unitless rule above.- There is no implicit form submission. A
<form onSubmit>will not fire when Enter is pressed in its only field; handle Enter inonKeyDown(the todo example keeps both). - An element with a background and no text still paints. Colored empty
divs are how bars, meters, and sparklines are drawn.
The agent example
examples/05-agent/ is the one example that is an application rather than a demonstration: a terminal coding agent — a streaming transcript, tool calls that render as they run, an approval prompt that blocks a tool until you press a key, and Esc to interrupt a turn.
The agent itself is the Claude Agent SDK, not the Claude API. query() spawns the claude you already have logged in, so the example inherits that authentication — there is no API key handling anywhere in it — and inherits Claude Code's own tools with it: Read, Glob, Grep, Bash, Edit. Nothing here implements a tool; this package only renders them and gates them.
It is split so the UI is testable without a model. protocol.ts defines the only thing the React tree knows about an agent: a stream of events (thinking, text, tool-start, tool-end, usage, error). agent-sdk.ts produces those from a real run; scripted.ts produces them from a fixed transcript. The app cannot tell which it has, which is why npm run agent works on a machine with no Claude Code installed and why the test suite never spawns anything.
Four SDK options carry the example, and three of them were found by running it:
includePartialMessagesturns the run into a token stream, so the pane fills as the model writes. Without it the transcript arrives a paragraph at a time.permissionMode: "auto"is the default here: a model classifier answers each permission question, so the agent works without stopping to ask. What it refuses arrives as apermission_deniedmessage and is rendered in the transcript — an agent that quietly loses a tool call reads as one that changed its mind. The SDK's other modes aredefault,acceptEdits,dontAsk,planandbypassPermissions.canUseToolis the human-in-the-loop hook, and it is whatpermissionMode: "default"routes to: the approval prompt in the UI blocks the tool until a keypress, and returning{behavior: "deny", message}is a first-class answer the model is told about. Read-only tools are auto-allowed in the handler itself; an agent that asks before everygrepis unusable. Inautothe classifier decides first, so this path stays quiet.settingSources: []. It defaults to loading the machine's own Claude Code settings, and an allow-rule there is consulted before the callback — so a tool the user once allowed globally would run with no prompt, from a UI whose entire job is prompting. The cost is that CLAUDE.md and project rules stop loading; pass["project"]to trade back.systemPromptwith theclaude_codepreset plus one appended line naming the session root. Without it the agent guesses where new files go — in testing it wrote into/tmprather than the directory it was pointed at.
Do not pass tool names in allowedTools if you also use canUseTool: a bare name there auto-approves the tool before the callback is consulted, and the SDK warns that your hook is shadowed. One decision point is enough.
What the human gate covers, when you turn it on. In permissionMode: "default", verified against the real CLI: Write and a network-touching Bash both stop and wait for a keypress, while echo hello runs without prompting — Claude Code auto-approves Bash commands it can execute inside its own sandbox, so trivially safe commands never become a permission decision. The prompt covers what escapes the sandbox, which is the part worth interrupting a human for. In the default auto mode none of it stops: the header shows the mode next to the model so that is visible rather than inferred.
Auto-scroll, and why there is no scrolling code. The transcript pins itself to the bottom with CSS alone: the pane clips (overflow: hidden) and its content is pushed down (justify-content: flex-end), so the newest lines are always the visible ones and older ones fall off the top. The browser reflexes do not work here — TermDOM 0.1.1 has no element scrolling (scrollTop is inert, scrollHeight reports the visible height), and under fullscreen the document does not scroll either, because the stage element is the viewport: body.scrollHeight is 0 and window.scrollTo clamps to nothing. scrollIntoView() on the status line works inline and silently does nothing full-screen, which is exactly the bug this replaced. The app's height comes from useTerminalSize().rows, so the composer is always the last row.
Newlines need white-space: pre-wrap. A text node's line breaks are ordinary whitespace, so a streamed multi-line answer otherwise collapses into one wrapped paragraph.
AGENT_ENGINE=scripted forces the offline path, AGENT_ENGINE=claude forces the live one, and AGENT_MODEL overrides the model the local Claude Code would otherwise choose.
Testing
test/harness.ts puts a headless xterm behind TermDOM's transport interface, so a test drives the app the way a user would and reads back what is actually on screen — no tty, no snapshots of ANSI.
const terminal = new TestTerminal({cols: 40, rows: 10});
const app = await render(<Counter />, {transport: terminal});
terminal.type("+++"); // keys in, escape sequences literal
await terminal.settle(); // wait for React, TermDOM, and the emulator
assert.match(terminal.screen, /n=3/);
await app.dispose();terminal.screen and terminal.lines are the emulator's cell grid as text; terminal.cell(x, y) reports the resolved style of one cell, for asserting that something came out bold or red. settle() waits for the terminal to go quiet rather than counting ticks — a resize is coalesced for tens of milliseconds before anything repaints, and tick-counting breaks the day one of the three schedulers in the path changes.
The suite covers the renderer (state updates, stylesheets, flexbox, teardown), the events that were most at risk (synthetic events, controlled inputs, checkboxes, <select>, Ctrl-C), the four small examples themselves — the same modules npm run hello executes, since each one exports its component and only starts the app when run directly — and the agent, driven through a full turn, an approval, a denial and an interrupt against its scripted engine, plus the permission policy on its own.
Known limits
- React DevTools, Suspense with streaming, and
react-dom/serverare untried.createPortalshould work — it takes a DOM node — but is not covered by the suite. app.closedresolves on the decision to close, not on the last byte: the frame is flushed and the terminal handed back a few turns later. Await the transport's ownclosedif you need the latter.unmount()leaves the globals up for one turn. React's unmount commit schedules a callback that readswindow.event; pullingwindowout from under it throws.dispose()waits that turn out for you.- Two roots in one process share
globalThis. The hooks read the terminal from context rather than globals, so components are safe, but the lastrender()wins the globals.
