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
| Prompt | Asks | Returns |
|---|---|---|
text | free-form input, optional default and live preview hint | the entered string |
confirm | yes/no with a default (Y/n hint, single keypress) | bool |
select | one choice from a list — ↑/↓ to move, Enter or Space to pick | the chosen index |
multiSelect | several choices — Space toggles, per-item defaults | the chosen indices |
password | masked input, never echoed | the entered string |
number | numeric input with optional min/max, validation inline | the parsed number |
editor | opens $EDITOR on a seeded temp file | the 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_keysand the prompt returnserror.Interruptedwhen 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 terminal | piped / CI | |
|---|---|---|
text / password | live editing, masked for password | reads a line |
confirm | single keypress | reads y/n from a line |
select | arrow-key highlighted list; Enter or Space selects | numbered list, reads a number |
multiSelect | Space-toggle list | prints the list, reads selections |
editor | opens $EDITOR | reads 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.