/** * Interactive cursor-based selection menus. * * Provides arrow-key navigation for option selection in the terminal. */ import chalk from 'chalk'; import { truncateText } from '../utils/index.js'; import { resolveTtyPolicy, assertTtyIfForced } from './tty.js'; /** Option type for selectOption */ export interface SelectOptionItem { label: string; value: T; description?: string; details?: string[]; } /** * Render the menu options to the terminal. * Exported for testing. */ export function renderMenu( options: SelectOptionItem[], selectedIndex: number, hasCancelOption: boolean, cancelLabel = 'Cancel', ): string[] { const maxWidth = process.stdout.columns || 80; const labelPrefix = 4; const descPrefix = 5; const detailPrefix = 9; const lines: string[] = []; for (let i = 0; i < options.length; i++) { const opt = options[i]; if (!opt) continue; const isSelected = i === selectedIndex; const cursor = isSelected ? chalk.cyan('❯') : ' '; const truncatedLabel = truncateText(opt.label, maxWidth - labelPrefix); const label = isSelected ? chalk.cyan.bold(truncatedLabel) : truncatedLabel; lines.push(` ${cursor} ${label}`); if (opt.description) { const truncatedDesc = truncateText(opt.description, maxWidth - descPrefix); lines.push(chalk.gray(` ${truncatedDesc}`)); } if (opt.details && opt.details.length > 0) { for (const detail of opt.details) { const truncatedDetail = truncateText(detail, maxWidth - detailPrefix); lines.push(chalk.dim(` • ${truncatedDetail}`)); } } } if (hasCancelOption) { const isCancelSelected = selectedIndex === options.length; const cursor = isCancelSelected ? chalk.cyan('❯') : ' '; const label = isCancelSelected ? chalk.cyan.bold(cancelLabel) : chalk.gray(cancelLabel); lines.push(` ${cursor} ${label}`); } return lines; } /** * Count total rendered lines for a set of options. * Exported for testing. */ export function countRenderedLines( options: SelectOptionItem[], hasCancelOption: boolean, ): number { let count = 0; for (const opt of options) { count++; if (opt.description) count++; if (opt.details) count += opt.details.length; } if (hasCancelOption) count++; return count; } /** Result of handling a key input */ export type KeyInputResult = | { action: 'move'; newIndex: number } | { action: 'confirm'; selectedIndex: number } | { action: 'cancel'; cancelIndex: number } | { action: 'bookmark'; selectedIndex: number } | { action: 'remove_bookmark'; selectedIndex: number } | { action: 'exit' } | { action: 'none' }; /** * Pure function for key input state transitions. * Exported for testing. */ export function handleKeyInput( key: string, currentIndex: number, totalItems: number, hasCancelOption: boolean, optionCount: number, ): KeyInputResult { if (key === '\x1B[A' || key === 'k') { return { action: 'move', newIndex: (currentIndex - 1 + totalItems) % totalItems }; } if (key === '\x1B[B' || key === 'j') { return { action: 'move', newIndex: (currentIndex + 1) % totalItems }; } if (key === '\r' || key === '\n') { return { action: 'confirm', selectedIndex: currentIndex }; } if (key === '\x03') { return { action: 'exit' }; } if (key === '\x1B') { return { action: 'cancel', cancelIndex: hasCancelOption ? optionCount : -1 }; } if (key === 'b') { return { action: 'bookmark', selectedIndex: currentIndex }; } if (key === 'r') { return { action: 'remove_bookmark', selectedIndex: currentIndex }; } return { action: 'none' }; } /** Print the menu header (message + hint). */ function printHeader(message: string, hasCustomKeyHandler: boolean): void { console.log(); console.log(chalk.cyan(message)); const hint = hasCustomKeyHandler ? ' (↑↓ to move, Enter to select, b to bookmark, r to remove)' : ' (↑↓ to move, Enter to select)'; console.log(chalk.gray(hint)); console.log(); } /** Set up raw mode on stdin and return cleanup function. */ function setupRawMode(): { cleanup: (listener: (data: Buffer) => void) => void; wasRaw: boolean } { const wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); return { wasRaw, cleanup(listener: (data: Buffer) => void): void { process.stdin.removeListener('data', listener); process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); }, }; } /** Redraw the menu using relative cursor movement. */ function redrawMenu( options: SelectOptionItem[], selectedIndex: number, hasCancelOption: boolean, prevTotalLines: number, cancelLabel?: string, ): number { process.stdout.write(`\x1B[${prevTotalLines}A`); process.stdout.write('\x1B[J'); const newLines = renderMenu(options, selectedIndex, hasCancelOption, cancelLabel); process.stdout.write(newLines.join('\n') + '\n'); return newLines.length; } /** Callbacks for interactive select behavior */ export interface InteractiveSelectCallbacks { /** * Custom key handler called before default key handling. * Return updated options to handle the key and re-render. * Return null to delegate to default handler. */ onKeyPress?: (key: string, value: T, index: number) => SelectOptionItem[] | null; /** Custom label for cancel option (default: "Cancel") */ cancelLabel?: string; } /** Result of interactive selection */ interface InteractiveSelectResult { selectedIndex: number; finalOptions: SelectOptionItem[]; } /** Interactive cursor-based menu selection. */ function interactiveSelect( message: string, options: SelectOptionItem[], initialIndex: number, hasCancelOption: boolean, callbacks?: InteractiveSelectCallbacks, ): Promise> { return new Promise((resolve) => { let currentOptions = options; let totalItems = hasCancelOption ? currentOptions.length + 1 : currentOptions.length; let selectedIndex = initialIndex; const cancelLabel = callbacks?.cancelLabel ?? 'Cancel'; printHeader(message, !!callbacks?.onKeyPress); process.stdout.write('\x1B[?7l'); let totalLines = countRenderedLines(currentOptions, hasCancelOption); const lines = renderMenu(currentOptions, selectedIndex, hasCancelOption, cancelLabel); process.stdout.write(lines.join('\n') + '\n'); const { useTty, forceTouchTty } = resolveTtyPolicy(); assertTtyIfForced(forceTouchTty); if (!useTty) { process.stdout.write('\x1B[?7h'); resolve({ selectedIndex: initialIndex, finalOptions: currentOptions }); return; } const rawMode = setupRawMode(); const cleanup = (listener: (data: Buffer) => void): void => { rawMode.cleanup(listener); process.stdout.write('\x1B[?7h'); }; const onKeypress = (data: Buffer): void => { try { const key = data.toString(); // Try custom key handler first if (callbacks?.onKeyPress && selectedIndex < currentOptions.length) { const item = currentOptions[selectedIndex]; if (item) { const customResult = callbacks.onKeyPress(key, item.value, selectedIndex); if (customResult !== null) { // Custom handler processed the key const currentValue = item.value; currentOptions = customResult; totalItems = hasCancelOption ? currentOptions.length + 1 : currentOptions.length; const newIdx = currentOptions.findIndex((o) => o.value === currentValue); selectedIndex = newIdx >= 0 ? newIdx : Math.min(selectedIndex, currentOptions.length - 1); totalLines = redrawMenu(currentOptions, selectedIndex, hasCancelOption, totalLines, cancelLabel); return; } } } // Delegate to default handler const result = handleKeyInput( key, selectedIndex, totalItems, hasCancelOption, currentOptions.length, ); switch (result.action) { case 'move': selectedIndex = result.newIndex; totalLines = redrawMenu(currentOptions, selectedIndex, hasCancelOption, totalLines, cancelLabel); break; case 'confirm': cleanup(onKeypress); resolve({ selectedIndex: result.selectedIndex, finalOptions: currentOptions }); break; case 'cancel': cleanup(onKeypress); resolve({ selectedIndex: result.cancelIndex, finalOptions: currentOptions }); break; case 'bookmark': // Handled by custom onKeyPress break; case 'remove_bookmark': // Ignore - should be handled by custom onKeyPress break; case 'exit': cleanup(onKeypress); process.exit(130); break; case 'none': break; } } catch { cleanup(onKeypress); resolve({ selectedIndex: -1, finalOptions: currentOptions }); } }; process.stdin.on('data', onKeypress); }); } /** * Prompt user to select from a list of options using cursor navigation. * @returns Selected option or null if cancelled */ export async function selectOption( message: string, options: SelectOptionItem[], callbacks?: InteractiveSelectCallbacks, ): Promise { if (options.length === 0) return null; const { selectedIndex, finalOptions } = await interactiveSelect(message, options, 0, true, callbacks); if (selectedIndex === finalOptions.length || selectedIndex === -1) { return null; } const selected = finalOptions[selectedIndex]; if (selected) { console.log(chalk.green(` ✓ ${selected.label}`)); return selected.value; } return null; } /** * Prompt user to select from a list of options with a default value. * @returns Selected option value, or null if cancelled (ESC pressed) */ export async function selectOptionWithDefault( message: string, options: { label: string; value: T }[], defaultValue: T, ): Promise { if (options.length === 0) return defaultValue; const defaultIndex = options.findIndex((opt) => opt.value === defaultValue); const initialIndex = defaultIndex >= 0 ? defaultIndex : 0; const decoratedOptions: SelectOptionItem[] = options.map((opt) => ({ ...opt, label: opt.value === defaultValue ? `${opt.label} ${chalk.green('(default)')}` : opt.label, })); const { selectedIndex } = await interactiveSelect(message, decoratedOptions, initialIndex, true); if (selectedIndex === options.length || selectedIndex === -1) { return null; } const selected = options[selectedIndex]; if (selected) { console.log(chalk.green(` ✓ ${selected.label}`)); return selected.value; } return defaultValue; }