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

@crow-agent/tui

v0.2.2

Published

Terminal UI toolkit with differential rendering and keyboard-first components.

Readme

@crow-agent/tui

Build interactive terminal applications in TypeScript: chat interfaces, dashboards, editors, and installers. One shared TUI contract drives two renderers — a scrollback-preserving main-screen renderer and a full-viewport alternate-screen renderer with app-owned scrolling. Frames are diffed and flushed inside synchronized output, so updates land atomically without flicker.

Install

npm install @crow-agent/tui

The package ships ESM only (dist/, type declarations included). Node 22 or newer is required.

Hello, terminal

import {
  Editor,
  ProcessTerminal,
  Text,
  TuiMainScreen,
  matchesKey,
  type TUI,
} from "@crow-agent/tui";

const screen: TUI = new TuiMainScreen(new ProcessTerminal());
screen.addChild(new Text("Type something, then hit Enter."));

const editor = new Editor(screen, myEditorTheme);
editor.onSubmit = (line) => {
  screen.addChild(new Text(`Heard: ${line}`));
  screen.requestRender();
};
screen.addChild(editor);
screen.setFocus(editor);

screen.addInputListener((data) => {
  // Raw mode swallows Ctrl+C, so intercept it for a clean exit.
  if (matchesKey(data, "ctrl+c")) {
    screen.stop();
    process.exit(0);
  }
});

screen.start();

Everything below builds on three ideas:

  • Components render themselves: render(width) returns one string per line.
  • The TUI owns the frame loop: add children, call requestRender(), handle input.
  • Terminals are pluggable: ProcessTerminal talks to stdio; VirtualTerminal (backed by @xterm/headless) drives tests headlessly.

Renderers

TUI is the only contract components ever see. Choose the concrete renderer once, at construction:

| Renderer | Buffer | Scrolling | Best for | | --- | --- | --- | --- | | TuiMainScreen | Normal buffer, scrollback kept | The terminal's | CLIs, installers, simple prompts | | TuiAltScreen | Alternate buffer, terminal-height viewport | The app's | Chat UIs, dashboards, full-screen apps |

import { TuiAltScreen, TuiMainScreen, type TUI } from "@crow-agent/tui";

const screen: TUI = new TuiMainScreen(terminal);
// or, for a viewport app:
// const screen: TUI = new TuiAltScreen(terminal);

screen.addChild(widget);
screen.removeChild(widget);
screen.start(); // enters raw mode, draws the first frame
screen.stop(); // restores the terminal; alt-screen prints the final document
screen.requestRender();

How frames are drawn

TuiMainScreen picks the cheapest correct strategy per update: print everything on the first frame; clear and repaint when the width changed or an edit landed above the viewport; otherwise move the cursor to the first changed line and repaint from there down.

TuiAltScreen keeps a fixed-height viewport. With no layout root it scrolls a single document; with setLayoutRoot() it manages independent regions (see Layout). Changed rows are repainted in place, streaming output follows the bottom while pinned, and a manual scroll position is preserved while content grows underneath.

