Documentation

HQTUI is a rendering library for building terminal applications in TypeScript. It owns the terminal directly — ANSI sequences, a typed-array framebuffer, differential rendering, Braille graphics and truecolor — with no ncurses, no browser DOM, no React, and no native addon.

Install

bun add @profullstack/hqtui   # Bun is the default runtime
npm  add @profullstack/hqtui   # Node 22.6+ works unchanged

There is also a CLI: hqtui doctor reports what your terminal actually supports, and hqtui opens a built-in showcase.

Your first app

Everything has a default. createApp() sets up the dark theme, truecolor with automatic 256/16-colour fallback, mouse tracking, the alternate screen, resize handling and adaptive frame pacing — and it restores your terminal on Ctrl+C, SIGTERM, or an uncaught exception.

hello.ts
import { createApp } from "@profullstack/hqtui";

const app = await createApp();

app.render(({ ui }) => {
  ui.panel({ title: "Hello" }, (panel) => {
    panel.text("Hello, terminal.");
    panel.label("Press q to quit.");
  });
});

await app.start();
A hello world panel rendered by HQTUI

The render callback runs on every frame. Keep it pure: read your state, describe the screen, and let the renderer work out what actually changed. Call app.invalidate() when your data changes and the scheduler coalesces repeated calls into one frame.

Layout

Containers collect their children first and solve the layout once, which is why "1fr" works without a retained tree. Sizes may be a number of cells, a percentage, a fraction, auto, or fill, each with optional min and max.

ui.grid({ columns: ["2fr", "1fr"], rows: [14, "1fr"], gap: 1 }, (grid) => {
  grid.panel({ title: "CPU" });
  grid.panel({ title: "Memory" });
  grid.panel({ title: "Processes", colSpan: 2 });
});

ui.row({ gap: 1 }, (row) => {
  row.panel({ width: 30 });          // fixed
  row.panel({ width: "40%" });       // percentage
  row.panel({ width: "2fr", min: 20 }); // fraction with a floor
});

Responsive layouts pick a branch by the width actually available, so the same view works in a 60-column pane and a 240-column window.

ui.responsive({
  150: (wide) => wide.row({ gap: 1 }, (r) => { /* four columns */ }),
  100: (medium) => medium.row({ gap: 1 }, (r) => { /* three columns */ }),
  0: (compact) => compact.column({}, (c) => { /* stacked */ }),
});

Widgets

Every widget is a method on the container, sized by the same layout engine and themed by the same tokens. Panels, tables, trees, log viewers, key/value lists, meters, gauges, donuts, progress bars, sparklines, line and area graphs, histograms, heat bars, tabs, status bars, buttons, checkboxes, toggles, radios, selects, text inputs, modals, command palettes, tooltips, badges and dividers.

widgets
The HQTUI widget catalogue
p.table({
  rows: processes,
  selected: 3,
  offset,
  scrollbar: true,
  zebra: true,
  columns: [
    { key: "pid", title: "PID", width: 7, align: "right" },
    { key: "name", title: "Name", color: theme.primary },
    { key: "cpu", title: "CPU%", width: 6, align: "right",
      color: (row) => heatColor(theme, row.cpu / 100) },
  ],
});

Graphics

Unicode Braille gives every cell a 2×4 pixel matrix, so a 40×10 panel plots at 80×40 resolution. When the terminal cannot render Braille, the same call degrades to block elements and then to ASCII.

p.graph({ values: cpu, min: 0, max: 100, fill: true });        // braille
p.graph({ values: cpu, mode: "block", colors: theme.heat });   // block elements
p.graph({ values: cpu, mode: "ascii" });                       // last resort

p.multiGraph([
  { values: read,  color: theme.success, label: "read" },
  { values: write, color: theme.secondary, label: "write" },
], { legend: true, axis: true });

Graphs scale to the window that is actually drawn, not the whole history buffer, so an old spike never flattens the live line.

Themes

Nine themes ship in the box and the dark one is the default. A theme is a flat set of tokens; override any of them with defineTheme().

import { createApp, themes, defineTheme, hex } from "@profullstack/hqtui";

const brand = defineTheme({
  name: "brand",
  primary: hex("#7c5cff"),
  success: hex("#22d3a5"),
  graph: [hex("#7c5cff"), hex("#22d3a5"), hex("#ffb020")],
});

const app = await createApp({ theme: brand });
app.setTheme(themes.nord); // switch at runtime

Input

Keys arrive normalized — "ctrl+c", "up", "f5", "shift+tab" — never as escape sequences. Mouse press, release, drag, move and scroll are decoded from SGR reporting, bracketed paste arrives as one event, and Tab traversal works without wiring anything up.

app.on("key", (event) => {
  if (event.key === "ctrl+k") openPalette();
  if (event.name === "down") selected++;
});

app.on("mouse", (event) => {
  if (event.action === "scroll") offset += event.scroll;
});

// Controls that take an action join the Tab order automatically.
p.button({ label: "Restart", onPress: () => restart() });

Testing

The headless renderer draws into an in-memory framebuffer with no TTY, no PTY and no escape sequences, then gives you the text, the ANSI, the HTML, or the raw cell grid with per-cell colours and attributes.

dashboard.test.ts
import { renderToScreen, renderToText } from "@profullstack/hqtui";

const screen = renderToScreen(({ ui }) => dashboard(ui, state), {
  width: 120,
  height: 40,
});

expect(screen.contains("CPU")).toBe(true);
expect(screen.find("bun")).toEqual({ x: 10, y: 4 });
expect(screen.cell(0, 4).bg).toBe(theme.selection);
expect(renderToText(view, { width: 40, height: 10 })).toMatchSnapshot();

Every frame on this website is produced by renderToHtml() at build time — the same renderer, the same output, just emitted as HTML instead of ANSI.

Escape hatches

Nothing is off limits. Draw straight onto the surface you were given, or take a Braille canvas and blit it yourself.

p.draw((surface) => {
  surface.text(0, 0, "raw access", { fg: theme.accent });
  surface.fillRect(0, 1, surface.width, 1, { bg: theme.selection });
});

p.canvas((canvas) => {
  canvas.circle(canvas.width / 2, canvas.height / 2, 12);
  canvas.line(0, 0, canvas.width, canvas.height);
});

Performance

The screen is four typed arrays; nothing allocates per cell in a hot path. Frames are diffed and only changed runs are written, merged across short clean gaps because rewriting five cells costs less than the escape sequence to skip them. A terminal pen-state cache means no redundant SGR is ever emitted.

# 160x50 (8,000 cells), bun 1.4, linux x64
renderer.frame.unchanged     0.068ms      no output
renderer.frame.1pct          0.140ms      627 bytes/frame
renderer.frame.10pct         0.291ms      2,639 bytes/frame
renderer.frame.100pct        1.409ms      8,341 bytes/frame
widgets.dashboard            0.425ms      6 panels

Compatibility

Tier 1: Linux TTY, SSH, tmux, Kitty, WezTerm, Ghostty, Alacritty, GNOME Terminal, Konsole, macOS Terminal, iTerm2 and Windows Terminal. Capability detection covers truecolor, Unicode, Braille, mouse, synchronized output, bracketed paste and focus events, and every one can be overridden by option or environment variable.

NO_COLOR is honoured, colours quantize automatically to 256 or 16, Braille falls back to blocks and then ASCII, and frame rate drops to 15 fps over SSH.

Full API reference in the repository, and the original product requirements are in docs/PRD.md. Questions and bugs: open an issue.

← Back home