Prompts
Interactive terminal prompts for CLI applications — input, password, confirm, select, multiselect, filter, and multifilter.
@crustjs/prompts provides interactive terminal prompts for building polished CLI experiences. It includes seven prompt types, a customizable theme system, fuzzy matching, and a low-level renderer for building custom prompts.
Spinner support has moved to @crustjs/progress. @crustjs/prompts still re-exports spinner temporarily for compatibility, but that re-export is deprecated and will be removed in v0.1.0.
For schema-based prompt validation, use promptValidator() from @crustjs/validate to create a validate function from a Zod, Effect, or any Standard Schema-compatible schema.
All prompt UI renders to stderr so it never pollutes piped stdout. Every prompt accepts an initial option to skip interactivity, which is useful for prefilling from CLI flags or CI environments.
Install
bun add @crustjs/promptsQuick Example
import {
input,
confirm,
select,
multiselect,
filter,
multifilter,
} from "@crustjs/prompts";
const name = await input({ message: "Project name?" });
const useTS = await confirm({ message: "Use TypeScript?" });
const framework = await select({
message: "Framework",
choices: ["react", "vue", "svelte"],
});
const features = await multiselect({
message: "Features",
choices: ["linting", "testing", "ci"],
required: true,
});
const language = await filter({
message: "Language",
choices: ["typescript", "javascript", "rust"],
});
const addons = await multifilter({
message: "Add-ons",
choices: ["tailwind", "testing", "storybook", "eslint"],
});Prompts
input(options)
Single-line text input with cursor editing, placeholder, default value, and validation.
const name = await input({
message: "What is your name?",
placeholder: "John Doe",
default: "anonymous",
validate: (v) => v.length > 0 || "Name is required",
});| Option | Type | Default | Description |
|---|---|---|---|
message | string | — | Prompt message displayed to the user |
placeholder | string | — | Placeholder text shown when input is empty |
default | string | — | Value used when the user submits empty input |
initial | string | — | Skip prompt and return this value immediately |
validate | ValidateFn<string> | — | Return true or an error message string |
theme | PartialPromptTheme | — | Per-prompt theme overrides |
password(options)
Masked text input. Characters are displayed as the mask character, which defaults to *. After submission, a fixed-length mask is shown to avoid revealing password length.
const secret = await password({
message: "Enter your password:",
validate: (v) => v.length >= 8 || "Must be at least 8 characters",
});| Option | Type | Default | Description |
|---|---|---|---|
message | string | — | Prompt message |
mask | string | "*" | Character used to mask the input |
initial | string | — | Skip prompt and return this value immediately |
validate | ValidateFn<string> | — | Return true or an error message string |
theme | PartialPromptTheme | — | Per-prompt theme overrides |
confirm(options)
Yes/no boolean prompt. Toggle with arrow keys, h/l, y/n, or Tab. Confirm with Enter.
const proceed = await confirm({
message: "Accept the license?",
active: "Accept",
inactive: "Decline",
default: false,
});| Option | Type | Default | Description |
|---|---|---|---|
message | string | — | Prompt message |
default | boolean | true | Value used when Enter is pressed without toggling |
initial | boolean | — | Skip prompt and return this value immediately |
active | string | "Yes" | Label for the affirmative option |
inactive | string | "No" | Label for the negative option |
theme | PartialPromptTheme | — | Per-prompt theme overrides |
select(options)
Single selection from a list. Navigate with Up/Down or k/j, then confirm with Enter. Scrolls when the list exceeds maxVisible.
const port = await select({
message: "Choose a port",
choices: [
{ label: "HTTP", value: 80 },
{ label: "HTTPS", value: 443, hint: "recommended" },
],
default: 443,
});| Option | Type | Default | Description |
|---|---|---|---|
message | string | — | Prompt message |
choices | Choice<T>[] | — | Strings or { label, value, hint? } objects |
default | T | — | Sets initial cursor to the matching choice |
initial | T | — | Skip prompt and return this value immediately |
maxVisible | number | 10 | Maximum visible choices before scrolling |
theme | PartialPromptTheme | — | Per-prompt theme overrides |
multiselect(options)
Checkbox-style multi-selection. Use Space to toggle, a to toggle all, i to invert, and Enter to confirm.
When max is set, toggle-all and invert respect the constraint.
const features = await multiselect({
message: "Enable features",
choices: [
{ label: "TypeScript", value: "ts", hint: "recommended" },
{ label: "ESLint", value: "eslint" },
{ label: "Prettier", value: "prettier" },
],
default: ["ts"],
required: true,
max: 2,
});| Option | Type | Default | Description |
|---|---|---|---|
message | string | — | Prompt message |
choices | Choice<T>[] | — | Strings or { label, value, hint? } objects |
default | T[] | — | Pre-selects matching choices |
initial | T[] | — | Skip prompt and return this value immediately |
required | boolean | false | Require at least one selection |
min | number | — | Minimum selections required |
max | number | — | Maximum selections allowed |
maxVisible | number | 10 | Maximum visible choices before scrolling |
theme | PartialPromptTheme | — | Per-prompt theme overrides |
filter(options)
Fuzzy-search selection. Type to filter, navigate with Up/Down, and confirm with Enter. Matched characters are highlighted in results.
Use filter when users should choose a single item from a long list and need search to narrow it quickly. For fuzzy multi-selection, use multifilter.
const lang = await filter({
message: "Search for a language",
choices: ["TypeScript", "JavaScript", "Rust", "Python", "Go"],
placeholder: "Type to filter...",
});| Option | Type | Default | Description |
|---|---|---|---|
message | string | — | Prompt message |
choices | Choice<T>[] | — | Strings or { label, value, hint? } objects |
initial | T | — | Skip prompt and return this value immediately |
default | T | — | Sets initial highlight; used when not a TTY |
placeholder | string | — | Placeholder text for the query input |
maxVisible | number | 10 | Maximum visible results before scrolling |
theme | PartialPromptTheme | — | Per-prompt theme overrides |
multifilter(options)
Fuzzy-search multi-selection. Type to filter, navigate with Up/Down, use Space to toggle the highlighted result, and confirm with Enter. Selected values are returned in original choice order.
Use multifilter when the list is large enough that users need a search box before they can comfortably select multiple items.
filter and multifilter mirror the select and multiselect split:
filterreturns a singleTmultifilterreturnsT[]
const features = await multifilter({
message: "Enable features",
choices: [
{ label: "TypeScript", value: "ts", hint: "recommended" },
{ label: "ESLint", value: "eslint" },
{ label: "Prettier", value: "prettier" },
],
default: ["ts"],
required: true,
});| Option | Type | Default | Description |
|---|---|---|---|
message | string | — | Prompt message |
choices | Choice<T>[] | — | Strings or { label, value, hint? } objects |
default | T[] | — | Pre-selects matching choices |
initial | T[] | — | Skip prompt and return these values immediately |
required | boolean | false | Require at least one selection |
min | number | — | Minimum selections required |
max | number | — | Maximum selections allowed |
placeholder | string | — | Placeholder text for the query input |
maxVisible | number | 10 | Maximum visible results before scrolling |
theme | PartialPromptTheme | — | Per-prompt theme overrides |
Themes
All prompts share a theming system with 10 style slots. Each slot is a StyleFn, which is a (text: string) => string function from @crustjs/style.
Default Theme
| Slot | Default | Used for |
|---|---|---|
prefix | cyan | Prefix glyph before the message |
message | bold | Prompt message text |
placeholder | dim | Placeholder text in empty inputs |
cursor | cyan | Cursor indicator in inputs and lists |
selected | cyan | Active or selected item |
unselected | dim | Inactive or unselected items |
error | red | Validation error messages |
success | green | Confirmed or submitted values |
hint | dim | Hint text and scroll indicators |
filterMatch | cyan | Matched characters in filter results |
Custom Themes
import { setTheme, createTheme, input } from "@crustjs/prompts";
import { magenta, cyan, italic } from "@crustjs/style";
setTheme({ prefix: magenta, success: cyan });
const myTheme = createTheme({ prefix: magenta, success: cyan });
setTheme(myTheme);
const name = await input({
message: "Name?",
theme: { message: italic },
});Theme resolution order, from lowest to highest priority:
defaultTheme- Global overrides from
setTheme() - Per-prompt overrides from the
themeoption
Non-Interactive Environments
All prompts require an interactive TTY. When stdin is not a TTY, prompts throw NonInteractiveError, unless initial is provided, in which case the value is returned immediately without rendering.
import { input, NonInteractiveError, CancelledError } from "@crustjs/prompts";
try {
const name = await input({
message: "Name?",
initial: process.env.CI ? "ci-user" : undefined,
});
} catch (err) {
if (err instanceof NonInteractiveError) {
// Not a TTY and no initial value
}
if (err instanceof CancelledError) {
// User pressed Ctrl+C
}
}For non-interactive async progress output, use @crustjs/progress.
Custom Prompts
The low-level runPrompt API lets you build custom interactive prompts with full control over state, rendering, and keypress handling.
import { runPrompt, submit } from "@crustjs/prompts";
import type { KeypressEvent } from "@crustjs/prompts";
import { getTheme } from "@crustjs/prompts";
interface CounterState {
count: number;
}
const result = await runPrompt<CounterState, number>({
initialState: { count: 0 },
theme: getTheme(),
render: (state, theme) => `${theme.prefix("○")} Count: ${state.count}`,
handleKey: (key, state) => {
if (key.name === "return") return submit(state.count);
if (key.name === "up") return { count: state.count + 1 };
if (key.name === "down") return { count: state.count - 1 };
return state;
},
renderSubmitted: (state, value, theme) =>
`${theme.prefix("○")} Count: ${theme.success(String(value))}`,
});Text Editing Helper
For custom prompts that need text input, use the shared handleTextEdit helper instead of reimplementing cursor editing logic.
import { handleTextEdit, CURSOR_CHAR } from "@crustjs/prompts";
const edit = handleTextEdit(key, currentText, cursorPos);
if (edit) {
return { ...state, text: edit.text, cursorPos: edit.cursorPos };
}handleTextEdit handles backspace, delete, left, right, home, end, and printable character insertion. It returns null for non-text-editing keys so you can handle them separately.
Only one prompt can be active at a time. Running a second prompt while one is already active rejects with an error.
Exports
Prompt Functions
| Function | Description |
|---|---|
input | Single-line text input |
password | Masked password input |
confirm | Yes/no boolean prompt |
select | Single selection from a list |
multiselect | Checkbox-style multi-selection |
filter | Fuzzy-search selection |
multifilter | Fuzzy-search multi-selection |
Theme
| Export | Description |
|---|---|
defaultTheme | Default prompt theme |
createTheme | Create a theme with partial overrides |
setTheme | Set the global theme for all prompts |
getTheme | Get the current resolved global theme |
Renderer
| Export | Description |
|---|---|
runPrompt | Low-level prompt runner for custom prompts |
submit | Create a submit result to resolve a prompt |
assertTTY | Throw NonInteractiveError if stdin is not a TTY |
NonInteractiveError | Error thrown when a TTY is required but absent |
CancelledError | Error thrown when the user presses Ctrl+C |
Utilities
| Export | Description |
|---|---|
handleTextEdit | Shared text-editing keypress handler |
CURSOR_CHAR | Cursor indicator character (│) |
fuzzyMatch | Score a query against a single candidate string |
fuzzyFilter | Filter and rank choices by fuzzy query |
normalizeChoices | Normalize choices to { label, value } |
formatPromptLine | Format prompt header with inline content |
formatSubmitted | Format submitted line for a prompt |
calculateScrollOffset | Calculate viewport scroll offset for lists |
Types
| Type | Description |
|---|---|
InputOptions | Options for input() |
PasswordOptions | Options for password() |
ConfirmOptions | Options for confirm() |
SelectOptions<T> | Options for select() |
MultiselectOptions<T> | Options for multiselect() |
FilterOptions<T> | Options for filter() |
MultifilterOptions<T> | Options for multifilter() |
PromptTheme | Complete theme with all style slots |
PartialPromptTheme | Partial theme for overrides |
Choice<T> | Choice item: string or { label, value, hint? } |
ValidateFn<T> | Validation function type |
ValidateResult | true or an error message string |
KeypressEvent | Structured keypress event |
PromptConfig<S,T> | Configuration for runPrompt() |
SubmitResult<T> | Submit action type |
HandleKeyResult<S,T> | Return type of keypress handlers |
NormalizedChoice<T> | Normalized choice with explicit label and value |
FuzzyMatchResult | Single fuzzy match result with score and indices |
FuzzyFilterResult<T> | Fuzzy filter result with item and match data |
TextEditState | Text plus cursor position for handleTextEdit |
TextEditResult | TextEditState | null |
Design
@crustjs/prompts is the interactive layer of the Crust ecosystem. It depends only on @crustjs/style for ANSI styling and has no other runtime dependencies.
Key design decisions:
- stderr rendering: All prompt UI writes to stderr, keeping stdout clean for piped output.
initialescape hatch: Every prompt acceptsinitialto skip interactivity in CI and scripted usage.- Single active prompt: Only one prompt can run at a time; concurrent calls are rejected with a clear error.
- Composable themes: Three-layer resolution with
StyleFnslots from@crustjs/style. - Shared text editing: Common cursor-editing logic lives in
handleTextEditfor consistency and reuse in custom prompts.