Both wrap every update in synchronized output (\x1b[?2026h … \x1b[?2026l).

Components

Every component implements this contract:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
  invalidate(): void;
}

Rules that keep rendering predictable:

  • render(width) returns exactly one string per terminal line, and no line may be wider than width — the TUI raises otherwise. Reach for truncateToWidth() or wrapTextWithAnsi() to guarantee it.
  • Every rendered line gets a full SGR + OSC 8 reset appended, so styles never leak across lines. For multi-line styled text, re-apply styles per line (or wrap with wrapTextWithAnsi(), which carries them).
  • invalidate() drops cached render state. Components without a cache leave it empty — but it must exist.

The library ships these building blocks:

| Component | What it does | | --- | --- | | Text | Word-wrapped multi-line text with padding and an optional background painter | | TruncatedText | A single line, truncated to the viewport — status lines, headers | | Input | Single-line input with horizontal scrolling and word-wise editing | | Editor | Multi-line editor: autocomplete, file completion, paste markers, scrolling | | Markdown | Markdown rendering with themes and optional syntax highlighting | | Loader / CancellableLoader | Animated spinner; the cancellable one exposes an AbortSignal on Escape | | SelectList | Keyboard/mouse navigable option list with filtering | | SettingsList | Settings panel with value cycling and submenus | | MouseRegion | Adds pointer behavior to any component without changing its rendering | | Spacer | Blank lines for vertical rhythm | | Image | Inline images via Kitty or iTerm2 graphics protocols, with text fallback | | Box / Container | Padding + background (Box); plain grouping (Container) | | VStack / HStack | Constrained layout regions for the alternate-screen viewport | | ScrollView | App-owned scrolling for one layout region |

A few notes on the interactive ones:

  • Text(content, padX?, padY?, bgPaint?) — setText() swaps content, setCustomBgFn() swaps the background painter. Blank content renders nothing.
  • Input — onSubmit, setValue()/getValue(); click-to-position the cursor in alt-screen mode.
  • Editor(tui, theme, options?) — needs the TUI for height-aware scrolling. onSubmit / onChange, disableSubmit to pause submissions, setPaddingX(). Pastes longer than 10 lines collapse into a [paste #1 +50 lines] marker.
  • SelectList(items, maxVisible, theme) — onSelect, onCancel, onSelectionChange, setFilter().
  • SettingsList(items, maxVisible, theme, onChange, onCancel) — rows either cycle through values on Enter/Space or open a submenu; updateValue() pokes a row programmatically.

Default key bindings

Input and Editor share the familiar readline-style bindings:

  • Enter submit · Shift+Enter / Ctrl+Enter / Alt+Enter newline (editor)
  • Ctrl+A / Ctrl+E line start/end · Ctrl+K / Ctrl+U kill to end/start of line
  • Ctrl+W / Alt+Backspace kill word back · Alt+D / Alt+Delete kill word forward
  • Ctrl+Left / Ctrl+Right and Alt+Left / Alt+Right word hop
  • Ctrl+] jump forward to a typed character, Ctrl+Alt+] jump backward (editor)
  • Tab autocomplete, arrows/Backspace/Delete as expected

The registry is global and configurable — see Keybindings.

Layout

Layout only exists on TuiAltScreen, where the app owns scrolling. VStack and HStack carve the viewport into constrained regions; a ScrollView makes one region scrollable. (TuiMainScreen deliberately has none of this — the terminal owns its scrollback.)

import { Container, ScrollView, Text, VStack, isViewportTUI } from "@crow-agent/tui";

const transcript = new Container();
const dock = new VStack([editor, new Text("hint bar")]);

if (isViewportTUI(screen)) {
  screen.setLayoutRoot(
    new VStack([
      {
        component: new ScrollView(transcript, {
          follow: "end", // pin to the bottom while new rows stream in
          primary: true, // receives viewport keys + wheel over dead areas
          overscroll: "chain", // hand unused wheel delta to outer scrollers
        }),
        basis: 0,
        grow: 1,
        minSize: 1,
      },
      { component: dock, basis: "auto", shrink: 1, minSize: 1 },
    ]),
  );
}

Stack slots accept basis, grow, shrink, minSize, maxSize, and a responsive visible(termWidth, termHeight) predicate. Geometry is rebuilt per frame while stateful components and their render caches survive.

The primary scroll view gets the alternate-screen keyboard navigation (with four rows of page overlap), wheel routing under the pointer, and jumps between OSC 133 semantic prompt markers. Its scrollbar supports hover expansion, thumb dragging, and track-click jumps. Ctrl+Shift+F toggles a bordered search panel over the transcript with clickable arrows, match highlighting styles, and Escape to close. A scrollToEndIndicator label can sit centered on the last row while a follow: "end" view is scrolled up — clicking it resumes following.

Overlays

Overlays float a component above the current content — dialogs, menus, popups — without replacing it.

