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

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.