All files / cli / _prompt_select.ts

90.00% Branches 36/40
97.06% Lines 165/170
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
 
 
 
x2
 
x2
 
x2
x2
 
x2
x2
 
x2
x2
x2
x2
 
 
 
 
 
 
 
 
 
 
 
x2
x2
x2
x2
x2
x2
x2
x2
x2
 
 
 
 
 
x2
 
x32
x32
x32
x135
x135
x32
x32
 
x32
x32
x32
 
x32
x32
x32
x32
 
x32
x32
 
x32
x32
x33
x33
x33
x33
x33
 
x32
x32
x32
 
 
x32
x142
x142
x142
 
 
x142
x518
x813
x518
 
 
x599
x142
x142
x142
 
x142
x142
 
x142
x146
x146
 
x146
 
x142
x142
x142
x142
x142
x142
x142
x142
x142
x441
x441
x441
x441
x441
x441
x441
x441
 
 
x441
 
x142
x157
x157
 
x157
x142
 
x142
 
x142
x142
x142
x142
x142
x144
x144
x144
x142
x142
x146
x148
x148
x148
x148
x148
x148
x142
x142
x176
x181
x181
x176
x205
 
x205
x209
x209
x205
x142
x142
x154
x154
x142
x142
x159
x159
x159
x142
 
 
x142
x144
x142
x278
x278
 
x142
x143
x143
x143
 
x142
x317
x317
x318
 
x318
x142
x142
x142
 
x142
x142
x142
 
x32
x34
x34
x34
 
x60
x60
x32
 
x3
x3
x3
 
x3
 
x3
x3
 
 
x3
 
 
x3
x3
x3
x3
 
x3






















































































I










































I





















































































I







I

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

import type { PromptEntry } from "./unstable_prompt_select.ts";
import { stripAnsiCode } from "@std/fmt/colors";

const SAFE_PADDING = 4;

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

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");
const QUERY_CURSOR_POSITION = encoder.encode("\x1b[6n");

/**
 * @param message The prompt message to show to the user.
 * @param indicator The string to indicate the selected item.
 * @param values The values for the prompt.
 * @param clear Whether to clear the lines after the user's input.
 * @param visibleLinesInit The initial number of lines to be visible at once.
 * @param fitToRemainingHeight Whether to calculate visible lines based on remaining height from cursor position.
 * @param valueChange A function that is called when the value changes.
 * @param handleInput A function that handles the input from the user. If it returns false, the prompt will continue. If it returns true, the prompt will exit with clean ups of terminal state (Use this for finalizing the selection). If it returns "return", the prompt will exit immediately without clean ups of terminal state (Use this for exiting the program).
 */