const handle = screen.showOverlay(dialog); // centered, max 80 columns

const handle2 = screen.showOverlay(dialog, {
  width: 60, // fixed columns…
  // width: "80%", // …or a share of the terminal
  minWidth: 40,
  maxHeight: "50%",
  anchor: "bottom-right", // default "center"; see values below
  offsetX: 2,
  offsetY: -1,
  // row: "25%", col: "50%", // percentage positioning…
  // row: 5, col: 10, // …or absolute positioning
  margin: { top: 1, right: 2, bottom: 1, left: 2 },
  visible: (w, h) => w >= 100, // re-evaluated every frame
  nonCapturing: true, // show without stealing focus
});

Anchor values: center, top-left, top-right, bottom-left, bottom-right, top-center, bottom-center, left-center, right-center. Resolution order: minWidth floors the computed width; absolute row/col beat percentage row/col, which beat anchor; margin clamps inside the terminal; visible decides per frame whether it draws at all.

handle.hide(); // remove permanently
handle.setHidden(true); // stash temporarily; setHidden(false) brings it back
handle.focus(); // focus and raise to the visual front
handle.unfocus(); // release to the normal fallback
handle.unfocus({ target: panel }); // release to a specific component
handle.unfocus({ target: null }); // release and leave focus empty
handle.isFocused();
handle.isHidden();
handle.getBounds(); // last rendered terminal-relative bounds

screen.hideOverlay(); // dismiss the topmost overlay
screen.hasOverlay(); // any visible overlay active?

Keyboard input

Raw terminal bytes arrive at the focused component's handleInput(). Use matchesKey() with the Key helper (Kitty keyboard protocol aware) instead of hand-rolling escape-sequence parsing:

import { Key, matchesKey } from "@crow-agent/tui";

if (matchesKey(data, Key.ctrl("c"))) shutdown();
else if (matchesKey(data, Key.enter)) submit();
else if (matchesKey(data, Key.escape)) cancel();
else if (matchesKey(data, Key.up)) moveUp();

Prefer the Key.* builders for editor autocomplete (Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p"), Key.enter, Key.space, …); plain strings like "ctrl+c" work too.

Keybindings

The alternate-screen viewport actions (scroll one line / half page / page, jump to top/bottom, toggle search, …) live in a global registry:

import { getKeybindings, setKeybindings, TUI_KEYBINDINGS } from "@crow-agent/tui";

const bindings = getKeybindings();
setKeybindings({ ...bindings, scrollDown: ["ctrl+n"] });

Ctrl-modified viewport navigation is routed to the focused component first, so an Editor keeps Ctrl+N for its own use while unfocused views still scroll.

Mouse input

TuiAltScreen normalizes SGR mouse reports and hit-tests components and overlays. Each event carries component-local x/y, absolute screenX/screenY, bounds, button, modifiers, click count, and wheel delta. (TuiMainScreen never captures the mouse — the terminal owns its scrollback.)

import type { TuiMouseEvent, TuiMouseEventResult } from "@crow-agent/tui";

handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
  if (event.type === "click" && event.button === "left") {
    this.open = !this.open;
    return { handled: true };
  }
  if (event.type === "press" && event.button === "left") {
    return { handled: true, capture: true, focus: true };
  }
  if (event.type === "drag") {
    this.slideTo(event.x, event.y);
    return { handled: true, render: true };
  }
  return undefined;
}

Result flags: handled suppresses the default behavior; capture keeps drag and release routed to this component; focus takes keyboard focus. The render flag controls repainting — press/click/drag/wheel repaint by default, move/release do not. Return render: true when a hover visibly changed something, or render: false for a handled no-op; requests are coalesced and output stays differential.

Unhandled gestures fall back to the viewport defaults: wheel scrolls the nearest ScrollView (chaining leftover delta outward), primary-button drags select text, OSC 8 hyperlinks open before parent click handlers run, and unhandled right-click keeps the configured paste behavior. A click only fires when press and release complete without a drag.

