All specs
Interactive · prompt

Prompt

The most common point of interaction in a transactional CLI. Sets the tone for everything else.

Variants

VariantPurpose
textSingle-line text input
passwordMasked text input
confirmBoolean yes/no
selectSingle choice from a list
multiSelectMultiple choices from a list
numberNumeric input with optional bounds

All variants share the same anatomy, the same micro-interactions, and the same accessibility guarantees.

---

Anatomy

Every prompt, regardless of variant, is anchored by ^ — the Caret brand mark. The anchor tells the user "this is a Caret moment" and marks where the prompt begins in the scrollback.

### text variant

^ Project name
  What should we call your project?
  ▸ my-project█
  ⚠ Must be lowercase letters and dashes

1. **Anchor** (^) — Caret brand mark, accent color, single character, always present

2. **Label** — bold, fg.default, required

3. **Description** — fg.muted, optional, wraps on narrow terminals

4. **Input row** — ▸ prefix (accent when focused, fg.muted when idle) + user's text + cursor

5. **Hint row** — fg.muted for hints, danger with ⚠ for errors, single line

### select variant

^ Framework
  Choose your frontend framework

  ● Next.js
  ○ Remix
  ○ SvelteKit
  ○ Astro

  ↑↓ navigate   ↵ select   esc cancel

▸● marks the highlighted option (accent)

▸○ marks the other options (fg.muted)

▸Footer row shows keyboard hints in fg.subtle, auto-hidden on narrow terminals

### confirm variant

^ Deploy to production?
  This will push to my-app.vercel.app

  ● Yes    ○ No

  ←→ toggle   ↵ confirm   esc cancel

### password variant

^ API token
  Paste your personal access token
  ▸ •••••••••••••••█

Mask character is • by default (configurable, but discouraged).

### Resolved state

After submit, every variant collapses to a single line:

^ Project name: my-project

The label is fg.muted, the value is fg.default. Cancelled prompts resolve to:

^ Project name: —

(dim, italic — in fg.subtle)

---

States

StateDescription
idleInitial render, cursor visible, prefix in accent
typingUser is entering input, cursor tracks position
validatingAsync validation running, inline dim spinner after input
errorValidation failed, ⚠ line visible, prefix in danger
submittingEnter pressed, validation passed, brief accent pulse
submittedResolved to single-line summary in scrollback
cancelledESC pressed, resolved to — placeholder
disabledNon-interactive mode (pipe, CI), auto-fails with guidance

---

Micro-interactions

Every transition is bounded, inline-safe, and auto-disabled outside a live TTY or when CARET_REDUCED_MOTION=1.

TriggerMotionDuration token
Mount (focus)▸ prefix fades from fg.muted → accentmotion.duration.quick
TypingCursor moves, no other animation—
Select nav (↑/↓)Highlighted ● marker fades dim → accent on the new rowmotion.duration.instant
Confirm toggle (←/→)● swaps sides, color crossfade on both optionsmotion.duration.quick
SubmitInput row flashes accent, resolves to scrollback linemotion.duration.default
Error reveal⚠ line fades in below inputmotion.duration.quick
CancelPrompt collapses to — in dim italicmotion.duration.quick
Validating (async)Inline spinner appears after input, resolves with morphmotion.duration.default

Reduced-motion fallback: all transitions become instant. The end state is always the same.

---

Tokens used

Color

▸fg.default — label, input value, resolved value

▸fg.muted — description, hint text, idle prefix, label in resolved state

▸fg.subtle — keyboard hint footer, cancelled —

▸accent — ^ anchor, focused ▸ prefix, ● selected marker, submit flash

▸danger — ⚠ symbol, error text, error-state prefix

▸success — brief submit pulse on success (optional, subtle)

Motion

▸motion.duration.instant · motion.duration.quick · motion.duration.default

▸motion.easing.standard

Symbols

▸^ — anchor, required at the top of every prompt

▸▸ — focused input prefix

▸· — unfocused prefix (used only in multi-prompt forms; not in v0)

▸● / ○ — selected / unselected marker for select and confirm

▸⚠ — error marker

▸— — cancelled placeholder

Spacing

▸0 lines between anchor and label

▸1 line between label and description

▸1 line between description and input

▸1 line between input and hint/error

▸0 lines after resolved state (it is a single line)

---

Behavior

### Keyboard

