refactor(ui): update menu, selector for TTY detection; thin dispatcher
- 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)
This commit is contained in:
+33
-89
@@ -1,4 +1,5 @@
|
|||||||
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
|
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
|
||||||
|
import { isStdinTTY } from "./tty";
|
||||||
|
|
||||||
const UP = "\x1b[A";
|
const UP = "\x1b[A";
|
||||||
const DOWN = "\x1b[B";
|
const DOWN = "\x1b[B";
|
||||||
@@ -38,13 +39,8 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
|
|||||||
doneLabel?: string;
|
doneLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCursor() {
|
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
||||||
process.stdout.write("\x1b[?25l");
|
function showCursor() { process.stdout.write("\x1b[?25h"); }
|
||||||
}
|
|
||||||
|
|
||||||
function showCursor() {
|
|
||||||
process.stdout.write("\x1b[?25h");
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveUp(lines: number) {
|
function moveUp(lines: number) {
|
||||||
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
||||||
@@ -65,10 +61,10 @@ function padLabel(label: string, width: number) {
|
|||||||
function controls(mode: "single" | "multi", showBackHint = true) {
|
function controls(mode: "single" | "multi", showBackHint = true) {
|
||||||
if (mode === "single") {
|
if (mode === "single") {
|
||||||
const backHint = showBackHint ? " · ←/backspace back" : "";
|
const backHint = showBackHint ? " · ←/backspace back" : "";
|
||||||
return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`;
|
return `${DIM()}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET()}`;
|
||||||
}
|
}
|
||||||
const backHint = showBackHint ? " · ←/backspace back" : "";
|
const backHint = showBackHint ? " · ←/backspace back" : "";
|
||||||
return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`;
|
return `${DIM()}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPrompt(lines: string[], previousLines: number) {
|
function renderPrompt(lines: string[], previousLines: number) {
|
||||||
@@ -79,10 +75,7 @@ function renderPrompt(lines: string[], previousLines: number) {
|
|||||||
}
|
}
|
||||||
moveUp(previousLines);
|
moveUp(previousLines);
|
||||||
}
|
}
|
||||||
|
for (const line of lines) process.stdout.write(`${line}\n`);
|
||||||
for (const line of lines) {
|
|
||||||
process.stdout.write(`${line}\n`);
|
|
||||||
}
|
|
||||||
moveUp(lines.length);
|
moveUp(lines.length);
|
||||||
return lines.length;
|
return lines.length;
|
||||||
}
|
}
|
||||||
@@ -98,28 +91,19 @@ function clearPrompt(lines: number) {
|
|||||||
function normalizeKey(key: string, escapeBuf: string) {
|
function normalizeKey(key: string, escapeBuf: string) {
|
||||||
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
|
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||||
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||||
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) {
|
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" };
|
||||||
return { action: "back", escapeBuf: "" };
|
|
||||||
}
|
|
||||||
if (key === SPACE) return { action: "space", escapeBuf: "" };
|
if (key === SPACE) return { action: "space", escapeBuf: "" };
|
||||||
if (key === ENTER) return { action: "enter", escapeBuf: "" };
|
if (key === ENTER) return { action: "enter", escapeBuf: "" };
|
||||||
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
|
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
|
||||||
|
|
||||||
if (key === "\x1b" || key.startsWith("\x1b[")) {
|
if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key };
|
||||||
return { action: null, escapeBuf: key };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (escapeBuf) {
|
if (escapeBuf) {
|
||||||
const next = escapeBuf + key;
|
const next = escapeBuf + key;
|
||||||
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||||
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||||
if (next === LEFT || next === ALT_LEFT) {
|
if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" };
|
||||||
return { action: "back", escapeBuf: "" };
|
return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next };
|
||||||
}
|
|
||||||
return {
|
|
||||||
action: null,
|
|
||||||
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { action: null, escapeBuf: "" };
|
return { action: null, escapeBuf: "" };
|
||||||
@@ -130,41 +114,35 @@ function createLines<T>(
|
|||||||
mode: "single" | "multi",
|
mode: "single" | "multi",
|
||||||
cursor: number,
|
cursor: number,
|
||||||
) {
|
) {
|
||||||
const labelWidth = Math.max(
|
const labelWidth = Math.max(...options.items.map((item) => visibleLength(item.label)), 0) + 2;
|
||||||
...options.items.map((item) => visibleLength(item.label)),
|
|
||||||
0,
|
|
||||||
) + 2;
|
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"",
|
"",
|
||||||
` ${BOLD}${options.title}${RESET}`,
|
` ${BOLD()}${options.title}${RESET()}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`);
|
if (options.subtitle) lines.push(` ${DIM()}${options.subtitle}${RESET()}`);
|
||||||
lines.push(` ${controls(mode, options.allowBack !== false)}`, "");
|
lines.push(` ${controls(mode, options.allowBack !== false)}`, "");
|
||||||
|
|
||||||
for (let i = 0; i < options.items.length; i++) {
|
for (let i = 0; i < options.items.length; i++) {
|
||||||
const item = options.items[i]!;
|
const item = options.items[i]!;
|
||||||
const active = i === cursor;
|
const active = i === cursor;
|
||||||
const pointer = active ? `${CYAN}❯${RESET}` : " ";
|
const pointer = active ? `${CYAN()}❯${RESET()}` : " ";
|
||||||
const marker = mode === "single"
|
const marker = mode === "single"
|
||||||
? active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`
|
? active ? `${GREEN()}●${RESET()}` : `${DIM()}○${RESET()}`
|
||||||
: item.selected ? `${GREEN}◼${RESET}` : `${DIM}□${RESET}`;
|
: item.selected ? `${GREEN()}◼${RESET()}` : `${DIM()}□${RESET()}`;
|
||||||
const label = active ? `${BOLD}${item.label}${RESET}` : item.label;
|
const label = active ? `${BOLD()}${item.label}${RESET()}` : item.label;
|
||||||
const description = item.description
|
const description = item.description
|
||||||
? active ? item.description : `${DIM}${item.description}${RESET}`
|
? active ? item.description : `${DIM()}${item.description}${RESET()}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
lines.push(
|
lines.push(` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`);
|
||||||
` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTTY(title: string) {
|
function ensureTTY(title: string) {
|
||||||
if (process.stdin.isTTY !== true) {
|
if (!isStdinTTY()) {
|
||||||
throw new Error(`${title} requires a TTY.`);
|
throw new Error(`${title} requires a TTY.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,12 +162,8 @@ export async function selectOne<T>(
|
|||||||
hideCursor();
|
hideCursor();
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
renderedLines = renderPrompt(
|
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
|
||||||
createLines(options, "single", cursor),
|
|
||||||
renderedLines,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -210,16 +184,10 @@ export async function selectOne<T>(
|
|||||||
escapeBuf = result.escapeBuf;
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
if (result.action === "cancel") return finish(null);
|
if (result.action === "cancel") return finish(null);
|
||||||
if (result.action === "back" && options.allowBack !== false) {
|
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||||
return finish(BACK);
|
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||||
}
|
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
|
||||||
if (result.action === "up" && cursor > 0) {
|
else if (result.action === "space" || result.action === "enter") {
|
||||||
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);
|
finish(options.items[cursor]!.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -257,11 +225,8 @@ export async function selectMany<T>(
|
|||||||
const toggle = (index: number) => {
|
const toggle = (index: number) => {
|
||||||
const item = items[index]!;
|
const item = items[index]!;
|
||||||
item.selected = !item.selected;
|
item.selected = !item.selected;
|
||||||
|
|
||||||
if (options.selectAllLabel && index === 0) {
|
if (options.selectAllLabel && index === 0) {
|
||||||
for (let i = 1; i < items.length; i++) {
|
for (let i = 1; i < items.length; i++) items[i]!.selected = item.selected;
|
||||||
items[i]!.selected = item.selected;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
syncSelectAll();
|
syncSelectAll();
|
||||||
}
|
}
|
||||||
@@ -269,19 +234,10 @@ export async function selectMany<T>(
|
|||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
renderedLines = renderPrompt(
|
renderedLines = renderPrompt(
|
||||||
createLines(
|
createLines({ title: options.title, subtitle: options.subtitle, items }, "multi", cursor),
|
||||||
{
|
|
||||||
title: options.title,
|
|
||||||
subtitle: options.subtitle,
|
|
||||||
items,
|
|
||||||
},
|
|
||||||
"multi",
|
|
||||||
cursor,
|
|
||||||
),
|
|
||||||
renderedLines,
|
renderedLines,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -302,24 +258,12 @@ export async function selectMany<T>(
|
|||||||
escapeBuf = result.escapeBuf;
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
if (result.action === "cancel") return finish(null);
|
if (result.action === "cancel") return finish(null);
|
||||||
if (result.action === "back" && options.allowBack !== false) {
|
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||||
return finish(BACK);
|
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||||
}
|
else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
|
||||||
if (result.action === "up" && cursor > 0) {
|
else if (result.action === "space") { toggle(cursor); render(); }
|
||||||
cursor--;
|
else if (result.action === "enter") {
|
||||||
render();
|
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
|
||||||
} 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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -1,5 +1,6 @@
|
|||||||
import type { FileEntry } from "./types";
|
import type { FileEntry } from "./types";
|
||||||
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
|
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
|
||||||
|
import { isStdinTTY } from "./tty";
|
||||||
import { BACK, selectMany } from "./menu";
|
import { BACK, selectMany } from "./menu";
|
||||||
import type { PromptBack } from "./menu";
|
import type { PromptBack } from "./menu";
|
||||||
|
|
||||||
@@ -10,13 +11,13 @@ export async function selectFiles(
|
|||||||
if (unstagedFiles.length === 0) return [];
|
if (unstagedFiles.length === 0) return [];
|
||||||
|
|
||||||
if (stagedFiles.length > 0) {
|
if (stagedFiles.length > 0) {
|
||||||
process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`);
|
process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
|
||||||
for (const f of stagedFiles) {
|
for (const f of stagedFiles) {
|
||||||
process.stdout.write(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`);
|
process.stdout.write(` ${GREEN()}✓${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.stdin.isTTY !== true) return [];
|
if (!isStdinTTY()) return [];
|
||||||
|
|
||||||
const selected = await selectMany({
|
const selected = await selectMany({
|
||||||
title: "Select files to stage",
|
title: "Select files to stage",
|
||||||
@@ -35,7 +36,7 @@ export async function selectFiles(
|
|||||||
|
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
|
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user