MouseRegion wraps any component to add pointer behavior without touching its rendering, and Container/Box route events to children from the last frame's recorded geometry — hit-testing never rerenders children.

Text selection and clipboard

Primary-button drags select visible text; on release it is copied via OSC 52 unless TuiAltScreenOptions.copyOnSelect is false. Double-click selects a word (slash/hyphen runs coalesce), triple-click a line, and dragging past the viewport's edge auto-scrolls while extending the selection. Word and grapheme boundaries respect CJK, emoji, and combining marks. An injected copySelection handler can take over copying (and report failures with a flash message); copySelectionToClipboard() copies programmatically.

Focus and IME

Components that draw a text cursor and need IME support implement Focusable:

import { CURSOR_MARKER, type Component, type Focusable } from "@crow-agent/tui";

class Prompt implements Component, Focusable {
  focused = false; // the TUI sets this on focus changes

  render(width: number): string[] {
    const marker = this.focused ? CURSOR_MARKER : "";
    return [`> ${this.before}${marker}\x1b[7m${this.at}\x1b[27m${this.after}`];
  }

  invalidate(): void {}
}

On focus the TUI sets focused = true, scans the output for CURSOR_MARKER (a zero-width APC sequence), and parks the hardware cursor there — kept hidden unless showHardwareCursor is enabled — so IME candidate windows anchor correctly. Editor and Input already do this. A container holding an input must itself be Focusable and forward focused to the child, or CJK candidate windows land in the wrong place.

Colors and styling

Colors are values, not strings: parse, convert, and mix them before rendering.

import {
  colorToRgb,
  foregroundAnsi,
  getTerminalColorMode,
  mixColors,
  parseColor,
  rgbColor,
  styleText,
} from "@crow-agent/tui";

const accent = parseColor("oklch(70% 0.12 220)");
const surface = parseColor("#20242a");
const ink = mixColors(accent, surface, 0.2);

const label = styleText("Ready", { fg: ink, bg: surface, bold: true }, getTerminalColorMode());

A Color is an indexed ANSI color, an sRGB color, an OKLCH color, or an OKHSL color. Everything converts to sRGB, so mixColors() always works; indices 0–15 follow the user's terminal palette and are therefore approximations. styleText() chooses truecolor or 256-color sequences from the requested mode.

OKHSL deserves a callout: saturation is relative to the sRGB gamut ceiling at that hue and lightness, so every OKHSL value is in-gamut and equal saturation reads equally vivid across hues. parseColor("okhsl(250 60% 55%)") parses it, okhslColor() builds it in code, and colorToOkhsl() reads any color's channels back.

Conversions are not cached, and OKLCH colors outside the sRGB gamut are the priciest to convert. For a color used on every frame, convert once and reuse the cheap form:

const { r, g, b } = colorToRgb(mixColors(accent, surface, 0.2));
const ink = rgbColor(r, g, b);
const inkCode = foregroundAnsi(ink, getTerminalColorMode());

The theme pipeline uses the OKHSL color method: userMessageBg and dim text are derived in OKHSL space (perceptually even steps) rather than hand-picked hex triples.

Inline images

Image renders where the terminal speaks the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images, and degrades to a styled text placeholder elsewhere. PNG, JPEG, GIF, and WebP are supported; dimensions are read from the image headers.

