DEV Community

Cover image for A live-preview terminal UI in plain Node.js, with zero dependencies
Wassim AZI
Wassim AZI

Posted on

A live-preview terminal UI in plain Node.js, with zero dependencies

In Google's Antigravity CLI (agy), the /statusline command turns the status bar on or off, and that's it. People expect more: a way to change what the bar shows without writing a custom shell script. Configuring a statusline by hand usually means wrestling with an undocumented JSON schema. You edit a property, save, switch windows, inspect the bar, and go back to edit again.

When building agy-powerline, I wanted the terminal customization experience to feel like Powerlevel10k for Google Antigravity: one command that opens an interactive configurator with a live preview of your bar, where you browse presets, toggle widgets, and audition themes with arrow keys, with the preview following every change.

The configure editor: widgets, presets, themes and layouts, with the live preview following the cursor

The project has a hard rule: zero runtime dependencies. No Ink, no blessed, no chalk. Partly for supply-chain reasons (people run it with npx, and it touches their dev configuration), partly because every module it doesn't load is start-up time it doesn't pay. So the interactive TUI is built on Node's standard library and a handful of raw ANSI escape codes. Here's how it works, and what I'd do the same way again.

The primitives: raw mode, keypresses, the alternate screen

Everything starts with four calls:

const readline = require("readline");

readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on("keypress", onKey);
Enter fullscreen mode Exit fullscreen mode

emitKeypressEvents parses the raw bytes into events like { name: "up" } or { name: "c", ctrl: true }, so arrow keys, Enter, Escape and letters all arrive in one handler.

Then the editor switches to the terminal's alternate screen and hides the cursor, so it doesn't scroll away your shell history:

const CSI = "\x1b[";
process.stdout.write(`${CSI}?1049h${CSI}?25l`); // alternate screen, hide cursor
Enter fullscreen mode Exit fullscreen mode

The most important part is the way out. In raw mode, Ctrl+C is just a key, so the key handler treats it as quit. SIGINT and SIGTERM get the same handler. Quitting always does the same three things: save, leave raw mode, and restore the screen.

function quit() {
  if (exiting) return;
  exiting = true;
  persist();
  process.stdin.setRawMode?.(false);
  process.stdout.write(`${CSI}?25h${CSI}?1049l`); // show cursor, main screen
  process.exit(0);
}
Enter fullscreen mode Exit fullscreen mode

A TUI that leaves your terminal in raw mode with no cursor is a TUI people uninstall.

Redraw everything, every time

There's no diffing and no virtual DOM. On every keypress, the editor builds the whole frame as an array of strings, moves the cursor home, clears to the end of the screen, and writes the frame:

function draw() {
  process.stdout.write(`${CSI}H${CSI}J` + frameLines().join("\n") + "\n");
}

process.stdout.on("resize", draw);
Enter fullscreen mode Exit fullscreen mode

For an editor that only redraws when you press a key, this is fast enough, and it's very simple to reason about: there's no previous state to get out of sync with. If it ever becomes a problem, writing only the lines that changed would be the next step.

The frame is a pure function, so it's testable

Because draw() only writes what frameLines() returns, the interesting part is a function of state and window size. The module exports a renderFrame() for tests that sets the state, pins the size and the color depth, and returns the frame as a string:

const frame = renderFrame({
  config: defaultConfig(),
  screen: "layout",
  cursor: 1,
  columns: 200,
});
Enter fullscreen mode Exit fullscreen mode

That makes a terminal UI testable like any other output. One test, for example, checks that each layout's description in the details panel is exactly three lines under 90 columns, separated by blank lines. Another draws every screen at seven window sizes, from 60×16 to 220×50, in Unicode and ASCII mode, and checks that no frame is wider or taller than its window. No pseudo-terminal, no screenshots, no timing.

The preview is the real renderer

The live preview isn't a mock of the bar. It calls the same renderStatusline() function the installed statusline runs on every refresh in agy, with sample session data and a terminal width set to the preview panel's width:

