Readiness: Implemented
Targets
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.
What you will be able to do: build a terminal application with the components and hooks you write for a page, handle the keyboard, focus, the mouse and selection, give a reader a list to choose from and a box to write in, and test it without a terminal.
What you need first: a project — Your first project — and React you already know.
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. npm/tui/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.
Wrapping, and boxes out of the line
flexWrap="wrap" starts a new line for a child that would not fit, and the box
grows to hold it; rowGap goes between the lines and alignContent places
them, starting — as Yoga's and so OpenTUI's does — at "flex-start", where
CSS would stretch them. "wrap-reverse" stacks them from the other side.
position="absolute" takes a box out of its parent's line: its siblings are
laid out as if it were not there, and it is placed against the inside of the
parent's border by top, right, bottom and left, in cells or as a
percentage. Two offsets on one axis stretch it between them. With none it goes
where the parent's justifyContent and alignItems would put it, so an
overlay is centred by the props that centre anything else. A negative offset is
allowed, which is how a badge sits on its parent's border:
<Box border={true} width={20} height={5}>
<Text position="absolute" top={-1} right={1}>
3 new
</Text>
</Box>
On a box left in the line — position="relative", the default — the same four
offsets nudge where it is drawn without moving anything around it.
position="static" is in the line too, ignores the offsets, and is not what an
absolute box under it is placed against: that is the nearest ancestor that is
not static — so a tooltip inside a row can sit against the panel the row is
in — or the screen when there is none. Percentages are of that ancestor as
well. With no offset on an axis, though, the box still goes where its own
parent's alignment would put it, and it is still drawn with its parent:
inside its parent's overflow="hidden" and in its parent's zIndex order,
which is also where the mouse finds it. And
zIndex decides which of two overlapping siblings is on top, for the mouse as
well as for the eye; siblings that do not say stack in tree order.
A ScrollBox reads neither flexWrap nor position: its children are one
column whose heights it keeps between frames, which is what makes it cost its
window, and a child out of that column would be a second answer to which rows
are showing. There is no aspectRatio either.
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
npm/tui/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,108 | 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
npm/tui/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 a window costs
The window, and not the content. Moving the offset over a hundred thousand rows
measures none of them, lays out and paints the twenty-four that are on the
screen, and never visits the rest; appending a line to that log measures the
line. The one pass that does grow with the content is the first frame, and
it has to: the height of the content is what Number.MAX_SAFE_INTEGER is
clamped against, so every row is asked its height once. After that the answer is
kept until something under that row changes.
That is counted rather than claimed. npm/tui/tui.test.js builds the
layout tree by hand so that it can count the calls layout makes into its leaves,
and asserts a hundred thousand measurements on the first frame and zero on
every scroll after it — a renderer that laid all hundred thousand rows out and
clipped them would draw the same frames and fail that. Beside it, the same suite
renders ten thousand rows through the real renderer and asserts that 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 clock agrees, which is tools/bench/tui/window.js — uf run bench:tui:window. Layout, paint and diff, at 80×24; each cell is the median of
twenty keystrokes, and the range is across three whole runs on the machine in
the provenance note above, while it was shared with other work:
| rows | first frame | one row scrolled | one line appended |
|---|---|---|---|
| 30 | 0.33 ms | 0.16 – 0.29 ms | 0.17 – 0.32 ms |
| 1,000 | 5.8 ms | 0.20 – 0.28 ms | 0.16 – 0.41 ms |
| 10,000 | 65 ms | 0.21 – 0.25 ms | 0.23 – 0.28 ms |
| 100,000 | 602 ms | 0.21 – 0.31 ms | 0.26 – 0.33 ms |
Read the last two columns as one number: at every size a frame lands in the same fifth of a millisecond, and the thirty-row band and the hundred-thousand-row band overlap. Three thousand times the content, and the frame cannot tell.
Before this was windowed the same scroll cost 0.23 ms, 3.64 ms, 59.64 ms and
491.25 ms — a straight line through the origin, which is what a ScrollBox that
measures every child on every frame looks like from the outside. The first-frame
column is still that line, deliberately, and it is the noisy one: it allocates a
row's worth of everything per row, so it is the step a garbage collection lands
in, and across the three runs the hundred-thousand-row mount ranged from 602 ms
to 1.3 s. It is reported as the fastest of three for the reason the start-up
table below gives.
The benchmark prints a second table that is not this component's, and it is
there because reading only the first one would be reading half of it. React
reconciling the tree costs about 0.2 ms at thirty rows and a few hundred at a
hundred thousand, because a hundred thousand <Text> children are a hundred
thousand fibers no matter who is scrolling them. That is the application's
decision and not something a ScrollBox can undo — it cannot render children
the caller did not create. An application with a log that long should hold its
rows as elements it does not rebuild, which is what the benchmark does and what
makes the first table the renderer's rather than Array.from's.
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.
Choosing from a list
Select is OpenTUI's select: a list of options, each a name with a
description under it, that a reader moves through and chooses from.
component Tasks() {
const [ran, setRan] = useState<string>("");
return (
<Box>
<Select
focused={true}
height={4}
options={[
{ name: "build", description: "Compile the project" },
{ name: "test", description: "Run the suite" },
{ name: "lint", description: "Check the style" },
]}
onSelect={(index, option) => setRan(option?.name ?? "")}
/>
<Text>{ran === "" ? "nothing chosen" : `ran ${ran}`}</Text>
</Box>
);
}
▶ build
Compile the project
test
Run the suite
nothing chosen
The keys are OpenTUI's: ↑ or k and ↓ or
j move, Shift with an arrow moves fastScrollStep items
(five unless you say), and Enter chooses. Moving calls
onChange(index, option) and choosing calls onSelect(index, option), which
are the names and the arguments OpenTUI's React binding uses. wrapSelection
lets the ends meet; showDescription, showSelectionIndicator,
showScrollIndicator and itemSpacing change the drawing.
Give it a height and it scrolls: the selected item is kept in the middle of the rows there are, the window stops at either end of the list, and an item that would not fit whole is not drawn. Without a height it is as tall as all its items — OpenTUI's is as tall as its style says and no taller, and a box here is as tall as its content.
selectedIndex is where it starts, and it moves on from there by itself. Pass
a different number and it goes there. That is OpenTUI's model rather than
React's controlled one, and it is what makes a list that remembers its place
across screens a one-line change.
TabSelect is the same thing sideways — OpenTUI's tab-select: a row of
tabs tabWidth cells wide (twenty unless you say), a ▬ rule under the
selected one, and its description under that. ← or [
and → or ] move, Enter chooses. It shows as
many tabs as fit, with ‹ and › when some do not, and its height is not
yours to set: it is one row for the names, one for the rule and one for the
description, less whichever of those two is turned off.
The selected item is drawn in OpenTUI's colours — a slate background, a yellow name — and everything else in the terminal's own, where OpenTUI draws white on near-black. A hard-coded white is invisible on a light terminal. Each colour is a prop under OpenTUI's name if you want something else.
Not here: OpenTUI's font, which draws the names in ASCII-art letters
(AsciiFont is not implemented), and keyBindings, which rebinds the keys.
Several lines of text
Textarea is OpenTUI's textarea: an editor for text with newlines in it.
Enter is a newline, so submitting is Meta+Enter.
<Textarea
focused={true}
height={6}
placeholder="What changed?"
onContentChange={setMessage}
onSubmit={(text) => commit(text)}
/>
Lines wrap at a word by default — wrapMode also takes "char" and "none",
and with "none" the box scrolls sideways instead. With a height it scrolls to
keep the cursor in view, and moves no further than it has to.
The keys are OpenTUI's defaults. The arrows move by character and by row, and a row is a row as drawn, so ↓ inside a wrapped paragraph goes to its next row rather than to the next paragraph. Home and End go to the ends of the whole text; Ctrl+A and Ctrl+E to the ends of the line, and Meta+A and Meta+E to the ends of the row. Meta+F and Meta+B move by word. Ctrl+W deletes the word before the cursor and Meta+D the one after it; Ctrl+K and Ctrl+U delete to the end and the start of the line; Ctrl+- undoes and Ctrl+. redoes. A paste goes in whole, newlines and all, without the escape sequences a copy out of another terminal brings with it.
Some of those need a terminal that can say them. Without the Kitty keyboard protocol, Ctrl+Backspace arrives as Backspace, Ctrl+Shift+D (delete the line) does not arrive at all, and Ctrl+- is a control character nothing can tell from Ctrl+_.
It is controlled when you pass value and uncontrolled otherwise, as Input
is. OpenTUI's is always uncontrolled and is read through a ref, which this
package does not hand out — so onContentChange and onSubmit carry the text
instead of an empty event, and initialValue is OpenTUI's name for where it
starts.
Not here: selecting inside the text with Shift and the arrows (a
drag selects a textarea's text the way it selects any other, but an edit has no
range to replace), the Super bindings, keyBindings, and a column
the cursor remembers while it moves up and down through shorter lines.
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.
What the trade buys back is the next section: with the mouse on, this renderer draws and reports the selection itself, so the gesture a reader knows still works — over a smaller amount of text than the terminal could offer, but with the same hand.
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 — and zIndex is how a sibling asks to
be painted later.
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 reported
only by a terminal that speaks the Kitty keyboard protocol; everywhere else
KeyEvent.eventType is "press", because that is all the terminal says. 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.
Selecting text
Turning the mouse on takes the terminal's own click-and-drag selection away from the reader, so the mouse brings a replacement with it. A left press on text and a drag from it selects the cells between the two, drawn inverse, and the application reads them back:
component Output(lines: Array<string>) {
const renderer = useRenderer();
const clipboard = useClipboard();
const [yanked, setYanked] = useState<string>("");
useKeyboard((key) => {
if (key.name === "y" && renderer.hasSelection()) {
const text = renderer.getSelectedText();
if (clipboard.copy(text)) {
setYanked("copied");
} else {
setYanked(text);
}
renderer.clearSelection();
}
});
return (
<Box>
{lines.map((line) => (
<Text key={line}>{line}</Text>
))}
<Text dim={true}>{yanked}</Text>
</Box>
);
}
useClipboard() is write-only and terminal-shaped. On an interactive terminal
it sends OSC 52, with the selected string UTF-8 encoded and base64 wrapped for
the terminal emulator. On redirected output and in memory it reports
supported: false, and copy() returns false; there is no hidden fallback,
because OSC 52 has no acknowledgement and a terminal may still refuse the
request by policy after the sequence has been written. What the renderer owns
is the selection: one per renderer, because a frame has one way of showing that
a cell is selected.
A selection is two cells of the screen, not a range inside your text. That
is the same thing a terminal's own selection is, and it is why dragging works
over a ScrollBox, over clipped content, and over two Texts in a row without
any of them knowing about each other. It also has a consequence worth saying
out loud: a selection survives a re-render, and if the text under it moved, the
selection now covers what moved in. A terminal behaves the same way, and the
alternative — dropping the selection on every commit — would drop it on the
keystroke that scrolled the window somebody was selecting from. A resize does
drop it, because the emulator reflowed the screen in a way this renderer
neither performed nor can predict.
Each row of a selection runs from its first selectable cell to its last and keeps what is between them, so a row laid out as two columns comes back with the gap in it rather than as one run-on word. Blanks outside that span are the shape of the box and are not copied.
Text is selectable unless something says otherwise, which is OpenTUI's
default. selectable={false} is inherited like a colour, so one prop keeps a
whole status bar or a decorative frame out of a copy:
<Box selectable={false}>
<Text dim={true}>3 workers · 12 files · 1.2s</Text>
</Box>
selectionFg and selectionBg name the colours instead of inverting, and are
inherited the same way. Inverse is the default because SGR 7 exists on
terminals with no colour at all, and because toggling it — rather than setting
it — leaves the cell an Input draws its cursor in visible as a hole in the
highlight.
A press clears the selection, and preventDefault() is how a box keeps it.
That is the renderer's one default and therefore the only thing that method
does. A box that means its own thing by a drag — a splitter, a canvas, a slider
— suppresses it and leaves the reader's highlight alone:
<Box id="splitter" onMouseDown={(event) => event.preventDefault()} onMouseDrag={resize} />
What is not here is OpenTUI's repeated-press gestures: a second press does not
select the word under the pointer and a third does not select the line. There
is one selection behaviour rather than three, so there is no behavior to
report, and that is #314 as
well.
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 —
TERM=dumb, or a locale that is not UTF-8. 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.NO_COLORis not one of them. It asks for no colour, and the convention at no-color.org is about ANSI colour rather than characters — a UTF-8 terminal whose owner asked for plain output can still draw├─, and giving it+-instead would be worse output for no reason. uf and@uniflowed/tuidisagreed about this once, in opposite directions and both on purpose;tests/library/tui.test.jsreads the rule out of both files and compares it now. -
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
npm/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
Slider and ScrollBar; the rich-content components (Code, Markdown,
LineNumbers, Diff, TextTable); AsciiFont, FrameBuffer, Image,
QrCode and EmbeddedTerminal; and OpenTUI's remaining application APIs —
notifications, audio, Timeline — are not implemented. They are
#314, and they are absent
rather than present as functions that throw: uf inspect reports seven
components because there are seven.
Two smaller gaps are on the same issue. The repeated-press gestures that widen
a text selection to a word or a line are absent, and so is aspectRatio, and
wrapping or positioning inside a ScrollBox.
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.
Where to go next
That is the end of Targets. Reference is next: every command, flag, config key and export, for when a guide has told you which one you need. The toolchain is the commands you run against all of this.
Edit this pagedocs/app/guide/tui/$page.mdx