Crust logoCrust

Prompts

Interactive terminal prompts for CLI applications — input, password, confirm, select, multiselect, filter, and spinner.

@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.

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 — useful for prefilling from CLI flags or CI environments.

Install

bun add @crustjs/prompts

Quick Example

import { input, confirm, select, multiselect } 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,
});

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",
});
OptionTypeDefaultDescription
messagestringPrompt message displayed to the user
placeholderstringPlaceholder text shown when input is empty
defaultstringValue used when the user submits empty input
initialstringSkip prompt and return this value immediately
validateValidateFn<string>Return true or an error message string
themePartialPromptThemePer-prompt theme overrides

password(options)

Masked text input — characters are displayed as the mask character (default *). After submission, a fixed-length mask is shown to prevent revealing the password length.

const secret = await password({
  message: "Enter your password:",
  validate: (v) => v.length >= 8 || "Must be at least 8 characters",
});
OptionTypeDefaultDescription
messagestringPrompt message
maskstring"*"Character used to mask the input
initialstringSkip prompt and return this value immediately
validateValidateFn<string>Return true or an error message string
themePartialPromptThemePer-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,
});
OptionTypeDefaultDescription
messagestringPrompt message
defaultbooleantrueDefault value when Enter is pressed without toggle
initialbooleanSkip prompt and return this value immediately
activestring"Yes"Label for the affirmative option
inactivestring"No"Label for the negative option
themePartialPromptThemePer-prompt theme overrides

select(options)

Single selection from a list. Navigate with Up/Down (or k/j), 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,
});
OptionTypeDefaultDescription
messagestringPrompt message
choicesChoice<T>[]Strings or { label, value, hint? } objects
defaultTSets initial cursor to the matching choice
initialTSkip prompt and return this value immediately
maxVisiblenumber10Maximum visible choices before scrolling
themePartialPromptThemePer-prompt theme overrides

multiselect(options)

Checkbox-style multi-selection. Space to toggle, a to toggle all, i to invert, Enter to confirm.

When max is set, toggle-all and invert respect the constraint — selections are capped at max items.

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,
});
OptionTypeDefaultDescription
messagestringPrompt message
choicesChoice<T>[]Strings or { label, value, hint? } objects
defaultT[]Pre-selects matching choices
initialT[]Skip prompt and return this value immediately
requiredbooleanfalseRequire at least one selection
minnumberMinimum selections required
maxnumberMaximum selections allowed
maxVisiblenumber10Maximum visible choices before scrolling
themePartialPromptThemePer-prompt theme overrides

filter(options)

Fuzzy-search selection. Type to filter, navigate with Up/Down, confirm with Enter. Matched characters are highlighted in results.

const lang = await filter({
  message: "Search for a language",
  choices: ["TypeScript", "JavaScript", "Rust", "Python", "Go"],
  placeholder: "Type to filter...",
});
OptionTypeDefaultDescription
messagestringPrompt message
choicesChoice<T>[]Strings or { label, value, hint? } objects
initialTSkip prompt and return this value immediately
placeholderstringPlaceholder text for the query input
maxVisiblenumber10Maximum visible results before scrolling
themePartialPromptThemePer-prompt theme overrides

spinner(options)

Display a spinner animation while an async task runs. Non-interactive — output-only. Shows a success or error indicator when the task completes.

In non-interactive environments (when stderr is not a TTY), the spinner skips all animation and ANSI escape codes — only the final success () or error () line is printed.

const data = await spinner({
  message: "Fetching data...",
  task: async () => {
    const res = await fetch("https://api.example.com/data");
    return res.json();
  },
  spinner: "arc",
});

The task receives a SpinnerController that lets you update the displayed message mid-operation:

await spinner({
  message: "Installing dependencies...",
  task: async ({ updateMessage }) => {
    await installDeps();
    updateMessage("Building project...");
    await buildProject();
    updateMessage("Running checks...");
    await runChecks();
  },
});

The success/error indicator always shows the latest message.

OptionTypeDefaultDescription
messagestringMessage displayed alongside the spinner
task(controller: SpinnerController) => Promise<T>Async task to run (receives a controller for runtime updates)
spinnerSpinnerType"dots"Animation style: "dots", "line", "arc", "bounce", or custom { frames, interval }
themePartialPromptThemePer-prompt theme overrides

Themes

