npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@george43g/tui-kit

v0.5.1

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.

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-ink is lazy-imported. Returns a FullScreenHandle, which is exported so you can name it.
  • ThemeProvider / useTheme / makeTheme / derivePalette — a whole palette derived from one accent color, plus safe/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 to onUnhandled.
  • useMouse — opt-in mouse reporting, with TuiMouseEvent for 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 2s setState on 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 to true.
  • useTerminalSize — current { rows, columns }, re-read on terminal resize, falling back to 24x80 when stdout is not a TTY.
  • viewportRows() and visibleWindow() — 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() and MemoryCache — bounded-list eviction and a TTL + memory-pressure cache.

Install

pnpm add @george43g/tui-kit @george43g/robustness ink react

Node.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 budgetvisualWidth(result) <= maxCols always 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 <= 0 returns "". 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":

  1. 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.
  2. 0x26000x27BF are 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 accidentx <= 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.

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 tell

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.

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")'
true

The 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.