select function
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;
}