Getting started
pnpm add @textui/core @textui/terminal
Node ≥ 22. There are no other dependencies.
The smallest thing that runs
import { createApp, registerBuiltins, WRITER_KEY } from '@textui/core';
import { createNodeTerminal, createWriter } from '@textui/terminal';
const terminal = createNodeTerminal();
const app = createApp({
terminal,
root: { component: 'text', content: 'hello' },
onBoot: registerBuiltins,
});
app.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
await app.start();
registerBuiltins puts the component catalog, the surface layouts and the built-in shells into the registries. The writer is a service rather than an import so the core never depends on terminal encoding - which is what lets the test harness and the static renderer run the same application with no writer at all.
JSX
Point the compiler at the runtime:
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@textui/core"
}
}
Then <Row gap={1}/> produces { component: 'Row', gap: 1 }. Lowercase names are host primitives - box, text, canvas, spacer - and capitalised names are components you import, which is what gives them prop types.
import { Column, Panel, Row, Table, useStoreValue } from '@textui/core';
function Services() {
const services = useStoreValue<Service[]>('$/services/list', []) ?? [];
return (
<Column flex={1} gap={1} padding={1}>
<Panel title="Services" meta={`${services.length}`}>
<Table
columns={[
{ key: 'name', header: 'NAME', width: 18 },
{ key: 'status', header: 'STATUS', width: 10, priority: 90 },
{ key: 'cpu', header: 'CPU', width: 7, align: 'right', priority: 40 },
]}
rows={services}
/>
</Panel>
</Column>
);
}
Adding a shell
A root node fills the terminal. To get chrome - a header, a sidebar, tabs, a status bar - mount into surfaces and let a shell arrange them:
const app = createApp({
terminal,
shell: 'workbench',
onBoot: (app) => {
registerBuiltins(app);
app.open({ surface: 'header', key: 'title', target: { component: 'text', content: 'TextUI' } });
app.open({ surface: 'sidebar', key: 'nav', target: <Navigation /> });
app.open({ surface: 'main', key: 'services', target: <Services />, display: { title: 'Services' } });
app.open({ surface: 'status', key: 'hints', target: <KeyHints hints={[{ keys: 'q', label: 'quit' }]} /> });
},
});
Switch shell to 'console' or 'paper' and the same mounts render as a dense bordered console or an airy report. Nothing above changes.
Commands, not handlers
onBoot: (app: TextUIApp) => {
registerBuiltins(app);
app.commands.register({
id: 'app.quit',
title: 'Quit',
slots: ['palette', 'hints'],
run: () => void app.stop().then(() => process.exit(0)),
});
app.keybindings.register({ keys: 'q', commandId: 'app.quit' });
app.keybindings.register({ keys: 'ctrl+c', commandId: 'app.quit' });
}
The command is now reachable from the chord, from the palette, and from app.execute('app.quit') - one implementation, three ways in.
Rendering without a terminal
The same component model renders to a string, which is what makes it useful for reports, --help output and tests:
import { renderToString } from '@textui/core';
console.log(renderToString(<Services />, {
width: 80,
initialState: { '$/services/list': services },
}));
Where to go next
- Store - paths, scopes, providers
- Components - the catalog
- Themes - tokens and capability downgrade
- Testing - the harness