Files
gai/index.ts
T
Mplan 311d059e52 refactor: clean up main entry point
- Use shared terminal helpers (hideCursor, showCursor, clearLine,
  clearScreen, visibleLength, padRight) from terminal.ts
- Simplify allCommandDefs dedup with new Set()
- Centralize terminal state restore into exitMenu() closure + finally

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:57:01 +08:00

440 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
// gai — AI-powered git commit and PR helper
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
import { handleCommit } from "./src/commands/commit";
import { handlePR } from "./src/commands/pr";
import { handleConfig } from "./src/commands/config";
import { handleExplain } from "./src/commands/explain";
import { handleReview } from "./src/commands/review";
import { handleChangelog } from "./src/commands/changelog";
import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled, BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, clearScreen, visibleLength, padRight } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty";
import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ─────────────────────────────────────
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", 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" },
];
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 renderMenu(cursor: number): number {
process.stdout.write("\x1b[H"); // cursor home
let lineCount = 0;
const C = CYAN();
const D = DIM();
const G = GREEN();
const R = RESET();
const width = 72;
const separator = `${D}${"─".repeat(width)}${R}`;
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) => visibleLength(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;
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}`);
}
}
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");
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 waitForEnter(): Promise<void> {
const D = DIM();
const R = RESET();
process.stdout.write(`\n ${D}Press Enter to return to menu...${R}`);
// Read a line from stdin (works in cooked mode — blocks until Enter)
await new Promise<void>((resolve) => {
const onData = (data: Buffer) => {
process.stdin.removeListener("data", onData);
resolve();
};
process.stdin.on("data", onData);
// Resume stdin in case it was paused
process.stdin.resume();
});
clearScreen();
}
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
clearScreen();
const result = await dispatchMenuAction(item.key);
if (result === (SKIP_WAIT as unknown as number)) {
return 0; // user explicitly backed out — skip "Press Enter" and return directly
}
await waitForEnter();
return result;
}
async function showMenu(): Promise<number> {
if (!isStdinTTY()) {
console.error("Error: Interactive menu requires a TTY. Use --help for usage.");
return 1;
}
let cursor = 0;
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
// Initial render
renderMenu(cursor);
const exitMenu = (exitCode: number): number => {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
return exitCode;
};
try {
while (true) {
const raw = await readKey();
// Escape sequences (arrows)
if (raw === "\x1b[A" || raw === "\x1bOA") {
if (cursor > 0) { cursor--; renderMenu(cursor); }
continue;
}
if (raw === "\x1b[B" || raw === "\x1bOB") {
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
continue;
}
// Enter
if (raw === "\r" || raw === "\n") {
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
if (result !== 0) return exitMenu(result);
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(cursor);
continue;
}
// Ctrl+C
if (raw === "\x03") {
process.stdout.write("\n");
return exitMenu(0);
}
// Number hotkeys (1-8)
if (raw >= "1" && raw <= "8") {
const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) {
cursor = idx;
renderMenu(cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
if (result !== 0) return exitMenu(result);
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(cursor);
continue;
}
}
// Letter hotkeys
const lower = raw.toLowerCase();
if (lower === "h") {
clearScreen();
console.log(formatHelp(commands));
return exitMenu(0);
}
if (lower === "v") {
clearScreen();
console.log(`gai v${VERSION}`);
return exitMenu(0);
}
if (lower === "q") {
process.stdout.write("\n");
return exitMenu(0);
}
}
} finally {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
}
}
// ── Command Definitions ────────────────────────────────────────────────
const commands = registerCommands(
{
name: "",
description: "Open interactive menu",
usage: "gai",
handler: async () => showMenu(),
} as CommandDef,
{
name: "help",
aliases: ["h"],
description: "Show help for gai or a specific command",
usage: "gai help [command]",
handler: async (args: ParsedArgs) => {
const commandsMap = registerCommands(...allCommandDefs);
const cmdName = args.positional[0] || undefined;
console.log(formatHelp(commandsMap, cmdName));
return 0;
},
} as CommandDef,
{
name: "commit",
aliases: ["c", "ci"],
description: "Generate AI commit message for staged changes",
usage: "gai commit [-a|--all] [-d|--dry-run] [-m|--message <msg>] [--amend]",
flags: [
{ long: "all", short: "a", type: "boolean", description: "Auto-stage all changed files" },
{ long: "auto", type: "boolean", description: "Alias for --all" },
{ long: "dry-run", short: "d", type: "boolean", description: "Generate message without committing" },
{ long: "message", short: "m", type: "string", description: "Use provided message (skip AI)" },
{ long: "amend", type: "boolean", description: "Amend the last commit with AI-generated message" },
],
examples: [
"gai commit",
"gai commit -a",
"gai commit -d",
"gai commit -m 'fix: typo in README'",
"gai commit --amend",
"git diff --staged | gai commit",
],
handler: handleCommit,
},
{
name: "pr",
aliases: ["p"],
description: "Create a PR with AI-generated title and body",
usage: "gai pr [--draft]",
flags: [
{ long: "draft", type: "boolean", description: "Create as draft PR" },
],
examples: [
"gai pr",
"gai pr --draft",
],
handler: handlePR,
},
{
name: "config",
aliases: ["cfg"],
description: "Configure API settings",
usage: "gai config [get <key>|set <key> <value>|list]",
flags: [],
examples: [
"gai config",
"gai config list",
"gai config get model",
"gai config set model gpt-4o",
],
handler: handleConfig,
},
{
name: "explain",
aliases: ["x"],
description: "Explain staged changes in plain language",
usage: "gai explain [--unstaged]",
flags: [
{ long: "unstaged", short: "u", type: "boolean", description: "Explain unstaged changes instead of staged" },
{ long: "staged", short: "s", type: "boolean", description: "Explain staged changes (default)" },
],
examples: [
"gai explain",
"gai explain --unstaged",
"git diff main..feature | gai explain",
],
handler: handleExplain,
},
{
name: "review",
aliases: ["r", "rv"],
description: "AI code review of staged changes",
usage: "gai review [--strict|--lenient] [--unstaged]",
flags: [
{ long: "strict", type: "boolean", description: "Thorough review, flag minor issues" },
{ long: "lenient", type: "boolean", description: "Focus only on major issues" },
{ long: "unstaged", short: "u", type: "boolean", description: "Review unstaged changes instead of staged" },
],
examples: [
"gai review",
"gai review --strict",
"gai review --unstaged",
"git diff main..feature | gai review",
],
handler: handleReview,
},
{
name: "changelog",
aliases: ["cl", "log"],
description: "Generate changelog from recent commits",
usage: "gai changelog [--from <ref>] [--to <ref>] [--count <n>]",
flags: [
{ long: "from", short: "f", type: "string", description: "Starting ref (tag or commit)" },
{ long: "to", short: "t", type: "string", description: "Ending ref (default: HEAD)" },
{ long: "count", short: "n", type: "string", description: "Number of recent commits (default: 20)" },
],
examples: [
"gai changelog",
"gai changelog --from v1.0.0",
"gai changelog --from v1.0.0 --to v1.1.0",
"gai changelog -n 50",
],
handler: handleChangelog,
},
{
name: "suggest",
aliases: ["sg"],
description: "Suggest branch name or commit type based on changes",
usage: "gai suggest [branch|type]",
flags: [
{ long: "unstaged", short: "u", type: "boolean", description: "Use unstaged changes" },
],
examples: [
"gai suggest branch",
"gai suggest type",
"git diff | gai suggest branch",
],
handler: handleSuggest,
},
);
// Keep canonical command defs accessible for help command (deduplicate by reference)
const allCommandDefs = [...new Set(commands.values())];
// ── Main ───────────────────────────────────────────────────────────────
process.on("SIGINT", () => {
process.stdout.write("\x1b[?25h"); // ensure cursor is shown
process.stdout.write("\n");
process.exit(130);
});
const args = process.argv.slice(2);
// Initialize TTY detection early (before any command handlers run)
initTTY();
// Apply --no-color early
if (args.includes("--no-color")) {
setColorEnabled(false);
}
runCLI(args, commands)
.then((exitCode) => {
process.exit(exitCode);
})
.catch((err) => {
console.error(`\n Unexpected error: ${err.message ?? err}\n`);
process.exit(1);
});