From a4e0b6f7477465517841a5730eda9536910f68f8 Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 9 Jun 2026 18:21:23 +0800 Subject: [PATCH] feat: implement gai CLI for AI-generated Conventional Commits (#1) Reviewed-on: https://git.catpl.top/Mplan/gai/pulls/1 --- README.md | 52 +++++++++---- index.ts | 228 ++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 225 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ed367b0..e48e3d1 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,11 @@ Generate **Conventional Commits** messages using AI — based on your project co ## Features +- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter - **3-layer context** — project overview, staged diff, and recent commit history - **Conventional Commits** — `feat(scope): description` format by default - **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all" +- **Inline editing** — edit AI-generated messages right in the terminal with cursor movement - **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, and more - **Review before commit** — confirm, edit, or abort the generated message - **Zero dependencies** — built entirely on Bun native APIs @@ -31,36 +33,56 @@ Generate **Conventional Commits** messages using AI — based on your project co bun install # Configure your API key -bun run gai config +gai config -# Generate a commit message -bun run gai +# Open interactive menu +gai + +# Or directly generate a commit message +gai commit ``` ## Usage ``` -gai Generate commit message (interactive file selection) -gai --auto Auto-stage all changed files -gai --dry-run Generate message without committing +gai Open interactive menu +gai commit Generate commit message (interactive file selection) +gai commit --auto Auto-stage all changed files +gai commit -d Generate message without committing gai config Configure API settings gai --help Show help gai --version Show version ``` -### Interactive Flow +### Interactive Menu ``` $ gai + gai + + ↑/↓ navigate, space/enter select + + ❯ ◉ commit Generate AI commit message +``` + +### Commit Flow + +``` +$ gai commit + + Staged files (will be included): + ✓ src/git.ts (modified) + + Unstaged files: + 1. src/ai.ts (modified) + 2. src/newfile.ts (new) + Select files to stage: ❯ ◉ Select all - ◉ src/git.ts (modified) ○ src/ai.ts (modified) ◉ src/newfile.ts (new) - ↑/↓ navigate, space select, enter confirm - Generating commit message... Generated commit message: @@ -68,7 +90,9 @@ $ gai Use this message? [Y/n/e] Y - Committed successfully! + ✔ Committed successfully! + [main a3f7c2b] feat(git): add interactive file staging and commit wrapper + 1 file changed, 45 insertions(+), 12 deletions(-) ``` ## Configuration @@ -76,7 +100,7 @@ $ gai ### Via `gai config` (interactive) ```bash -bun run gai config +gai config ``` ### Via environment variables @@ -85,7 +109,7 @@ bun run gai config |---|---|---| | `GAI_API_KEY` | — | **Required.** Your API key | | `GAI_API_BASE` | `https://api.deepseek.com/v1` | API base URL | -| `GAI_MODEL` | `deepseek-v4-flash` | Model name | +| `GAI_MODEL` | `deepseek-chat` | Model name | | `GAI_MAX_TOKENS` | `500` | Max response tokens | | `GAI_TEMPERATURE` | `0.7` | Sampling temperature | @@ -96,7 +120,7 @@ Bun auto-loads `.env` — no dotenv needed: ```bash GAI_API_KEY=sk-your-key GAI_API_BASE=https://api.deepseek.com/v1 -GAI_MODEL=deepseek-v4-flash +GAI_MODEL=deepseek-chat ``` ### Using other providers diff --git a/index.ts b/index.ts index ea8cab7..5a6151a 100644 --- a/index.ts +++ b/index.ts @@ -27,9 +27,10 @@ function showHelp() { ${BOLD}gai${RESET} — AI-powered git commit message generator ${BOLD}Usage:${RESET} - gai Generate commit message for staged/changed files - gai --auto Auto-stage all changed files - gai --dry-run Generate message without committing + gai Open interactive menu + gai commit Generate commit message for staged/changed files + gai commit --auto Auto-stage all changed files + gai commit -d Generate message without committing gai config Configure API settings gai --help Show this help message gai --version Show version @@ -38,7 +39,7 @@ ${BOLD}Configuration:${RESET} Set via ${CYAN}gai config${RESET} or environment variables: GAI_API_KEY OpenAI-compatible API key GAI_API_BASE API base URL (default: https://api.deepseek.com/v1) - GAI_MODEL Model name (default: deepseek-v4-flash) + GAI_MODEL Model name (default: deepseek-chat) GAI_MAX_TOKENS Max tokens (default: 500) GAI_TEMPERATURE Temperature (default: 0.7) `); @@ -119,11 +120,6 @@ async function editMessage(current: string): Promise { const ENTER = "\r"; const CTRL_C = "\x03"; const BACKSPACE = "\x7f"; - const DELETE = "\x1b[3~"; - const LEFT = "\x1b[D"; - const RIGHT = "\x1b[C"; - const HOME = "\x1b[H" /* or \x01 */; - const END = "\x1b[F" /* or \x05 */; function render() { process.stdout.write("\x1b[2K\r > " + buffer); @@ -253,25 +249,154 @@ async function editMessage(current: string): Promise { }); } -async function main() { - if (args.includes("--help") || args.includes("-h")) { - showHelp(); - return; +function printCommitResult( + result: { branch: string; hash: string; files: number; insertions: number; deletions: number }, + msg: string, +) { + console.log(`\n ${GREEN}${BOLD}✔ Committed successfully!${RESET}`); + + const id = result.branch && result.hash + ? `${YELLOW}[${result.branch} ${result.hash}]${RESET}` + : result.hash + ? `${YELLOW}${result.hash}${RESET}` + : ""; + console.log(` ${id} ${msg}`); + + const parts: string[] = []; + if (result.files > 0) parts.push(`${YELLOW}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET}`); + if (result.insertions > 0) parts.push(`${GREEN}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET}`); + if (result.deletions > 0) parts.push(`${RED}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET}`); + if (parts.length > 0) console.log(` ${parts.join(", ")}`); +} + +interface MenuAction { + key: string; + label: string; + description: string; +} + +const MENU_ACTIONS: MenuAction[] = [ + { key: "commit", label: "commit", description: "Generate AI commit message" }, + { key: "config", label: "config", description: "Configure API settings" }, +]; + +async function showMenu(): Promise { + const actions = MENU_ACTIONS; + let cursor = 0; + + const headerLines = 4; + + process.stdout.write(`\n ${BOLD}gai${RESET}\n`); + process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`); + + const totalLines = headerLines + actions.length; + + function render() { + for (let i = 0; i < actions.length; i++) { + process.stdout.write("\x1b[2K\r"); + const a = actions[i]!; + const pointer = i === cursor ? `${CYAN}❯${RESET} ` : " "; + const dot = i === cursor ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`; + const name = i === cursor ? `${BOLD}${a.label}${RESET}` : a.label; + const desc = i === cursor ? a.description : `${DIM}${a.description}${RESET}`; + process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 14 - a.label.length))}${desc}\n`); + } + process.stdout.write(`\x1b[${actions.length}A`); } - if (args.includes("--version") || args.includes("-v")) { - console.log("gai v0.1.0"); - return; + function clearMenu() { + process.stdout.write(`\x1b[${headerLines}A`); + for (let i = 0; i < totalLines; i++) { + process.stdout.write("\r\x1b[2K\n"); + } + process.stdout.write(`\x1b[${totalLines}A`); } - if (args[0] === "config") { - await handleConfig(); - return; + if (!process.stdin.isTTY) { + console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`); + process.exit(1); } - const autoMode = args.includes("--auto") || args.includes("-a"); - const dryRun = args.includes("--dry-run") || args.includes("-d"); + const savedRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdout.write("\x1b[?25l"); + render(); + + return new Promise((resolve) => { + let escapeBuf = ""; + + function handleSeq(seq: string) { + if (seq === "\x1b[A" || seq === "\x1bOA") { + if (cursor > 0) { + cursor--; + render(); + } + } else if (seq === "\x1b[B" || seq === "\x1bOB") { + if (cursor < actions.length - 1) { + cursor++; + render(); + } + } + } + + process.stdin.on("data", (data: Buffer) => { + const key = data.toString(); + + if (key === "\x03") { + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + clearMenu(); + process.stdout.write("\x1b[?25h"); + resolve(); + return; + } + + if (key === "\x1b" || key.startsWith("\x1b[")) { + escapeBuf = key; + if (key.length >= 3) { + handleSeq(key); + escapeBuf = ""; + } + return; + } + + if (escapeBuf) { + escapeBuf += key; + if (/^[A-Za-z~]$/.test(key)) { + handleSeq(escapeBuf); + escapeBuf = ""; + } else if (escapeBuf.length > 8) { + escapeBuf = ""; + } + return; + } + + if (key === " " || key === "\r") { + const selected = actions[cursor]!; + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + + clearMenu(); + process.stdout.write("\x1b[?25h"); + + if (selected.key === "commit") { + handleCommit(false, false).then(resolve); + } else if (selected.key === "config") { + handleConfig().then(resolve); + } else { + resolve(); + } + return; + } + }); + }); +} + +async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { const config = await loadConfig(); if (!config.apiKey) { @@ -355,26 +480,6 @@ async function main() { return; } -function printCommitResult( - result: { branch: string; hash: string; files: number; insertions: number; deletions: number }, - msg: string, -) { - console.log(`\n ${GREEN}${BOLD}✔ Committed successfully!${RESET}`); - - const id = result.branch && result.hash - ? `${YELLOW}[${result.branch} ${result.hash}]${RESET}` - : result.hash - ? `${YELLOW}${result.hash}${RESET}` - : ""; - console.log(` ${id} ${msg}`); - - const parts: string[] = []; - if (result.files > 0) parts.push(`${YELLOW}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET}`); - if (result.insertions > 0) parts.push(`${GREEN}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET}`); - if (result.deletions > 0) parts.push(`${RED}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET}`); - if (parts.length > 0) console.log(` ${parts.join(", ")}`); -} - const action = await confirmCommit(message); if (action === "y") { @@ -412,6 +517,47 @@ function printCommitResult( } } +async function main() { + if (args.includes("--help") || args.includes("-h")) { + showHelp(); + return; + } + + if (args.includes("--version") || args.includes("-v")) { + console.log("gai v0.1.0"); + return; + } + + const subcommand = args[0]; + + if (subcommand === "config") { + await handleConfig(); + return; + } + + if (subcommand === "commit") { + const autoMode = args.includes("--auto") || args.includes("-a"); + const dryRun = args.includes("--dry-run") || args.includes("-d"); + await handleCommit(autoMode, dryRun); + return; + } + + if (!subcommand) { + await showMenu(); + return; + } + + console.error(` ${RED}Unknown command: ${subcommand}${RESET}`); + showHelp(); + process.exit(1); +} + +process.on("SIGINT", () => { + process.stdout.write("\x1b[?25h"); + process.stdout.write("\n"); + process.exit(130); +}); + main().catch((err) => { console.error(` ${RED}Unexpected error: ${err}${RESET}`); process.exit(1);