@george43g/tui-kit
v0.6.0
Published
Reusable Ink/React TUI building blocks: accent-driven theme system, useDevStats / useMouse / useVimKeys hooks, FullScreenInk wrapper integrated with the shutdown registry, DevStatsPanel, bounded-list eviction helper, and TTL+memory-pressure cache.
Maintainers
Readme
@george43g/tui-kit
Reusable Ink/React building blocks for terminal UIs: an accent-driven theme system, vim-style key and mouse hooks, a fullscreen renderer wired into the graceful-shutdown registry, and bounded-memory helpers.
The package includes:
renderFullScreen()— mounts an Ink tree fullscreen and registers its unmount with@george43g/robustness's shutdown registry, so the screen tears down cleanly on SIGINT/SIGTERM.fullscreen-inkis lazy-imported. Returns aFullScreenHandle, which is exported so you can name it.ThemeProvider/useTheme/makeTheme/derivePalette— a whole palette derived from one accent color, plussafe/rich glyph presets and contrast helpers (contrastRatio,relativeLuminance,tint,rotateHue).useVimKeys— j/k/gg/G/half-page navigation with a numeric count buffer, forwarding anything it doesn't handle toonUnhandled. Optional ctrl-e/y line and page up/down handlers. The keys come from@george43g/keymap.createVimKeyRouter— the same dispatch as a pure function, for an app that keeps exactly oneuseInput;vimKeyHints—HelpBarhints from the same key table.useMouse— opt-in mouse reporting, withTuiMouseEventfor the payload.useDevStats(visible)— reads watchdog state for a live diagnostics panel. Pass the panel's visibility: while hidden it stops the 2s sampling interval and rides the watchdog's 60s memory sample instead, because a 2ssetStateon a hidden panel re-renders the whole app 30x/min forever (measured at ~17-20MB/min of heap churn behind two real OOM kills). Defaults totrue.useTerminalSize— current{ rows, columns }, re-read on terminal resize, falling back to 24x80 when stdout is not a TTY.viewportRows()andvisibleWindow()— pure scroll-window arithmetic (CHROME_ROWS,MIN_VIEWPORT,VisibleWindow): terminal height in, a[start, end)slice with the cursor centred out.StatusBar,HelpBar,DevStatsPanel— presentational chrome.boundIfNeeded()andMemoryCache— bounded-list eviction and a TTL + memory-pressure cache.
Install
pnpm add @george43g/tui-kit @george43g/robustness ink reactNode.js 24 or later and ESM are required.
ink, react, and @george43g/robustness are peer dependencies — install
them alongside this package. Keeping them peers matters: React hooks break if
two copies of React end up in the tree, and @george43g/robustness holds
process-global shutdown and watchdog state that renderFullScreen and
useDevStats must share with the host application.
Basic usage
import { useState } from "react";
import { Box, Text } from "ink";
import { renderFullScreen, StatusBar, ThemeProvider, useTheme, useVimKeys } from "@george43g/tui-kit";
function App() {
const theme = useTheme();
const [row, setRow] = useState(0);
// `delta` carries the count prefix, so `5j` moves five rows.
useVimKeys({ onMove: (delta) => setRow((r) => Math.max(0, r + delta)) });
return (
<Box flexDirection="column">
<Text color={theme.palette.accent}>row {row}</Text>
<StatusBar mode="NORMAL" hint="j/k to move · q to quit" />
</Box>
);
}
const screen = await renderFullScreen(
<ThemeProvider accent="#7c5cff">
<App />
</ThemeProvider>,
);
await screen.waitUntilExit();Scrolling lists
import { useTerminalSize, viewportRows, visibleWindow } from "@george43g/tui-kit";
function List({ items, cursor }: { items: string[]; cursor: number }) {
const { rows } = useTerminalSize();
const { start, end } = visibleWindow(cursor, items.length, viewportRows(rows));
return <>{items.slice(start, end).map((item) => <Text key={item}>{item}</Text>)}</>;
}The window is always exactly min(viewport, items.length) tall — it does not
shrink as the cursor nears the end of the list. viewportRows and
visibleWindow are pure functions with no Ink or React dependency, so list
maths can be unit-tested without a renderer.
Text width and truncation
Never truncate terminal text with String#slice. It counts UTF-16 code units,
so it splits surrogate pairs — one emoji is two units — and paints a broken
glyph. It also assumes one unit is one cell, which is wrong for emoji and CJK.
import { truncateToWidth, visualWidth } from "@george43g/tui-kit";
visualWidth("🎉Hi"); // 4, not 3
truncateToWidth("🎉Hello", 5); // "🎉He…"truncateToWidth is the function you actually want; visualWidth and
clusterWidth are exposed for layout maths. Contract:
- The ellipsis counts against the budget —
visualWidth(result) <= maxColsalways holds. The alternative (appending after) overflows every truncated row by one column, which a flexbox parent then wraps or clips, and it presents as a layout bug rather than a width bug. - A string that already fits is returned unmodified, with no ellipsis.
maxCols <= 0returns"". If the ellipsis alone does not fit, as many clusters as fit are returned with no ellipsis.- Best-effort and never throws: it runs on a render path over arbitrary content.
Two properties are deliberate and should not be "corrected":
- The width model is coarse — a range check, not a UAX #11 table. It covers the failure that actually happens (emoji in user-supplied names) at a cost a render path can pay every frame.
0x2600–0x27BFare width 1, though UAX #11 calls them ambiguous. Terminals paint▶ ◀ ● ✉ ✓ ✗ ★single-cell, and following the standard here misaligns every layout drawn with them.
There is no padding counterpart on purpose — ink's flexbox handles padding.
List primitives (added in 0.5.0)
Five pure functions extracted after a design negotiation with four TUI consumers — imsg-mcp, gmail-cli-mcp, up-bank-mcp and browser-tab-mcp — all of which had written the same machinery separately. Deliberately not a component: no keys, no state ownership, no render loop.
The brief was a multi-column tree navigator. Two consumers argued against it
from opposite tree shapes and both were right, so what shipped is the layer
underneath one. docs/plans/2026-08-tui-shared-primitives.md in the
template repo has the
full record.
lineWindow — windowing by RENDERED LINES
const w = lineWindow({
itemCount: messages.length,
cursor, // -1 = follow the tail
budgetLines: bodyHeight,
heightOf: (i) => estimateRows(messages[i], width),
anchor: chooseAnchor(cursor, messages.length),
});
messages.slice(w.start, w.end);Never window by row count when rows have different heights. Row-count windowing is what produces the height-0 overpaint bug family: a yoga-shrunk box paints its text over the next row, and diagnosing it costs hexdump archaeology.
heightOf is yours because only you know how your content wraps, and it is
memoised per call — the walk revisits indices, so you do not need useCallback
to make an expensive estimator affordable.
Two contract points worth knowing: the cursor is always inside the window,
even when one item is taller than the whole budget (usedLines may then exceed
budgetLines — returning empty would clip the only thing on screen); and
cursor: -1 forces anchor: "end", because following the tail is
end-anchoring.
chooseAnchor(cursor, itemCount, nearEnd = 2) picks between the two algorithms.
They are genuinely two: near the tail you want the LAST item pinned to the
bottom edge while the cursor sits a row or two above it, and no aboveFraction
expresses that, because aboveFraction anchors the cursor.
navReduce — cursor transitions, no keys
const next = navReduce(nav, { kind: "down" }, { itemCount, pageSize, groupBoundary });Intents: up/down/pageUp/pageDown/top/bottom, digit (vim count
prefix), groupJump, set, and itemsReplaced.
itemsReplaced is the one to read twice. Item arrays are not stable and
indices are not durable — eviction collapses the middle of a long list, lazy
loading prepends to the front. Hand it the remap you already compute:
navReduce(nav, { kind: "itemsReplaced", remap: (old) => old + prepended }, ctx);The kit clamps the remap's output, so a remap pointing into a removed region
degrades to the nearest survivor rather than crashing your window — you do not
have to be defensive about your own eviction maths. And the -1 sentinel is
never remapped: following the tail is a relationship to the end of a list, not
to an index.
Count semantics: digits accumulate (count * 10 + digit); a movement consumes
the count as a repeat factor and resets it; anything else resets it.
applyRestore(policy, prev, itemCount) decides where the cursor lands when a
column's contents are replaced wholesale. It is a parameter because the
right default is contested inside a single app — a conversation pane wants
snap-end, a file tree wants restore, a log pane wants
follow-until-touched.
allocateWidths — columns, not rows
const { widths, collapsed } = allocateWidths(columns, [
{ id: "list", min: 24, preferred: 60, priority: 10, collapse: "min" },
{ id: "detail", min: 30, preferred: 40, priority: 0, collapse: "drop" },
]);Two orthogonal knobs. priority says who yields first; collapse says
what yielding means. Trying to express both with one number is how
allocators grow config objects.
The rule for choosing collapse: columns whose content is CONTEXT collapse to
a breadcrumb; columns whose content is ELABORATION drop. A detail pane whose
information is already in the selected list row should vanish rather than spend
ten columns repeating it; an ancestor column that tells you which folder you are
inside must stay visible or you lose your place. "min" is the third case — a
column that must always exist.
Mode-gated columns are yours: omit a closed column from the array. The allocator does not know about modes.
fitToWidth and splitNavChunk
fitToWidth(s, cols) truncates and pads in one call, guaranteeing
visualWidth(result) === cols exactly. See the width section above for why
exactness matters.
splitNavChunk(input, owned) fans a keystroke chunk out per character only if
every character is one you own, else returns null so you pass the chunk
through whole. Ink delivers a paste as one useInput call with the whole
string; all-or-nothing is what stops a paste driving navigation. It is the pure
half of a router — it knows nothing about modes and cannot quit your app.
Non-finite input fails closed (fixed in 0.5.1)
Every numeric parameter that feeds a loop's break condition is validated with a
positive predicate — Number.isFinite(x) && x > 0, never x <= 0.
The difference is not stylistic. Every comparison with NaN is false, so
x <= 0 silently ADMITS NaN, and once NaN reaches an accumulator every
used + next > budget is false forever — a bounded walk becomes an unbounded
one. In 0.5.0 that made lineWindow return an entire 5,000-item list when a
non-TTY render environment produced a NaN row count, costing a consumer 64MB of
retained React fiber in a memory test.
The detail worth carrying: their hand-rolled predecessor failed closed under
the same input by accident — x <= NaN is also always false, so its loops
never ran and it rendered one item. Extracting it into a shared primitive
inverted an accidental safety into a fail-open. That is a hazard of lifting in
general, not of this lift.
So: a non-finite budget yields the cursor's own row; a heightOf returning NaN
counts as 0; a non-finite allocator total yields floors only; navReduce holds
the cursor rather than teleporting it to index 0; and fitToWidth returns ""
rather than throwing RangeError from " ".repeat(Infinity).
Reported by the eqstack session within an hour of adopting 0.5.0, along with the rule above.
How to check whether YOUR app was ever exposed, contributed by the browser-tab-mcp session after auditing its own tree for this class:
a consumer that floors its root width with a positive fallback is immune to the extraction-inverts-safety class regardless of what the kit does
Terminal size enters an app at one or two places. If every downstream width is arithmetic over a root that cannot be non-finite, nothing further downstream can be — so the audit is one grep for where terminal size enters, not a pass over every call site. Two walls, either sufficient:
// kit side, 0.5.1 useTerminalSize — NaN > 0 is false, so NaN falls back
rows !== undefined && rows > 0 ? rows : FALLBACK.rows
// app side — NaN is falsy, so this holds even if the hook regressed
const usableCols = Math.max(20, (termColumns || 80) - 2);Their audit found no non-finite path and produced no code change, which is the point: the check is cheap enough to run before assuming a lift carried the old behaviour's safety with it. What it does not cover is negative-but-finite geometry — a viewport that goes negative on a terminal shorter than the chrome — which is the positive-predicate rule above, not this one.
Nerd Font detection
For TUIs offering a glyph preset that needs a patched font. Without a check the user picks "powerline" and gets blank boxes with no explanation.
import { detectNerdFont } from "@george43g/tui-kit";
const font = detectNerdFont();
if (font.detected === false) warnHard(); // fc-list confirmed none
else if (font.detected === null) warnSoftly(); // could not tellThe result is cached per process. Tests that stub the environment call
_resetDetectNerdFontCache() (exported from the package root) between cases;
it is a test seam, not a way to re-detect in production.
The three-variant result is the whole point:
| Result | Meaning |
|---|---|
| { detected: true, source: "fc-list" } | confirmed present |
| { detected: false, source: "fc-list" } | confirmed absent |
| { detected: null, source: "unavailable", reason } | could not determine |
null is never collapsed to false. Default macOS ships no fontconfig, and
those users routinely do have a patched font — so answering false fires the
"no Nerd Font" warning at exactly the people for whom it is wrong. Render a hard
warning only for a confirmed absence and a soft hint for null.
reason distinguishes "not on PATH" from "timed out" when a user reports the
warning. The result is cached per process (the probe costs ~1s); the exported
cache reset is a test seam only.
Vim keys
useVimKeys, createVimKeyRouter and vimKeyHints share one key table, the
vimNavigation preset from
@george43g/keymap, and
one matcher, so every tool gets the same vim semantics. @george43g/keymap is
an ordinary dependency (it has no runtime dependencies and no shared state), so
you do not install it yourself unless you import it to pass a custom keymap.
| Keys | Handler | Consumed when |
|---|---|---|
| j k, down, up (count: 5j) | onMove(delta) | always |
| gg | onTop() | always |
| G | onBottom() | always |
| ctrl-d, ctrl-u | onHalfPageDown(), onHalfPageUp() | always |
| ctrl-e, ctrl-y | onLineDown(count), onLineUp(count) | the handler is supplied |
| page down or ctrl-f, page up or ctrl-b | onPageDown(count), onPageUp(count) | the handler is supplied |
| anything else, or a pasted chunk | onUnhandled(input, key) | never |
The last four handlers are new and opt-in: a key whose handler you do not
pass is not consumed, so it reaches onUnhandled exactly as before. An app
that already bound ctrl-f itself keeps it. For the same reason home and end are
not bound by default. To bind them, pass the whole preset:
import { defineKeymap, vimNavigation } from "@george43g/keymap";
// Module scope: `keymap` and `ggTimeoutMs` are read on the first render only.
const keymap = defineKeymap(vimNavigation); // gg/home → top, G/end → bottom
useVimKeys({ keymap, onTop, onBottom, onMove, onLineDown: (n) => scroll(n) });keymap takes any defineKeymap result, so you can rebind a key that clashes
with your own (overrides: { lineDown: "ctrl+j" }). The hook dispatches only
the preset's ids. Keep the defaults where you can: the same key doing the same
thing in every tool is the point.
One input router: createVimKeyRouter
An app that allows exactly one useInput — a second dispatcher is how a q
typed into a text field once quit an app — calls the router from inside it.
useVimKeys is a thin wrapper over this function.
import { createVimKeyRouter } from "@george43g/tui-kit";
const vim = createVimKeyRouter(); // once, e.g. in a useRef
useInput((input, key) => {
if (mode === "edit") return editText(input, key);
// Handlers are passed per call, so they always see the current state.
if (vim(input, key, { onMove: (d) => setRow((r) => r + d), onTop, onBottom })) return;
if (input === "q") exit();
});It returns true when it consumed the key, and otherwise calls onUnhandled
(when given) and returns false. vim.getCount() reads and clears the typed
count, for a count bound to your own key (5x). vim.reset() forgets the count
and a pending g, for example on a mode change. Options: keymap,
ggTimeoutMs (default 500), and now (a clock, for tests).
Help text: vimKeyHints
<HelpBar hints={[...vimKeyHints(undefined, ["down", "up", "top", "bottom"]), { key: "q", label: "quit" }]} />vimKeyHints(keymap?, ids?, style?) renders the same table the router matches,
so the help bar cannot disagree with the keys. Pass the ids your app handles;
an unknown id throws. style is "vim" (<C-d>, the default), "plain"
(ctrl+d) or "symbols" (⌃D). The hint text is rendered output and not
covered by semver.
Behaviour kept from before @george43g/keymap
Rendered output is unchanged, and so is every key existing callers relied on. A differential test runs thousands of key sequences through the router and a copy of the previous hook and requires identical handler calls. Kept on purpose:
- A count survives
G, ctrl-d, ctrl-u and a key you handle inonUnhandled, which is what letsonUnhandledread it withgetCount(). - A lone
0is swallowed, not forwarded. - Modifiers are ignored on the old keys: ctrl-k still moves up, and
shift-down still moves down. This applies only to a key your
keymapdoes not bind exactly.
Two differences, both in input no current caller produces on purpose:
- A pending
gis cancelled by any other key except a digit. Before, it survived, sog j gmoved down and then jumped to the top. Now the secondgstarts a newgg, as in vim. - Key-release events are ignored. Only Ink's kitty keyboard mode with event types turned on sends them, and the old hook treated each one as a second press.
And one in configuration: ggTimeoutMs (like the new keymap) is read on the
first render. The old hook re-read it on every render; no known caller changes
it at runtime.
Subpath exports
@george43g/tui-kit/theme exposes the theme and color layer on its own, for
consumers that want the palette without the components.
Fixed after 0.4.0: useVimKeys lost fast keystrokes
If you are on 0.4.0 or earlier, upgrade — this one is invisible until you
look for it.
Ink delivers a fast keystroke burst or a paste as one useInput call
containing the whole string, but every comparison in the hook was
input === "j". A burst matched nothing and was dropped: measured against a
real 56-row list, jj sent as one write moved 0 rows, and ten rapid j
moved 4.
The count buffer made it worse. The digit guard was input >= "0" && input
<= "9" — a lexicographic string range, which "5j" satisfies. So a chunked
count landed in the buffer and was replayed on the next keystroke, giving
you a lone j that silently jumps 5 rows.
A chunk is now dispatched per character, but only when every character is a key
the hook owns (0-9 g G j k). Anything else still reaches onUnhandled
intact, so a pasted paragraph cannot drive motion or reach your
destructive-key handlers one character at a time.
Reported by a downstream consumer running an adversarial TUI stress pass. No API change.
Check your own useInput handlers for the same two bugs
This is not a useVimKeys bug so much as an ink bug class, and every
hand-written key router is exposed to it. The first consumer to read this fix
went looking and found both defects in their own router the same day — they had
filed the symptom ("gg ignored when both g's arrive in one chunk") as minor
polish, not realising it shared a cause with keystroke loss.
Two greps will tell you:
# 1. Single-character equality — misses every burst and paste
$ grep -n 'input === "' src/**/*.tsx
# 2. A lexicographic digit range — "5j" satisfies it
$ node -e 'console.log("5j" >= "0" && "5j" <= "9")'
trueThe fix that works: fan a chunk out only when every character is a key you
own, and pass anything else through whole. Do not fan out unconditionally — if
any single key of yours is destructive (quit, delete, send, write a file),
pasting text containing that character will fire it. One consumer had already
shipped exactly that incident: a paste containing q quit the app.
Stability
This package is pre-1.0. Minor version bumps may contain breaking changes to the public surface; patch versions will not.
License
MIT — see LICENSE.
Upgrading to 0.3.0
No API breaks. useDevStats gained an optional visible parameter (defaults
to true, matching the 0.2.x call shape) and DevStatsPanel now suspends its
2s sampling while hidden — if you rendered the panel conditionally to work
around the constant re-renders, you can pass visible and keep it mounted.
Upgrading from 0.1.x
Three renames and one behaviour fix in 0.2.0:
| Before | After | Why |
|---|---|---|
| MouseEvent | TuiMouseEvent | The DOM lib declares a global MouseEvent. Exporting ours from the barrel shadowed it for any consumer compiling with lib: ["dom"], silently turning their MouseEvent into this four-field terminal type. |
| FullScreenInkProps | (removed) | Nothing used it — renderFullScreen takes a ReactNode directly. |
| (not exported) | FullScreenHandle | It is the return type of renderFullScreen, so consumers could not write the type of a value they already held. |
brighten(hex, stops) now raises lightness relative to the input colour.
It previously set an absolute lightness derived from stops alone, discarding
the input entirely — so every colour in a palette brightened to the same value,
and anything lighter than L=0.55 came back darker. If you compensated for
that, remove the workaround.
