Interactive prompts

The prompts package is a complete question toolkit: seven primary prompt types that render as live, repainting lines at a terminal — and fall back to plain line input automatically whenever stdin or stdout isn’t a terminal, so the same command works in a pipe, a script, or CI without a non-interactive flag.

In a command, context.prompts() returns an instance pre-wired to the command’s streams, arena allocator, and theme:

// Pre-wired to the command's streams, allocator, and theme.
const p = context.prompts();

const name = try p.text(.{
    .message = "Project name:", .default = "my-project",
});
const features = try p.multiSelect(.{
    .message = "Features:",
    .choices = &.{ "typescript", "eslint", "prettier" },
    .defaults = &.{ true, true, false },
    .search = true,
});
const pw = try p.password(.{
    .message = "Token:",
});

The seven types

PromptAsksReturns
textfree-form input, optional default and live preview hintthe entered string
confirmyes/no with a default (Y/n hint, single keypress)bool
selectone choice from a list — ↑/↓ to move, Enter or Space to pickthe chosen index
multiSelectseveral choices — Space toggles, per-item defaultsthe chosen indices
passwordmasked input, never echoedthe entered string
numbernumeric input with optional min/max, validation inlinethe parsed number
editoropens $EDITOR on a seeded temp filethe edited text

Explicit editing

For an edit command, open immediately with the reusable editor operation:

const p = context.prompts();
var result = try p.edit(.{
    .io = context.io,
    .environ = context.environ,
    .default = existing_document,
    .extension = ".md",
});
defer result.deinit(context.allocator);

switch (result) {
    .edited => |content| try save(content),
    .failed => |failure| {
        if (failure.recovery_path) |path|
            try context.stderr().print("Content preserved at {s}\n", .{path});
        return error.EditCancelled;
    },
}

The editor opens on a controlling terminal even when stdout is redirected. Its UI never goes into the command’s redirected output. Missing terminals, launch failures, unsuccessful termination and read-back failures are distinct structured failures. Successful editing returns exact bytes: trailing newlines, unchanged text and empty documents are valid.

Supply .argv = &.{ "code", "--wait" } for explicit program configuration. Otherwise an explicit editor_cmd takes precedence over nonempty $VISUAL, then $EDITOR, then vi (notepad on Windows), from the supplied environment. Command strings support quotes and backslash escaping, without shell expansion or pipelines. Prefer .argv for Windows paths containing backslashes. Explicit shells work: EDITOR="sh -c 'echo edited > \"\$1\"' --". Lookup uses the supplied PATH and the child receives the supplied environment.

Scratch files are exclusive, randomized and private. They are removed on success. Changed or unreadable content is preserved on failure, with an owned recovery_path; calling result.deinit frees result memory and does not delete that file. .recovery = .discard deliberately disables recovery. .max_bytes bounds the saved document read (128 MiB default); oversized saved content remains recoverable. .attachment = .inherit is available explicitly for noninteractive tools or tests.

The existing p.editor prompt still invites Enter and falls back to reading stdin when noninteractive. Set .immediate = true to bypass the invitation at an interactive prompt; its noninteractive stdin fallback remains unchanged. The p.editor prompt returns EditorFailed, EditorLaunchFailed or EditorReadFailed rather than silently returning the initial text; it displays a recovery path when available. Use p.edit for editing with redirected stdout or structured failure details. Its expected failures are .failed result values; allocation failures remain Zig errors.

Searchable choices

Set .search = true on select or multiSelect for a case-insensitive substring filter. Printable characters other than ASCII Space filter, Backspace edits the query, Up/Down navigate, and the Space key selects or toggles the highlighted choice; Enter selects or commits. ASCII Space is not query text; hidden multi-select choices remain selected.

Terminal behavior, handled

  • Wrapping & resize — long messages word-wrap at the terminal width (grapheme- and ANSI-aware), and every prompt re-lays-out live on window resize.
  • Unicode-correct editing — backspace deletes one visual character; wide characters and combining sequences count correctly.
  • Answers persist — a completed prompt emits its styled question-and-answer as a static line into scrollback, so the transcript reads cleanly afterward.
  • Interrupts are yours — pass interrupt_keys and the prompt returns error.Interrupted when one is pressed; the caller decides whether that means cancel, back, or help.

Off a TTY

Redirect either stream — myapp setup < answers.txt, myapp setup > log.txt, a pipeline, CI — and every prompt degrades to a line-based equivalent with the same return type and value. There is no second code path to write:

at a terminalpiped / CI
text / passwordlive editing, masked for passwordreads a line
confirmsingle keypressreads y/n from a line
selectarrow-key highlighted list; Enter or Space selectsnumbered list, reads a number
multiSelectSpace-toggle listprints the list, reads selections
editoropens $EDITORreads remaining stdin

Fallback, or interactive-only

The fallback above is the default, and for most commands it is the right answer: one code path serves a terminal, a pipe, and CI.

Some commands have nothing sensible to do off a terminal — a wizard whose whole job is the conversation, or a prompt whose line fallback would silently accept something destructive. Those guard once, before the first question:

const p = context.prompts();

// One documented call, up front — nothing is asked if this fails.
try p.requireInteractive(); // error.NotInteractive when piped

const name = try p.text(.{ .message = "Project name:" });
const ok = try p.confirm(.{ .message = "Create it?" });

requireInteractive returns error.NotInteractive unless both stdin and stdout are terminals: stdin so keystrokes can be read in raw mode, stdout so the rendered frame lands on the screen instead of a redirected file. It is the same check each prompt makes, so the guard’s verdict is exactly what the next prompt would have done — no second detection to keep in sync.

To branch rather than fail, ask p.isInteractive() — that is how zcli add command scaffolds a plain skeleton off a terminal and runs the wizard at one.

Setting .interactive on the instance overrides the detection for that instance, guard and prompts alike. Be precise about what false means: it forces line mode, so the prompts still print and still read stdin — at a terminal that waits for a typed line, on a closed stream it is error.EndOfStream. It is not a --no-input switch; a flag that means “ask me nothing” has to skip the prompt sequence itself and take defaults or required options instead.

One case sets .interactive for you: an instance from context.prompts() reports non-interactive whenever the Context’s stdout is captured or its stdin injected — which is what a runCommand unit test does. The streams the prompt was handed decide, not the process’s descriptors, so a test can’t enter raw mode and read the real keyboard just because the test binary was started from a terminal. A command that guards therefore reports error.NotInteractive under runCommand, and belongs in the PTY-backed E2E tier.

Pick one per command: no guard means “this works piped”, the guard means “this needs a terminal, and says so before asking”.

Prompts or widgets?

Prompts are one-shot questions: they block, return a value, and move on — right for a wizard, a scaffold, a few sequential questions. For a persistent, laid-out, keyboard-driven surface — a live form, a dashboard — build a full-screen App from the interactive widgets instead: see prompts vs widgets.

Standalone use

Like every zcli package, prompts works without the framework — construct a Prompts value with your own writer, reader, allocator, and theme context, and every prompt type behaves identically. The package’s examples/ directory has a runnable example per prompt type.