previewData.terminal_width = width - 4;
const rendered = renderStatusline(previewData, previewConfig(), { color: getActiveDepth() });
Enter fullscreen mode Exit fullscreen mode

So the preview fits, drops widgets and cuts lines exactly the way the real bar will in a terminal that wide. If it looks right in the editor, it looks right in agy.

With no dependencies, the same modules also run in a browser. The web configurator bundles the runtime's own CommonJS modules into the page, so its preview comes from the same renderer as the bar in your terminal.

Hovering is a preview, not a change. Moving the cursor over a theme, a preset or a layout redraws the preview with it, and the preview's title says so (preview, enter to apply). Enter applies it; moving away leaves your setup as it was.

One safety detail: the bar has an exec widget that runs a shell command you configure. The preview never runs it, and shows [cmd] instead. Scrolling through your config shouldn't execute anything.

Responsive, in a terminal

A terminal can be 80 columns or 200, and 20 rows or 60. The editor changes shape at two widths:

  • From 80 columns, the descriptions line up in their own column.
  • From 140 columns, the list gets a details panel next to it: the selected widget drawn on its own with its options, a preset's rows, or a theme's colors.

Height is handled the other way around. When the window is short, the editor gives things up one step at a time: first the tagline, then the blank lines between panels, then the notes, then the preview's toggles. That way the list keeps at least six rows. A list longer than its panel scrolls, and the scroll position is kept between frames, so the list only moves when the cursor leaves the visible part.

Counting columns is harder than it looks

Every box, panel and padding depends on one function: how many columns a string takes on screen. string.length is wrong in several ways:

  • Escape sequences (colors, hyperlinks) take no columns, so strip them first.
  • Most emoji and CJK characters take two.
  • Combining marks, zero-width joiners, variation selectors and skin-tone modifiers take none.

And terminals don't always agree. When I recorded the demo GIF with VHS, one emoji (🪙) was drawn one column wide, while the width count says two. Everything after it on that row moved over by one column. There's no perfect answer: you pick the width most terminals use and accept the odd exception.

Colors for every terminal

The editor and the bar detect the color depth: truecolor, 256 colors, or none (NO_COLOR is respected). In 256-color mode each color maps to the nearest one in the terminal's palette. Getting that right needed care: an early version turned near-black text into navy blue. There's also an ASCII mode for terminals without a Nerd Font, and a test checks that every screen draws in plain ASCII when it's on.

Every theme is also checked in the test suite: WCAG AA contrast (4.5:1 minimum ratio) for text on each pill and for each color on the terminal background. For the colorblind-* themes, the tests simulate protanopia, deuteranopia and tritanopia to mathematically guarantee that caution and danger states remain distinguishable.

Recording the demo

The GIF at the top is recorded from a script with VHS, so it can be re-recorded after each change. The tape starts the editor with a demo config in a temporary folder, so recording it never touches my real settings, and drives it with keypresses: add a widget by typing its name, browse a few presets, themes and layouts, open the last tab.

What I'd do differently

The editor is one file of about 2,100 lines. That was fine while the design was moving, but it's time to split it into one module per screen. Everything else (raw mode, full redraws, a pure frame function, the real renderer as the preview) I'd do the same way again.

If you're building a small CLI and think you need a heavy TUI framework, try readline and a few escape codes first. You may not need anything else.

To test the editor yourself:

npx agy-powerline configure
Enter fullscreen mode Exit fullscreen mode

Or test all 16 themes, 3 layouts, and custom meter styles right in your browser with the web configurator.

🤖 One-shot install with Antigravity:

Hey agy, please install agy-powerline by running `npx agy-powerline init --yes`, then tell me to restart agy.

The code is MIT: wassim-azi/agy-powerline, in src/cli/configure.js and src/cli/tui-style.js. Questions and suggestions are welcome in the comments.

Top comments (0)