promptMultiSelect<T> static method

List<T> promptMultiSelect<T>({
  1. required String message,
  2. required List<T> options,
  3. required String labelProvider(
    1. T
    ),
  4. List<T>? defaultSelections,
})

Prompts the user to select multiple options using spacebar & arrow keys.

Implementation

static List<T> promptMultiSelect<T>({
  required String message,
  required List<T> options,
  required String Function(T) labelProvider,
  List<T>? defaultSelections,
}) {
  // If not running in a terminal, fallback to text prompt
  if (!stdin.hasTerminal) {
    print('\n${Logger.green}?${Logger.reset} ${Logger.bold}$message${Logger.reset} ${Logger.gray}(Comma-separated numbers, e.g. "1, 3")${Logger.reset}');
    for (int i = 0; i < options.length; i++) {
      final isSelected = defaultSelections?.contains(options[i]) ?? false;
      final marker = isSelected
          ? '${Logger.green}[✓]${Logger.reset}'
          : '${Logger.gray}[ ]${Logger.reset}';
      final label = labelProvider(options[i]);
      final indexStr = '${Logger.gray}[${i + 1}]${Logger.reset}';
      print('  $indexStr $marker $label');
    }
    while (true) {
      stdout.write('  ${Logger.bold}Enter options [all / none / numbers]:${Logger.reset} ');
      final input = stdin.readLineSync(encoding: utf8)?.trim().toLowerCase() ?? '';
      if (input.isEmpty) {
        return defaultSelections ?? [];
      }
      if (input == 'all') {
        return List<T>.from(options);
      }
      if (input == 'none') {
        return <T>[];
      }
      final parts = input.split(RegExp(r'[\s,]+'));
      final selections = <T>[];
      bool valid = true;
      for (final part in parts) {
        if (part.isEmpty) continue;
        final index = int.tryParse(part);
        if (index != null && index >= 1 && index <= options.length) {
          selections.add(options[index - 1]);
        } else {
          Logger.warning('Invalid option "$part".');
          valid = false;
          break;
        }
      }
      if (valid) {
        return selections;
      }
    }
  }

  // Hide cursor
  stdout.write('\x1B[?25l');

  print('${Logger.green}?${Logger.reset} ${Logger.bold}$message${Logger.reset} ${Logger.gray}(Use arrow keys, Space to toggle, Enter to select)${Logger.reset}');

  int activeIndex = 0;
  final selectedIndices = <int>{};
  if (defaultSelections != null) {
    for (int i = 0; i < options.length; i++) {
      if (defaultSelections.contains(options[i])) {
        selectedIndices.add(i);
      }
    }
  }

  void drawOptions() {
    for (int i = 0; i < options.length; i++) {
      final isActive = i == activeIndex;
      final isSelected = selectedIndices.contains(i);

      final pointer = isActive ? '${Logger.cyan}❯${Logger.reset}' : ' ';
      final checkbox = isSelected
          ? '${Logger.green}[✓]${Logger.reset}'
          : '${Logger.gray}[ ]${Logger.reset}';

      final label = labelProvider(options[i]);
      if (isActive) {
        print('  $pointer $checkbox ${Logger.cyan}$label${Logger.reset}');
      } else {
        print('    $checkbox $label');
      }
    }
  }

  drawOptions();

  while (true) {
    final key = _readKey();
    bool changed = false;

    if (key == 1) { // UP
      if (activeIndex > 0) {
        activeIndex--;
      } else {
        activeIndex = options.length - 1;
      }
      changed = true;
    } else if (key == 2) { // DOWN
      if (activeIndex < options.length - 1) {
        activeIndex++;
      } else {
        activeIndex = 0;
      }
      changed = true;
    } else if (key == 32) { // SPACE
      if (selectedIndices.contains(activeIndex)) {
        selectedIndices.remove(activeIndex);
      } else {
        selectedIndices.add(activeIndex);
      }
      changed = true;
    } else if (key == 10) { // ENTER
      final results = selectedIndices.map((idx) => options[idx]).toList();
      final labelSummary = results.isEmpty
          ? '${Logger.gray}none${Logger.reset}'
          : results.map(labelProvider).join(', ');

      // Move cursor up to overwrite previous prompt block
      stdout.write('\x1B[${options.length + 1}A');
      stdout.write('\x1B[J');
      // Print clean confirmation line
      print('${Logger.green}✔${Logger.reset} ${Logger.bold}$message${Logger.reset} ${Logger.cyan}$labelSummary${Logger.reset}');

      // Restore cursor
      stdout.write('\x1B[?25h');
      return results;
    }

    if (changed) {
      // Move cursor up to redraw options
      stdout.write('\x1B[${options.length}A');
      stdout.write('\x1B[J');
      drawOptions();
    }
  }
}