Getting Started with Terminal#

@sigx/terminal lets you build terminal user interfaces (TUIs) and interactive CLI apps using the same reactive model and TSX components you use on the web. It is built on @sigx/reactivity and @sigx/runtime-core, and ships a cell renderer that paints your component tree to the terminal as ANSI-styled lines.

The package is an umbrella: it re-exports the terminal renderer, the headless @sigx/terminal-zero foundation and the themed @sigx/terminal-ui components, so you can import everything you need from a single @sigx/terminal entry. See Architecture for how the pieces fit together.

What you get#

  • TSX intrinsics for the terminal — box, text, and br — with colors, borders, labels and drop shadows.
  • A render entry (renderTerminal / terminalMount) with inline and fullscreen render modes, a non-TTY plain-text fallback and a reactive getTerminalSize().
  • Layered keyboard inputonKey(handler, { layer }) dispatch with built-in Tab/Shift+Tab focus traversal and a focusState signal.
  • A themed component library — forms (Input, Select, MultiSelect, …), feedback (Spinner, ProgressBar, Badge), navigation, layout, data, fx and task components. See Components.
  • A prompt kit — imperative text / select / confirm / spinner wizards. See Prompts.

Install#

Terminal
pnpm add @sigx/terminal

The umbrella brings the reactive engine (@sigx/reactivity, @sigx/runtime-core) and the whole TUI stack with it — there is nothing else to add. See Installation for the required TSX/Vite wiring.

A minimal app#

Every .tsx file that uses terminal intrinsics needs the JSX pragma at the top so TSX compiles against the terminal runtime:

TSX
/** @jsxImportSource @sigx/terminal */
import { component, defineApp, signal } from '@sigx/terminal';

const App = component(() => {
    const count = signal(0);
    const tick = () => count.value++;

    return () => (
        <box border="rounded" label="Counter" color="cyan">
            <text>Count is {count.value}</text>
            <br />
            <text color="green">Press the button to increment.</text>
        </box>
    );
});

defineApp(<App />).mount({ clearConsole: true });

defineApp(<App />).mount(...) works without passing a mount function because the terminal runtime registers terminalMount as the platform default. Any reactive change in your tree schedules a repaint of the whole screen.

Adding interactivity#

The runtime ships focusable components with two-way model binding. Tab and Shift+Tab move focus between them:

TSX
/** @jsxImportSource @sigx/terminal */
import { component, defineApp, signal } from '@sigx/terminal';
import { Input, Button } from '@sigx/terminal';

const App = component(() => {
    const name = signal('');

    return () => (
        <box border="single" label="Greeting">
            <Input model={() => name} placeholder="Your name" autofocus label="Name" />
            <br />
            <text>Hello, {name.value || 'stranger'}!</text>
            <br />
            <Button label="Done" onClick={() => console.log(name.value)} />
        </box>
    );
});

defineApp(<App />).mount({ clearConsole: true });

Loading data#

Terminal apps are full SignalX clients, so the value-first async primitives work here too. @sigx/terminal re-exports useData (along with useStream, useAction and all): call it during setup with a key and a fetcher, then render every state through .match(). No window is required — the terminal runtime declares itself a live client, so keyed reads actually fetch instead of parking at pending.

TSX
/** @jsxImportSource @sigx/terminal */
import { component, defineApp, useData } from '@sigx/terminal';

const App = component(() => {
    const joke = useData('joke', async (key, { signal }) => {
        const res = await fetch('https://icanhazdadjoke.com/', {
            headers: { Accept: 'application/json' },
            signal,
        });
        return (await res.json()).joke as string;
    });

    return () => (
        <box border="rounded" label="Dad joke" color="cyan">
            {joke.match({
                pending: () => <text color="dim">Loading…</text>,
                error: (e) => <text color="warn">Failed: {e.message}</text>,
                ready: (text) => <text>{text}</text>,
            })}
        </box>
    );
});

defineApp(<App />).mount({ clearConsole: true });

The fetcher receives the resolved key and an AbortSignal — pass it straight to fetch. useStream follows the same shape for progressive, token-at-a-time output, which is handy for piping an LLM response into a <text> cell.

Next steps#