refactor: improve menu with shared helpers and error boundaries
- Use shared terminal helpers (hideCursor, showCursor, clearLine, moveUp, visibleLength, padRight) from terminal.ts - Fix normalizeKey to properly buffer SS3 escape sequences (\x1bO) - Add try/catch+cleanup error boundaries in selectOne and selectMany to prevent terminal state leaks (raw mode, hidden cursor) on errors Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+71
-49
@@ -1,4 +1,4 @@
|
||||
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
|
||||
import { BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength, padRight } from "./terminal";
|
||||
import { isStdinTTY } from "./tty";
|
||||
|
||||
const UP = "\x1b[A";
|
||||
@@ -43,22 +43,7 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
|
||||
doneLabel?: string;
|
||||
}
|
||||
|
||||
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
||||
function showCursor() { process.stdout.write("\x1b[?25h"); }
|
||||
|
||||
function moveUp(lines: number) {
|
||||
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
||||
}
|
||||
|
||||
function clearLine() {
|
||||
process.stdout.write("\r\x1b[2K");
|
||||
}
|
||||
|
||||
function visibleLength(value: string) {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
|
||||
function padLabel(label: string, width: number) {
|
||||
function padLabel(label: string, width: number): string {
|
||||
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
|
||||
}
|
||||
|
||||
@@ -92,7 +77,8 @@ function clearPrompt(lines: number) {
|
||||
moveUp(lines);
|
||||
}
|
||||
|
||||
function normalizeKey(key: string, escapeBuf: string) {
|
||||
function normalizeKey(key: string, escapeBuf: string): { action: string | null; escapeBuf: string } {
|
||||
// Single-chunk actions
|
||||
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" };
|
||||
@@ -100,14 +86,20 @@ function normalizeKey(key: string, escapeBuf: string) {
|
||||
if (key === ENTER) return { action: "enter", escapeBuf: "" };
|
||||
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
|
||||
|
||||
if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key };
|
||||
// Start of an escape sequence — buffer it
|
||||
if (key === "\x1b" || key.startsWith("\x1b[") || key.startsWith("\x1bO")) {
|
||||
return { action: null, escapeBuf: key };
|
||||
}
|
||||
|
||||
// Continue buffering an escape sequence
|
||||
if (escapeBuf) {
|
||||
const next = escapeBuf + key;
|
||||
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||
if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" };
|
||||
return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next };
|
||||
// If key is a terminal character (letter/digit/~) or buffer got too long, flush
|
||||
if (/^[A-Za-z~0-9]$/.test(key) || next.length > 10) return { action: null, escapeBuf: "" };
|
||||
return { action: null, escapeBuf: next };
|
||||
}
|
||||
|
||||
return { action: null, escapeBuf: "" };
|
||||
@@ -168,15 +160,25 @@ export async function selectOne<T>(
|
||||
const render = () => {
|
||||
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
|
||||
};
|
||||
render();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
clearPrompt(renderedLines);
|
||||
showCursor();
|
||||
};
|
||||
|
||||
try {
|
||||
render();
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (value: T | null | PromptBack) => {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
clearPrompt(renderedLines);
|
||||
showCursor();
|
||||
cleanup();
|
||||
if (value === null && options.cancelMessage) {
|
||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||
}
|
||||
@@ -184,15 +186,20 @@ export async function selectOne<T>(
|
||||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const result = normalizeKey(data.toString(), escapeBuf);
|
||||
escapeBuf = result.escapeBuf;
|
||||
try {
|
||||
const result = normalizeKey(data.toString(), escapeBuf);
|
||||
escapeBuf = result.escapeBuf;
|
||||
|
||||
if (result.action === "cancel") return finish(null);
|
||||
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
|
||||
else if (result.action === "space" || result.action === "enter") {
|
||||
finish(options.items[cursor]!.value);
|
||||
if (result.action === "cancel") return finish(null);
|
||||
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
|
||||
else if (result.action === "space" || result.action === "enter") {
|
||||
finish(options.items[cursor]!.value);
|
||||
}
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -243,15 +250,25 @@ export async function selectMany<T>(
|
||||
renderedLines,
|
||||
);
|
||||
};
|
||||
render();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
clearPrompt(renderedLines);
|
||||
showCursor();
|
||||
};
|
||||
|
||||
try {
|
||||
render();
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (value: T[] | null | PromptBack) => {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
clearPrompt(renderedLines);
|
||||
showCursor();
|
||||
cleanup();
|
||||
if (value === null && options.cancelMessage) {
|
||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||
}
|
||||
@@ -259,16 +276,21 @@ export async function selectMany<T>(
|
||||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const result = normalizeKey(data.toString(), escapeBuf);
|
||||
escapeBuf = result.escapeBuf;
|
||||
try {
|
||||
const result = normalizeKey(data.toString(), escapeBuf);
|
||||
escapeBuf = result.escapeBuf;
|
||||
|
||||
if (result.action === "cancel") return finish(null);
|
||||
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||
else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
|
||||
else if (result.action === "space") { toggle(cursor); render(); }
|
||||
else if (result.action === "enter") {
|
||||
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
|
||||
if (result.action === "cancel") return finish(null);
|
||||
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||
else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
|
||||
else if (result.action === "space") { toggle(cursor); render(); }
|
||||
else if (result.action === "enter") {
|
||||
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
|
||||
}
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user