/** * Custom ANSI selectors with live preview support. * The tree IS the selector — arrow keys navigate, right panel updates live. * * @module */ import chalk from "chalk"; export interface ISelectOption { label: string; value: string; hint?: string; } // ─── Theme ─────────────────────────────────────────────────────────────────── const T = { accent: "#a875ff", // brighter purple for bullets selectedBg: "#2d1f5e", // visible bg even on dark terminals selectedFg: "#ffffff", // white text on selected normal: "#cccccc", // normal unselected labels (high contrast) dim: "#888888", // dimmed unselected labels hint: "#999999", // hints next to labels sep: "#444444", // separator lines }; // ─── Dual-panel select (tree left + preview right) ─────────────────────────── /** * Interactive selector rendered at a position, with a callback on each * highlight change so the caller can update a right-side preview panel. * * @param options - Selectable items * @param startRow - Terminal row (1-based) where the selector starts * @param startCol - Terminal column (1-based) where the selector starts * @param onHighlight - Called on each arrow key move with the highlighted value. * Use this to re-render a detail panel to the right. * @returns Selected value on Enter, null on Esc/Ctrl+C */ export function inlineSelect( options: ISelectOption[], startRow: number, startCol: number, onHighlight?: (value: string, index: number) => void, ): Promise { let selected = 0; // Skip separators while (selected < options.length && (!options[selected].value || options[selected].value === "_sep")) selected++; function render(clearExtra = false): void { for (let i = 0; i < options.length; i++) { process.stdout.write(`\x1b[${startRow + i};${startCol}H\x1b[K`); process.stdout.write(formatItem(options[i], i === selected)); } // Clear leftover lines from previous menus (up to 15 extra lines) if (clearExtra) { for (let i = 0; i < 15; i++) { process.stdout.write(`\x1b[${startRow + options.length + i};${startCol}H\x1b[K`); } } } return new Promise((resolve) => { if (!process.stdin.isTTY) { resolve(null); return; } process.stdout.write("\x1b[?25l"); // hide cursor const wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); render(true); // First render clears leftover lines from previous menus onHighlight?.(options[selected].value, selected); const onData = (key: string): void => { if (key === "\x1b[A" || key === "k") { // Up do { selected = (selected - 1 + options.length) % options.length; } while (!options[selected].value || options[selected].value === "_sep"); render(); onHighlight?.(options[selected].value, selected); return; } if (key === "\x1b[B" || key === "j") { // Down do { selected = (selected + 1) % options.length; } while (!options[selected].value || options[selected].value === "_sep"); render(); onHighlight?.(options[selected].value, selected); return; } if (key.charCodeAt(0) === 13) { cleanup(); resolve(options[selected].value); return; } // Enter if ((key.charCodeAt(0) === 27 && key.length === 1) || key === "q") { cleanup(); resolve(null); return; } // Esc if (key.charCodeAt(0) === 3) { cleanup(); resolve(null); return; } // Ctrl+C }; function cleanup(): void { process.stdin.removeListener("data", onData); process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); process.stdout.write("\x1b[?25h"); process.stdout.write(`\x1b[${startRow + options.length + 1};1H`); } process.stdin.on("data", onData); }); } // ─── Full-width select (below current output) ─────────────────────────────── /** * Full-width selector below current output. For sub-menus (actions, logs, env). */ export async function fullSelect( title: string, options: ISelectOption[], onHighlight?: (value: string, index: number, startRow: number) => void, ): Promise { let selected = 0; while (selected < options.length && (!options[selected].value || options[selected].value === "_sep")) selected++; // Print title + reserve space for options if (title) console.log(`\n ${chalk.bold(title)}`); // Use CSI 6n to get actual cursor row BEFORE printing option lines const cursorRowBefore = await getCursorRowAsync(); const optionsStartRow = cursorRowBefore; // Reserve vertical space for (let i = 0; i < options.length; i++) process.stdout.write("\n"); function render(): void { // Use absolute row positioning to avoid drift for (let i = 0; i < options.length; i++) { process.stdout.write(`\x1b[${optionsStartRow + i};4H\x1b[K`); process.stdout.write(formatItem(options[i], i === selected)); } // Park cursor after the list process.stdout.write(`\x1b[${optionsStartRow + options.length};1H`); } return new Promise((resolve) => { if (!process.stdin.isTTY) { resolve(null); return; } process.stdout.write("\x1b[?25l"); const wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); render(); onHighlight?.(options[selected].value, selected, optionsStartRow); const onData = (key: string): void => { if (key === "\x1b[A" || key === "k") { do { selected = (selected - 1 + options.length) % options.length; } while (!options[selected].value || options[selected].value === "_sep"); render(); onHighlight?.(options[selected].value, selected, optionsStartRow); return; } if (key === "\x1b[B" || key === "j") { do { selected = (selected + 1) % options.length; } while (!options[selected].value || options[selected].value === "_sep"); render(); onHighlight?.(options[selected].value, selected, optionsStartRow); return; } if (key.charCodeAt(0) === 13) { cleanup(); resolve(options[selected].value); return; } if ((key.charCodeAt(0) === 27 && key.length === 1) || key === "q") { cleanup(); resolve(null); return; } if (key.charCodeAt(0) === 3) { cleanup(); resolve(null); return; } }; function cleanup(): void { process.stdin.removeListener("data", onData); process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); process.stdout.write("\x1b[?25h"); } process.stdin.on("data", onData); }); } // ─── Text input ────────────────────────────────────────────────────────────── export function textInput(message: string): Promise { return new Promise((resolve) => { if (!process.stdin.isTTY) { resolve(null); return; } process.stdout.write(`\n ${chalk.hex(T.accent)("?")} ${chalk.bold(message)} `); process.stdin.setRawMode(false); process.stdin.resume(); process.stdin.setEncoding("utf8"); const onData = (data: string): void => { process.stdin.removeListener("data", onData); process.stdin.pause(); resolve(data.trim() || null); }; process.stdin.on("data", onData); }); } export function confirm(message: string): Promise { return new Promise((resolve) => { if (!process.stdin.isTTY) { resolve(null); return; } process.stdout.write(`\n ${chalk.hex(T.accent)("?")} ${chalk.bold(message)} ${chalk.gray("(y/n)")} `); const wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); const onData = (key: string): void => { process.stdin.removeListener("data", onData); process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); process.stdout.write("\n"); if (key === "y" || key === "Y") resolve(true); else if (key.charCodeAt(0) === 3 || key.charCodeAt(0) === 27) resolve(null); else resolve(false); }; process.stdin.on("data", onData); }); } // ─── Formatting ────────────────────────────────────────────────────────────── function formatItem(opt: ISelectOption, isSelected: boolean): string { if (!opt.value || opt.value === "_sep") { return chalk.hex(T.sep)(" ───────────────────"); } if (isSelected) { const bullet = chalk.hex(T.accent)("▸ "); const label = chalk.bgHex(T.selectedBg).hex(T.selectedFg).bold(` ${opt.label} `); // Don't wrap hint in a color — it may contain pre-colored text (●, ✗) const hint = opt.hint ? ` ${opt.hint}` : ""; return `${bullet}${label}${hint}`; } const bullet = chalk.hex(T.dim)(" "); const label = chalk.hex(T.normal)(opt.label); // Hints on unselected items: pass through (may have colors) const hint = opt.hint ? ` ${opt.hint}` : ""; return `${bullet}${label}${hint}`; } // ─── Cursor position query ─────────────────────────────────────────────────── /** * Get the current cursor row using CSI 6n (Device Status Report). * Falls back to a reasonable guess if the terminal doesn't respond. */ function getCursorRowAsync(): Promise { return new Promise((resolve) => { if (!process.stdout.isTTY || !process.stdin.isTTY) { resolve(10); return; } const timeout = setTimeout(() => { process.stdin.removeListener("data", onData); process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); resolve(10); // fallback }, 150); const wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); const onData = (data: string): void => { const match = data.match(/\x1b\[(\d+);(\d+)R/); if (match) { clearTimeout(timeout); process.stdin.removeListener("data", onData); process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); resolve(parseInt(match[1], 10)); } }; process.stdin.on("data", onData); process.stdout.write("\x1b[6n"); // Query cursor position }); }