/ libs / typescript / integrations / qwen-code / src / ui-select.ts
ui-select.ts
 1  /**
 2   * Minimal arrow-key select prompt for the `mlflow-qwen-code` setup CLI.
 3   *
 4   * No runtime dependency on `inquirer`/`prompts`: we manage raw-mode stdin
 5   * ourselves so the bundled binary stays small, matching the policy in
 6   * `ui.ts`. Falls back to the default option when stdin is not a TTY so
 7   * piped input (CI, `echo "" | mlflow-qwen-code setup`) never hangs.
 8   */
 9  
10  import { emitKeypressEvents } from 'node:readline';
11  
12  import { bold, cyan, dim } from './ui.js';
13  
14  export interface SelectOption<T> {
15    value: T;
16    label: string;
17    hint?: string;
18  }
19  
20  export interface SelectPromptOptions<T> {
21    question: string;
22    options: SelectOption<T>[];
23    defaultIndex?: number;
24    input?: NodeJS.ReadStream;
25    output?: NodeJS.WriteStream;
26  }
27  
28  export function selectPrompt<T>(opts: SelectPromptOptions<T>): Promise<T> {
29    const input = opts.input ?? process.stdin;
30    const output = opts.output ?? process.stdout;
31    const defaultIndex = opts.defaultIndex ?? 0;
32    const { options, question } = opts;
33  
34    if (!input.isTTY) {
35      return Promise.resolve(options[defaultIndex].value);
36    }
37  
38    let current = defaultIndex;
39    let linesRendered = 0;
40  
41    const formatLines = (): string[] => [
42      `${bold('?')} ${question}`,
43      ...options.map((o, i) => {
44        const marker = i === current ? cyan('●') : dim('○');
45        const label = i === current ? cyan(o.label) : o.label;
46        const hint = o.hint ? `  ${dim(o.hint)}` : '';
47        const defaultTag = i === defaultIndex ? dim('  (default)') : '';
48        return `  ${marker} ${label}${hint}${defaultTag}`;
49      }),
50      '',
51      `  ${dim('↑/↓ to move, enter to select')}`,
52    ];
53  
54    const render = (): void => {
55      if (linesRendered > 0) {
56        output.write(`\x1b[${linesRendered}A\x1b[0J`);
57      }
58      const lines = formatLines();
59      output.write(lines.join('\n') + '\n');
60      linesRendered = lines.length;
61    };
62  
63    emitKeypressEvents(input);
64    const wasRaw = input.isRaw;
65    input.setRawMode(true);
66    input.resume();
67  
68    return new Promise<T>((resolvePromise) => {
69      const cleanup = (): void => {
70        input.removeListener('keypress', onKeypress);
71        if (!wasRaw) {
72          input.setRawMode(false);
73        }
74        input.pause();
75      };
76  
77      const onKeypress = (_str: string, key: { name?: string; ctrl?: boolean }): void => {
78        if (key.ctrl && key.name === 'c') {
79          cleanup();
80          process.exit(130);
81        } else if (key.name === 'up' || key.name === 'k') {
82          current = (current - 1 + options.length) % options.length;
83          render();
84        } else if (key.name === 'down' || key.name === 'j') {
85          current = (current + 1) % options.length;
86          render();
87        } else if (key.name === 'return') {
88          cleanup();
89          resolvePromise(options[current].value);
90        }
91      };
92  
93      input.on('keypress', onKeypress);
94      render();
95    });
96  }