All files / cli / unstable_prompt_multiple_select.ts

89.47% Branches 17/19
94.29% Lines 66/70
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
 
 
 
 
 
 
 
 
x1
x1
x1
x1
x1
x1
 
x1
x1
 
x1
x1
x1
x1
 
x1
x1
x1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x1
x1
x1
x1
 
x11
 
x20
 
x20
x20
x20
 
x20
x20
 
x20
 
x11
x11
x39
x39
x123
x123
x123
x123
x123
x123
x39
 
x39
 
x39
x39
x40
x40
x39
x41
x41
x39
x47
x47
x39
x47
x39
 
 
 
x48
x48
x48
x39
x58
x58
 
x11
x12
x12
x12
 
x19
x19
 
x57
x11






































































I















I


















// Copyright 2018-2025 the Deno authors. MIT license.

/** Options for {@linkcode promptMultipleSelect}. */
export interface PromptMultipleSelectOptions {
  /** Clear the lines after the user's input. */
  clear?: boolean;
}

const ETX = "\x03";
const ARROW_UP = "\u001B[A";
const ARROW_DOWN = "\u001B[B";
const CR = "\r";
const INDICATOR = "❯";
const PADDING = " ".repeat(INDICATOR.length);

const CHECKED = "◉";
const UNCHECKED = "◯";

const input = Deno.stdin;
const output = Deno.stdout;
const encoder = new TextEncoder();
const decoder = new TextDecoder();

const CLEAR_ALL = encoder.encode("\x1b[J"); // Clear all lines after cursor
const HIDE_CURSOR = encoder.encode("\x1b[?25l");
const SHOW_CURSOR = encoder.encode("\x1b[?25h");

/**
 * Shows the given message and waits for the user's input. Returns the user's selected value as string.
 *
 * @param message The prompt message to show to the user.
 * @param values The values for the prompt.
 * @param options The options for the prompt.
 * @returns The selected values as an array of strings or `null` if stdin is not a TTY.
 *
 * @example Usage
 * ```ts ignore
 * import { promptMultipleSelect } from "@std/cli/unstable-prompt-multiple-select";
 *
 * const browsers = promptMultipleSelect("Please select browsers:", ["safari", "chrome", "firefox"], { clear: true });
 * ```
 */
export function promptMultipleSelect(
  message: string,
  values: string[],
  options: PromptMultipleSelectOptions = {},
): string[] | null {
  if (!input.isTerminal()) return null;

  const { clear } = options;

  const length = values.length;
  let selectedIndex = 0;
  const selectedIndexes = new Set<number>();

  input.setRaw(true);
  output.writeSync(HIDE_CURSOR);

  const buffer = new Uint8Array(4);

  loop:
  while (true) {
    output.writeSync(encoder.encode(`${message}\r\n`));
    for (const [index, value] of values.entries()) {
      const selected = index === selectedIndex;
      const start = selected ? INDICATOR : PADDING;
      const checked = selectedIndexes.has(index);
      const state = checked ? CHECKED : UNCHECKED;
      output.writeSync(encoder.encode(`${start} ${state} ${value}\r\n`));
    }
    const n = input.readSync(buffer);
    if (n === null || n === 0) break;
    const string = decoder.decode(buffer.slice(0, n));

    switch (string) {
      case ETX:
        output.writeSync(SHOW_CURSOR);
        return Deno.exit(0);
      case ARROW_UP:
        selectedIndex = (selectedIndex - 1 + length) % length;
        break;
      case ARROW_DOWN:
        selectedIndex = (selectedIndex + 1) % length;
        break;
      case CR:
        break loop;
      case " ":
        if (selectedIndexes.has(selectedIndex)) {
          selectedIndexes.delete(selectedIndex);
        } else {
          selectedIndexes.add(selectedIndex);
        }
        break;
    }
    output.writeSync(encoder.encode(`\x1b[${length + 1}A`));
  }

  if (clear) {
    output.writeSync(encoder.encode(`\x1b[${length + 1}A`));
    output.writeSync(CLEAR_ALL);
  }

  output.writeSync(SHOW_CURSOR);
  input.setRaw(false);

  return [...selectedIndexes].map((it) => values[it] as string);
}