All prompts share a theming system with 11 style slots. Each slot is a StyleFn — a (text: string) => string function from @crustjs/style.

Default Theme

SlotDefaultUsed for
prefixcyanPrefix glyph before the message
messageboldPrompt message text
placeholderdimPlaceholder text in empty inputs
cursorcyanCursor indicator in inputs and lists
selectedcyanActive / selected item
unselecteddimInactive / unselected items
errorredValidation error messages
successgreenConfirmed / submitted values
hintdimHint text and scroll indicators
spinnermagentaSpinner frame characters
filterMatchcyanMatched characters in filter results

Custom Themes

import { setTheme, createTheme } from "@crustjs/prompts";
import { magenta, cyan, italic } from "@crustjs/style";

// Set a global theme — applies to all prompts
setTheme({ prefix: magenta, success: cyan });

// Or create a reusable theme object
const myTheme = createTheme({ prefix: magenta, success: cyan });
setTheme(myTheme);

// Per-prompt overrides (highest priority)
const name = await input({
  message: "Name?",
  theme: { message: italic },
});

Theme resolution order (later wins):

  1. defaultTheme (base)
  2. Global overrides (setTheme())
  3. Per-prompt overrides (theme option)

Non-Interactive Environments

All prompts (except spinner) 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.

The spinner works in both interactive and non-interactive environments. In non-TTY mode, it skips animation and only prints the final success/error line to stderr.

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
  }
}

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";

// In your handleKey function:
const edit = handleTextEdit(key, currentText, cursorPos);
if (edit) {
  // edit.text and edit.cursorPos are updated
  return { ...state, text: edit.text, cursorPos: edit.cursorPos };
}
// null means not a text-editing key — handle other keys

handleTextEdit handles: backspace, delete, left, right, home, end, and printable character insertion. Returns null for non-text-editing keys so you can handle them separately (e.g., Enter for submit, Up/Down for list navigation).

Only one prompt can be active at a time. Running a second prompt while one is already active will reject with an error.

Exports

Prompt Functions

FunctionDescription
inputSingle-line text input
passwordMasked password input
confirmYes/no boolean prompt
selectSingle selection from a list
multiselectCheckbox-style multi-selection
filterFuzzy-search selection
spinnerSpinner animation for async tasks

Theme

ExportDescription
defaultThemeDefault prompt theme
createThemeCreate a theme with partial overrides
setThemeSet the global theme for all prompts
getThemeGet the current resolved global theme

Renderer

ExportDescription
runPromptLow-level prompt runner for custom prompts
submitCreate a submit result to resolve a prompt
assertTTYThrow NonInteractiveError if stdin is not a TTY
NonInteractiveErrorError thrown when a TTY is required but absent
CancelledErrorError thrown when the user presses Ctrl+C

Utilities

ExportDescription
handleTextEditShared text-editing keypress handler for custom prompts
CURSOR_CHARCursor indicator character ()
fuzzyMatchScore a query against a single candidate string
fuzzyFilterFilter and rank a list of choices by fuzzy query
normalizeChoicesNormalize string/object choices to { label, value }
formatPromptLineFormat prompt header with inline content
formatSubmittedFormat submitted line for a prompt
calculateScrollOffsetCalculate viewport scroll offset for lists

Types

TypeDescription
InputOptionsOptions for input()
PasswordOptionsOptions for password()
ConfirmOptionsOptions for confirm()
SelectOptions<T>Options for select()
MultiselectOptions<T>Options for multiselect()
FilterOptions<T>Options for filter()
SpinnerOptions<T>Options for spinner()
SpinnerControllerController passed to spinner task (updateMessage)
SpinnerTypeSpinner animation type
PromptThemeComplete theme with all style slots
PartialPromptThemePartial theme for overrides
Choice<T>Choice item — string or { label, value, hint? }
ValidateFn<T>Validation function type
ValidateResulttrue or error message string
KeypressEventStructured 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/value
FuzzyMatchResultSingle fuzzy match result with score and indices
FuzzyFilterResult<T>Fuzzy filter result with item and match data
TextEditStateText + cursor position for handleTextEdit
TextEditResultTextEditState | 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
  • initial escape hatch — Every prompt accepts initial to skip interactivity, enabling 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 (default, global, per-prompt) with StyleFn slots from @crustjs/style
  • Shared text editing — Common cursor-editing logic is extracted into handleTextEdit for consistency and reuse in custom prompts

On this page