Getting started
Render your first terminal frame with React or Solid
Install
pnpm add @uniview/tui-react react
pnpm add -D typescript @types/react @types/nodeThe development-only line supplies React TSX and Node process types; it does not change the
runtime package boundary.
pnpm add @uniview/tui-solid solid-js
pnpm add -D typescript vite vite-node @types/nodeSolid does not require @types/react. Its development-only line supplies the TSX compiler,
Vite runner, and Node process types.
A first frame
import { Panel, Text, render } from "@uniview/tui-react";
const app = render(
<Panel title="Hello" focused>
<Text bold>from React</Text>
</Panel>,
);
process.on("SIGINT", () => {
app.destroy();
process.exit(0);
});Compile Solid TSX through the Uniview Vite helper
This example is not runnable through plain tsx/esbuild. Solid TSX must use
univiewSolid() and load the public @uniview/tui-solid/jsx-runtime augmentation. vite,
vite-node, TypeScript, and @types/node are development tooling only; the runtime install remains
@uniview/tui-solid plus solid-js. See the complete Solid setup.
import { univiewSolid } from "@uniview/tui-solid/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [univiewSolid()] });import type {} from "@uniview/tui-solid/jsx-runtime";import { Panel, Text, render } from "@uniview/tui-solid";
// Mounted once. Signal writes drive every later frame — there is no rerender().
const app = render(() => (
<Panel title="Hello" focused>
<Text bold>from Solid</Text>
</Panel>
));
process.on("SIGINT", () => {
app.destroy();
process.exit(0);
});Advanced API: custom input and surfaces
render() is the normal path: it creates the ANSI surface and terminal driver for you. Use
the low-level API only when you need custom terminal behavior or a non-terminal surface. The
binding re-exports the common core facilities, so the React example still has one dependency.
TerminalDriver puts the terminal in raw mode and emits normalized events. Feed them to the
root and they route to whichever component is focused.
import {
AnsiCellSurface,
createTuiReactRoot,
StyleTable,
TerminalDriver,
} from "@uniview/tui-react";
const styles = new StyleTable();
let surface: AnsiCellSurface | undefined;
let root: ReturnType<typeof createTuiReactRoot> | undefined;
const driver = new TerminalDriver({
input: process.stdin,
output: process.stdout,
mouse: "motion", // or "off"
onEvent: (event) => {
if (!root) return;
if (event.type === "resize") {
root.host.renderer.resize({ width: event.width, height: event.height });
return;
}
root.dispatchInput(event);
},
});
try {
driver.start({
cleanup: () => {
if (root) root.destroy();
else surface?.destroy();
},
});
surface = new AnsiCellSurface({
write: (chunk) => process.stdout.write(chunk),
styles,
});
root = createTuiReactRoot({
surface,
styles,
size: {
width: process.stdout.columns ?? 80,
height: process.stdout.rows ?? 24,
},
});
} catch (error) {
try {
driver.stop();
} catch {
// Preserve the construction error; the pending cleanup remains retryable.
}
throw error;
}
process.on("SIGINT", () => driver.stop());The cleanup callback and terminal resources form one ownership session. A failed cleanup keeps both stream identities reserved, so this driver or the next owner retries it before terminal acquisition; a stale root cannot interfere with the replacement session.
Once root/renderer teardown starts, the old host and renderer handles are invalidated too.
Queued frames are cancelled, and later render, resize, cursor, flush, mutation, or event-dispatch
calls reject instead of writing into a replacement session.
Keys reach the focused node
A key event only reaches a component that is focused and has an onKeyDown — bubbling
up to the nearest ancestor that handles it. In tests, dispatch a Tab first, or nothing is
focused and every key is dropped. Note also that letters and digits arrive as text events
while named keys (ArrowDown, Tab, Enter) arrive as key events.
Testing without a terminal
Swap the surface. MemoryCellSurface renders into memory, and you assert on the text:
import {
createTuiReactRoot,
MemoryCellSurface,
StyleTable,
} from "@uniview/tui-react";
const styles = new StyleTable();
const surface = new MemoryCellSurface({ styles });
const root = createTuiReactRoot({ surface, styles, size: { width: 20, height: 3 } });
root.render(<Panel title="Hi" width={20} height={3} />);
await new Promise((r) => setTimeout(r, 20));
expect(surface.text({ trimRight: true })).toContain("Hi");Custom surfaces are synchronous
Every CellSurface lifecycle method completes in the calling stack:
mount(), resize(), and destroy() return void, and present() returns
PresentStats. Do not return a Promise or thenable. Queue asynchronous
transport work inside the surface if necessary, while completing the
renderer-visible operation synchronously; a returned thenable invalidates the
renderer. The owning host then permanently rejects later tree mutations,
focus changes, event activation, and input dispatch; destroy the failed
session and create a new one instead of reusing stale handlers.
You can also assert on the style of an individual cell — that a focused border really is green, say:
const frame = surface.cells()!;
const corner = frame.cells[0][0];
expect(styles.get(corner.styleId).fg).toBe("green");Exporting a frame as an image
SvgCellSurface records a frame and hands you an SVG — that is how every screenshot in
these docs is produced, and why they cannot drift from the code.
import { SvgCellSurface } from "@uniview/tui-react";
const surface = new SvgCellSurface({ styles });
// …render…
writeFileSync("frame.svg", surface.toSVG()!);