select function

Future<int?> select(
  1. Console console, {
  2. required List<String> header,
  3. required List<SelectOption> options,
  4. bool boxed = false,
})

Shows options below header and returns the chosen index, or null when the user presses Esc or Ctrl-C. Arrow keys move, Enter or a number picks.

With boxed, the menu is drawn inside a rounded border, like Claude Code's permission prompts.

Implementation

Future<int?> select(
  Console console, {
  required List<String> header,
  required List<SelectOption> options,
  bool boxed = false,
}) async {
  final terminal = console.terminal;
  if (!terminal.interactive) {
    return _selectFromLine(console, header, options);
  }

  var selected = 0;
  void render() {
    final width = max(20, terminal.columns - 1) - (boxed ? 4 : 0);
    final lines = <String>[...header.expand((line) => wrapText(line, width))];
    for (var index = 0; index < options.length; index++) {
      final option = options[index];
      final label = '${index + 1}. ${option.label}';
      lines.add(index == selected ? Style.accent('❯ $label') : '  $label');
      if (option.description case final description? when description.isNotEmpty) {
        lines.addAll(wrapText(description, width - 5).map((line) => Style.dim('     $line')));
      }
    }
    final hint = Style.dim('Enter to select · ↑/↓ to navigate · Esc to cancel');
    console.showLive(boxed ? <String>[..._box(lines, width + 4), hint] : <String>[...lines, '', hint]);
  }

  final result = Completer<int?>();
  render();
  final stopListening = terminal.listen((event) {
    if (result.isCompleted) {
      return true;
    }
    switch (event.key) {
      case Key.up || Key.shiftTab:
        selected = (selected - 1) % options.length;
      case Key.down || Key.tab:
        selected = (selected + 1) % options.length;
      case Key.enter:
        result.complete(selected);
        return true;
      case Key.escape || Key.ctrlC || Key.ctrlD:
        result.complete(null);
        return true;
      case Key.text:
        final number = int.tryParse(event.text);
        if (number != null && number >= 1 && number <= options.length) {
          result.complete(number - 1);
          return true;
        }
      default:
        break;
    }
    render();
    return true;
  });
  final choice = await result.future;
  stopListening();
  console.live.clear();
  return choice;
}