All files / cli / unstable_prompt_select.ts

91.18% Branches 31/34
95.58% Lines 108/113
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x1
x1
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
 
x24
 
x24
 
x13
x13
x45
x45
 
x45
 
x45
x48
x48
 
x48
 
x45
x156
x156
x156
x156
 
 
x156
 
x45
x53
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
 
 
x66
x66
x66
x66
x45
x45
x45
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;
}

/**
 * Value for {@linkcode promptSelect}.
 * If an object, it must have a title and a value, else it can just be a string.
 *
 * @typeParam V The value of the underlying Entry, if any.
 */
export type PromptEntry<V = undefined> = V extends undefined ? string
  : PromptEntryWithValue<V>;

/**
 * A {@linkcode PromptEntry} with an underlying value.
 *
 * @typeParam V The value of the underlying Entry.
 */
export interface PromptEntryWithValue<V> {
  /** The title for this entry. */
  title: string;
  /** The underlying value representing this entry. */
  value: V;
}

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

const MORE_CONTENT_BEFORE_INDICATOR = "...";
const MORE_CONTENT_AFTER_INDICATOR = "...";

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.
 *
 * @typeParam V The value of the underlying Entry, if any.
 * @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 Basic usage
 * ```ts ignore
 * import { promptSelect } from "@std/cli/unstable-prompt-select";
 *
 * const browser = promptSelect("Please select browser", [
 *   "Chrome",
 *   "Firefox",
 *   "Safari",
 * ], { clear: true });
 * ```
 *
 * @example With title and value
 * ```ts ignore
 * import { promptSelect } from "@std/cli/unstable-prompt-select";
 *
 * const browsers = promptSelect(
 *   "Please select browsers:",
 *   [{
 *     title: "safari",
 *     value: 1,
 *   }, {
 *     title: "chrome",
 *     value: 2,
 *   }, {
 *     title: "firefox",
 *     value: 3,
 *   }],
 *   { clear: true },
 * );
 * ```
 *
 * @example With multiple options
 * ```ts ignore
 * import { promptSelect } from "@std/cli/unstable-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<V = undefined>(
  message: string,
  values: PromptEntry<V>[],
  options: PromptSelectOptions = {},
): PromptEntry<V> | null {
  if (!input.isTerminal()) return null;

  const SAFE_PADDING = 4;
  let {
    // Deno.consoleSize().rows - 3 because we need to output the message, the up arrow, the terminal line and the down arrow
    visibleLines = Math.min(
      Deno.consoleSize().rows - SAFE_PADDING,
      values.length,
    ),
    indicator = "❯",
  } = options;
  const PADDING = " ".repeat(indicator.length);
  const ARROW_PADDING = " ".repeat(indicator.length + 1);

  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);

  let hasUpArrow = false;

  loop:
  while (true) {
    output.writeSync(encoder.encode(`${message}\r\n`));
    const chunk: PromptEntry<V>[] = values.slice(offset, visibleLines + offset);

    const hasDownArrow = visibleLines + offset < length;

    if (offset !== 0) {
      output.writeSync(
        encoder.encode(`${ARROW_PADDING}${MORE_CONTENT_BEFORE_INDICATOR}\r\n`),
      );
    }

    for (const [index, value] of chunk.entries()) {
      const start = index === showIndex ? indicator : PADDING;
      output.writeSync(
        encoder.encode(
          `${start} ${typeof value === "string" ? value : value.title}\r\n`,
        ),
      );
    }

    if (hasDownArrow) {
      output.writeSync(
        encoder.encode(`${ARROW_PADDING}${MORE_CONTENT_AFTER_INDICATOR}\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,
    );

    output.writeSync(
      encoder.encode(
        `\x1b[${
          1 + // message
          (hasUpArrow ? 1 : 0) +
          visibleLines +
          (hasDownArrow ? 1 : 0)
        }A`,
      ),
    );

    output.writeSync(CLEAR_ALL);
    hasUpArrow = offset !== 0;
  }

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

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

  return values[selectedIndex] ?? null;
}