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.
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.
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.// 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.
| Node | What it is |
|---|---|
| box | the only container — row and column are boxes with a direction; carries gap, padding, and an optional border |
| text | a styled string; ui.textOpts chooses wrap / truncate / clip and a fixed width |
| spacer | flexible empty space; put one before a node to right-align it, one on each side to center |
| custom | the 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.
.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).
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.
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:
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.
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