Arrow-key driven selector. Redraws the list in place as the highlight moves.
(
question: string,
options: Array<{ value: T; label: string; hint?: string }>,
defaultValue: T,
)
| 83 | |
| 84 | /** Arrow-key driven selector. Redraws the list in place as the highlight moves. */ |
| 85 | function chooseInteractive<T extends string>( |
| 86 | question: string, |
| 87 | options: Array<{ value: T; label: string; hint?: string }>, |
| 88 | defaultValue: T, |
| 89 | ): Promise<T> { |
| 90 | return new Promise<T>((resolve) => { |
| 91 | let idx = initialIndex(options, defaultValue); |
| 92 | const stdin = process.stdin; |
| 93 | const stdout = process.stdout; |
| 94 | |
| 95 | // Window the list so it never grows taller than the terminal. Without this, a list longer |
| 96 | // than the visible rows scrolls the top options off-screen, and the in-place redraw |
| 97 | // (\x1b[NA) can't reach those rows anymore — so only the bottom few stay visible and the |
| 98 | // highlight appears stuck. We show a sliding window of `pageSize` rows that follows the |
| 99 | // cursor, with ▲/▼ markers when there's more above/below. |
| 100 | const termRows = (stdout.rows && stdout.rows > 0) ? stdout.rows : 24; |
| 101 | // Reserve rows for: the question line already printed, the hint line, the optional |
| 102 | // more-above / more-below markers, and a little breathing room. |
| 103 | const pageSize = Math.max(3, Math.min(options.length, termRows - 5)); |
| 104 | const windowed = options.length > pageSize; |
| 105 | let top = 0; // index of the first visible option |
| 106 | // The drawn block = (more-above marker?) + visible rows + (more-below marker?) + hint line. |
| 107 | let block = 0; |
| 108 | let drawn = false; |
| 109 | |
| 110 | console.log(question); |
| 111 | |
| 112 | const clampWindow = (): void => { |
| 113 | if (idx < top) top = idx; |
| 114 | else if (idx >= top + pageSize) top = idx - pageSize + 1; |
| 115 | if (top < 0) top = 0; |
| 116 | if (top > Math.max(0, options.length - pageSize)) top = Math.max(0, options.length - pageSize); |
| 117 | }; |
| 118 | |
| 119 | const render = (): void => { |
| 120 | clampWindow(); |
| 121 | if (drawn && block > 0) stdout.write(`\x1b[${block}A`); // jump back to the top of the block |
| 122 | stdout.write('\x1b[0J'); // clear from cursor down |
| 123 | |
| 124 | // Count PHYSICAL rows (accounting for terminal wrap), not logical lines — a long |
| 125 | // option that wraps occupies >1 row, and if we undercount the cursor-up leaves the |
| 126 | // top rows behind and every keypress piles up another stale copy. |
| 127 | const cols = (stdout.columns && stdout.columns > 0) ? stdout.columns : 80; |
| 128 | let physical = 0; |
| 129 | const emit = (s: string): void => { stdout.write(s + '\n'); physical += physicalRows(s, cols); }; |
| 130 | |
| 131 | const end = Math.min(options.length, top + pageSize); |
| 132 | |
| 133 | if (windowed && top > 0) emit(dim(` ↑ ${top} more above`)); |
| 134 | for (let i = top; i < end; i++) { |
| 135 | const opt = options[i]!; |
| 136 | const sel = i === idx; |
| 137 | const marker = sel ? '▸' : ' '; |
| 138 | const num = String(i + 1).padStart(2); |
| 139 | const label = sel ? `\x1b[36m\x1b[1m${opt.label}\x1b[0m` : opt.label; |
| 140 | const hint = opt.hint ? ` ${dim(opt.hint)}` : ''; |
| 141 | emit(` ${marker} ${num}. ${label}${hint}`); |
| 142 | } |
no test coverage detected