All files / cli / unstable_prompt_select.ts

89.66% Branches 26/29
94.74% Lines 90/95
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x1
x1
x1
x1
 
x1
x1
x1
x1
 
x1
x1
x1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x1
x1
x1
x1
 
x13
 
x24
x24
 
 
x24
x24
x24
x24
x24
x24
x24
 
x24
x24
x24
x24
 
x24
x24
 
x24
 
x13
x13
x45
x45
x45
x156
x156
x156
x45
x45
x53
x53
x45
 
x45
 
x45
x45
x46
x46
x92
x47
x47
x47
x48
x48
x48
x48
 
 
 
x47
x47
x109
x64
x64
x64
x67
x67
x64
x93
x80
x83
x83
x64
x64
x45
x55
x45
 
x66
x66
x66
 
 
x45
x45
 
x45
x45
 
x13
x14
x14
x14
 
x23
x23
 
 
x13



























































































I














I








































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

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

  /** The number of lines to be visible at once */
  visibleLines?: number;

  /** The string to indicate the selected item */
  indicator?: string;
}

const ETX = "\x03";
const ARROW_UP = "\u001B[A";
const ARROW_DOWN = "\u001B[B";
const CR = "\r";

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

const CLR_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 string that was entered or `null` if stdin is not a TTY.
 *
 * @example Usage
 * ```ts ignore
 * import { promptSelect } from "@std/cli/prompt-select";
 *
 * const browser = promptSelect("What country are you from?", [
 *   "Brazil",
 *   "United States",
 *   "Japan",
 *   "China",
 *   "Canada",
 *   "Spain",
 * ], { clear: true, visibleLines: 3, indicator: "*" });
 * ```
 */
export function promptSelect(
  message: string,
  values: string[],
  options: PromptSelectOptions = {},
): string | null {
  if (!input.isTerminal()) return null;

  const SAFE_PADDING = 3;
  let {
    // Deno.consoleSize().rows - 3 because we need to output the message, the terminal line
    // and we use the last line to display the "..."
    visibleLines = Math.min(
      Deno.consoleSize().rows - SAFE_PADDING,
      values.length,
    ),
    indicator = "❯",
  } = options;
  const PADDING = " ".repeat(indicator.length);

  const length = values.length;
  let selectedIndex = 0;
  let showIndex = 0;
  let offset = 0;

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

  const buffer = new Uint8Array(4);

  loop:
  while (true) {
    output.writeSync(encoder.encode(`${message}\r\n`));
    const chunk = values.slice(offset, visibleLines + offset);
    for (const [index, value] of chunk.entries()) {
      const start = index === showIndex ? indicator : PADDING;
      output.writeSync(encoder.encode(`${start} ${value}\r\n`));
    }
    const moreContent = visibleLines + offset < length;
    if (moreContent) {
      output.writeSync(encoder.encode("...\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: {
        const atTop = selectedIndex === 0;
        selectedIndex = atTop ? length - 1 : selectedIndex - 1;
        if (atTop) {
          offset = Math.max(length - visibleLines, 0);
          showIndex = Math.min(visibleLines - 1, length - 1);
        } else if (showIndex > 0) {
          showIndex--;
        } else {
          offset = Math.max(offset - 1, 0);
        }
        break;
      }
      case ARROW_DOWN: {
        const atBottom = selectedIndex === length - 1;
        selectedIndex = atBottom ? 0 : selectedIndex + 1;
        if (atBottom) {
          offset = 0;
          showIndex = 0;
        } else if (showIndex < visibleLines - 1) {
          showIndex++;
        } else {
          offset++;
        }
        break;
      }
      case CR:
        break loop;
    }

    visibleLines = Math.min(
      Deno.consoleSize().rows - SAFE_PADDING,
      visibleLines,
    );
    // if we print the "...\r\n" we need to clear an additional line
    output.writeSync(
      encoder.encode(`\x1b[${visibleLines + (moreContent ? 2 : 1)}A`),
    );
    output.writeSync(CLR_ALL);
  }

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

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

  return values[selectedIndex] ?? null;
}