Stanza is a dependency-free line editor for Zig terminal programs. It provides a readline/linenoise-style prompt with history, completion, hints, highlighting, and vi or Emacs-style editing, without linking libc or a C readline library.
The editor is allocator-explicit and instance-based. It talks to terminals directly, uses raw mode and bracketed paste, detects terminal size, and falls back to a plain line read when input or output is not a terminal.
The current scope is macOS, Linux, BSD, and Windows console hosts with virtual terminal support. Unicode handling is codepoint- and display-width-based, not full grapheme-cluster editing.
Four scenes: ghost-text hints, Tab completion, Ctrl-R history search, and a
vi-mode fix · the completion menu chaining command arguments on one line ·
asynchronous output printing above the line being edited (printAbove) ·
wrapped multi-line editing. Recorded with
assets/demo.tape.
- Modal vi editing (default) — start in insert,
Escto normal:h j k l 0 $ ^ w b emotions,i a I A sinserts,x D C r ~edits,d/coperators (dw,cc,d$), counts (3w), and a cursor that turns into a block in normal mode. Set.editing = .emacsfor modeless readline keys. - Emacs editing too — Ctrl-A/E, Ctrl-B/F, Alt-B/F by word, Ctrl-W/U/K kill,
Ctrl-Y yank, Ctrl-T transpose, arrows, Home/End, Delete (always available in
insert mode; the full keymap when
.editing = .emacs). - History — de-duplicated, size-bounded, persists to a file (truncate or append-merge for concurrent instances), navigable with Up/Down and searchable with Ctrl-R (reverse incremental search).
- Completion — a line-aware
Tabcallback. Stanza can list candidates, cycle through them, or keep a small selectable menu below the prompt; candidates can carry a dimmed annotation (addDetail("512x512", "balanced")). - Hints — dim ghost text after the cursor (e.g. suggest the rest of a word).
- Syntax highlighting — a paint callback styles the line as you type.
- UTF-8 + display widths — cursor motion, deletion, and rendering are codepoint- and cell-width-aware. See Limitations for the Unicode scope.
- Bracketed paste — multi-line pastes are inserted as text, not executed.
- Password mode — render a mask character instead of input.
- Single-line or wrapped — a long line scrolls one row by default, or set
.multiline = trueto wrap it across rows. - Resize aware — hosts can notify the editor after a terminal resize; a
simple opt-in SIGWINCH handler is available on POSIX. The detected size is
exposed as
ed.term.cols/ed.term.rowsfor hosts sizing their own output. - Graceful fallback — when stdin/stdout is not a terminal, it does a plain line read so pipelines keep working.
- Blocking or event-loop driven — call
prompt, or drive it from your own event loop witheditStart/editFeed/editStop. - Async output above the prompt —
printAbove()prints host output above the line being edited (built onhide()/show());clearScreen()is the public Ctrl-L. - Job control — Ctrl-Z restores the terminal and suspends (POSIX); on
fgthe editor re-enters raw mode and repaints the line as it was. - Panic-safe restore —
restoreTerminal()is allocator-free and callable from a panic handler, so a crashing host never leaves the shell raw. - Bounded terminal probes — POSIX size probing uses timeouts.
const std = @import("std");
const stanza = @import("stanza");
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const alloc = gpa.allocator();
var ed = stanza.Editor.init(alloc, .{});
defer ed.deinit();
ed.history.load(".myapp_history") catch {};
while (true) {
const line = ed.prompt("app ❯ ") catch |err| switch (err) {
error.Eof => break, // Ctrl-D on an empty line
error.Interrupted => continue, // Ctrl-C
else => return err,
};
defer alloc.free(line);
if (line.len == 0) continue;
try ed.history.add(line);
// ... do something with line ...
}
ed.history.save(".myapp_history") catch {};
}prompt returns a slice owned by the caller (free it), or error.Eof /
error.Interrupted.
Stanza defaults to vi keys and starts in insert mode; press Esc for
normal mode (the cursor becomes a block). Tab/Ctrl-R/history work in both modes.
Normal mode (vi)
| Key | Action | Key | Action |
|---|---|---|---|
h l (Space) |
move by char | 0 ^ / $ |
line start / end |
w b e |
move by word | 3w etc. |
counts repeat motions |
i a |
insert before / after | I A |
insert at start / end |
x |
delete char | s |
substitute char |
D / C |
delete / change to end | r |
replace one char |
dw d$ dd |
delete word / to-end / line | cw cc |
change (then insert) |
p |
paste last kill | ~ |
toggle case |
Insert mode & emacs (.editing = .emacs)
| Key | Action | Key | Action |
|---|---|---|---|
| ←/→ Ctrl-B/F | move by char | Ctrl-A / Ctrl-E | start / end of line |
| Alt-B / Alt-F | move by word | Ctrl-←/→ | move by word |
| Backspace / Ctrl-H | delete left | Ctrl-D / Delete | delete right |
| Ctrl-W / Alt-Backspace | kill word left | Alt-D | kill word right |
| Ctrl-U | kill to start | Ctrl-K | kill to end |
| Ctrl-Y | yank (paste kill) | Ctrl-T | transpose chars |
| ↑/↓ Ctrl-P/N | history prev / next | Ctrl-R | reverse search |
| Tab | complete | Ctrl-L | clear screen |
| Enter | submit | Ctrl-C / Ctrl-D (empty) | cancel / EOF |
Everything beyond plain editing is opt-in through Config callbacks. Callbacks
use function pointers plus an opaque ctx, which keeps Editor as one concrete
runtime-configurable type.
fn complete(
_: ?*anyopaque,
line: []const u8,
cursor: usize,
word: []const u8,
out: *stanza.Completions,
) anyerror!void {
if (std.mem.eql(u8, line[0 .. cursor - word.len], "size ")) {
for (&.{ "256x256", "512x512", "1024x1024" }) |s| {
if (std.mem.startsWith(u8, s, word)) try out.add(s);
}
return;
}
for (subcommands) |c| if (std.mem.startsWith(u8, c, word)) try out.add(c);
}
fn hint(_: ?*anyopaque, line: []const u8) ?stanza.Hint {
return if (line.len == 0) .{ .text = "type a command…" } else null;
}
fn paint(_: ?*anyopaque, line: []const u8, out: *stanza.Painter) anyerror!void {
try out.put(line, .{ .color = .green, .bold = true });
}
var ed = stanza.Editor.init(alloc, .{
.complete = complete,
.complete_style = .menu,
.hint = hint,
.paint = paint,
// .editing = .emacs, // modeless readline keys (default is .vi)
// .mask = '*', // password mode
// .install_resize_handler = true, // opt-in POSIX SIGWINCH handler
// .ctx = &my_state, // handed back to every callback
// .max_history = 5000,
});The highlighter must keep the visible characters identical to the input and add only zero-width SGR escapes; it applies when the whole line fits on screen. Text returned by callbacks — completion candidates, hints, and painted spans — is trusted application output and should be valid UTF-8. Stanza copies or emits it as provided.
Completion styles:
.listinserts the longest common prefix; if none grows, candidates are printed below the prompt..cyclereplaces the current word with each candidate in order; Shift-Tab walks backward..menukeeps candidates visible below the prompt (at most 8 rows; the window follows the selection). Candidates added without.addDetail("512x512", "balanced")show the detail dimmed beside the insert text; only the insert text reaches the line. Tab/Down moves forward, Shift-Tab/Up moves backward, Enter/Right accepts, and Esc/Ctrl-G cancels. Typing closes the menu and keeps the original word, so another Tab reopens it with the filtered prefix. In multiline mode it falls back to cycling.
prompt blocks. To drive Stanza from your own event loop instead, use
editStart / editFeed / editStop: start editing, wait until input is ready,
call editFeed, and it returns .more until Enter yields a .line.
editFeed processes bytes already available on the descriptor and returns
without waiting for the rest of a partial UTF-8 sequence, bracketed paste, or
reverse-search query. A lone Esc waits up to 30 ms before it is treated as an
Escape key so split terminal escape sequences are not misread.
var ed = stanza.Editor.init(alloc, .{});
defer ed.deinit();
try ed.editStart("app ❯ ");
defer ed.editStop();
while (true) {
if (!ed.waitInput(1000)) {
// 1s passed with no key — do other work, then keep going
continue;
}
switch (ed.editFeed() catch |err| switch (err) {
error.Eof, error.Interrupted => break,
else => return err,
}) {
.line => |line| {
defer alloc.free(line);
// ... handle line ...
try ed.editStart("app ❯ ");
},
.more => {},
}
}To print asynchronous output (log lines, job results) while a line is being
edited, use printAbove — it erases the prompt, writes your bytes, and
repaints the line:
try ed.printAbove("event: build finished\r\n");It is state-aware, so the same call works everywhere: with no active prompt
(or after an explicit hide()) it degrades to a plain write. For finer
control, hide()/show() are the underlying pair. End rows with \r\n;
raw mode does not translate bare newlines.
See examples/async.zig (zig build async) for a runnable
version with a clock that ticks above the prompt while you type.
A command handler that runs after prompt returns owns the terminal — print
progress freely, no editor calls needed. Only output produced while a prompt
is live (background jobs, event-loop ticks) needs printAbove:
while (running) {
if (job.poll()) |msg| try ed.printAbove(msg); // above the live prompt
if (!ed.waitInput(50)) continue;
switch (try ed.editFeed()) { ... }
}By default Stanza does not install signal handlers. If your program owns
SIGWINCH, call ed.notifyResize() after observing a resize. For small CLI
programs that do not need their own handler, set .install_resize_handler = true on POSIX. On Windows, leave it false and call notifyResize if the host
observes a resize.
zig build demo # interactive showcase: completion, hints, highlight
zig build menu # the completion-menu example (chain commands on a line)
zig build async # the event-loop example (ticks print above the prompt)
zig build wrap # the multi-line wrapping example (try a narrow window)
zig build keycodes # show the raw bytes each key sends (for bug reports)
zig build test # unit tests
zig build test-portable # platform-independent unit tests
zig build test -Dtarget=x86_64-windows -Dtest-no-exec=true # local Windows compile check
python3 tools/pty_smoke.py # drive the demo through a real PTY
python tools/conpty_smoke.py # Windows-only ConPTY smoke test
zig build qa # zig fmt --checkStanza has no dependencies, so you can either add it as a package:
zig fetch --save git+https://github.com/gmontana/stanza// build.zig
const stanza = b.dependency("stanza", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("stanza", stanza.module("stanza"));…or simply vendor src/*.zig into your tree and @import("root.zig").
- Single physical row by default. The default renderer scrolls horizontally
to keep the cursor visible. Set
.multiline = truefor a renderer that packs the prompt and input across rows with explicit CR/LF breaks. - Glyph model. Rendering uses
(byte offset, byte length, cell width)records so scrolling, masking, and cursor placement use the same width model. - No readline dependency. Terminal IO goes through small platform backends:
POSIX uses file descriptors and
tcgetattr; Windows uses console handles and virtual-terminal mode. - Small modules. The source is split into
sys,unicode,config,key,line,history,completion,vi,render,editor, androot. - Backend boundary. Platform-specific terminal and file-handle code lives
behind
src/backend.zig. - Runtime-configurable callbacks. Completion, hints, and highlighting use
plain function pointers with an opaque
ctx, soEditorcan be stored, passed around, and reconfigured at runtime.
- Terminal hosts vary. Stanza expects a terminal with ANSI/VT behavior. Windows support uses the Windows console virtual-terminal mode.
- Codepoints, not graphemes. Cursor motion and width are per-codepoint (with wcwidth-style tables for CJK/emoji/combining marks). Multi-codepoint grapheme clusters — ZWJ emoji sequences, flags, Hangul jamo composition — are edited as their individual codepoints.
- Resize handler. Stanza does not install one by default. On POSIX,
.install_resize_handler = trueinstalls a process-wide SIGWINCH handler once; a handler your program installed earlier for that signal is replaced. On Windows, hosts should callEditor.notifyResize()when they observe a resize. - Bracketed paste waits for the end marker. A paste that does not send the
closing
ESC [ 2 0 1 ~(a misbehaving terminal multiplexer, for example) leaves the editor in paste mode until the marker arrives or input ends. In the event-loop API,editFeedreturns.morewhile waiting. - Ctrl-Z on Windows. Job-control suspension is POSIX-only; on Windows the key is ignored.
- Runtime reconfiguration.
ed.cfgfields (mask, multiline, hints,complete_style, …) may be changed between prompts; mid-edit changes apply on the next redraw. Useed.history.setMax(n)to change retention at runtime. - Highlighter contract. A
paintcallback must emit the same visible characters as the input line, adding only zero-width SGR escapes; cursor placement assumes it.
