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";
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user