Skip to content

Forms & Input

Interactive CLI wizards guide users through multi-step configuration, installation, or setup flows. Silvery provides the building blocks as first-class React components: each wizard step is a React component with its own state, and transitions between steps are just state changes. You get full control over layout, focus, and rendering without leaving the React model.

Key Benefits

  • SelectList — Built-in single-select with keyboard navigation (arrow keys, j/k, Home/End), disabled item support, and automatic scroll windowing via maxVisible. No external prompt library needed.

  • TextInput — Text input with full readline shortcuts (Ctrl+A/E for Home/End, Ctrl+K/U for kill line, Alt+B/F for word movement, Ctrl+Y for yank, kill ring). Supports controlled and uncontrolled modes, placeholder text, and password masking.

  • ProgressBar and Spinner — Visual progress feedback during long operations. ProgressBar supports both determinate (0–1 value) and indeterminate (animated) modes. Spinner ships with four animation presets (dots, line, arc, bounce).

  • Focus scopes — Each wizard step can live inside a Box with focusScope, so Tab and arrow key navigation cycles within that step rather than leaking to the entire app.

  • Static rendering — When the wizard finishes, use renderStatic() to produce pipe-friendly output with no cursor control sequences. Pass { plain: true } to strip ANSI codes entirely.

Source Code

A complete multi-step wizard using Silvery's built-in components — SelectList handles keyboard navigation, TextInput provides readline editing, and ProgressBar shows progress. No manual useInput() for selection or text entry:

tsx
import { useState, useEffect } from "react"
import { Box, Text, SelectList, TextInput, ProgressBar, render, useApp, createTerm } from "silvery"

type Step = "select" | "name" | "install" | "done"

function Wizard() {
  const { exit } = useApp()
  const [step, setStep] = useState<Step>("select")
  const [framework, setFramework] = useState("")
  const [name, setName] = useState("")
  const [progress, setProgress] = useState(0)

  // Simulate installation progress
  useEffect(() => {
    if (step !== "install") return
    const timer = setInterval(() => {
      setProgress((p) => {
        if (p >= 1) {
          clearInterval(timer)
          setStep("done")
          return 1
        }
        return p + 0.05
      })
    }, 100)
    return () => clearInterval(timer)
  }, [step])

  if (step === "select") {
    return (
      <Box flexDirection="column" gap={1} focusScope>
        <Text bold>Step 1: Choose a framework</Text>
        <SelectList
          items={[
            { label: "React", value: "react" },
            { label: "Vue", value: "vue" },
            { label: "Svelte", value: "svelte" },
            { label: "Angular (coming soon)", value: "angular", disabled: true },
          ]}
          onSelect={(item) => {
            setFramework(item.label)
            setStep("name")
          }}
          maxVisible={5}
        />
        <Text color="$muted">↑↓ navigate Enter select</Text>
      </Box>
    )
  }

  if (step === "name") {
    return (
      <Box flexDirection="column" gap={1} focusScope>
        <Text bold>Step 2: Project name</Text>
        <TextInput
          placeholder="my-app"
          onSubmit={(val) => {
            setName(val || "my-app")
            setStep("install")
          }}
          prompt="> "
        />
        <Text color="$muted">Type a name and press Enter (Ctrl+A/E, Ctrl+K/U, Alt+B/F all work)</Text>
      </Box>
    )
  }

  if (step === "install") {
    return (
      <Box flexDirection="column" gap={1}>
        <Text bold>Step 3: Installing {framework}...</Text>
        <ProgressBar value={progress} width={40} color="$success" />
        <Text color="$muted">Setting up {name || "my-app"}</Text>
      </Box>
    )
  }

  return (
    <Box flexDirection="column" gap={1}>
      <Text color="$success" bold>
        Done!
      </Text>
      <Text>
        Created {name || "my-app"} with {framework}.
      </Text>
      <Text color="$muted">Press q to exit</Text>
    </Box>
  )
}

using term = createTerm()
await render(<Wizard />, term)

Key Patterns

Multi-Step State Machine

Wizard steps are just state. Transitions are setStep() calls. Each step renders different content:

tsx
type Step = "select" | "name" | "install" | "done"
const [step, setStep] = useState<Step>("select")

// In render:
if (step === "select") return <SelectionUI />
if (step === "name") return <NameInputUI />
if (step === "install") return <ProgressUI />
return <DoneUI />

SelectList — Not Manual Cursor Tracking

Use SelectList instead of building your own list with useInput() + cursor state. It handles arrow keys, j/k, Home/End, disabled items, scroll windowing, and selection callbacks:

tsx
<SelectList
  items={[
    { label: "React", value: "react" },
    { label: "Vue", value: "vue" },
    { label: "Angular", value: "angular", disabled: true },
  ]}
  onSelect={(item) => handleSelection(item.value)}
  maxVisible={5}
/>

TextInput — Full Readline Built In

TextInput ships with Emacs keybindings out of the box — no manual key handling needed:

  • Ctrl+A / Ctrl+E — Home / End
  • Ctrl+K / Ctrl+U — Kill to end / Kill to start
  • Alt+B / Alt+F — Word backward / forward
  • Ctrl+Y — Yank (paste from kill ring)
  • Ctrl+W — Kill word backward
tsx
<TextInput placeholder="my-app" onSubmit={(val) => setName(val)} prompt="> " />

Focus Scopes — Isolate Navigation Per Step

Each wizard step wraps in focusScope so Tab cycles within that step, not the entire app:

tsx
<Box focusScope>
  <SelectList items={options} onSelect={handleSelect} />
</Box>

When the step transitions, focus automatically moves to the new scope's first focusable element.

Features Used

FeatureUsage
SelectListSingle-select with keyboard navigation
TextInputText entry with readline shortcuts
ProgressBarVisual installation progress
SpinnerLoading animation
focusScopeTab navigation isolation per step
useInput()Custom keyboard handling
renderStatic()Pipe-friendly final output

What Silvery Adds

Most terminal UI libraries force you to chain sequential readline prompts or shell out to separate prompt utilities. Silvery takes a different approach: each wizard step is a React component with its own state. One framework, no plugin constellation.

Exercises

  1. Add validation — Reject empty project names, show error messages
  2. Add a back button — Press Escape to go to the previous step
  3. Multi-select — Let users pick multiple frameworks with Space to toggle
  4. Animated progress — Use indeterminate ProgressBar during dependency resolution

Released under the MIT License.