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
+71 -49
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"; import { isStdinTTY } from "./tty";
const UP = "\x1b[A"; const UP = "\x1b[A";
@@ -43,22 +43,7 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
doneLabel?: string; doneLabel?: string;
} }
function hideCursor() { process.stdout.write("\x1b[?25l"); } function padLabel(label: string, width: number): string {
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) {
return label + " ".repeat(Math.max(1, width - visibleLength(label))); return label + " ".repeat(Math.max(1, width - visibleLength(label)));
} }
@@ -92,7 +77,8 @@ function clearPrompt(lines: number) {
moveUp(lines); 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 === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", 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 === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", 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) { if (escapeBuf) {
const next = escapeBuf + key; const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) return { action: "back", 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: "" }; return { action: null, escapeBuf: "" };
@@ -168,15 +160,25 @@ export async function selectOne<T>(
const render = () => { const render = () => {
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines); 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) => { const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
clearPrompt(renderedLines); cleanup();
showCursor();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -184,15 +186,20 @@ export async function selectOne<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf); try {
escapeBuf = result.escapeBuf; const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) return finish(BACK); if (result.action === "back" && options.allowBack !== false) return finish(BACK);
if (result.action === "up" && cursor > 0) { cursor--; render(); } if (result.action === "up" && cursor > 0) { cursor--; render(); }
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); } else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
else if (result.action === "space" || result.action === "enter") { else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value); finish(options.items[cursor]!.value);
}
} catch (err) {
cleanup();
reject(err);
} }
}; };
@@ -243,15 +250,25 @@ export async function selectMany<T>(
renderedLines, 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) => { const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
clearPrompt(renderedLines); cleanup();
showCursor();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -259,16 +276,21 @@ export async function selectMany<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf); try {
escapeBuf = result.escapeBuf; const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) return finish(BACK); if (result.action === "back" && options.allowBack !== false) return finish(BACK);
if (result.action === "up" && cursor > 0) { cursor--; render(); } if (result.action === "up" && cursor > 0) { cursor--; render(); }
else if (result.action === "down" && cursor < items.length - 1) { 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 === "space") { toggle(cursor); render(); }
else if (result.action === "enter") { else if (result.action === "enter") {
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T)); finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
}
} catch (err) {
cleanup();
reject(err);
} }
}; };