diff --git a/src/menu.ts b/src/menu.ts index 92158fe..96413b2 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -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 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( 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( }; 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( 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( }; 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); } };