diff --git a/src/__tests__/prompt.test.ts b/src/__tests__/prompt.test.ts new file mode 100644 index 0000000..7c30f06 --- /dev/null +++ b/src/__tests__/prompt.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for prompt module (cursor-based interactive menu) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import chalk from 'chalk'; +import type { SelectOptionItem, KeyInputResult } from '../prompt/index.js'; +import { renderMenu, countRenderedLines, handleKeyInput } from '../prompt/index.js'; + +// Disable chalk colors for predictable test output +chalk.level = 0; + +describe('prompt', () => { + describe('renderMenu', () => { + const basicOptions: SelectOptionItem[] = [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + { label: 'Option C', value: 'c' }, + ]; + + it('should render all options with cursor on selected item', () => { + const lines = renderMenu(basicOptions, 0, false); + + // 3 options = 3 lines + expect(lines).toHaveLength(3); + // First item selected - contains cursor marker + expect(lines[0]).toContain('❯'); + expect(lines[0]).toContain('Option A'); + // Other items should not have cursor + expect(lines[1]).not.toContain('❯'); + expect(lines[2]).not.toContain('❯'); + }); + + it('should move cursor to second item when selectedIndex is 1', () => { + const lines = renderMenu(basicOptions, 1, false); + + expect(lines[0]).not.toContain('❯'); + expect(lines[1]).toContain('❯'); + expect(lines[1]).toContain('Option B'); + expect(lines[2]).not.toContain('❯'); + }); + + it('should move cursor to last item', () => { + const lines = renderMenu(basicOptions, 2, false); + + expect(lines[0]).not.toContain('❯'); + expect(lines[1]).not.toContain('❯'); + expect(lines[2]).toContain('❯'); + expect(lines[2]).toContain('Option C'); + }); + + it('should include Cancel option when hasCancelOption is true', () => { + const lines = renderMenu(basicOptions, 0, true); + + // 3 options + 1 cancel = 4 lines + expect(lines).toHaveLength(4); + expect(lines[3]).toContain('Cancel'); + }); + + it('should highlight Cancel when it is selected', () => { + const lines = renderMenu(basicOptions, 3, true); + + // Cancel is at index 3 (options.length) + expect(lines[3]).toContain('❯'); + expect(lines[3]).toContain('Cancel'); + // Other items should not have cursor + expect(lines[0]).not.toContain('❯'); + expect(lines[1]).not.toContain('❯'); + expect(lines[2]).not.toContain('❯'); + }); + + it('should render description lines', () => { + const optionsWithDesc: SelectOptionItem[] = [ + { label: 'Option A', value: 'a', description: 'Description for A' }, + { label: 'Option B', value: 'b' }, + ]; + + const lines = renderMenu(optionsWithDesc, 0, false); + + // Option A has label + description = 2 lines, Option B = 1 line + expect(lines).toHaveLength(3); + expect(lines[1]).toContain('Description for A'); + }); + + it('should render detail lines', () => { + const optionsWithDetails: SelectOptionItem[] = [ + { + label: 'Option A', + value: 'a', + description: 'Desc A', + details: ['Detail 1', 'Detail 2'], + }, + { label: 'Option B', value: 'b' }, + ]; + + const lines = renderMenu(optionsWithDetails, 0, false); + + // Option A: label + description + 2 details = 4 lines, Option B = 1 line + expect(lines).toHaveLength(5); + expect(lines[2]).toContain('Detail 1'); + expect(lines[3]).toContain('Detail 2'); + }); + + it('should handle empty options array', () => { + const lines = renderMenu([], 0, false); + expect(lines).toHaveLength(0); + }); + + it('should handle empty options with cancel', () => { + const lines = renderMenu([], 0, true); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('Cancel'); + }); + }); + + describe('countRenderedLines', () => { + it('should count basic options (1 line each)', () => { + const options: SelectOptionItem[] = [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + { label: 'C', value: 'c' }, + ]; + + expect(countRenderedLines(options, false)).toBe(3); + }); + + it('should add 1 for cancel option', () => { + const options: SelectOptionItem[] = [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + ]; + + expect(countRenderedLines(options, true)).toBe(3); + }); + + it('should count description lines', () => { + const options: SelectOptionItem[] = [ + { label: 'A', value: 'a', description: 'Desc A' }, + { label: 'B', value: 'b' }, + ]; + + // A: label + desc = 2, B: label = 1, total = 3 + expect(countRenderedLines(options, false)).toBe(3); + }); + + it('should count detail lines', () => { + const options: SelectOptionItem[] = [ + { + label: 'A', + value: 'a', + description: 'Desc', + details: ['D1', 'D2', 'D3'], + }, + ]; + + // label + desc + 3 details = 5 + expect(countRenderedLines(options, false)).toBe(5); + }); + + it('should count combined description and details with cancel', () => { + const options: SelectOptionItem[] = [ + { + label: 'A', + value: 'a', + description: 'Desc A', + details: ['D1'], + }, + { label: 'B', value: 'b', description: 'Desc B' }, + ]; + + // A: 1 + 1 + 1 = 3, B: 1 + 1 = 2, cancel: 1, total = 6 + expect(countRenderedLines(options, true)).toBe(6); + }); + + it('should return 0 for empty options without cancel', () => { + expect(countRenderedLines([], false)).toBe(0); + }); + + it('should return 1 for empty options with cancel', () => { + expect(countRenderedLines([], true)).toBe(1); + }); + }); + + describe('handleKeyInput', () => { + // 3 options + cancel = 4 total items + const totalItems = 4; + const optionCount = 3; + const hasCancelOption = true; + + describe('move up (arrow up / k)', () => { + it('should move up with arrow key', () => { + const result = handleKeyInput('\x1B[A', 1, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 0 }); + }); + + it('should move up with vim k key', () => { + const result = handleKeyInput('k', 2, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 1 }); + }); + + it('should wrap around from first item to last', () => { + const result = handleKeyInput('\x1B[A', 0, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 3 }); + }); + }); + + describe('move down (arrow down / j)', () => { + it('should move down with arrow key', () => { + const result = handleKeyInput('\x1B[B', 0, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 1 }); + }); + + it('should move down with vim j key', () => { + const result = handleKeyInput('j', 1, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 2 }); + }); + + it('should wrap around from last item to first', () => { + const result = handleKeyInput('\x1B[B', 3, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 0 }); + }); + }); + + describe('confirm (Enter)', () => { + it('should confirm with carriage return', () => { + const result = handleKeyInput('\r', 2, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'confirm', selectedIndex: 2 }); + }); + + it('should confirm with newline', () => { + const result = handleKeyInput('\n', 0, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'confirm', selectedIndex: 0 }); + }); + + it('should confirm cancel position when Enter on cancel item', () => { + const result = handleKeyInput('\r', 3, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'confirm', selectedIndex: 3 }); + }); + }); + + describe('cancel (Escape)', () => { + it('should return optionCount as cancelIndex when hasCancelOption', () => { + const result = handleKeyInput('\x1B', 1, totalItems, true, optionCount); + expect(result).toEqual({ action: 'cancel', cancelIndex: 3 }); + }); + + it('should return -1 as cancelIndex when no cancel option', () => { + const result = handleKeyInput('\x1B', 1, 3, false, optionCount); + expect(result).toEqual({ action: 'cancel', cancelIndex: -1 }); + }); + }); + + describe('exit (Ctrl+C)', () => { + it('should return exit action', () => { + const result = handleKeyInput('\x03', 0, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'exit' }); + }); + }); + + describe('unrecognized keys', () => { + it('should return none for regular characters', () => { + const result = handleKeyInput('a', 0, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'none' }); + }); + + it('should return none for space', () => { + const result = handleKeyInput(' ', 0, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'none' }); + }); + + it('should return none for numbers', () => { + const result = handleKeyInput('1', 0, totalItems, hasCancelOption, optionCount); + expect(result).toEqual({ action: 'none' }); + }); + }); + + describe('without cancel option', () => { + const noCancelTotal = 3; + + it('should wrap up correctly without cancel', () => { + const result = handleKeyInput('\x1B[A', 0, noCancelTotal, false, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 2 }); + }); + + it('should wrap down correctly without cancel', () => { + const result = handleKeyInput('\x1B[B', 2, noCancelTotal, false, optionCount); + expect(result).toEqual({ action: 'move', newIndex: 0 }); + }); + }); + + describe('single option', () => { + it('should wrap around with 1 item + cancel (totalItems=2)', () => { + const result = handleKeyInput('\x1B[B', 1, 2, true, 1); + expect(result).toEqual({ action: 'move', newIndex: 0 }); + }); + + it('should confirm single option', () => { + const result = handleKeyInput('\r', 0, 1, false, 1); + expect(result).toEqual({ action: 'confirm', selectedIndex: 0 }); + }); + }); + }); + + describe('selectOption', () => { + it('should return null for empty options', async () => { + const { selectOption } = await import('../prompt/index.js'); + const result = await selectOption('Test:', []); + expect(result).toBeNull(); + }); + }); + + describe('selectOptionWithDefault', () => { + it('should return default for empty options', async () => { + const { selectOptionWithDefault } = await import('../prompt/index.js'); + const result = await selectOptionWithDefault('Test:', [], 'fallback'); + expect(result).toBe('fallback'); + }); + }); +}); diff --git a/src/prompt/index.ts b/src/prompt/index.ts index 79a4155..8d507e3 100644 --- a/src/prompt/index.ts +++ b/src/prompt/index.ts @@ -1,68 +1,250 @@ /** * Interactive prompts for CLI * - * Provides simple input prompts for user interaction. + * Provides cursor-based selection menus using arrow keys. + * Users navigate with ↑/↓ keys and confirm with Enter. */ import * as readline from 'node:readline'; import chalk from 'chalk'; +/** Option type for selectOption */ +export interface SelectOptionItem { + label: string; + value: T; + description?: string; + details?: string[]; +} + /** - * Prompt user to select from a list of options + * Render the menu options to the terminal. + * Writes directly to stdout using ANSI escape codes. + * Exported for testing. + */ +export function renderMenu( + options: SelectOptionItem[], + selectedIndex: number, + hasCancelOption: boolean +): string[] { + const lines: string[] = []; + + for (let i = 0; i < options.length; i++) { + const opt = options[i]!; + const isSelected = i === selectedIndex; + const cursor = isSelected ? chalk.cyan('❯') : ' '; + const label = isSelected ? chalk.cyan.bold(opt.label) : opt.label; + lines.push(` ${cursor} ${label}`); + + if (opt.description) { + lines.push(chalk.gray(` ${opt.description}`)); + } + if (opt.details && opt.details.length > 0) { + for (const detail of opt.details) { + lines.push(chalk.dim(` • ${detail}`)); + } + } + } + + if (hasCancelOption) { + const isCancelSelected = selectedIndex === options.length; + const cursor = isCancelSelected ? chalk.cyan('❯') : ' '; + const label = isCancelSelected ? chalk.cyan.bold('Cancel') : chalk.gray('Cancel'); + 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++; // main label line + 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: 'exit' } + | { action: 'none' }; + +/** + * Pure function for key input state transitions. + * Maps a key string to an action and new state. + * Exported for testing. + */ +export function handleKeyInput( + key: string, + currentIndex: number, + totalItems: number, + hasCancelOption: boolean, + optionCount: number +): KeyInputResult { + // Up arrow or vim 'k' + if (key === '\x1B[A' || key === 'k') { + return { action: 'move', newIndex: (currentIndex - 1 + totalItems) % totalItems }; + } + // Down arrow or vim 'j' + if (key === '\x1B[B' || key === 'j') { + return { action: 'move', newIndex: (currentIndex + 1) % totalItems }; + } + // Enter + if (key === '\r' || key === '\n') { + return { action: 'confirm', selectedIndex: currentIndex }; + } + // Ctrl+C - exit process + if (key === '\x03') { + return { action: 'exit' }; + } + // Escape - cancel + if (key === '\x1B') { + return { action: 'cancel', cancelIndex: hasCancelOption ? optionCount : -1 }; + } + return { action: 'none' }; +} + +/** + * Print the menu header (message + hint). + */ +function printHeader(message: string): void { + console.log(); + console.log(chalk.cyan(message)); + console.log(chalk.gray(' (↑↓ to move, Enter to select)')); + 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 by moving cursor up and re-rendering. + */ +function redrawMenu( + options: SelectOptionItem[], + selectedIndex: number, + hasCancelOption: boolean, + totalLines: number +): void { + process.stdout.write(`\x1B[${totalLines}A`); + process.stdout.write('\x1B[J'); + const newLines = renderMenu(options, selectedIndex, hasCancelOption); + process.stdout.write(newLines.join('\n') + '\n'); +} + +/** + * Interactive cursor-based menu selection. + * Uses raw mode to capture arrow key input for navigation. + */ +function interactiveSelect( + message: string, + options: SelectOptionItem[], + initialIndex: number, + hasCancelOption: boolean +): Promise { + return new Promise((resolve) => { + const totalItems = hasCancelOption ? options.length + 1 : options.length; + let selectedIndex = initialIndex; + + printHeader(message); + + const totalLines = countRenderedLines(options, hasCancelOption); + const lines = renderMenu(options, selectedIndex, hasCancelOption); + process.stdout.write(lines.join('\n') + '\n'); + + if (!process.stdin.isTTY) { + resolve(initialIndex); + return; + } + + const rawMode = setupRawMode(); + + const onKeypress = (data: Buffer): void => { + const result = handleKeyInput( + data.toString(), + selectedIndex, + totalItems, + hasCancelOption, + options.length + ); + + switch (result.action) { + case 'move': + selectedIndex = result.newIndex; + redrawMenu(options, selectedIndex, hasCancelOption, totalLines); + break; + case 'confirm': + rawMode.cleanup(onKeypress); + resolve(result.selectedIndex); + break; + case 'cancel': + rawMode.cleanup(onKeypress); + resolve(result.cancelIndex); + break; + case 'exit': + rawMode.cleanup(onKeypress); + process.exit(130); + break; + case 'none': + break; + } + }; + + 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: { label: string; value: T; description?: string; details?: string[] }[] + options: SelectOptionItem[] ): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); + if (options.length === 0) return null; - console.log(); - console.log(chalk.cyan(message)); - console.log(); + const selectedIndex = await interactiveSelect(message, options, 0, true); - options.forEach((opt, idx) => { - console.log(chalk.yellow(` ${idx + 1}. `) + opt.label); - // Display description if provided - if (opt.description) { - console.log(chalk.gray(` ${opt.description}`)); - } - // Display additional details if provided - if (opt.details && opt.details.length > 0) { - opt.details.forEach((detail) => { - console.log(chalk.dim(` • ${detail}`)); - }); - } - }); - console.log(chalk.gray(` 0. Cancel`)); - console.log(); + // Cancel selected (last item or escape) + if (selectedIndex === options.length || selectedIndex === -1) { + return null; + } - return new Promise((resolve) => { - rl.question(chalk.green('Select [0-' + options.length + ']: '), (answer) => { - rl.close(); + const selected = options[selectedIndex]; + if (selected) { + console.log(chalk.green(` ✓ ${selected.label}`)); + return selected.value; + } - const num = parseInt(answer.trim(), 10); - - if (isNaN(num) || num === 0) { - resolve(null); - return; - } - - if (num >= 1 && num <= options.length) { - const selected = options[num - 1]; - if (selected) { - resolve(selected.value); - return; - } - } - - console.log(chalk.red('Invalid selection')); - resolve(null); - }); - }); + return null; } /** @@ -91,8 +273,8 @@ export async function promptInput(message: string): Promise { } /** - * Prompt user to select from a list of options with a default value - * User can press Enter to select default, or enter a number to select specific option + * Prompt user to select from a list of options with a default value. + * Uses cursor navigation. Enter immediately selects the default. * @returns Selected option value */ export async function selectOptionWithDefault( @@ -100,53 +282,33 @@ export async function selectOptionWithDefault( options: { label: string; value: T }[], defaultValue: T ): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - console.log(); - console.log(chalk.cyan(message)); - console.log(); + if (options.length === 0) return defaultValue; + // Find default index const defaultIndex = options.findIndex((opt) => opt.value === defaultValue); + const initialIndex = defaultIndex >= 0 ? defaultIndex : 0; - options.forEach((opt, idx) => { - const isDefault = opt.value === defaultValue; - const marker = isDefault ? chalk.green(' (default)') : ''; - console.log(chalk.yellow(` ${idx + 1}. `) + opt.label + marker); - }); - console.log(); + // Mark default in label + const decoratedOptions: SelectOptionItem[] = options.map((opt) => ({ + ...opt, + label: opt.value === defaultValue ? `${opt.label} ${chalk.green('(default)')}` : opt.label, + })); - const hint = defaultIndex >= 0 ? ` [Enter=${defaultIndex + 1}]` : ''; + const selectedIndex = await interactiveSelect(message, decoratedOptions, initialIndex, false); - return new Promise((resolve) => { - rl.question(chalk.green(`Select [1-${options.length}]${hint}: `), (answer) => { - rl.close(); + // Escape pressed - use default + if (selectedIndex === -1) { + console.log(chalk.gray(` Using default: ${defaultValue}`)); + return defaultValue; + } - const trimmed = answer.trim(); + const selected = options[selectedIndex]; + if (selected) { + console.log(chalk.green(` ✓ ${selected.label}`)); + return selected.value; + } - // Empty input = use default - if (!trimmed) { - resolve(defaultValue); - return; - } - - const num = parseInt(trimmed, 10); - - if (num >= 1 && num <= options.length) { - const selected = options[num - 1]; - if (selected) { - resolve(selected.value); - return; - } - } - - // Invalid input, use default - console.log(chalk.gray(`Invalid selection, using default: ${defaultValue}`)); - resolve(defaultValue); - }); - }); + return defaultValue; } /**