import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; const UP = "\x1b[A"; const DOWN = "\x1b[B"; const ALT_UP = "\x1bOA"; const ALT_DOWN = "\x1bOB"; const SPACE = " "; const ENTER = "\r"; const CTRL_C = "\x03"; export interface Choice { label: string; value: T; description?: string; selected?: boolean; } interface BasePromptOptions { title: string; subtitle?: string; cancelMessage?: string; } 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") { if (mode === "single") { return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`; } return `${DIM}↑/↓ navigate · space toggle · enter confirm · 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 === 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: "" }; 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)}`, ""); 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 (process.stdin.isTTY !== true) { 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) => { 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 === "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) => { 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 === "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); }); }