Crust logoCrust

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/prompts

Quick 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",
});
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, 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",
});
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
defaultbooleantrueValue used when Enter is pressed without toggling
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, 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,
});
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. 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,
});
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, 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...",
});
OptionTypeDefaultDescription
messagestringPrompt message
choicesChoice<T>[]Strings or { label, value, hint? } objects
initialTSkip prompt and return this value immediately
defaultTSets initial highlight; used when not a TTY
placeholderstringPlaceholder text for the query input
maxVisiblenumber10Maximum visible results before scrolling
themePartialPromptThemePer-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:

  • filter returns a single T
  • multifilter returns T[]
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,
});
OptionTypeDefaultDescription
messagestringPrompt message
choicesChoice<T>[]Strings or { label, value, hint? } objects
defaultT[]Pre-selects matching choices
initialT[]Skip prompt and return these values immediately
requiredbooleanfalseRequire at least one selection
minnumberMinimum selections required
maxnumberMaximum selections allowed
placeholderstringPlaceholder text for the query input
maxVisiblenumber10Maximum visible results before scrolling
themePartialPromptThemePer-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

SlotDefaultUsed for
prefixcyanPrefix glyph before the message
messageboldPrompt message text
placeholderdimPlaceholder text in empty inputs
cursorcyanCursor indicator in inputs and lists
selectedcyanActive or selected item
unselecteddimInactive or unselected items
errorredValidation error messages
successgreenConfirmed or submitted values
hintdimHint text and scroll indicators
filterMatchcyanMatched 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:

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

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

FunctionDescription
inputSingle-line text input
passwordMasked password input
confirmYes/no boolean prompt
selectSingle selection from a list
multiselectCheckbox-style multi-selection
filterFuzzy-search selection
multifilterFuzzy-search multi-selection

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
CURSOR_CHARCursor indicator character ()
fuzzyMatchScore a query against a single candidate string
fuzzyFilterFilter and rank choices by fuzzy query
normalizeChoicesNormalize 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()
MultifilterOptions<T>Options for multifilter()
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 an 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 and value
FuzzyMatchResultSingle fuzzy match result with score and indices
FuzzyFilterResult<T>Fuzzy filter result with item and match data
TextEditStateText plus 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 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 StyleFn slots from @crustjs/style.
  • Shared text editing: Common cursor-editing logic lives in handleTextEdit for consistency and reuse in custom prompts.

On this page