feat: メニュー選択を数字入力からカーソル(上下キー)方式に変更
This commit is contained in:
parent
d900ee8bc4
commit
9a5d290ce3
319
src/__tests__/prompt.test.ts
Normal file
319
src/__tests__/prompt.test.ts
Normal file
@ -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<string>[] = [
|
||||||
|
{ 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<string>[] = [
|
||||||
|
{ 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<string>[] = [
|
||||||
|
{
|
||||||
|
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<string>[] = [
|
||||||
|
{ 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<string>[] = [
|
||||||
|
{ label: 'A', value: 'a' },
|
||||||
|
{ label: 'B', value: 'b' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(countRenderedLines(options, true)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count description lines', () => {
|
||||||
|
const options: SelectOptionItem<string>[] = [
|
||||||
|
{ 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<string>[] = [
|
||||||
|
{
|
||||||
|
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<string>[] = [
|
||||||
|
{
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,68 +1,250 @@
|
|||||||
/**
|
/**
|
||||||
* Interactive prompts for CLI
|
* 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 * as readline from 'node:readline';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
/** Option type for selectOption */
|
||||||
|
export interface SelectOptionItem<T extends string> {
|
||||||
|
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<T extends string>(
|
||||||
|
options: SelectOptionItem<T>[],
|
||||||
|
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<T extends string>(
|
||||||
|
options: SelectOptionItem<T>[],
|
||||||
|
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<T extends string>(
|
||||||
|
options: SelectOptionItem<T>[],
|
||||||
|
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<T extends string>(
|
||||||
|
message: string,
|
||||||
|
options: SelectOptionItem<T>[],
|
||||||
|
initialIndex: number,
|
||||||
|
hasCancelOption: boolean
|
||||||
|
): Promise<number> {
|
||||||
|
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
|
* @returns Selected option or null if cancelled
|
||||||
*/
|
*/
|
||||||
export async function selectOption<T extends string>(
|
export async function selectOption<T extends string>(
|
||||||
message: string,
|
message: string,
|
||||||
options: { label: string; value: T; description?: string; details?: string[] }[]
|
options: SelectOptionItem<T>[]
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
const rl = readline.createInterface({
|
if (options.length === 0) return null;
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log();
|
const selectedIndex = await interactiveSelect(message, options, 0, true);
|
||||||
console.log(chalk.cyan(message));
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
options.forEach((opt, idx) => {
|
// Cancel selected (last item or escape)
|
||||||
console.log(chalk.yellow(` ${idx + 1}. `) + opt.label);
|
if (selectedIndex === options.length || selectedIndex === -1) {
|
||||||
// Display description if provided
|
return null;
|
||||||
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();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
rl.question(chalk.green('Select [0-' + options.length + ']: '), (answer) => {
|
|
||||||
rl.close();
|
|
||||||
|
|
||||||
const num = parseInt(answer.trim(), 10);
|
|
||||||
|
|
||||||
if (isNaN(num) || num === 0) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (num >= 1 && num <= options.length) {
|
const selected = options[selectedIndex];
|
||||||
const selected = options[num - 1];
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
resolve(selected.value);
|
console.log(chalk.green(` ✓ ${selected.label}`));
|
||||||
return;
|
return selected.value;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(chalk.red('Invalid selection'));
|
return null;
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -91,8 +273,8 @@ export async function promptInput(message: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt user to select from a list of options with a default value
|
* 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
|
* Uses cursor navigation. Enter immediately selects the default.
|
||||||
* @returns Selected option value
|
* @returns Selected option value
|
||||||
*/
|
*/
|
||||||
export async function selectOptionWithDefault<T extends string>(
|
export async function selectOptionWithDefault<T extends string>(
|
||||||
@ -100,53 +282,33 @@ export async function selectOptionWithDefault<T extends string>(
|
|||||||
options: { label: string; value: T }[],
|
options: { label: string; value: T }[],
|
||||||
defaultValue: T
|
defaultValue: T
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const rl = readline.createInterface({
|
if (options.length === 0) return defaultValue;
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log();
|
|
||||||
console.log(chalk.cyan(message));
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
|
// Find default index
|
||||||
const defaultIndex = options.findIndex((opt) => opt.value === defaultValue);
|
const defaultIndex = options.findIndex((opt) => opt.value === defaultValue);
|
||||||
|
const initialIndex = defaultIndex >= 0 ? defaultIndex : 0;
|
||||||
|
|
||||||
options.forEach((opt, idx) => {
|
// Mark default in label
|
||||||
const isDefault = opt.value === defaultValue;
|
const decoratedOptions: SelectOptionItem<T>[] = options.map((opt) => ({
|
||||||
const marker = isDefault ? chalk.green(' (default)') : '';
|
...opt,
|
||||||
console.log(chalk.yellow(` ${idx + 1}. `) + opt.label + marker);
|
label: opt.value === defaultValue ? `${opt.label} ${chalk.green('(default)')}` : opt.label,
|
||||||
});
|
}));
|
||||||
console.log();
|
|
||||||
|
|
||||||
const hint = defaultIndex >= 0 ? ` [Enter=${defaultIndex + 1}]` : '';
|
const selectedIndex = await interactiveSelect(message, decoratedOptions, initialIndex, false);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
// Escape pressed - use default
|
||||||
rl.question(chalk.green(`Select [1-${options.length}]${hint}: `), (answer) => {
|
if (selectedIndex === -1) {
|
||||||
rl.close();
|
console.log(chalk.gray(` Using default: ${defaultValue}`));
|
||||||
|
return defaultValue;
|
||||||
const trimmed = answer.trim();
|
|
||||||
|
|
||||||
// Empty input = use default
|
|
||||||
if (!trimmed) {
|
|
||||||
resolve(defaultValue);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = parseInt(trimmed, 10);
|
const selected = options[selectedIndex];
|
||||||
|
|
||||||
if (num >= 1 && num <= options.length) {
|
|
||||||
const selected = options[num - 1];
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
resolve(selected.value);
|
console.log(chalk.green(` ✓ ${selected.label}`));
|
||||||
return;
|
return selected.value;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid input, use default
|
return defaultValue;
|
||||||
console.log(chalk.gray(`Invalid selection, using default: ${defaultValue}`));
|
|
||||||
resolve(defaultValue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user