import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; import { isStdinTTY } from "./tty"; const UP = "\x1b[A"; const DOWN = "\x1b[B"; const LEFT = "\x1b[D"; const ALT_UP = "\x1bOA"; const ALT_DOWN = "\x1bOB"; const ALT_LEFT = "\x1bOD"; const SPACE = " "; const ENTER = "\r"; const CTRL_C = "\x03"; const BACKSPACE = "\x7f"; export const BACK = Symbol("prompt-back"); export type PromptBack = typeof BACK; export interface Choice { label: string; value: T; description?: string; selected?: boolean; } interface BasePromptOptions { title: string; subtitle?: string; cancelMessage?: string; allowBack?: boolean; } interface SinglePromptOptions extends BasePromptOptions { items: Choice[]; } interface MultiPromptOptions extends BasePromptOptions { items: Choice[]; selectAllLabel?: string; 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) { return label + " ".repeat(Math.max(1, width - visibleLength(label))); } function controls(mode: "single" | "multi", showBackHint = true) { if (mode === "single") { const backHint = showBackHint ? " · ←/backspace back" : ""; return `${DIM()}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET()}`; } const backHint = showBackHint ? " · ←/backspace back" : ""; return `${DIM()}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET()}`; } function renderPrompt(lines: string[], previousLines: number) { if (previousLines > 0) { for (let i = 0; i < previousLines; i++) { clearLine(); process.stdout.write("\n"); } moveUp(previousLines); } for (const line of lines) process.stdout.write(`${line}\n`); moveUp(lines.length); return lines.length; } function clearPrompt(lines: number) { for (let i = 0; i < lines; i++) { clearLine(); process.stdout.write("\n"); } moveUp(lines); } function normalizeKey(key: string, escapeBuf: string) { 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: "" }; if (key === SPACE) return { action: "space", escapeBuf: "" }; 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 }; 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 }; } return { action: null, escapeBuf: "" }; } function createLines( options: BasePromptOptions & { items: Choice[] }, mode: "single" | "multi", cursor: number, ) { const labelWidth = Math.max(...options.items.map((item) => visibleLength(item.label)), 0) + 2; const lines = [ "", ` ${BOLD()}${options.title}${RESET()}`, ]; if (options.subtitle) lines.push(` ${DIM()}${options.subtitle}${RESET()}`); lines.push(` ${controls(mode, options.allowBack !== false)}`, ""); for (let i = 0; i < options.items.length; i++) { const item = options.items[i]!; const active = i === cursor; const pointer = active ? `${CYAN()}❯${RESET()}` : " "; const marker = mode === "single" ? active ? `${GREEN()}●${RESET()}` : `${DIM()}○${RESET()}` : item.selected ? `${GREEN()}◼${RESET()}` : `${DIM()}□${RESET()}`; const label = active ? `${BOLD()}${item.label}${RESET()}` : item.label; const description = item.description ? active ? item.description : `${DIM()}${item.description}${RESET()}` : ""; lines.push(` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`); } return lines; } function ensureTTY(title: string) { if (!isStdinTTY()) { throw new Error(`${title} requires a TTY.`); } } export async function selectOne( options: SinglePromptOptions, ): Promise { ensureTTY(options.title); let cursor = 0; let renderedLines = 0; let escapeBuf = ""; const wasRaw = process.stdin.isRaw; if (wasRaw !== true) process.stdin.setRawMode(true); process.stdin.resume(); hideCursor(); const render = () => { renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines); }; render(); return new Promise((resolve) => { const finish = (value: T | null | PromptBack) => { process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdin.removeListener("data", onData); clearPrompt(renderedLines); showCursor(); if (value === null && options.cancelMessage) { process.stdout.write(` ${options.cancelMessage}\n`); } resolve(value); }; const onData = (data: Buffer) => { 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); } }; process.stdin.on("data", onData); }); } export async function selectMany( options: MultiPromptOptions, ): Promise { ensureTTY(options.title); const items: Choice[] = options.selectAllLabel ? [ { label: options.selectAllLabel, value: null, selected: false }, ...options.items.map((item) => ({ ...item })), ] : options.items.map((item) => ({ ...item })); let cursor = 0; let renderedLines = 0; let escapeBuf = ""; const wasRaw = process.stdin.isRaw; if (wasRaw !== true) process.stdin.setRawMode(true); process.stdin.resume(); hideCursor(); const syncSelectAll = () => { if (!options.selectAllLabel) return; items[0]!.selected = items.slice(1).every((item) => item.selected); }; const toggle = (index: number) => { const item = items[index]!; item.selected = !item.selected; if (options.selectAllLabel && index === 0) { for (let i = 1; i < items.length; i++) items[i]!.selected = item.selected; } else { syncSelectAll(); } }; const render = () => { renderedLines = renderPrompt( createLines({ title: options.title, subtitle: options.subtitle, items }, "multi", cursor), renderedLines, ); }; render(); return new Promise((resolve) => { const finish = (value: T[] | null | PromptBack) => { process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdin.removeListener("data", onData); clearPrompt(renderedLines); showCursor(); if (value === null && options.cancelMessage) { process.stdout.write(` ${options.cancelMessage}\n`); } resolve(value); }; const onData = (data: Buffer) => { 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)); } }; process.stdin.on("data", onData); }); }