export function handlePromptSelect<V>(
  message: string,
  indicator: string,
  values: PromptEntry<V>[],
  clear: boolean | undefined,
  visibleLinesInit: number | undefined,
  fitToRemainingHeight: boolean | undefined,
  valueChange: (active: boolean, absoluteIndex: number) => string | void,
  handleInput: (str: string, absoluteIndex: number | undefined, actions: {
    etx(): "return";
    up(): void;
    down(): void;
    remove(): void;
    inputStr(): void;
  }) => boolean | "return",
) {
  const input = Deno.stdin;
  const output = Deno.stdout;
  const indexedValues = values.map((value, absoluteIndex) => ({
    value,
    absoluteIndex,
  }));
  let clearLength = indexedValues.length + 1;

  const indicatorLength = stripAnsiCode(indicator).length;
  const PADDING = " ".repeat(indicatorLength);
  const ARROW_PADDING = " ".repeat(indicatorLength + 1);

  let activeIndex = 0;
  let offset = 0;
  let searchBuffer = "";
  const buffer = new Uint8Array(4);

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

  let availableHeight = Deno.consoleSize().rows - SAFE_PADDING;
  if (fitToRemainingHeight) {
    const cursorRow = getCursorRow(input, output);
    if (cursorRow !== undefined) {
      availableHeight = Deno.consoleSize().rows - cursorRow - SAFE_PADDING + 1;
    }
  }

  let visibleLines = visibleLinesInit ?? Math.min(
    availableHeight,
    values.length,
  );

  while (true) {
    output.writeSync(
      encoder.encode(
        `${message + (searchBuffer ? ` (filter: ${searchBuffer})` : "")}\r\n`,
      ),
    );
    const filteredChunks = indexedValues.filter((item) => {
      if (searchBuffer === "") {
        return true;
      } else {
        return (typeof item.value === "string" ? item.value : item.value.label)
          .toLowerCase().includes(searchBuffer.toLowerCase());
      }
    });
    const visibleChunks = filteredChunks.slice(offset, visibleLines + offset);
    const length = visibleChunks.length;

    const hasUpArrow = offset !== 0;
    const hasDownArrow = (length + offset) < filteredChunks.length;

    if (hasUpArrow) {
      output.writeSync(
        encoder.encode(`${ARROW_PADDING}${MORE_CONTENT_BEFORE_INDICATOR}\r\n`),
      );
    }

    for (
      const [
        index,
        {
          absoluteIndex,
          value,
        },
      ] of visibleChunks.entries()
    ) {
      const active = index === (activeIndex - offset);
      const start = active ? indicator : PADDING;
      const maybePrefix = valueChange(active, absoluteIndex);
      output.writeSync(
        encoder.encode(
          `${start}${maybePrefix ? ` ${maybePrefix}` : ""} ${
            typeof value === "string" ? value : value.label
          }\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));

    const processedInput = handleInput(
      string,
      filteredChunks[activeIndex]?.absoluteIndex,
      {
        etx: () => {
          output.writeSync(SHOW_CURSOR);
          Deno.exit(0);
          return "return";
        },
        up: () => {
          if (activeIndex === 0) {
            activeIndex = filteredChunks.length - 1;
            offset = Math.max(filteredChunks.length - visibleLines, 0);
          } else {
            activeIndex--;
            offset = Math.max(offset - 1, 0);
          }
        },
        down: () => {
          if (activeIndex === (filteredChunks.length - 1)) {
            activeIndex = 0;
            offset = 0;
          } else {
            activeIndex++;

            if (activeIndex >= visibleLines) {
              offset++;
            }
          }
        },
        remove: () => {
          activeIndex = 0;
          searchBuffer = searchBuffer.slice(0, -1);
        },
        inputStr: () => {
          activeIndex = 0;
          searchBuffer += string;
        },
      },
    );

    if (processedInput === "return") {
      return;
    } else if (processedInput) {
      break;
    }

    if (fitToRemainingHeight) {
      availableHeight = Math.min(
        availableHeight,
        Deno.consoleSize().rows - SAFE_PADDING,
      );
    } else {
      availableHeight = Deno.consoleSize().rows - SAFE_PADDING;
    }
    visibleLines = Math.min(availableHeight, visibleLines);

    clearLength = 1 + // message
      (hasUpArrow ? 1 : 0) +
      length +
      (hasDownArrow ? 1 : 0);

    output.writeSync(encoder.encode(`\x1b[${clearLength}A`));
    output.writeSync(CLEAR_ALL);
  }

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

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

function getCursorRow(
  input: typeof Deno.stdin,
  output: typeof Deno.stdout,
): number | undefined {
  output.writeSync(QUERY_CURSOR_POSITION);

  const buffer = new Uint8Array(32);
  const n = input.readSync(buffer);
  if (n === null || n === 0) return undefined;

  const response = decoder.decode(buffer.subarray(0, n)).trim();

  // deno-lint-ignore no-control-regex
  const match = response.match(/\x1b\[(\d+);(\d+)R/);
  if (match) {
    return parseInt(match[1]!, 10);
  }
  return undefined;
}