KeyEffect
↵Submit (runs validation first)
escCancel (returns null or throws CaretCancelled per config)
ctrl+cHard exit; SIGINT passed through, never swallowed
↑ / ↓Navigate in select/multiSelect; noop in text
← / →Toggle in confirm; cursor move in text/password/number
spaceToggle in multiSelect; insert space in text
tabNext option in select/multiSelect; ignored in text by default
backspaceDelete character in text inputs
ctrl+uClear line in text inputs
ctrl+a / ctrl+eStart / end of line in text inputs

Caret owns the keyboard layer. Key bindings are not user-configurable — consistency across every Caret CLI is the point.

### Validation

▸**Sync**: validate: (value) => string | null — return an error message string, or null for valid

▸**Async**: validate: async (value) => string | null — shows inline spinner during the async call

▸Validation runs on submit by default; use validateOn: 'change' for live validation

▸Validation errors are rendered in the error state (hint row becomes red with ⚠)

### Submit

▸Validation passes → submitting state → pulse → resolved single-line

▸Validation fails → error state; cursor stays in the input; user can edit and retry

### Cancel

▸esc triggers cancel

▸Default: cancel: 'throw' — throws CaretCancelled so callers can handle the flow explicitly

▸Alternative: cancel: 'null' — returns null; useful when the prompt is part of an optional path

### Non-interactive environments

When stdin is not a TTY (piped, CI, scripted):

▸If a default is provided → Caret uses it silently and emits the resolved line as usual

▸If no default → Caret fails with a clear error pointing at the missing flag or env var

▸Caret never hangs waiting for input that will never come

---

Accessibility

▸**NO_COLOR**: every color is stripped; symbols (^, ▸, ●, ○, ⚠, —) carry all meaning

▸**Reduced motion**: all micro-interactions become instant; final states render immediately

▸**Narrow terminal (< 40 cols)**: description wraps, footer hint hides, input truncates with horizontal scroll

▸**Dumb terminal**: renders as plain Label: [user input] with no cursor positioning

▸**Screen reader**: each prompt announces label + current state via stderr metadata where supported; at minimum the final resolved line is readable as plain text

▸**Colorblind**: the ● / ○ / ⚠ / — symbols distinguish all states without color

---

API

import { prompt } from './caret'

// text
const name = await prompt.text({
  label: 'Project name',
  description: 'What should we call your project?',
  placeholder: 'my-project',
  default: 'my-project',
  validate: (v) =>
    /^[a-z][a-z0-9-]*$/.test(v) ? null : 'Lowercase letters, numbers, and dashes only',
})

// password
const token = await prompt.password({
  label: 'API token',
  description: 'Paste your personal access token',
})

// confirm
const ok = await prompt.confirm({
  label: 'Deploy to production?',
  description: 'This will push to my-app.vercel.app',
  default: false,
})

// select
const framework = await prompt.select({
  label: 'Framework',
  description: 'Choose your frontend framework',
  options: [
    { value: 'next',   label: 'Next.js' },
    { value: 'remix',  label: 'Remix' },
    { value: 'svelte', label: 'SvelteKit' },
    { value: 'astro',  label: 'Astro' },
  ],
  default: 'next',
})

// multiSelect
const features = await prompt.multiSelect({
  label: 'Features',
  options: [
    { value: 'auth',  label: 'Authentication' },
    { value: 'db',    label: 'Database' },
    { value: 'email', label: 'Email' },
  ],
  min: 1,
})

// number
const port = await prompt.number({
  label: 'Port',
  min: 1024,
  max: 65535,
  default: 3000,
})

All variants share these common options:

type PromptCommon = {
  label: string
  description?: string
  cancel?: 'throw' | 'null'   // default: 'throw'
}

---

Do & don't

Do

▸Use description only when the label is ambiguous on its own

▸Provide default for prompts that have a sensible common answer

▸Use validate for format rules (regex, length, range)

▸Pair a prompt with spinner when the resolved value triggers a long operation

Don't

▸Don't stack 10 prompts back-to-back — if you need a form, wait for the form component

▸Don't use prompt.text for dangerous confirmations — use prompt.confirm with default: false

▸Don't use validate for network calls or availability checks — they're slow and block the input

▸Don't write ANSI codes in label or description — Caret owns this layer

▸Don't customize the anchor, prefix, or marker symbols — they are the brand

---

Out of scope (for v0)

▸**Multi-field forms on one screen** — separate form component, later

▸**Autocomplete from a remote list** — will become commandPalette, later

▸**Rich markdown in description** — label and description are plain text only

▸**Custom key bindings** — Caret owns the keyboard

▸**Persistent prompt history** (readline-style ↑ recall) — not in v0

▸**Masked input with custom mask character that breaks on Unicode** — • or nothing