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:
2026-06-18 01:55:39 +08:00
parent 10c7b9a688
commit d4ba4da9d5
+50 -28
View File
@@ -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 finish = (value: T | null | PromptBack) => {
const cleanup = () => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T | null | PromptBack) => {
process.stdin.removeListener("data", onData);
cleanup();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
@@ -184,6 +186,7 @@ export async function selectOne<T>(
};
const onData = (data: Buffer) => {
try {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
@@ -194,6 +197,10 @@ export async function selectOne<T>(
else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value);
}
} catch (err) {
cleanup();
reject(err);
}
};
process.stdin.on("data", onData);
@@ -243,15 +250,25 @@ export async function selectMany<T>(
renderedLines,
);
};
render();
return new Promise((resolve) => {
const finish = (value: T[] | null | PromptBack) => {
const cleanup = () => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T[] | null | PromptBack) => {
process.stdin.removeListener("data", onData);
cleanup();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
@@ -259,6 +276,7 @@ export async function selectMany<T>(
};
const onData = (data: Buffer) => {
try {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
@@ -270,6 +288,10 @@ export async function selectMany<T>(
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);
}
};
process.stdin.on("data", onData);