The CLI/TUI hybrid

Prompts and progress render on zcli.ui — a terminal-native layout engine — and it's yours to use directly. Stream lines into scrollback while a live region — a full layout, from one line up to the whole viewport — repaints in place just above them: the shape of modern agent-style CLIs.

A full-screen TUI takes the terminal over; a plain CLI can't paint. The hybrid is the third shape — the one Claude Code, modern installers, and deploy tools use. zcli's engine gives you both halves at once:

  • A static stream flows upward into scrollback, one line at a time, exactly like normal output — copy-pasteable, greppable, permanent.
  • A live region repaints in place just above the scrollback — anything from a one-line spinner to a full-viewport layout. It's positioned above committed output, not a fixed strip, and shares the screen rather than taking it over, so your scrollback survives.
  • Immediate mode means a component is just a function returning a node. You rebuild the tree every frame from your own state; the engine diffs it and sends only the cells that changed.
Built on it The prompts and progress packages both render on this engine — so cursor hygiene, diffed repaints, and resize re-layout are solved once, in one place.

The frame model

The live region is rebuilt from scratch every frame: the node tree is allocated into a per-frame arena, measured (constraints down, sizes up), painted onto a cell surface, and diffed against the previous surface. An unchanged frame writes zero bytes; a ticking spinner repaints one cell, not the screen.

Two verbs drive it. emit writes a static line that scrolls up and away; frame replaces the live region with a new node tree. emit is the only way to write while a region is up — it erases the region, prints the line into scrollback, and repaints the region below it.

Not a full-screen TUI "Above the scrollback" describes where the live region sits, not how big it is — it's a full layout tree, one line to the whole viewport (a full-screen app is just a root sized fill). This is the same static-above / live-below split as Ink's <Static> and dynamic render: the engine shares the terminal and never switches to the alternate screen, so scrollback is preserved. The trade is that there's one live region and it's always the bottom-most live content — top-anchored or multiple independent live regions would need the alt-screen model this deliberately avoids.
a component is just a functionzig
// State lives in your structs; the frame is a pure function of it.
fn statusLine(a: std.mem.Allocator, s: *const State) !ui.Node {
    return ui.row(a, .{ .gap = 1 }, &.{
        ui.widgets.spinner(.{}, s.tick),     // animation = your tick
        ui.text(.{ .bold = true }, s.message),
        ui.spacer(),                        // push the rest to the right edge
        ui.text(.{ .dim = true }, s.elapsed),
    });
}

Nodes & sizing

The whole vocabulary is four node kinds and three sizing words — small enough to hold in your head, expressive enough for a bordered, multi-column status frame.

NodeWhat it is
boxthe only container — row and column are boxes with a direction; carries gap, padding, and an optional border
texta styled string; ui.textOpts chooses wrap / truncate / clip and a fixed width
spacerflexible empty space; put one before a node to right-align it, one on each side to center
customthe escape hatch — a leaf that draws its own cells (this is how the progress bar is built)

Every dimension is one of three words. fit shrinks to content, len(n) pins an exact column count, and fill(weight) shares leftover space by weight — so percentages are fill weights and right-alignment is a spacer, not special-cased geometry.

Whitespace The default .wrap mode word-wraps and drops break spaces. Labels with significant whitespace — padded numbers, aligned columns — want .clip or .truncate via ui.textOpts.

The App

In a command, context.ui() returns a zcli.ui.App pre-wired to the command's stdout, allocator, theme capability, and TTY detection. The App owns everything stateful — the frame arena (app.arena(), reset when frame() returns), the live region's rows, the double-buffered surfaces, and the cursor (hidden while a region is up, parked at its top-left between calls, restored on deinit).

src/commands/deploy.zigzig
var app = try context.ui();
defer app.deinit(); // restores the terminal; the final frame persists in scrollback

try app.emit("compiled {s}", .{name});               // static -> scrollback
try app.frame(try statusLine(app.arena(), &state)); // live  -> diffed repaint

Builders copy their child slices into the arena, so component functions compose freely — no dangling-temporary hazards from returning a node built over a stack array. Styles on the nodes are theme.Style values, and the diff renderer emits them capability-aware, from NO_COLOR through true color.

Widgets

ui.widgets is the progress vocabulary as component functions: a spinner is a text node, a bar is one custom leaf, a multi-bar is a column of rows. No widget owns a repaint loop or hidden state — animation is your tick, progress is your fraction, the frame diff does the rest. Styling flows through the theme's progress tokens, the same ones the progress package reads.

a bordered status framezig
const a = app.arena();
try app.frame(try ui.column(a, .{ .border = .rounded, .padding = .symmetric(1, 0) }, &.{
    try ui.row(a, .{ .gap = 1 }, &.{
        ui.widgets.spinner(.{}, state.tick),
        ui.text(.{ .bold = true }, "Reading dependencies"),
        ui.spacer(),
        ui.text(.{ .dim = true }, state.elapsed),
    }),
    try ui.widgets.multiBar(a, .{}, &.{
        .{ .label = "src/parser.zig",    .fraction = 0.6 },
        .{ .label = "src/formatter.zig", .fraction = 0.9 },
    }),
}));

Resize & piping

The live region re-measures against the terminal on every frame, so it re-lays-out on resize for free; its height clamps to the viewport and clips from the bottom. Scrollback is the terminal's authority — but the engine retains the source text of the visible static tail and, on a width change, synchronously rewraps it so the seam between the reflowed tail and the live frame stays clean.

  • Live region — full relayout against the new width, every frame.
  • Visible static tail — retained in source form and repainted to rewrap with the new width.
  • Scrollback above the fold — left to the terminal; reflow there is the terminal's job, not the app's.

When stdout is not a TTY — a pipe, a file, a CI log — there is no live region to repaint: emit lines print as normal, and a frame degrades to the plain lines it would draw. The same code path produces an interactive frame and a clean log.

Standalone use

Like every zcli package, ui works without the framework. Outside a command there's no context, so construct the App yourself — it owns heap state (surfaces, retained scrollback), so it takes an explicit init/deinit rather than the value-bundle shape of the stateless packages:

standalonezig
const ui = @import("ui");

var app = try ui.App.init(gpa, writer, .{ .capability = .ansi_256 });
defer app.deinit();

try app.emit("✓ built {s}", .{name});
try app.frame(try statusLine(app.arena(), &state));

Try it

Two runnable examples live in the ui package — both want a real terminal. Resize the window while either runs and watch the live frame re-lay-out as the visible static tail rewraps with it.

packages/uish
zig build run-demo     # deploy-style task frame: spinner, task list, live gauges
zig build run-hybrid   # the Claude-Code shape: streaming prose + a live multi-bar