Tutorial: Select prompt

In this tutorial you'll build an interactive select prompt using effect-boxes for layout and Prompt.custom from Effect for input handling. The final result renders a styled menu that responds to keyboard input:

Select a framework React  Vue  Svelte
  ↓ down  ↑ up  enter select

This tutorial assumes familiarity with Effect's Effect.gen and basic understanding of the Prompt module from effect/unstable/cli.

Step 1: Define choices and state

A select prompt needs a list of choices and a cursor position:

import { Data, Effect, Match } from "effect";
import { Prompt } from "effect/unstable/cli";
import { Ansi, Box, Cmd } from "effect-boxes";
 
type Choice<A> = {
  title: string;
  value: A;
};
 
const choices: Choice<string>[] = [
  { title: "React", value: "react" },
  { title: "Vue", value: "vue" },
  { title: "Svelte", value: "svelte" },
];
 
// State is just the cursor index
type State = number;

Step 2: Build the render function

The render function takes state and returns a Box. Keep it pure: state in, box out.

const renderLayout = (cursor: number, submitted: boolean) => {
  const label = Box.text("Select a framework").pipe(Box.annotate(Ansi.bold));
 
  if (submitted) {
    const selected = choices[cursor];
    return Box.hsep(
      [
        Box.text("✔").pipe(Box.annotate(Ansi.green)),
        label,
        Box.text(selected?.title ?? "").pipe(Box.annotate(Ansi.cyan)),
      ],
      1,
      Box.top
    );
  }
 
  const items = choices.map((choice, i) => {
    const isSelected = i === cursor;
    const indicator = Box.char(isSelected ? "⏵" : " ").pipe(
      Box.annotate(Ansi.cyan) 
    ); 
    const title = Box.text(choice.title).pipe(
      Box.annotate(isSelected ? Ansi.bold : Ansi.dim) 
    );
    return Box.hsep([indicator, title], 1, Box.left); 
  });
 
  const content = Box.vcat([label, Box.vcat(items, Box.left)], Box.left);
  return content;
};

The function has two modes: the active selection view, and a compact submitted view that shows the final choice.

Step 3: Understand the Prompt.custom contract

Prompt.custom takes an initial state and three callbacks:

const Action = Data.taggedEnum<Prompt.ActionDefinition>();
 
Prompt.custom<State, Value>(initialState, {
  render: (state, action) => Effect<string>,
  process: (input, state) => Effect<Action>,
  clear: (state) => Effect<string>,
});
  • render produces the string to write to stdout
  • process handles user input and returns the next action
  • clear produces cleanup output when the prompt exits

Step 4: Wire up rendering with Cmd

The render callback wraps renderLayout with cursor management. On the first frame, hide the cursor. On subsequent frames, clear the previous output before re-rendering:

let hasRendered = false;
 
const select = Prompt.custom<number, string>(0, {
  render: Effect.fnUntraced(function* (cursor, action) {
    const layout = Action.$match(action, {
      Submit: () => renderLayout(cursor, true),
      default: () => renderLayout(cursor, false),
    });
 
    const clear = hasRendered 
      ? Cmd.clearLines(renderLayout(cursor, false).rows) 
      : Cmd.cursorHide; 
    hasRendered = true;
 
    const cmds =
      action._tag === "Submit"
        ? Box.combine(Cmd.cursorShow, Cmd.cursorNextLine(1))
        : Cmd.cursorHide;
 
    return yield* Box.renderPretty(
      Box.combine(clear, layout.pipe(Box.combine(cmds))) 
    ); 
  }),
  // ...
});

Cmd.clearLines moves the cursor up and clears each line, which avoids the flicker you get from fully clearing the terminal.

Step 5: Handle input

The process callback maps key presses to actions. Arrow keys move the cursor, enter submits:

const select = Prompt.custom<number, string>(0, {
  // render: ...,
  process: Effect.fnUntraced(function* (input, cursor) {
    return Match.value(input).pipe(
      Match.when(
        { key: "down" },
        () => Action.NextFrame({ state: (cursor + 1) % choices.length }) 
      ),
      Match.when(
        { key: "up" },
        () =>
          Action.NextFrame({
            state: (cursor - 1 + choices.length) % choices.length,
          }) 
      ),
      Match.when({ key: "return" }, () => {
        const selected = choices[cursor];
        if (selected) return Action.Submit({ value: selected.value }); 
        return Action.Beep(); 
      }),
      Match.orElse(() => Action.NextFrame({ state: cursor }))
    );
  }),
  clear: () => Effect.succeed(""),
});

NextFrame re-renders with new state. Submit returns the value and exits the prompt. Beep signals invalid input without changing state.

Step 6: Run it

import { NodeRuntime } from "effect/unstable/cli";
 
const program = Effect.gen(function* () {
  const result = yield* select;
  yield* Effect.log(`You chose: ${result}`);
});
 
NodeRuntime.runMain(program);

What you've learned

  • Prompt.custom gives you a state machine with render, process, and clear slots
  • effect-boxes handles the layout and styling (Box composition + Ansi annotations)
  • Cmd module handles terminal control (clearing lines, cursor visibility)
  • Keeping renderLayout pure makes it testable without terminal interaction
  • The same layout function renders both the active and submitted states

The pattern scales to more complex prompts (multi-select, text input, nested menus) by changing the state type and adding more input handlers. See the Using Reactive guide for how position tracking enables cursor-based navigation in larger layouts.

TL;DR Full Example