refactor(ui): group menu by category and unify file selection
Build / bun-build (pull_request) Successful in 33s
Build / bun-build (push) Successful in 2m24s

This commit is contained in:
2026-06-16 23:46:19 +08:00
parent df10ce5b0e
commit 662609a78e
10 changed files with 206 additions and 133 deletions
+64 -45
View File
@@ -14,7 +14,7 @@ import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled } from "./src/terminal";
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty";
import { showBanner } from "./src/brand";
import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ─────────────────────────────────────
@@ -23,17 +23,18 @@ interface MenuItem {
key: string;
label: string;
description: string;
group: "Create" | "Inspect" | "Project";
}
const MENU_ITEMS: MenuItem[] = [
{ key: "commit", label: "Commit", description: "Generate AI commit message" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language" },
{ key: "review", label: "Review", description: "AI code review of staged changes" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message" },
{ key: "config", label: "Config", description: "Configure API settings" },
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
];
function hideCursor() { process.stdout.write("\x1b[?25l"); }
@@ -55,51 +56,70 @@ function visibleLen(s: string): number {
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function renderMenu(banner: string, cursor: number): number {
function padRight(value: string, width: number): string {
return value + " ".repeat(Math.max(0, width - visibleLen(value)));
}
function renderMenu(cursor: number): number {
process.stdout.write("\x1b[H"); // cursor home
let lineCount = 0;
for (const line of banner.split("\n")) {
clearLine();
process.stdout.write(line + "\n");
lineCount++;
}
const G = GREEN();
const C = CYAN();
const D = DIM();
const G = GREEN();
const R = RESET();
const ARROW = "➤";
const width = 72;
const separator = `${D}${"─".repeat(width)}${R}`;
// Calculate padding
const write = (line = "") => {
clearLine();
process.stdout.write(`${line}\n`);
lineCount++;
};
write("");
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
write(` ${separator}`);
write("");
const keyWidth = 3;
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
let currentGroup: MenuItem["group"] | null = null;
for (let i = 0; i < MENU_ITEMS.length; i++) {
const item = MENU_ITEMS[i]!;
const num = String(i + 1);
const active = i === cursor;
clearLine();
if (active) {
const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label)));
process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`);
} else {
const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`);
if (item.group !== currentGroup) {
if (currentGroup !== null) write("");
write(` ${D}${item.group.toUpperCase()}${R}`);
currentGroup = item.group;
}
const pointer = active ? `${C}${R}` : " ";
const key = active ? `${G}${num}${R}` : `${D}${num}${R}`;
const label = active ? `${BOLD()}${item.label}${R}` : item.label;
const description = active ? item.description : `${D}${item.description}${R}`;
const row = [
pointer,
padRight(key, keyWidth),
padRight(label, labelWidth),
description,
].join(" ");
if (active) {
write(` ${C}${R} ${row}`);
} else {
write(` ${row}`);
}
lineCount++;
}
// Footer
clearLine();
process.stdout.write("\n");
lineCount++;
clearLine();
process.stdout.write(` ${D}↑↓ | Enter | ${G}H${D} Help | ${G}V${D} Version | ${G}Q${D} Quit${R}\n`);
lineCount++;
clearLine();
process.stdout.write("\n");
lineCount++;
write("");
write(` ${separator}`);
write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
write("");
// Clear rest of screen
process.stdout.write("\x1b[J");
@@ -167,7 +187,6 @@ async function showMenu(): Promise<number> {
return 1;
}
const banner = showBanner();
let cursor = 0;
const wasRaw = process.stdin.isRaw;
@@ -176,7 +195,7 @@ async function showMenu(): Promise<number> {
hideCursor();
// Initial render
renderMenu(banner, cursor);
renderMenu(cursor);
try {
while (true) {
@@ -184,11 +203,11 @@ async function showMenu(): Promise<number> {
// Escape sequences (arrows)
if (raw === "\x1b[A" || raw === "\x1bOA") {
if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
if (cursor > 0) { cursor--; renderMenu(cursor); }
continue;
}
if (raw === "\x1b[B" || raw === "\x1bOB") {
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); }
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
continue;
}
@@ -199,7 +218,7 @@ async function showMenu(): Promise<number> {
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(banner, cursor);
renderMenu(cursor);
continue;
}
@@ -217,13 +236,13 @@ async function showMenu(): Promise<number> {
const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) {
cursor = idx;
renderMenu(banner, cursor);
renderMenu(cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
if (result !== 0) return result;
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(banner, cursor);
renderMenu(cursor);
continue;
}
}
@@ -243,7 +262,7 @@ async function showMenu(): Promise<number> {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log("gai v0.1.3");
console.log(`gai v${VERSION}`);
return 0;
}
if (lower === "q") {