const image = new Image(
  base64Bytes,
  "image/png",
  { fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` },
  { maxWidthCells: 60, filename: "screenshot.png" },
);

In the alternate-screen viewport, Kitty images are cropped to the visible rows as they scroll. iTerm2's protocol cannot delete or crop a placement, so alt-screen renders those as text placeholders to avoid stale images lingering over repaints; TuiMainScreen keeps rendering iTerm2 images normally.

Autocomplete

CombinedAutocompleteProvider serves slash commands and file paths to the Editor:

import { CombinedAutocompleteProvider } from "@crow-agent/tui";

editor.setAutocompleteProvider(
  new CombinedAutocompleteProvider(
    [
      { name: "help", description: "Show help" },
      { name: "clear", description: "Clear screen" },
    ],
    process.cwd(),
  ),
);

Type / for commands, Tab for path completion. It understands ~/, ./, ../, and the @ prefix (which filters to attachable files).

Utilities

import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@crow-agent/tui";

visibleWidth("\x1b[31mHello\x1b[0m"); // 5 — ANSI codes ignored
truncateToWidth("Hello World", 8); // "Hello..." — codes kept, closed properly
truncateToWidth("Hello World", 8, ""); // "Hello Wo" — no ellipsis
wrapTextWithAnsi("a fairly long line", 10); // ["a fairly", "long line"]

stripTerminalSequences() removes ANSI/OSC noise for searching or measuring, and findAltScreenSearchMatches() powers the transcript search panel.

Writing components

Keep render() honest: every line within width, styles re-applied per line, caches dropped in invalidate().

import { matchesKey, Key, truncateToWidth, type Component } from "@crow-agent/tui";

class Picker implements Component {
  private cursor = 0;
  private options = ["Alpha", "Beta", "Gamma"];
  onPick?: (index: number) => void;
  onCancel?: () => void;

  handleInput(data: string): void {
    if (matchesKey(data, Key.up)) this.cursor = Math.max(0, this.cursor - 1);
    else if (matchesKey(data, Key.down)) {
      this.cursor = Math.min(this.options.length - 1, this.cursor + 1);
    } else if (matchesKey(data, Key.enter)) this.onPick?.(this.cursor);
    else if (matchesKey(data, Key.escape)) this.onCancel?.();
  }

  render(width: number): string[] {
    return this.options.map((option, i) =>
      truncateToWidth(`${i === this.cursor ? "> " : "  "}${option}`, width),
    );
  }

  invalidate(): void {}
}

For expensive renders, memoize on width and invalidate on state changes:

class Cached implements Component {
  private text = "";
  private seenWidth?: number;
  private seenLines?: string[];

  render(width: number): string[] {
    if (this.seenLines && this.seenWidth === width) return this.seenLines;
    const lines = [truncateToWidth(this.text, width)];
    this.seenWidth = width;
    this.seenLines = lines;
    return lines;
  }

  invalidate(): void {
    this.seenWidth = undefined;
    this.seenLines = undefined;
  }
}

Terminals

Anything implementing Terminal can drive a TUI:

interface Terminal {
  start(onInput: (data: string) => void, onResize: () => void): void;
  stop(): void;
  write(data: string): void;
  get columns(): number;
  get rows(): number;
  moveBy(lines: number): void;
  hideCursor(): void;
  showCursor(): void;
  clearLine(): void;
  clearFromCursor(): void;
  clearScreen(): void;
}
  • ProcessTerminal — process.stdin/process.stdout.
  • VirtualTerminal — headless, backed by @xterm/headless; the test suite's workhorse.

Native clipboard

Clipboard access on macOS, Windows, and X11 goes through small native addons that are prebuilt into native/<platform>/prebuilds/. To rebuild them:

npm --prefix packages/tui run build:native:darwin
npm --prefix packages/tui run build:native:linux
npm --prefix packages/tui run build:native:win32

Each platform folder has its own README with toolchain details.

Debugging

# Capture the raw ANSI stream written to stdout
CROW_TUI_WRITE_LOG=/tmp/tui-ansi.log node tests/chat-simple.ts

CROW_TUI_DEBUG and CROW_TUI_DEBUG_REDRAW add progressively noisier internal logging.

Demos and development

test/chat-simple.ts is a complete chat demo (markdown messages, spinner, autocompletion); test/image-test.ts <file.png> exercises inline images. Both are manual smoke tests — run them directly:

node tests/chat-simple.ts
node tests/image-test.ts /tmp/screenshot.png

The automated suite runs on node --test:

npm --prefix packages/tui test