diff --git a/index.ts b/index.ts index ea8cab7..2be4127 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,145 @@ 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" }, +]; + +async function showMenu(): Promise { + const actions = MENU_ACTIONS; + let cursor = 0; + + console.log(`\n ${BOLD}gai${RESET}\n`); + process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`); + + 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`); + } + moveUp(actions.length); } - if (args.includes("--version") || args.includes("-v")) { - console.log("gai v0.1.0"); - return; + function moveUp(n: number) { + process.stdout.write(`\x1b[${n}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"); + for (let i = 0; i < actions.length; i++) process.stdout.write("\x1b[2K\n"); + moveUp(actions.length); + 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"); + + for (let i = 0; i < actions.length; i++) process.stdout.write("\x1b[2K\n"); + moveUp(actions.length); + process.stdout.write("\x1b[?25h"); + + if (selected.key === "commit") { + handleCommit(false, false).then(resolve); + } else { + resolve(); + } + return; + } + }); + }); +} + +async function handleCommit(autoMode: boolean, dryRun: boolean): Promise { const config = await loadConfig(); if (!config.apiKey) { @@ -355,26 +471,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 +508,41 @@ 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); +} + main().catch((err) => { console.error(` ${RED}Unexpected error: ${err}${RESET}`); process.exit(1);