Writing code
Terminal UI
@uniflowed/tui is React with a terminal for a host. The same components,
hooks, state and effects you write for a page, laid out with flexbox and drawn
as cells — and a diff that sends only the cells that changed.
A whole application
// @flow
import { useState } from "@uniflowed/react";
import { Box, Text, render, useKeyboard } from "@uniflowed/tui";
component Workers() {
const [running, setRunning] = useState<number>(3);
useKeyboard((key) => {
if (key.name === "up") {
setRunning((n) => n + 1);
}
if (key.name === "down") {
setRunning((n) => Math.max(0, n - 1));
}
});
return (
<Box border={true} borderStyle="rounded" padding={1} title=" uf test " titleAlignment="center" width={28}>
<Text bold={true}>Workers</Text>
<Box flexDirection="row" justifyContent="space-between">
<Text fg="gray">running</Text>
<Text>{String(running)}</Text>
</Box>
</Box>
);
}
const app = render(<Workers />);
process.on("SIGINT", () => app.stop());
That draws this, and the arrow keys change the number:
╭──────── uf test ─────────╮
│ │
│ Workers │
│ running 3 │
│ │
╰──────────────────────────╯
The frame above is not a screenshot. tests/library/tui.test.js renders the
component on this page and asserts it comes out character for character, so a
change to the renderer that would make this picture a lie fails the build
instead.
It follows OpenTUI
OpenTUI is the terminal-UI library uf follows, and
following it means the things a reader has to know are the same:
flexDirection starts at "column" rather than "row", because a terminal is
a stack of lines; alignItems starts at "stretch"; flexShrink starts at
0 for a child whose width you gave as a number, so forty columns stays forty
columns; keys are named "return" and "escape" rather than "enter" and
"esc"; and focus is a prop rather than something the library moves for you.
What is different is underneath. OpenTUI's core is Zig, reached over an FFI boundary. uf's is Flow — the argument is at the bottom of this page.
Only the cells that changed
A terminal program is not slow because laying out boxes is slow. An 80×24 terminal is 1,920 cells and laying that out is microseconds in any language. It is slow because of the bytes handed to the terminal, which an emulator on the other side of a pipe has to parse and re-render.
So @uniflowed/tui keeps the last frame it drew, compares the next one against
it cell by cell, and writes the difference. For the application above, in the
28×6 box it occupies:
| cells sent | bytes | |
|---|---|---|
| the first frame | 168 | 239 |
| pressing the up arrow | 1 | 8 |
Eight bytes: a cursor move, and the digit. Both numbers are asserted in
tests/library/tui.test.js.
Measured against React Ink
React Ink is the library most readers are
coming from, so the useful thing to publish is a measurement rather than an
adjective. tools/bench/tui/ holds one workload written twice — the same 80×24
frame, the same state container, the same kind of stream — and four changes made
to it: the first frame, one character of a status line at the top of the frame,
the list underneath it advancing by a row, and every line changing at once.
npm install --prefix tools/bench/tui # Ink, and nothing else in uf depends on it
uf run bench:tui
Bytes written to the terminal. Deterministic: every run of the benchmark produces these exactly, and it refuses to report a number that moved.
| first frame | one character | scroll one row | everything | |
|---|---|---|---|---|
@uniflowed/tui | 2,102 | 8 | 345 | 534 |
| ink 7.1.1, default | 1,122 | 1,307 | 1,307 | 1,328 |
ink 7.1.1, incrementalRendering | 1,122 | 128 | 1,224 | 1,283 |
Three things in that table are worth saying out loud.
uf loses the first frame, and for a reason that is not going away. Ink writes the frame as text and newlines; uf writes cells, each run preceded by an absolute cursor position, and pads every row to the full width of the terminal. On an empty screen that is nearly twice the bytes. It is the cost of the addressing that makes every later frame cheap, and a renderer that draws one frame and exits should not be this one.
Ink 7 is much better than "reprints from the change to the bottom", if you ask
it to be. incrementalRendering is off by default; turning it on takes one
character of a status line from 1,307 bytes to 128 — Ink moves the cursor up and
rewrites the changed line. uf sends the changed cell, which is 8 bytes, so
the gap is sixteen times rather than a hundred and sixty. Measuring only Ink's
default would have been choosing the comparison uf wins by more.
The advantage is smallest exactly where it matters least. When everything changes, cells and lines cost the same order of bytes, and uf's 534 against Ink's 1,283 is the terminal's own colour resets and cursor moves rather than an algorithmic difference.
Wall clock, from a state update to the write returning. Medians of 400 samples per step, with the fastest and slowest of them:
| first frame | one character | scroll one row | everything | |
|---|---|---|---|---|
@uniflowed/tui | 2.9 ms (1.6 – 27.8) | 2.8 ms (1.4 – 45.5) | 3.0 ms (1.6 – 38.2) | 3.0 ms (1.5 – 17.4) |
| ink, default | 3.9 ms (2.1 – 67.0) | 3.2 ms (1.5 – 95.9) | 3.3 ms (1.6 – 134.2) | 3.3 ms (1.6 – 78.6) |
ink, incrementalRendering | 3.9 ms (2.0 – 98.6) | 3.4 ms (1.5 – 48.5) | 3.5 ms (1.5 – 72.5) | 3.4 ms (1.6 – 132.8) |
This second table does not separate the three. uf's median came out below Ink's in every invocation, by three tenths of a millisecond to about one — but running the whole benchmark again on the same machine moved every median by more than that, between 1.5 ms and 3.9 ms, and the machine is one other work also runs on. What both libraries are actually waiting for at this size is React's scheduler, which they share. The byte counts are the measurement; this table is here because leaving it out would be choosing which of two numbers to publish after seeing both.
Provenance. Apple M2 Max, 12 cores, 96 GB, macOS 26.5.1. Node 24.19.0,
react 19.2.8, react-reconciler 0.33.0, ink 7.1.1, @uniflowed/tui at this
commit. Warm caches; each library is warmed up for three runs that are thrown
away, and every sample mounts a fresh application. The three configurations are
measured in one process, one after another, so a busy machine moves all three
together rather than one of them. Ink's frame limiter is raised
out of the way (maxFps), so its wall-clock figure is its renderer's rather than
its throttle's; that does not touch its byte counts. Input is out of scope — Ink
has no equivalent of Input to compare against.
uf's four byte counts are asserted against the renderer in
tests/library/tui.test.js, so this half of the table cannot go stale without a
test failing. Ink's are a recorded measurement: reproduce them with the command
above.
More than fits on the screen
ScrollBox is a window onto content taller than itself. Give it a height —
explicitly, or with flexGrow inside a parent that has one — and a row offset:
component Log(lines: $ReadOnlyArray<string>) {
const [top, setTop] = useState<number>(Number.MAX_SAFE_INTEGER);
useKeyboard((key) => {
if (key.name === "up") {
setTop((row) => Math.max(0, row - 1));
}
if (key.name === "down") {
setTop((row) => row + 1);
}
if (key.name === "end") {
setTop(Number.MAX_SAFE_INTEGER);
}
});
return (
<ScrollBox height={10} scrollTop={top}>
{lines.map((line, index) => (
<Text key={String(index)} wrap="none">
{line}
</Text>
))}
</ScrollBox>
);
}
A ScrollBox between a header and a footer wants flexGrow={1} on it and
flexShrink={0} on them. That is flexbox rather than this component: a
scrolling box asks for its whole content's height, so a header that is allowed
to shrink will — exactly as it would in CSS.
Number.MAX_SAFE_INTEGER is not a trick. Layout clamps the offset to the range
the content actually has, so the largest number there is means "the bottom" —
which is what a log that has just grown by a line needs, and it needs it without
having to know how long the log is.
The offset is the caller's, and no key is bound to it. That is the same rule focus follows, one level down: what an arrow key should do inside a scrolling region is the application's question — a log follows its tail, a file viewer does not, a list moves a selection and lets the box follow that. A component that owned the offset would also have to own "how far is a page", which is the viewport's height, which nothing knows until layout has run.
What it costs. Every child is measured, because the total height is what the
offset is clamped against. Only the children that intersect the window are laid
out, walked into, or painted — so ten thousand rows in a twenty-four row terminal
is one measure per row and a screenful of everything else. tests/library/tui.test.js
renders exactly that and asserts the first frame costs the terminal's 1,920 cells
rather than the log's, and that changing one visible row afterwards costs one.
The wheel arrives as onMouseScroll and moves nothing on its own, which is
the same rule one paragraph up: how far a notch goes is a question about the
content, and a component that answered it would answer it for a log, a form and
a picture alike.
<ScrollBox
height={10}
scrollTop={top}
onMouseScroll={(event) => {
setTop((row) => Math.max(0, row + (event.scroll?.direction === "up" ? -3 : 3)));
}}
>
There is no horizontal scrolling: content wider than a terminal is nearly always content that should have wrapped.
Keyboard, and what has focus
useKeyboard sees every key before anything else does, in the order the
components registered — that is where a global quit key goes. A Box that is
focusable and focused gets what is left, through onKeyDown.
The two ways to interrupt that are separate on purpose, and they are OpenTUI's:
key.stopPropagation()silences the handlers registered after this one and the focused box.key.preventDefault()lets the other global handlers run, and skips the focused box.
Focus itself is state. There is no automatic Tab traversal, here or in OpenTUI, because what Tab should do is the application's question — in a form it moves to the next field, in an editor it inserts a tab.
component Login() {
const [field, setField] = useState<"user" | "password">("user");
useKeyboard((key) => {
if (key.name === "tab") {
setField((current) => (current === "user" ? "password" : "user"));
}
});
return (
<Box>
<Input focused={field === "user"} placeholder="username" />
<Input focused={field === "password"} placeholder="password" />
</Box>
);
}
Pasting is not typing
A terminal delivers a paste as very fast typing, which is how pasting two lines
into a prompt runs the first one: the \r between them arrives as Enter.
@uniflowed/tui turns on the terminal's bracketed-paste mode, so pasted text
comes back wrapped and arrives as a single event:
useKeyboard((key) => {
if (key.name === "paste") {
setBuffer((text) => text + key.sequence);
}
});
key.sequence is the clipboard, verbatim and therefore untrusted: it can hold
newlines, control bytes and escape sequences of its own, and handing it over as
text rather than as keys is the whole point. Input takes the first line of one
and drops the control characters, because it is one line and cannot hold either.
A clipboard is as long as it is, and a large one arrives in whatever pieces the operating system's reads happen to make — including a piece that ends in the middle of a word, or part-way through the marker. The decoder holds the partial one back and delivers the paste once, whole.
One split is deliberately not held, and it is the first one. A piece that ends
on a lone ESC, with none of the marker after it, is delivered as the Escape
key — because that is what it usually is. ESC begins every escape sequence and
is also a key on the keyboard, nothing here has a timer to end a wait with, and
a decoder that held it would give you an Escape key that does nothing until you
press something else. From ESC[ on there is nothing else it could be, so that
is where the hold begins.
The mode is turned off again when the application stops: a terminal left in it hands the shell brackets around every paste.
The mouse, and what it is under
Mouse reporting is off until an application asks for it:
const app = render(<Board />, { mouse: true });
That is the one place this package deliberately differs from OpenTUI, whose
renderer turns the mouse on by default, and the reason is the reader rather
than the program. A terminal that is reporting the mouse stops handling
click-and-drag itself, so selecting a line to copy out of the window needs a
modifier key nobody mentioned. An application that handles the mouse is trading
that away on purpose; one that ignores it should not have it traded away on its
behalf. testRender turns it on, because there is no terminal there to damage.
With it on, every Box takes OpenTUI's handlers — onMouseDown, onMouseUp,
onMouseMove, onMouseDrag, onMouseDragEnd, onMouseDrop, onMouseOver,
onMouseOut, onMouseScroll, and onMouse for all of them:
component Cell(id: string, onPick: (string) => void) {
const [hot, setHot] = useState<boolean>(false);
return (
<Box
id={id}
width={9}
height={3}
border={true}
borderColor={hot ? "cyan" : "gray"}
onMouseOver={() => setHot(true)}
onMouseOut={() => setHot(false)}
onMouseDown={() => onPick(id)}
/>
);
}
An event goes to the box under the pointer and then to that box's parents.
event.target is the box the pointer is over and event.currentTarget is the
box whose handler is running, so a panel can handle a click anywhere inside it
without every child forwarding one, and event.stopPropagation() is how a
child keeps one to itself. There is no capture phase: OpenTUI documents one direction, and
a phase nothing can register for would be a field in an event rather than a
feature.
"Under the pointer" means what the reader can see. The hit test is a grid
the painter fills as it draws, not a walk over the tree asking whose rectangle
contains the point — those differ wherever overflow: "hidden" cut a child
off, and inside a ScrollBox, where a row that has been scrolled out still has
last frame's geometry on it. A box painted later is the box on top, because
that is what the reader is looking at. There is no zIndex to say otherwise.
A drag belongs to the box it started on. A left press remembers that box,
and every motion after it is delivered there rather than to whatever the
pointer has since moved over — without that, dragging a slider's handle stops
working the instant the pointer outruns it, which is every drag. It is also
the one case where event.target and event.currentTarget are not on the same
path: the handler runs on the source, while target keeps naming what the
pointer has moved over, which is what tells a drag what it would drop onto. The
release then produces drag-end and up at the box the drag started on, and
drop at whatever it ended over, carrying event.source:
<Box id="done" onMouseDrop={(event) => finish(event.source)} />
event.source is the id of the box that was dragged, which is why boxes a
drop has to be able to name take one. Ids are what the events carry, rather
than the nodes themselves: a node is the renderer's, it is replaced on a
commit, and an application holding one across a frame would be reading geometry
that is no longer true.
A press that never moved is a click, and produces one up and no drop.
Three of the terminal's own limits show through and are worth knowing before
they surprise you. A hover needs the terminal to report motion with no button
held, which is a report for every cell the pointer crosses — that is the cost
of over and out working before a reader has clicked anything, and it is
paid only by applications that asked for the mouse. Key release is not
reported at all: KeyEvent.eventType is always "press", because release
needs the Kitty keyboard protocol
(#314). And a terminal too
old for SGR-encoded reports gets no mouse rather than a wrong one — its
encoding cannot express a column past 223, and this decoder recognises those
reports only to discard them, so a click on such a terminal does nothing
instead of typing three characters into whatever has focus.
Terminals that cannot do what yours can
Capability is resolved once, before the first frame, from the same inputs and
in the same precedence order the uf CLI uses — --color, then NO_COLOR,
FORCE_COLOR, CLICOLOR_FORCE, TERM=dumb, CLICOLOR, whether the stream is
a terminal, and finally COLORTERM and TERM. Sharing that order is the point:
a project whose CLI and whose TUI disagree about this terminal is a project
that gets sent a screenshot of one of them being wrong.
- No truecolour. A 24-bit colour is downgraded to the 256-colour cube, and then to the sixteen base colours, rather than dropped. One theme, everywhere.
- No colour at all —
NO_COLOR,TERM=dumb, or a redirected stream. No escape sequences are written, including bold and underline, because they are the same mechanism. The layout, the borders and the text still arrive. - No Unicode. Box borders are drawn with
+,-and|. Every glyph in the ASCII vocabulary is one column wide, exactly like the one it replaces, so a box does not change size when its characters do. - Nobody watching. A stream that is not a terminal gets no cursor
addressing and no incremental updates at all — an editor's worth of
ESC[12;40Hin a CI log helps nobody. The final frame is written once, as plain lines, when the application stops.
How big the terminal is comes from the same two sides. COLUMNS and LINES
are POSIX's override and are what a watch, a script or a CI wrapper sets, so
they are asked first — each on its own, because a wrapper that cares about width
sets one of them — and what the stream itself reports comes second. uf's own
CLI resolves it the same way and the same test compares the two chains, which is
the point again: uf assumed 72 columns for its live regions until this was
written, and on a narrower terminal every row wrapped and every redraw walked
down the screen.
Text is measured in grapheme clusters and terminal columns, not in
String.length. 界 occupies two cells, a family emoji occupies two cells and
a dozen code points, and a combining mark occupies none — so a table of
Japanese paths lines up. The width tables are the CLI's own, and a test asserts
the two copies are identical data.
Testing without a terminal
testRender mounts the same tree through the same reconciler as render; the
only difference is where the frames go and who presses the keys.
import { testRender } from "@uniflowed/tui";
const app = testRender(<Workers />, { width: 28, height: 6 });
app.press("\u001b[A"); // the bytes a terminal sends for the up arrow
expect(app.text()).toContain("running 4");
Keys arrive as the bytes a terminal actually sends, so the escape-sequence
decoder is exercised by every input test rather than bypassed by one that
hands the renderer a { name: "up" } somebody typed.
Why this is JavaScript and not Rust
uf's rule is that anything which can become a bottleneck belongs in Rust, and
the declaration this package replaced promised exactly that: a native engine
and a native cell diff. It is Flow instead, and the reasons are in
packages/tui/index.js beside the code, in short:
The work is bounded by a terminal. It is not repository-wide, it does not scale with the size of a project, and it happens at the rate somebody presses keys. What is expensive is the write, and writing less is an algorithm rather than a language.
A native renderer also means prebuilt binaries for every platform uf supports
and an npm install that can fail in ways a Flow package cannot — and, more
immediately, uf has no JavaScript-to-native bridge at all today. Choosing Rust
would have meant building that first and shipping the renderer second, and in
the meantime @uniflowed/tui would still have been a contract with nothing
behind it, which is the thing this was fixing.
What is not here yet
Text selection, key release, images, the rich-content components (Code,
Markdown, Diff, TextTable) and OpenTUI's application APIs — clipboard,
notifications, audio, animation — are not implemented. They are
#314, and they are absent
rather than present as functions that throw: uf inspect reports four
components because there are four.
Selection is the one to expect next, and it is the mouse's other half: drag
over selectable text, one selection per renderer, getSelectedText(). It is
also where preventDefault() on a mouse event starts to mean something —
there is no such method today, because nothing this renderer does to a mouse
event by default could be prevented.
Nor does uf's own CLI draw through this yet. uf is a Rust binary and this is
JavaScript, and the two cannot meet without either a native bridge uf does not
have or a way to run a Flow entry point as a command —
#316 is that gap and what
would close it. That issue asks for one number before anything is committed to —
"a watch-mode UI that costs a Node start-up on every run is worse than the Rust
one it replaces" — and tools/bench/tui/startup.js now measures it:
| start to a drawn frame | |
|---|---|
uf info, a rendered block from the Rust binary | 8 ms |
node -e 0, Node with nothing in it | 58 ms |
| a Flow entry point that draws one frame, warm transform cache | 149 ms |
| the same, cold cache | 383 ms |
Fastest of forty runs each, on the machine above while it was shared with other
work; /usr/bin/true costs 3 ms through the same harness, so every row includes
that much process spawn. The minimum is the statistic because contention can
only make a start-up slower.
That settles half the question and leaves the interesting half open. A banner
cannot be written this way — the start-up alone is eighteen times the whole of
uf info, and most of it is Node rather than anything uf could make faster. A
command that already pays for Node and then runs for minutes can: uf test --watch starts worker processes before it draws anything, so a Flow front end
would add a sixth of a second once, at the start of a session that lasts as long
as somebody is editing.