feat(ui): adopt mole-style interactive menu with brand banner

- Add ASCII art brand banner (src/brand.ts)
- Redesign main menu to match mole's UI pattern:
  - Numbered items with ➤ arrow cursor (cyan for active, dim for inactive)
  - Label + description two-column layout
  - Number key hotkeys (1-8) for direct selection
  - Letter hotkeys: H for Help, V for Version, Q for Quit
  - Footer with controls hint: ↑↓ | Enter | H Help | V Version | Q Quit
  - Full-screen redraw using \033[H cursor home
  - Clear-screen transition when entering/exiting subcommands
- Keep selectOne/selectMany for other interactive dialogs (file selector,
  platform selector) unchanged
This commit is contained in:
2026-06-16 02:04:34 +08:00
parent b9de1267e3
commit 8ff481f630
2 changed files with 232 additions and 64 deletions
+212 -64
View File
@@ -14,9 +14,121 @@ import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled } from "./src/terminal"; import { setColorEnabled } from "./src/terminal";
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal"; import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty"; import { isStdinTTY, initTTY } from "./src/tty";
import { BACK, selectOne } from "./src/menu"; import { showBanner } from "./src/brand";
// ── Interactive Menu (default, no subcommand) ───────────────────────── // ── Interactive Menu (mole-style) ─────────────────────────────────────
interface MenuItem {
key: string;
label: string;
description: string;
}
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" },
];
function hideCursor() { process.stdout.write("\x1b[?25l"); }
function showCursor() { process.stdout.write("\x1b[?25h"); }
function clearLine() { process.stdout.write("\r\x1b[2K"); }
async function readKey(): Promise<string> {
return new Promise((resolve) => {
const onData = (data: Buffer) => {
process.stdin.removeListener("data", onData);
resolve(data.toString());
};
process.stdin.once("data", onData);
});
}
function visibleLen(s: string): number {
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function renderMenu(banner: string, 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 R = RESET();
const ARROW = "➤";
// Calculate padding
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
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`);
}
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++;
// Clear rest of screen
process.stdout.write("\x1b[J");
return lineCount;
}
async function dispatchMenuAction(key: string): Promise<number> {
const fakeArgs: ParsedArgs = {
command: key,
flags: {},
positional: [],
raw: [],
subcommand: { name: key, description: "", handler: async () => 0 },
};
if (key === "amend") fakeArgs.flags["amend"] = true;
switch (key) {
case "commit": return handleCommit(fakeArgs);
case "pr": return handlePR(fakeArgs);
case "config": return handleConfig(fakeArgs);
case "explain": return handleExplain(fakeArgs);
case "review": return handleReview(fakeArgs);
case "changelog": return handleChangelog(fakeArgs);
case "suggest": return handleSuggest(fakeArgs);
case "amend": return handleCommit(fakeArgs);
default: return 0;
}
}
async function showMenu(): Promise<number> { async function showMenu(): Promise<number> {
if (!isStdinTTY()) { if (!isStdinTTY()) {
@@ -24,72 +136,108 @@ async function showMenu(): Promise<number> {
return 1; return 1;
} }
while (true) { const banner = showBanner();
const selected = await selectOne({ let cursor = 0;
title: "gai", const wasRaw = process.stdin.isRaw;
subtitle: "AI-powered git helper — choose a workflow",
allowBack: false,
items: [
{ label: "commit", value: "commit", description: "Generate AI commit message" },
{ label: "pr", value: "pr", description: "Create a PR with AI-generated title" },
{ label: "explain", value: "explain", description: "Explain staged changes in plain language" },
{ label: "review", value: "review", description: "AI code review of staged changes" },
{ label: "changelog", value: "changelog", description: "Generate changelog from commits" },
{ label: "suggest", value: "suggest", description: "Suggest branch name or commit type" },
{ label: "amend", value: "amend", description: "Amend last commit with AI message" },
{ label: "config", value: "config", description: "Configure API settings" },
],
});
if (selected === null || selected === BACK) return 0; if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
// Build synthetic args for subcommand handlers // Initial render
const fakeArgs: ParsedArgs = { renderMenu(banner, cursor);
command: selected,
flags: {},
positional: [],
raw: [],
subcommand: {
name: selected,
description: "",
handler: async () => 0,
},
};
let result: number; try {
switch (selected) { while (true) {
case "commit": const raw = await readKey();
result = await handleCommit(fakeArgs);
break; // Escape sequences (arrows)
case "pr": if (raw === "\x1b[A" || raw === "\x1bOA") {
result = await handlePR(fakeArgs); if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
break; continue;
case "config": }
result = await handleConfig(fakeArgs); if (raw === "\x1b[B" || raw === "\x1bOB") {
break; if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); }
case "explain": continue;
result = await handleExplain(fakeArgs); }
break;
case "review": // Enter
result = await handleReview(fakeArgs); if (raw === "\r" || raw === "\n") {
break; const item = MENU_ITEMS[cursor]!;
case "changelog": showCursor();
result = await handleChangelog(fakeArgs); process.stdin.setRawMode(wasRaw === true);
break; process.stdin.pause();
case "suggest": process.stdout.write("\x1b[2J\x1b[H"); // clear screen
result = await handleSuggest(fakeArgs); const result = await dispatchMenuAction(item.key);
break; if (result !== 0) return result;
case "amend": // Return to menu
fakeArgs.flags["amend"] = true; hideCursor();
result = await handleCommit(fakeArgs); if (wasRaw !== true) process.stdin.setRawMode(true);
break; process.stdin.resume();
default: renderMenu(banner, cursor);
result = 0; continue;
}
// Ctrl+C
if (raw === "\x03") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\n");
return 0;
}
// Number hotkeys (1-8)
if (raw >= "1" && raw <= "8") {
const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) {
cursor = idx;
renderMenu(banner, cursor);
const item = MENU_ITEMS[idx]!;
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
const result = await dispatchMenuAction(item.key);
if (result !== 0) return result;
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(banner, cursor);
continue;
}
}
// Letter hotkeys
const lower = raw.toLowerCase();
if (lower === "h") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log(formatHelp(commands));
return 0;
}
if (lower === "v") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log("gai v0.2.0");
return 0;
}
if (lower === "q") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\n");
return 0;
}
} }
} finally {
// Return to menu unless they explicitly chose "back" showCursor();
if (result !== 0) return result; process.stdin.setRawMode(wasRaw === true);
// Loop back to menu for another action process.stdin.pause();
} }
} }
+20
View File
@@ -0,0 +1,20 @@
// Brand banner and ASCII art logo for gai
import { GREEN, CYAN, RESET } from "./terminal";
const VERSION = "0.2.0";
export function showBanner(): string {
const G = GREEN();
const C = CYAN();
const R = RESET();
return [
"",
`${G} ___ ___ _${R}`,
`${G} / _ \\ / _ | (_)${R}`,
`${G} | (_) | (_| | | |${R} ${C}AI-powered git helper${R}`,
`${G} \\___/ \\__,_|_|_|${R} ${C}v${VERSION}${R}`,
"",
].join("\n");
}