Interactive Prompts & Line Editing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A command-line program that asks the user something has three choices. It can read a line the way every program does and let the terminal do the editing, which gives only Backspace and the arrow keys of a minimal line discipline. It can use a line editor such as GNU Readline, which takes over the line to add cursor movement, history, completion and multi-line input. Or it can use a prompt library such as inquire, Huh or Inquirer.js, which takes over a whole question (a list to pick from, a yes or no, a masked secret) and returns a typed answer. This page covers the second and third choices for the Bookshelf tool: an interactive shelf shell with history and completion (a REPL), and a shelf add command that asks for a title, an author, a shelf and an API token when they were not given as flags.
The page was written against GNU Readline 8.3 (documentation), linenoise, replxx, rustyline 19.0.0, reedline 0.52.1, inquire 0.9.4, dialoguer 0.12.0, Huh v2.0.3, golang.org/x/term v0.47.0, @inquirer/prompts 8.7.4, @clack/prompts 1.8.1, prompt_toolkit 3.0.53, questionary and Python 3.15. Two snippets were compiled or run: the C REPL below was compiled and run on macOS against the Readline-compatible API that Apple’s libedit provides, with piped input, and the Node.js shelf add example was run under a pseudo-terminal with Node.js 24 and @inquirer/prompts 8.7.4 (the prompts, the masked token, cancelling with Ctrl-C and the non-terminal refusal all behaved as described). The Python REPL was run on Python 3.9 with piped input only, which exercises the fallback path described in the password section. The Rust, Go, Java and Windows snippets were not compiled or run while the page was written, because no Rust, Go or JVM toolchain was available and no Windows machine was used; they follow the official documentation linked after each one. Completion and history were not exercised against GNU Readline itself, only against libedit.
How a terminal delivers keys to a program (canonical and raw mode, echo, termios) is on Terminals, TTYs & Pseudo-Terminals; the escape sequences that line editors and prompt libraries emit to redraw a line are on Escape Sequences & Terminal Capabilities. The rule that a program may prompt only when standard input is a terminal, and must otherwise fail fast and name the flag that supplies the answer, is owned by the AI section: see TTY, Colour & Interactivity (the sections Prompting only on a TTY and --no-input and --yes). This page does not repeat it, but every example here applies it.
Three layers of interactive input
| Layer | What it does | Examples | Costs |
|---|---|---|---|
The terminal’s own line discipline |
Edits the line in the kernel (Backspace, Ctrl-U, Ctrl-W) and delivers it on Enter; the program sees only the finished line |
|
No history, no cursor movement, no completion; the arrow keys print |
A line editor |
Puts the terminal in a raw mode, redraws the line itself and offers history, completion, hints, multi-line editing and key bindings |
GNU Readline, libedit, linenoise, replxx, rustyline, reedline, prompt_toolkit, JLine |
A dependency, a licence to check (Readline is GPL), and the program must restore the terminal when it exits |
A prompt library |
Asks one structured question and returns a typed answer: text, password, confirm, single or multiple choice, path |
inquire, dialoguer, Huh, |
Same terminal cost as an editor, plus the library’s keys; a library cannot be driven by a script |
Pick the lowest layer that is enough. A tool that asks one question once (shelf loan --return asking "return this book?") should not pull in a full line editor, and a REPL should not be built from a chain of prompt-library questions.
How a line editor works
When a program calls fgets, the kernel’s line discipline is in canonical mode: it collects the characters, echoes them, handles the erase and kill characters, and wakes the reader only when Enter arrives. A line editor cannot work that way, because it must see each key as it is pressed (the Left arrow, Ctrl-A, Tab). So for the duration of one readline call it switches the terminal to a non-canonical mode with echo off (ICANON and ECHO cleared in termios, keeping ISIG so Ctrl-C still raises a signal in most editors), reads bytes, decodes the escape sequences the keys produce, keeps the line in its own buffer, and redraws that buffer with escape sequences (cursor movement, erase to end of line) after every key. When Enter arrives it restores the saved termios and returns the line. Everything a line editor offers follows from owning that buffer:
-
History: a list of previous lines that Up and Down, Ctrl-P and Ctrl-N and Ctrl-R search walk through, optionally saved to a file between runs.
-
Completion: on Tab, a callback receives the text before the cursor and returns candidates.
-
Hints and highlighting: grey suggestions and coloured words redrawn with the line.
-
Multi-line input: a validator decides whether Enter ends the input or inserts a newline.
-
Key bindings: Emacs or vi style, configurable.
The same mechanism has three consequences for the program that uses it:
-
The editor must not be used when standard input is not a terminal. Its raw mode cannot be set, and a script feeding lines through a pipe would see the prompt and the redraw sequences. Check
isattyfirst, as the TTY, Colour & Interactivity page says, and read plain lines otherwise. -
The terminal is left in raw mode while a line is being edited. A signal that terminates the program in that window leaves the user with a shell without echo. Editors install their own handlers for that (rustyline, for example, reports Ctrl-C as an error rather than dying), but a program that handles
SIGTERMorSIGTSTPitself must cooperate. See the restoring-state rules in Terminals, TTYs & Pseudo-Terminals. -
Prompts that contain colour escape sequences confuse editors that measure the prompt to place the cursor. GNU Readline needs the non-printing bytes bracketed with its
\1and\2markers (written\001and\002in C); its manual points torl_expand_promptfor prompts containing them. Libraries that take a structured prompt (rustyline’sPrompttrait, prompt_toolkit’s formatted text) avoid the problem.
Line editors
GNU Readline and libedit
GNU Readline is the library behind the interactive mode of Bash, gdb and psql. Its core is one function, char *readline(const char *prompt), which prints the prompt, edits a line and returns it in memory the caller must free. At end of file on an empty line it returns NULL, which is how Ctrl-D is detected. add_history(line) appends to the history list, and the manual recommends skipping empty lines. rl_bind_key(key, function) rebinds a key, and its manual example binds Tab to rl_insert to turn off completion. rl_readline_name is set by the application to a unique name that the user’s ~/.inputrc can test with $if, so users can configure key bindings per program. Completion is customised with a function installed in rl_attempted_completion_function, of the type char **(const char *text, int start, int end), which returns a NULL-terminated array of matches, usually built with rl_completion_matches and a generator function. Setting rl_attempted_completion_over to 1 stops Readline falling back to file-name completion. The GNU History library (read_history, write_history, stifle_history) saves and truncates the history file.
The following REPL uses those calls. Unknown commands are echoed rather than executed, so the example shows only the line-editing part.
// Build on Linux: cc -Wall -o shelf-repl shelf-repl.c -lreadline
// Build on macOS (libedit's compatible header): cc -Wall -o shelf-repl shelf-repl.c -ledit
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <readline/readline.h>
#include <readline/history.h>
static const char *commands[] = { "add", "list", "loan", "return", "help", "quit", NULL };
static char *command_generator(const char *text, int state) {
static int index;
static size_t len;
if (state == 0) { index = 0; len = strlen(text); }
while (commands[index] != NULL) {
const char *name = commands[index++];
if (strncmp(name, text, len) == 0) return strdup(name);
}
return NULL;
}
static char **shelf_completion(const char *text, int start, int end) {
(void)end;
rl_attempted_completion_over = 1; /* never fall back to file names */
if (start == 0) return rl_completion_matches(text, command_generator);
return NULL; /* no candidates after the verb */
}
int main(void) {
rl_readline_name = "shelf";
rl_attempted_completion_function = shelf_completion;
using_history();
char *line;
while ((line = readline("shelf> ")) != NULL) { /* NULL means Ctrl-D on an empty line */
if (*line != '\0') add_history(line);
if (strcmp(line, "quit") == 0) { free(line); break; }
printf("you typed: %s\n", line);
free(line);
}
putchar('\n');
return 0;
}
On macOS this program was compiled with -ledit and run with list and quit on standard input; it printed you typed: list and exited with status 0. Readline reads from rl_instream (standard input when it is NULL), so with a pipe it still ran, but see the pitfalls: a real program should not call it at all when isatty(0) is false.
libedit (the NetBSD editline library, also called editline) is a BSD-licensed re-implementation with a Readline-compatible wrapper header, <editline/readline.h> (the macOS SDK also exposes it as <readline/readline.h>), and its own native API (el_init, el_gets, history). It is what ships as libedit on macOS and the BSDs, and it is the reason a program that links "readline" on a Mac may silently get libedit. The Python documentation says the same of its readline module: it may be backed by GNU Readline or by editline, readline.backend (new in 3.13) reports which, the two read different configuration files (.inputrc and .editrc), and their history file formats may differ. Compatibility is partial, so test both if a program must run against both. The reason to prefer libedit is the licence: GNU Readline is GPL (a program linking it must be GPL-compatible), libedit is BSD. For the native API of libedit, consult the libedit home page.
linenoise and replxx
linenoise is a small, BSD-2-licensed alternative written for embedding (it came out of Redis). Its API is a handful of functions: linenoise(prompt) returns a heap-allocated line, or NULL on end of file; linenoiseHistoryAdd, linenoiseHistorySetMaxLen (history is disabled until the length is set), linenoiseHistorySave and linenoiseHistoryLoad; linenoiseSetCompletionCallback with linenoiseAddCompletion for Tab; linenoiseSetHintsCallback for suggestions shown to the right of the cursor; linenoiseSetMultiLine(1) for lines wider than the terminal; and linenoiseMaskModeEnable / linenoiseMaskModeDisable, which replace typed characters with *. Its README makes no Windows claim and mentions a related project, Linenoise NG, that aims at Windows.
replxx is a BSD-licensed readline and libedit replacement for C++ (and a C interface) with history, completion, syntax highlighting and hints. Its README lists Windows among the supported platforms and builds with CMake and Visual Studio. The repository has no published releases, so pin a commit. The README does not document the API; read the headers and the examples folder in the repository.
Source: linenoise — README and replxx — README
Python: readline, prompt_toolkit and questionary
Python’s input() uses GNU Readline or libedit automatically if the readline module has been imported on Unix. The module is documented as available on Unix only and not on Android, iOS or WASI, so import readline must be guarded; on Windows input() works but offers only what the console host’s line editing gives. The functions a REPL needs are read_history_file, write_history_file, set_history_length (the default is unlimited, which the documentation warns "may grow unruly"), set_completer (a function called as function(text, state) for state 0, 1, 2 until it returns a non-string) and parse_and_bind. Because the binding syntax differs between GNU Readline (tab: complete) and libedit (bind ^I rl_complete), the program below tests the module’s docstring for "libedit"; readline.backend is the cleaner check on Python 3.13 and later.
import atexit
import getpass
import os
import sys
try:
import readline # Unix only; GNU Readline or libedit, depending on the build
except ImportError: # Windows: input() still works, without history or completion
readline = None
COMMANDS = ["add", "list", "loan", "login", "return", "help", "quit"]
HISTFILE = os.path.join(os.path.expanduser("~"), ".shelf_history")
def setup_line_editing():
if readline is None or not sys.stdin.isatty():
return
if "libedit" in (readline.__doc__ or ""):
readline.parse_and_bind("bind ^I rl_complete") # libedit syntax
else:
readline.parse_and_bind("tab: complete") # GNU Readline syntax
try:
readline.read_history_file(HISTFILE)
except FileNotFoundError:
pass
readline.set_history_length(1000)
atexit.register(readline.write_history_file, HISTFILE)
def complete(text, state):
if readline.get_begidx() != 0:
return None
matches = [c for c in COMMANDS if c.startswith(text)]
return matches[state] if state < len(matches) else None
readline.set_completer(complete)
def main():
setup_line_editing()
interactive = sys.stdin.isatty()
while True:
try:
line = input("shelf> " if interactive else "").strip()
except EOFError: # Ctrl-D on an empty line
print()
break
except KeyboardInterrupt: # Ctrl-C abandons the line, not the program
print()
continue
if line == "quit":
break
if line == "login":
token = getpass.getpass("API token: ")
print(f"token of {len(token)} characters read")
elif line:
print(f"you typed: {line}")
if __name__ == "__main__":
main()
Source: Python — readline and Python — getpass
prompt_toolkit is a pure-Python line editor and prompt library with its own key handling, so it does not depend on Readline and works on Windows. prompt("...") returns a line; a PromptSession is created once and its .prompt() method is called for each line, keeping history between calls. The options that matter for a REPL are history=FileHistory(path), completer=WordCompleter([...]) (with complete_while_typing=True for as-you-type menus, which the documentation says conflicts with enable_history_search), auto_suggest=AutoSuggestFromHistory() for the grey suggestion that the right arrow or Ctrl-E accepts, multiline=True (Enter inserts a newline and Meta-Enter, or Escape then Enter, accepts), validator= (a Validator subclass or Validator.from_callable), bottom_toolbar=, is_password=True (asterisks) and vi_mode=True. The prompt_toolkit documentation does not describe how Ctrl-C and Ctrl-D surface; in practice they raise KeyboardInterrupt and EOFError as with input(), which was not verified, so catch both as in the example above.
from pathlib import Path
from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.history import FileHistory
session = PromptSession(
history=FileHistory(str(Path.home() / ".shelf_history")),
completer=WordCompleter(["add", "list", "loan", "return", "help", "quit"]),
auto_suggest=AutoSuggestFromHistory(),
)
while True:
try:
line = session.prompt("shelf> ")
except (EOFError, KeyboardInterrupt):
break
if line.strip() == "quit":
break
print(f"you typed: {line}")
questionary builds a prompt library on prompt_toolkit: questionary.text(), password(), path() (with path completion), confirm(), select(), rawselect() (choose by shortcut key), checkbox() and autocomplete(), each returning a question that is run with .ask(). Extra keyword arguments pass through to prompt_toolkit. The questionary documentation does not say what .ask() returns when the user presses Ctrl-C, so check the result before using it.
Rust: rustyline and reedline
rustyline is a Readline implementation in Rust based on linenoise. Its DefaultEditor is an Editor with the default helper and history. Editor::new() returns a Result; readline(&prompt) returns the line or an error; add_history_entry, load_history(path), save_history(path) and append_history(path) manage history; set_helper installs a type implementing the Helper trait (made of Completer, Hinter, Highlighter and Validator, each with a derive macro) for completion, hints, colouring and multi-line validation; Config selects the edit mode, completion type and history behaviour. ReadlineError is #[non_exhaustive] with the variants Io, Eof (Ctrl-D), Interrupted (Ctrl-C), Errno (Unix only) and Signal (a window-size change, SIGWINCH or WINDOW_BUFFER_SIZE_EVENT), so a match needs a wildcard arm. The rustyline documentation does not describe Windows behaviour, though the crate depends on Windows-specific crates (windows-sys, clipboard-win), which suggests it supports the Windows console.
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
fn history_path() -> std::path::PathBuf {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).unwrap_or_default();
std::path::PathBuf::from(home).join(".shelf_history")
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut editor = DefaultEditor::new()?;
let path = history_path();
let _ = editor.load_history(&path); // a missing file on the first run is not an error
loop {
match editor.readline("shelf> ") {
Ok(line) => {
if line.trim() == "quit" {
break;
}
editor.add_history_entry(line.as_str())?;
println!("you typed: {line}");
}
Err(ReadlineError::Interrupted) => continue, // Ctrl-C: abandon the line
Err(ReadlineError::Eof) => break, // Ctrl-D on an empty line
Err(error) => return Err(error.into()),
}
}
editor.save_history(&path)?;
Ok(())
}
Source: rustyline — docs.rs and Editor
reedline is the line editor of Nushell, a standalone crate built on crossterm. Reedline::create() makes an editor and read_line(&prompt) returns a Signal: Success(buffer), CtrlC or CtrlD. History is FileBackedHistory::with_file(size, path) passed to with_history; completion needs three parts (a completer such as DefaultCompleter, a menu such as ColumnarMenu, and a Tab keybinding that opens the menu); DefaultValidator treats unbalanced quotes and brackets as incomplete input and so enables multi-line entry; keybindings are added to default_emacs_keybindings(); Vi and Helix edit modes exist. Optional features are clipboard, bashisms, sqlite and an experimental external_printer. The reedline documentation states no platform policy. It is a larger API than rustyline and aimed at building a shell.
use reedline::{DefaultPrompt, FileBackedHistory, Reedline, Signal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let history = Box::new(FileBackedHistory::with_file(1000, ".shelf_history".into())?);
let mut editor = Reedline::create().with_history(history);
let prompt = DefaultPrompt::default();
loop {
match editor.read_line(&prompt)? {
Signal::Success(line) if line.trim() == "quit" => break,
Signal::Success(line) => println!("you typed: {line}"),
Signal::CtrlC => continue,
Signal::CtrlD => break,
_ => {}
}
}
Ok(())
}
Source: reedline — docs.rs
Java, Kotlin, .NET, Go and Node.js
JLine is the line editor of the JVM (a Terminal and a LineReader with history, completion and highlighting). It is covered, with a REPL example, in Java CLI Frameworks; the JVM basics (System.console(), readPassword) are in Command-Line Programs on the JVM & Kotlin. Node.js has node:readline with history and a completer option; see Console, Readline & Terminal I/O. The .NET equivalents are the prompts of Spectre.Console (next section) and Console.ReadLine, covered on .NET Console Applications. Go’s standard library has no line editor; golang.org/x/term provides NewTerminal, whose Terminal runs a VT100-style line-reading session with ReadLine, SetPrompt, a History type and bracketed-paste mode (ErrPasteIndicator), and its documentation is rendered for Linux, Windows, macOS and WebAssembly. Third-party Go editors exist but were not reviewed for this page.
Prompt libraries
A prompt library asks a single structured question. All of them run in raw mode and redraw with escape sequences, so all of them need a terminal on both input and output, and all of them need a plan for cancelling.
| Language | Library | Prompts | Cancelling |
|---|---|---|---|
Rust |
inquire 0.9.4 |
|
|
Rust |
dialoguer 0.12.0 |
|
|
Go |
Huh v2.0.3 |
|
|
Node.js |
|
|
Promise rejects with |
Node.js |
|
|
|
.NET |
Spectre.Console |
|
|
Python |
questionary |
|
Not documented |
Java |
JLine, picocli |
A |
Rust: inquire and dialoguer
inquire prompts are built with a message and optional settings, then run with prompt(), which returns an InquireResult. Select::new(message, options) takes a Vec<T> where T implements Display; Confirm::new(message).with_default(true) accepts y, yes, n, no case-insensitively and returns the default on an empty answer; Password::new(message) asks twice by default, so add without_confirmation() for a login, and its PasswordDisplayMode is Hidden (the default, no indicator at all), Masked (the mask is *, changeable through RenderConfig) or Full. with_display_toggle_enabled() lets the user reveal the input with Ctrl-R. Text input takes validators (the crate’s example rejects input over 140 characters with Validation::Invalid).
use inquire::{Confirm, InquireError, Password, Select, Text};
use std::io::IsTerminal;
fn main() {
if !std::io::stdin().is_terminal() {
eprintln!("shelf: add needs --title and --author when there is no terminal to ask on.");
std::process::exit(2);
}
match ask() {
Ok(()) => {}
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
eprintln!("shelf: cancelled");
std::process::exit(130);
}
Err(error) => {
eprintln!("shelf: {error}");
std::process::exit(1);
}
}
}
fn ask() -> Result<(), InquireError> {
let title = Text::new("Title").prompt()?;
let shelf = Select::new("Shelf", vec!["fiction", "reference"]).prompt()?;
let token = Password::new("API token").without_confirmation().prompt()?;
let ok = Confirm::new(&format!("Add \"{title}\" to {shelf}?")).with_default(true).prompt()?;
println!("added={ok} token_length={}", token.len());
Ok(())
}
Source: inquire — docs.rs
The check with IsTerminal is the program’s own: inquire reports InquireError::NotTTY itself when its input is not a terminal, but failing early with the flag to use is what the AI section recommends.
dialoguer offers the same family with a builder style. Confirm::new().with_prompt("...").interact() returns a bool (by default each keystroke is checked; wait_for_newline(true) makes the user press Enter), interact_opt() returns Option<bool> with None on Esc or q, and the dialog renders on stderr, so stdout stays clean for data. Password::new().with_prompt("...").with_confirmation("Confirm", "Passwords mismatching").interact() reads a hidden password, validate_with accepts a validator, and allow_empty_password(bool) defaults to false. Password, FuzzySelect, Editor, history and completion each sit behind a crate feature (the editor and password features are on by default). The dialoguer documentation does not say what happens when no terminal is attached; treat it as an error and check is_terminal first, as above.
Source: dialoguer — docs.rs
Go: Huh
Huh builds forms from groups of fields. The constructors are huh.NewForm(groups...), huh.NewGroup(fields...), huh.NewInput(), huh.NewText(), huh.NewSelect[T](), huh.NewMultiSelect[T](), huh.NewConfirm() and huh.NewNote(); each field has Title, Value(&variable) and Validate; selects take Options(huh.NewOptions("a", "b")...) or huh.NewOption(label, value). An Input is made a password with EchoMode(huh.EchoModePassword) (a mask character) or huh.EchoModeNone (nothing shown); the older Password(bool) method is deprecated. form.Run() or RunWithContext(ctx) return huh.ErrUserAborted when the user leaves before submitting, plus ErrTimeout for contexts with a deadline. WithAccessible(true) replaces the full-screen form with plain line-by-line prompts for screen readers, and the README suggests enabling it from an environment variable.
package main
import (
"context"
"errors"
"fmt"
"os"
"charm.land/huh/v2"
"golang.org/x/term"
)
func main() {
if !term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprintln(os.Stderr, "shelf: add needs --title and --author when there is no terminal to ask on.")
os.Exit(2)
}
var title, shelf, token string
var ok bool
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Title").Value(&title).
Validate(func(s string) error {
if s == "" {
return errors.New("a title is required")
}
return nil
}),
huh.NewSelect[string]().Title("Shelf").
Options(huh.NewOptions("fiction", "reference")...).Value(&shelf),
huh.NewInput().Title("API token").EchoMode(huh.EchoModePassword).Value(&token),
huh.NewConfirm().Title("Add this book?").Value(&ok),
),
).WithAccessible(os.Getenv("ACCESSIBLE") != "")
if err := form.RunWithContext(context.Background()); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "shelf: cancelled")
os.Exit(130)
}
fmt.Fprintln(os.Stderr, "shelf:", err)
os.Exit(1)
}
fmt.Println("added:", ok, "token length:", len(token))
}
Source: Huh v2 — pkg.go.dev and golang.org/x/term
Huh is part of the Bubble Tea family; a form can also be embedded as a model inside a larger interface, see Go TUIs with Bubble Tea.
Node.js: Inquirer.js and Clack
@inquirer/prompts exports one async function per prompt (input, select, checkbox, confirm, search, password, expand, editor, number, rawlist), each taking a configuration object and returning a promise of the answer. A second argument takes a context, which includes an output stream and a signal (an AbortSignal, for example AbortSignal.timeout(5000)) for cancelling from outside. Ctrl-C rejects the promise with an ExitPromptError, which the README says to handle with try/catch or a global uncaughtException handler. The editor prompt opens $VISUAL or $EDITOR, falling back to notepad on Windows and vim elsewhere. On Deno the prompts read the environment, so the script needs --allow-env.
The example below writes the prompts to stderr (output: process.stderr), so shelf add --json could still be piped on stdout. It is the program that was run under a pseudo-terminal.
import { input, select, confirm, password } from '@inquirer/prompts';
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error('shelf: add needs --title and --author when there is no terminal to ask on.');
process.exit(2);
}
try {
const context = { output: process.stderr };
const title = await input({ message: 'Title', required: true }, context);
const author = await input({ message: 'Author', default: 'Unknown' }, context);
const shelf = await select({
message: 'Shelf',
choices: [{ name: 'Fiction', value: 'fiction' }, { name: 'Reference', value: 'reference' }],
}, context);
const token = await password({ message: 'API token', mask: '*' }, context);
const ok = await confirm({ message: `Add "${title}" by ${author} to ${shelf}?`, default: true }, context);
console.log(JSON.stringify({ title, author, shelf, added: ok, tokenLength: token.length }));
} catch (error) {
if (error?.name === 'ExitPromptError') { console.error('shelf: cancelled'); process.exit(130); }
throw error;
}
Source: Inquirer.js — README
Run under a pseudo-terminal with the answers Dune, Herbert, Enter, a token and y, it printed {"title":"Dune","author":"Herbert","shelf":"fiction","added":true,"tokenLength":6}; the typed token never appeared in the output (only asterisks did), Ctrl-C at the first question exited with status 130, and piping echo x into it printed the refusal and exited with status 2. In the 8.7.4 release the password prompt also showed a "ctrl+t toggle visibility" hint line; the README does not document the mask option or that key, so check the password package page before relying on either.
@clack/prompts has a smaller, more opinionated look: intro, outro, text({ message, placeholder, initialValue, validate }), password({ message, mask, validate }), confirm, select (options with value, label, hint, disabled), multiselect, spinner() with start and stop, and group() to run named prompts that can read earlier results. Cancelling is not an exception here: after each prompt, isCancel(value) must be tested, or the onCancel callback of group used. A program that forgets the check carries a cancel symbol into its logic.
import * as p from '@clack/prompts';
p.intro('shelf add');
const answers = await p.group(
{
title: () => p.text({ message: 'Title', validate: (v) => (v ? undefined : 'A title is required') }),
token: () => p.password({ message: 'API token', mask: '*' }),
ok: ({ results }) => p.confirm({ message: `Add "${results.title}"?` }),
},
{ onCancel: () => { p.cancel('shelf: cancelled'); process.exit(130); } },
);
p.outro(answers.ok ? 'Added.' : 'Nothing added.');
Source: Clack — @clack/prompts README
.NET: Spectre.Console
The Spectre.Console prompts (TextPrompt<T> with validation, default value and Secret(), SelectionPrompt<T>, MultiSelectionPrompt<T> and the confirmation prompt, run with AnsiConsole.Prompt) and the Interactive capability that says whether anyone can answer them are on .NET TUIs with Spectre.Console & Terminal.Gui, with a complete shelf add example. This page does not repeat it.
Passwords and secrets
Reading a secret differs from reading a line only in that the terminal must not echo it. There are two levels: switching echo off yourself, or using a library or runtime call that does. In both cases the secret should come from a terminal, not from a pipe or a flag, and the best interface is not to prompt at all: offer shelf login --token-stdin and an environment variable for scripts, as the AI section recommends on Configuration & Authentication.
| Platform | Mechanism | What the documentation says |
|---|---|---|
POSIX (Linux, macOS) |
Clear |
Keep |
Windows |
|
|
#include <windows.h>
#include <stdio.h>
#include <string.h>
int read_token(char *buffer, DWORD capacity) {
HANDLE in = GetStdHandle(STD_INPUT_HANDLE);
DWORD saved;
if (!GetConsoleMode(in, &saved)) return -1; /* not a console: input is redirected */
SetConsoleMode(in, saved & ~ENABLE_ECHO_INPUT); /* keep ENABLE_LINE_INPUT */
fputs("API token: ", stderr);
DWORD read = 0;
BOOL ok = ReadConsoleA(in, buffer, capacity - 1, &read, NULL);
SetConsoleMode(in, saved); /* always restore */
fputc('\n', stderr);
if (!ok) return -1;
buffer[read] = '\0';
buffer[strcspn(buffer, "\r\n")] = '\0';
return 0;
}
Source: Microsoft Learn — SetConsoleMode
Runtimes wrap those calls:
-
Java:
System.console().readPassword(...)returns achar[](nullat end of stream) so the array can be overwritten after use, as the Javadoc advises.System.console()is documented as returningnullwhen no console device is available; the Javadoc for Java 21 says so, and later releases changed that behaviour, see Command-Line Programs on the JVM & Kotlin. -
Python:
getpass.getpass(prompt='Password: ', stream=None, *, echo_char=None). On Unix the default stream is/dev/tty, withsys.stderras the fallback. If echo-free input is unavailable,getpassprints a warning, reads fromsys.stdinand issues aGetPassWarning, meaning the secret may be echoed. The Python 3.9 run above hit exactly this path under a pipe. Theecho_charargument (a single printable ASCII character shown per keystroke) was added in Python 3.14. -
Go:
term.ReadPassword(fd)fromgolang.org/x/termreads without echo;Terminal.ReadPassworddoes the same inside aNewTerminalsession. The package documentation is built for Linux, Windows and macOS. -
.NET: Spectre.Console’s
Secret()masks, andConsole.ReadKey(true)does not echo; see the .NET console page. -
Rust: inquire’s
Password(hidden by default, optional mask) and dialoguer’sPassword; the standard library has nothing. -
Libraries: linenoise’s mask mode, prompt_toolkit’s
is_password=Trueand Inquirer’s and Clack’smaskshow asterisks.
Showing nothing, showing a mask or letting the user reveal the text are different choices. Hiding even the length (Unix sudo style, inquire’s Hidden mode) leaks less, but users cannot tell that a paste worked; masks help them and reveal the length. Choose per tool and document it.
Building a REPL
A REPL (read-eval-print loop) is a line editor plus a command dispatcher. Whatever library is used, the same decisions recur:
-
Where history lives. Per-user, outside the working directory, and not world-readable, because history records what was typed. Use the platform’s convention (
~/.local/state/shelf/historyor$XDG_STATE_HOMEon Linux,~/Library/Application Supporton macOS,%LOCALAPPDATA%on Windows) rather than a dotfile in the project. Never record lines typed at a password prompt: a REPL that reads secrets must read them through the password path, not through the history-recording one. Limit its length; Python’s default is unbounded. -
What Ctrl-C and Ctrl-D do. By convention Ctrl-C abandons the current line and returns to the prompt, and Ctrl-D at an empty prompt ends the session. Each library signals them differently:
NULLfromreadline,KeyboardInterruptandEOFErrorin Python,ReadlineError::InterruptedandEofin rustyline,Signal::CtrlCandCtrlDin reedline,ExitPromptErrorin Inquirer.js. Treat the exit as a normal exit with status 0, and an abandoned prompt in a script-driven command as status 130. -
Multi-line input. Let the editor’s validator decide whether Enter submits (prompt_toolkit’s
multiline, rustyline’sValidator, reedline’sDefaultValidator); do not guess in the dispatcher. -
Completion. Complete the verb from a static list and arguments from live data (the books on the shelf). Keep the callback fast, since it runs on every Tab; prompt_toolkit offers
complete_in_threadfor slow completers. -
One command implementation. The REPL line
loan 42and the commandshelf loan 42must run the same function. Parse each REPL line with the same argument parser as the command line, and let it report errors rather than exit. -
The non-terminal path. When standard input is not a terminal, read lines from it with plain buffered I/O, with no prompt and no history, as the Python example does. A script that pipes commands into
shelf shellthen works. -
Windows. GNU Readline and libedit are not part of the Windows toolchain; pick a library that states Windows support (prompt_toolkit, rustyline, replxx, JLine, reedline’s crossterm back-end), or accept the console host’s built-in line editing with
fgets,input()orConsole.ReadLine. Windows console input also differs in the keys it can report, covered on Windows Console & Processes.
Pitfalls and security
-
Interactive code reached without a terminal. The most common failure. Check
isatty(RustIsTerminal, Goterm.IsTerminal, Nodeprocess.stdin.isTTY, Pythonsys.stdin.isatty()), fail with exit status 2 and name the flag. See the AI section’s TTY page. -
Prompting on stdout. Write prompts to stderr (dialoguer does by default; Inquirer.js needs
output: process.stderr), soshelf list --jsonstyle output is never mixed with questions. -
Leaving the terminal broken. A crash while the editor is active leaves echo off. Install a panic hook or
finallyblock that restores it; libraries do this on normal return and on their handled signals, not onSIGKILL. -
GPL surprise. Linking GNU Readline makes the program subject to the GPL. Choose libedit, linenoise, replxx or a language-native editor when distributing under another licence.
-
Secrets in history, flags and logs. Do not put a token in
argv(visible in process listings), do not add secret lines to history, and do not log what a prompt returned. Overwrite buffers where the language allows it (Java’schar[]). -
Paste. A pasted multi-line block can run commands in a REPL that treats each newline as Enter. Editors that support bracketed paste (the
golang.org/x/termTerminalhasSetBracketedPasteMode) deliver it as one chunk; see Escape Sequences & Terminal Capabilities. -
Prompt injection into the terminal. Text from files or a server that is printed into a prompt, a completion list or a history entry can contain escape sequences. Strip control characters from anything you did not generate.
-
Accessibility. Cursor-redrawing prompts are hard for screen readers. Huh offers an accessible mode that falls back to plain line prompts; offer the same switch (an environment variable or
--no-inputwith flags) for any prompt flow. -
Unverified cancellation behaviour. The documentation of questionary, prompt_toolkit (Ctrl-C and Ctrl-D) and dialoguer (no terminal attached) does not describe these cases. Test them in your own build rather than assuming.