ui-select.ts
1 /** 2 * Minimal arrow-key select prompt for the `mlflow-codex` 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-codex 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 }