takt/src/shared/prompt/select.ts
nrslib b455e312af Fix Ctrl+C not working during piece execution
- Add interruptAllQueries() call in SIGINT handler to kill active SDK queries
- Suppress EPIPE errors from SDK writing to dead child process stdin
  (uncaughtException handler + Promise.resolve().catch() dual-layer)
- Wrap select.ts onKeypress in try/catch to prevent raw mode leak
- Add integration tests for SIGINT handler and EPIPE suppression
2026-02-06 15:49:47 +09:00

353 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<T extends string> {
label: string;
value: T;
description?: string;
details?: string[];
}
/**
* Render the menu options to the terminal.
* Exported for testing.
*/
export function renderMenu<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string>(
options: SelectOptionItem<T>[],
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<T extends string> {
/**
* 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<T>[] | null;
/** Custom label for cancel option (default: "Cancel") */
cancelLabel?: string;
}
/** Result of interactive selection */
interface InteractiveSelectResult<T extends string> {
selectedIndex: number;
finalOptions: SelectOptionItem<T>[];
}
/** Interactive cursor-based menu selection. */
function interactiveSelect<T extends string>(
message: string,
options: SelectOptionItem<T>[],
initialIndex: number,
hasCancelOption: boolean,
callbacks?: InteractiveSelectCallbacks<T>,
): Promise<InteractiveSelectResult<T>> {
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<T extends string>(
message: string,
options: SelectOptionItem<T>[],
callbacks?: InteractiveSelectCallbacks<T>,
): Promise<T | null> {
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<T extends string>(
message: string,
options: { label: string; value: T }[],
defaultValue: T,
): Promise<T | null> {
if (options.length === 0) return defaultValue;
const defaultIndex = options.findIndex((opt) => opt.value === defaultValue);
const initialIndex = defaultIndex >= 0 ? defaultIndex : 0;
const decoratedOptions: SelectOptionItem<T>[] = 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;
}