Uniview

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/node

The 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/node

Solid does not require @types/react. Its development-only line supplies the TSX compiler, Vite runner, and Node process types.

A first frame

main.tsx
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.

vite.config.ts
import { univiewSolid } from "@uniview/tui-solid/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [univiewSolid()] });
src/uniview-jsx.d.ts
import type {} from "@uniview/tui-solid/jsx-runtime";
main.tsx
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()!);

On this page