4b384a7581
- Update menu.ts and selector.ts to use isStdinTTY() and function-based terminal colors - Refactor index.ts from 995-line monolith to ~270-line dispatcher that registers all commands via the CLI parser and delegates to modules - Add initTTY() call at startup for correct pipe/TTY detection - Interactive menu expanded to include new commands (explain, review, changelog, suggest, amend)
273 lines
8.3 KiB
TypeScript
273 lines
8.3 KiB
TypeScript
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<T> {
|
||
label: string;
|
||
value: T;
|
||
description?: string;
|
||
selected?: boolean;
|
||
}
|
||
|
||
interface BasePromptOptions {
|
||
title: string;
|
||
subtitle?: string;
|
||
cancelMessage?: string;
|
||
allowBack?: boolean;
|
||
}
|
||
|
||
interface SinglePromptOptions<T> extends BasePromptOptions {
|
||
items: Choice<T>[];
|
||
}
|
||
|
||
interface MultiPromptOptions<T> extends BasePromptOptions {
|
||
items: Choice<T>[];
|
||
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<T>(
|
||
options: BasePromptOptions & { items: Choice<T>[] },
|
||
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<T>(
|
||
options: SinglePromptOptions<T>,
|
||
): Promise<T | null | PromptBack> {
|
||
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<T>(
|
||
options: MultiPromptOptions<T>,
|
||
): Promise<T[] | null | PromptBack> {
|
||
ensureTTY(options.title);
|
||
|
||
const items: Choice<T | null>[] = 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);
|
||
});
|
||
}
|