From 586487d89786b4f2270d601f15fe668a318e3a76 Mon Sep 17 00:00:00 2001 From: Mplan Date: Wed, 17 Jun 2026 00:17:31 +0800 Subject: [PATCH] feat: overhaul CLI with new AI commands and mole-style menu (#6) Major CLI redesign introducing a mole-style interactive menu with grouped categories. Added new AI-powered subcommands: explain, review, changelog, and suggest. Includes streaming output, pipe support, and updated brand logo. Refactored codebase into command modules for maintainability. Reviewed-on: https://git.catpl.top/Mplan/gai/pulls/6 --- README.md | 257 +++++-- index.ts | 1334 +++++++++++-------------------------- package.json | 4 +- src/ai.ts | 118 ++-- src/brand.ts | 22 + src/cli.ts | 295 ++++++++ src/clipboard.ts | 42 +- src/commands/changelog.ts | 81 +++ src/commands/commit.ts | 433 ++++++++++++ src/commands/config.ts | 333 +++++++++ src/commands/explain.ts | 129 ++++ src/commands/pr.ts | 209 ++++++ src/commands/review.ts | 130 ++++ src/commands/suggest.ts | 146 ++++ src/git.ts | 27 + src/menu.ts | 127 ++-- src/prompt.ts | 186 +++++- src/selector.ts | 49 +- src/terminal.ts | 42 +- src/tty.ts | 36 + src/types.ts | 14 + test/config.test.ts | 52 +- 22 files changed, 2824 insertions(+), 1242 deletions(-) create mode 100644 src/brand.ts create mode 100644 src/cli.ts create mode 100644 src/commands/changelog.ts create mode 100644 src/commands/commit.ts create mode 100644 src/commands/config.ts create mode 100644 src/commands/explain.ts create mode 100644 src/commands/pr.ts create mode 100644 src/commands/review.ts create mode 100644 src/commands/suggest.ts create mode 100644 src/tty.ts diff --git a/README.md b/README.md index f82e76c..61b1205 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,13 @@ # gai -**AI-powered Git commit and pull request helper** +**AI-powered Git helper — commit messages, PRs, code review, changelogs, and more** -[![Release](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgit.catpl.top%2Fapi%2Fv1%2Frepos%2FMplan%2Fgai%2Freleases%2Flatest&query=%24.tag_name&label=release&color=blue)](https://git.catpl.top/Mplan/gai/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![Bun](https://img.shields.io/badge/runtime-Bun-f9d71c.svg)](https://bun.sh) [![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6.svg)](https://www.typescriptlang.org/) -Generate **Conventional Commits** messages and pull request descriptions using AI, based on your project context, code diff, branch changes, and commit history. +Generate **Conventional Commits** messages, pull request descriptions, code reviews, changelogs, and more — powered by AI with full project context. @@ -17,15 +16,18 @@ Generate **Conventional Commits** messages and pull request descriptions using A ## Features -- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter -- **Context-aware commits** — reads 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 -- **AI-generated PRs** — create GitHub, Gitea, or GitLab pull requests with generated title and body -- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more -- **Review before commit** — confirm, edit, or abort the generated message -- **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies +- **🤖 AI Commit Messages** — generate Conventional Commits from staged diffs with project context +- **🔀 AI Pull Requests** — create GitHub, Gitea, or GitLab PRs with AI-generated title and body +- **📖 Explain Changes** — `gai explain` explains diffs in plain language +- **🔍 Code Review** — `gai review` provides thorough, constructive code review +- **📝 Changelog Generation** — `gai changelog` generates user-facing changelogs from commits +- **💡 Smart Suggestions** — `gai suggest` recommends branch names or commit types +- **✏️ Amend Commits** — `gai commit --amend` amends with AI-generated message +- **⚡ Streaming Output** — AI responses stream token-by-token for instant feedback +- **📂 Interactive File Selection** — ↑/↓ navigate, space toggle, "Select all" support +- **📋 Pipe Support** — pipe diffs directly: `git diff | gai explain` +- **🎨 Mainstream CLI UX** — proper flags, aliases, `--help` per command, `--no-color` +- **🔧 OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more ## Quick Start @@ -39,85 +41,196 @@ gai config # Open interactive menu gai -# Or directly generate a commit message +# Generate a commit message gai commit -# Or create an AI-generated pull request -gai pr +# Explain your changes +gai explain ``` ## Usage ``` -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 pr Create a PR with AI-generated title and body -gai pr --draft Create a draft PR -gai config Configure API settings -gai --help Show help -gai --version Show version +gai Open interactive menu +gai commit Generate AI commit message (interactive file selection) +gai commit -a Auto-stage all changed files +gai commit -d Generate message without committing +gai commit -m "msg" Use custom message (skip AI) +gai commit --amend Amend last commit with AI-generated message +gai pr Create a PR with AI-generated title and body +gai pr --draft Create a draft PR +gai explain Explain staged changes in plain language +gai explain --unstaged Explain unstaged changes +gai review AI code review of staged changes +gai review --strict Thorough review, flag minor issues +gai review --lenient Focus only on major issues +gai changelog Generate changelog from recent commits +gai changelog --from v1.0 --to v1.1 Range-based changelog +gai suggest branch Suggest branch names for current changes +gai suggest type Suggest Conventional Commit type +gai config Configure API settings (interactive) +gai config list List all settings +gai config get Get a specific setting +gai config set Set a setting +gai help Show help +gai help Show command-specific help +gai --version Show version +``` + +### Global Flags + +| Flag | Description | +|---|---| +| `-h, --help` | Show help | +| `-V, --version` | Show version | +| `-v, --verbose` | Verbose output | +| `--no-color` | Disable colored output | + +Also respects the `NO_COLOR` and `FORCE_COLOR` environment variables. + +### Subcommand Aliases + +| Command | Aliases | +|---|---| +| `commit` | `c`, `ci` | +| `pr` | `p` | +| `config` | `cfg` | +| `explain` | `x` | +| `review` | `r`, `rv` | +| `changelog` | `cl`, `log` | +| `suggest` | `sg` | +| `help` | `h` | + +### Pipe Support + +All AI commands accept piped input — no git repository required: + +```bash +# Explain any diff +git diff main..feature | gai explain + +# Review changes from a PR +curl https://patch-diff.githubusercontent.com/... | gai review + +# Suggest branch name for a diff +git diff | gai suggest branch ``` ### Interactive Menu -``` -$ gai - - gai - Choose a workflow - ↑/↓ navigate · enter/space select · ctrl+c cancel - - ❯ ● commit Generate AI commit message - ○ pr Create a PR with AI-generated title - ○ config Configure API settings -``` - -### Commit Flow +Run `gai` without arguments to open the mole-style interactive menu: ``` -$ gai commit + gai v0.1.3 + AI-powered git helper for commits, PRs, reviews, and changelogs + ──────────────────────────────────────────────────────────────────────── - Staged files (will be included): - ✓ src/git.ts (modified) + CREATE + │ › 1 Commit Generate AI commit message + 2 PR Create a PR with AI-generated title + 3 Amend Amend last commit with AI message - Select files to stage: - 2 unstaged files available - ↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel + INSPECT + 4 Explain Explain staged changes in plain language + 5 Review AI code review of staged changes + 6 Changelog Generate changelog from commits + 7 Suggest Suggest branch name or commit type - ❯ □ Select all - □ src/ai.ts modified - ■ src/newfile.ts new + PROJECT + 8 Config Configure API settings - Generating commit message... - - Generated commit message: - feat(git): add interactive file staging and commit wrapper - - Use this message? [Y/n/e] Y - - ✔ Committed successfully! - [main a3f7c2b] feat(git): add interactive file staging and commit wrapper - 1 file changed, 45 insertions(+), 12 deletions(-) + ──────────────────────────────────────────────────────────────────────── + ↑/↓ navigate enter run 1-8 jump h help v version q quit ``` -### Pull Request Flow +Number keys `1`–`8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu. -`gai pr` detects the remote platform from `origin`: +### Command Examples -- GitHub remotes use the `gh` CLI -- Gitea remotes use the `tea` CLI -- GitLab remotes use the `glab` CLI -- Unknown remotes prompt you to choose a platform - -The command compares your current branch against the default branch, pushes the branch if needed, generates a PR title/body from the branch commits and diff, then asks for confirmation before creating the PR. +#### Commit ```bash +# Interactive file selection +gai commit + +# Auto-stage everything +gai commit -a + +# Dry-run (no commit) +gai commit -d + +# Custom message (skip AI) +gai commit -m "fix: correct typo in README" + +# Amend last commit with AI message +gai commit --amend + +# Pipe diff for commit message +git diff --staged | gai commit +``` + +#### PR + +```bash +# Create PR gai pr + +# Draft PR gai pr --draft ``` +#### Explain + +```bash +# Explain staged changes +gai explain + +# Explain unstaged changes +gai explain --unstaged + +# Pipe any diff +git diff HEAD~3 | gai explain +``` + +#### Review + +```bash +# Review staged changes (normal strictness) +gai review + +# Thorough review +gai review --strict + +# Lenient review +gai review --lenient + +# Review unstaged changes +gai review --unstaged +``` + +#### Changelog + +```bash +# From last 20 commits +gai changelog + +# Custom count +gai changelog -n 50 + +# Between tags +gai changelog --from v1.0.0 --to v1.1.0 +``` + +#### Suggest + +```bash +# Suggest branch name +gai suggest branch + +# Suggest commit type +gai suggest type +``` + ## Configuration ### Via `gai config` (interactive) @@ -126,6 +239,15 @@ gai pr --draft gai config ``` +### CLI-based config + +```bash +gai config list +gai config get model +gai config set model gpt-4o +gai config set temperature 0.3 +``` + ### Via environment variables | Variable | Default | Description | @@ -193,13 +315,14 @@ GAI_MODEL=anthropic/claude-sonnet-4 │ │ │ 2. Collect code changes │ │ ├─ git diff --staged for commits │ -│ └─ branch diff for pull requests │ +│ ├─ Branch diff for pull requests │ +│ └─ Pipe support for external diffs │ │ │ │ 3. Collect commit history │ │ ├─ git log --oneline -10 for commits │ -│ └─ branch commits for pull requests │ +│ └─ Branch commits for pull requests │ │ │ -│ 4. Build prompt → Call AI API │ +│ 4. Build prompt → Call AI API (streaming) │ │ │ │ 5. Review → Confirm → Commit or Create PR │ └─────────────────────────────────────────────┘ diff --git a/index.ts b/index.ts index 4d962d1..b8169f0 100644 --- a/index.ts +++ b/index.ts @@ -1,995 +1,461 @@ #!/usr/bin/env bun -import * as readline from "node:readline"; -import { loadConfig, saveConfig } from "./src/config"; -import { - isGitRepo, - getRepoRoot, - getStagedFiles, - getUnstagedFiles, - getStagedDiff, - getRecentCommits, - stageFiles, - commit, -} from "./src/git"; -import { selectFiles } from "./src/selector"; -import { BACK, selectOne } from "./src/menu"; -import type { PromptBack } from "./src/menu"; -import { collectProjectContext } from "./src/context"; -import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt"; -import { generateCommitMessage } from "./src/ai"; -import { copyToClipboard } from "./src/clipboard"; -import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "./src/terminal"; -import type { Config } from "./src/types"; -import { - getDefaultBranch, - getBranchName, - getBranchPushStatus, - pushCurrentBranch, - getBranchCommits, - getBranchDiff, - detectPlatform, - getRemoteHostname, - createPR, -} from "./src/pr"; -import type { Platform } from "./src/pr"; -import { PR_SYSTEM_PROMPT, buildPRPrompt } from "./src/prompt"; -import { generatePRMessage } from "./src/ai"; +// gai — AI-powered git commit and PR helper +// v0.1.3 -const args = process.argv.slice(2); +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 } from "./src/terminal"; +import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal"; +import { isStdinTTY, initTTY } from "./src/tty"; +import { VERSION } from "./src/brand"; +import { SKIP_WAIT } from "./src/menu"; -function showHelp() { - console.log(` -${BOLD}gai${RESET} — AI-powered git commit message generator +// ── Interactive Menu (mole-style) ───────────────────────────────────── -${BOLD}Usage:${RESET} - 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 pr Create a PR with AI-generated title and body - gai pr --draft Create a draft PR - gai config Configure API settings - gai --help Show this help message - gai --version Show version - -${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-chat) - GAI_MAX_TOKENS Max tokens (default: 500) - GAI_TEMPERATURE Temperature (default: 0.7) -`); -} - -function ask(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -type ConfigKey = keyof Config; - -interface ConfigField { - key: ConfigKey; +interface MenuItem { + key: string; label: string; - format: (config: Config) => string; - initialEditValue: (config: Config) => string; - parse: (value: string) => { value: Config[ConfigKey] } | { error: string }; + description: string; + group: "Create" | "Inspect" | "Project"; } -const CONFIG_FIELDS: ConfigField[] = [ - { - key: "apiKey", - label: "API Key", - format: (config) => - config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW}(not set)${RESET}`, - initialEditValue: () => "", - parse: (value) => ({ value }), - }, - { - key: "apiBase", - label: "API Base", - format: (config) => config.apiBase, - initialEditValue: (config) => config.apiBase, - parse: (value) => ({ value }), - }, - { - key: "model", - label: "Model", - format: (config) => config.model, - initialEditValue: (config) => config.model, - parse: (value) => ({ value }), - }, - { - key: "maxTokens", - label: "Max Tokens", - format: (config) => String(config.maxTokens), - initialEditValue: (config) => String(config.maxTokens), - parse: (value) => { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - return { error: "Max Tokens must be a positive integer." }; - } - return { value: parsed }; - }, - }, - { - key: "temperature", - label: "Temperature", - format: (config) => String(config.temperature), - initialEditValue: (config) => String(config.temperature), - parse: (value) => { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return { error: "Temperature must be a finite number." }; - } - return { value: parsed }; - }, - }, +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" }, ]; -function visibleLength(value: string) { - return value.replace(/\x1b\[[0-9;]*m/g, "").length; +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 { + return new Promise((resolve) => { + const onData = (data: Buffer) => { + process.stdin.removeListener("data", onData); + resolve(data.toString()); + }; + process.stdin.once("data", onData); + }); } -function clearLine() { - process.stdout.write("\r\x1b[2K"); +function visibleLen(s: string): number { + return s.replace(/\x1b\[[0-9;]*m/g, "").length; } -function moveUp(lines: number) { - if (lines > 0) process.stdout.write(`\x1b[${lines}A`); +function padRight(value: string, width: number): string { + return value + " ".repeat(Math.max(0, width - visibleLen(value))); } -function renderConfigPage( - config: Config, - cursor: number, - previousLines: number, - status: string | null, - editState: { buffer: string; cursor: number } | null, -) { - if (previousLines > 0) { - for (let i = 0; i < previousLines; i++) { - clearLine(); - process.stdout.write("\n"); - } - moveUp(previousLines); - } +function renderMenu(cursor: number): number { + process.stdout.write("\x1b[H"); // cursor home - const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2; - const lines = [ - "", - ` ${BOLD}Configuration${RESET}`, - editState - ? ` ${DIM}editing · enter save · esc cancel · ctrl+c cancel${RESET}` - : ` ${DIM}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET}`, - "", - ]; + let lineCount = 0; + const C = CYAN(); + const D = DIM(); + const G = GREEN(); + const R = RESET(); + const width = 72; + const separator = `${D}${"─".repeat(width)}${R}`; - let activeValueOffset = 0; - for (let i = 0; i < CONFIG_FIELDS.length; i++) { - const field = CONFIG_FIELDS[i]!; - const active = i === cursor; - const pointer = active ? `${CYAN}❯${RESET}` : " "; - const marker = active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`; - const label = active ? `${BOLD}${field.label}${RESET}` : field.label; - const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label))); - const value = active && editState ? editState.buffer : field.format(config); - if (active && editState) { - activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`); - } - lines.push(` ${pointer} ${marker} ${label}${padding}${value}`); - } - - if (status) { - lines.push("", ` ${status}`); - } - - for (const line of lines) { + 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; + + 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}`); + } } - if (editState) { - moveUp(lines.length - (4 + cursor)); - const column = activeValueOffset + editState.cursor; - process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`); - } else { - moveUp(lines.length); - } - return lines.length; + + 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 handleConfig(): Promise<"done" | "back"> { - if (process.stdin.isTTY !== true) { - console.error(` ${RED}Error: Configuration requires a TTY.${RESET}`); - process.exit(1); +async function dispatchMenuAction(key: string): Promise { + 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 { + 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((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(); + }); + process.stdout.write("\x1b[2J\x1b[H"); // clear screen +} + +async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise { + 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 === (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 { + if (!isStdinTTY()) { + console.error("Error: Interactive menu requires a TTY. Use --help for usage."); + return 1; } - let config = await loadConfig(); let cursor = 0; - let renderedLines = 0; - let escapeBuf = ""; - let status: string | null = null; - let editState: { buffer: string; cursor: number } | null = null; - let renderedCursorRow = 0; const wasRaw = process.stdin.isRaw; if (wasRaw !== true) process.stdin.setRawMode(true); process.stdin.resume(); - process.stdout.write("\x1b[?25l"); + hideCursor(); - const render = () => { - moveUp(renderedCursorRow); - renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState); - renderedCursorRow = editState ? 4 + cursor : 0; - process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l"); - }; + // Initial render + renderMenu(cursor); - render(); + try { + while (true) { + const raw = await readKey(); - return new Promise((resolve) => { - const finish = (value: "done" | "back") => { - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); - process.stdin.removeListener("data", onData); - moveUp(renderedCursorRow); - for (let i = 0; i < renderedLines; i++) { - clearLine(); + // 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 result; + hideCursor(); + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + renderMenu(cursor); + continue; + } + + // Ctrl+C + if (raw === "\x03") { + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); process.stdout.write("\n"); + return 0; } - moveUp(renderedLines); - process.stdout.write("\x1b[?25h"); - resolve(value); - }; - const saveEdit = async () => { - if (!editState) return; - const field = CONFIG_FIELDS[cursor]!; - const value = editState.buffer.trim(); - editState = null; - - if (value === "") { - status = `${DIM}No changes.${RESET}`; - } else { - const parsed = field.parse(value); - if ("error" in parsed) { - status = `${RED}${parsed.error}${RESET}`; - } else { - await saveConfig({ [field.key]: parsed.value } as Partial); - config = await loadConfig(); - status = `${GREEN}${field.label} saved.${RESET}`; + // 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 result; + hideCursor(); + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + renderMenu(cursor); + continue; } } - render(); - }; - - const onData = (data: Buffer) => { - const key = data.toString(); - const UP = "\x1b[A"; - const DOWN = "\x1b[B"; - const LEFT = "\x1b[D"; - const ALT_UP = "\x1bOA"; - const ALT_DOWN = "\x1bOB"; - const ALT_LEFT = "\x1bOD"; - const SPACE = " "; - const ENTER = "\r"; - const ESC = "\x1b"; - const RIGHT = "\x1b[C"; - const ALT_RIGHT = "\x1bOC"; - const CTRL_C = "\x03"; - const BACKSPACE = "\x7f"; - - if (editState) { - if (key === CTRL_C || key === ESC) { - editState = null; - status = `${DIM}No changes.${RESET}`; - render(); - return; - } - if (key === ENTER) { - void saveEdit(); - return; - } - if (key === "\x01") { - editState.cursor = 0; - render(); - return; - } - if (key === "\x05") { - editState.cursor = editState.buffer.length; - render(); - return; - } - if (key === "\x0b") { - editState.buffer = editState.buffer.slice(0, editState.cursor); - render(); - return; - } - if (key === "\x15") { - editState.buffer = editState.buffer.slice(editState.cursor); - editState.cursor = 0; - render(); - return; - } - if (key === BACKSPACE) { - if (editState.cursor > 0) { - editState.buffer = - editState.buffer.slice(0, editState.cursor - 1) + - editState.buffer.slice(editState.cursor); - editState.cursor--; - render(); - } - return; - } - if (key === LEFT || key === ALT_LEFT) { - if (editState.cursor > 0) editState.cursor--; - render(); - return; - } - if (key === RIGHT || key === ALT_RIGHT) { - if (editState.cursor < editState.buffer.length) editState.cursor++; - render(); - return; - } - if (key.startsWith("\x1b[")) { - if (key === "\x1b[H" || key === "\x1b[1~") { - editState.cursor = 0; - } else if (key === "\x1b[F" || key === "\x1b[4~") { - editState.cursor = editState.buffer.length; - } else if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) { - editState.buffer = - editState.buffer.slice(0, editState.cursor) + - editState.buffer.slice(editState.cursor + 1); - } - render(); - return; - } - if (key >= " " && key !== "\x7f") { - editState.buffer = - editState.buffer.slice(0, editState.cursor) + - key + - editState.buffer.slice(editState.cursor); - editState.cursor += key.length; - render(); - } - return; + // 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; } - - const action = (() => { - if (key === UP || key === ALT_UP) return "up"; - if (key === DOWN || key === ALT_DOWN) return "down"; - if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return "back"; - if (key === SPACE) return "edit"; - if (key === CTRL_C) return "cancel"; - if (key === "\x1b" || key.startsWith("\x1b[")) { - escapeBuf = key; - return null; - } - if (escapeBuf) { - const next = escapeBuf + key; - escapeBuf = /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next; - if (next === UP || next === ALT_UP) return "up"; - if (next === DOWN || next === ALT_DOWN) return "down"; - if (next === LEFT || next === ALT_LEFT) return "back"; - } - return null; - })(); - - if (action === "cancel") return finish("done"); - if (action === "back") return finish("back"); - if (action === "up" && cursor > 0) { - cursor--; - status = null; - render(); - } else if (action === "down" && cursor < CONFIG_FIELDS.length - 1) { - cursor++; - status = null; - render(); - } else if (action === "edit") { - const value = CONFIG_FIELDS[cursor]!.initialEditValue(config); - editState = { buffer: value, cursor: value.length }; - status = null; - render(); + if (lower === "v") { + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdout.write("\x1b[2J\x1b[H"); + console.log(`gai v${VERSION}`); + return 0; } - }; - - process.stdin.on("data", onData); - }); -} - -async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { - console.log(`\n ${BOLD}Generated commit message:${RESET}`); - console.log(` ${GREEN}${message}${RESET}\n`); - const answer = await ask(` Use this message? [${GREEN}Y${RESET}/n/e] `); - const lower = answer.toLowerCase(); - if (lower === "n") return "n"; - if (lower === "e") return "e"; - return "y"; -} - -async function editMessage(current: string): Promise { - if (!process.stdin.isTTY) return null; - - process.stdout.write(` ${DIM}Edit message (Enter to confirm, Esc to abort):${RESET}\n`); - - const savedRaw = process.stdin.isRaw; - process.stdin.setRawMode(true); - process.stdin.resume(); - - let buffer = current; - let cursor = current.length; - const ESC = "\x1b"; - const ENTER = "\r"; - const CTRL_C = "\x03"; - const BACKSPACE = "\x7f"; - - function render() { - process.stdout.write("\x1b[2K\r > " + buffer); - if (cursor < buffer.length) { - process.stdout.write(`\x1b[${buffer.length - cursor}D`); - } - } - - process.stdout.write(" > "); - process.stdout.write(buffer); - - return new Promise((resolve) => { - let escapeBuf = ""; - - function handleSeq(seq: string) { - if (seq === "\x1b[D" || seq === "\x1bOD") { - if (cursor > 0) { - cursor--; - process.stdout.write("\x1b[D"); - } - } else if (seq === "\x1b[C" || seq === "\x1bOC") { - if (cursor < buffer.length) { - cursor++; - process.stdout.write("\x1b[C"); - } - } else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") { - if (cursor > 0) { - process.stdout.write(`\x1b[${cursor}D`); - cursor = 0; - } - } else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") { - if (cursor < buffer.length) { - process.stdout.write(`\x1b[${buffer.length - cursor}C`); - cursor = buffer.length; - } - } else if (seq === "\x1b[3~") { - if (cursor < buffer.length) { - buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); - render(); - } + if (lower === "q") { + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdout.write("\n"); + return 0; } } - - process.stdin.on("data", (data: Buffer) => { - const key = data.toString(); - - if (key === CTRL_C) { - process.stdin.setRawMode(savedRaw === true); - process.stdin.pause(); - process.stdin.removeAllListeners("data"); - process.stdout.write("\n"); - resolve(null); - return; - } - - if (key === ESC || 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 === ENTER) { - process.stdin.setRawMode(savedRaw === true); - process.stdin.pause(); - process.stdin.removeAllListeners("data"); - process.stdout.write("\n"); - const result = buffer.trim(); - resolve(result || null); - return; - } - - if (key === BACKSPACE) { - if (cursor > 0) { - buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor); - cursor--; - render(); - } - return; - } - - if (key === "\x01") { - if (cursor > 0) { - process.stdout.write(`\x1b[${cursor}D`); - cursor = 0; - } - return; - } - if (key === "\x05") { - if (cursor < buffer.length) { - process.stdout.write(`\x1b[${buffer.length - cursor}C`); - cursor = buffer.length; - } - return; - } - if (key === "\x0b") { - buffer = buffer.slice(0, cursor); - render(); - return; - } - if (key === "\x15") { - buffer = buffer.slice(cursor); - cursor = 0; - render(); - return; - } - - if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) { - buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor); - cursor++; - render(); - } - }); - }); -} - -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: "commit" | "pr" | "config"; - label: string; - description: string; -} - -const MENU_ACTIONS: MenuAction[] = [ - { key: "commit", label: "commit", description: "Generate AI commit message" }, - { key: "pr", label: "pr", description: "Create a PR with AI-generated title" }, - { key: "config", label: "config", description: "Configure API settings" }, -]; - -async function showMenu(): Promise { - if (!process.stdin.isTTY) { - console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`); - process.exit(1); - } - - while (true) { - const selected = await selectOne({ - title: "gai", - subtitle: "Choose a workflow", - allowBack: false, - items: MENU_ACTIONS.map((action) => ({ - label: action.label, - value: action.key, - description: action.description, - })), - }); - - if (selected === null || selected === BACK) return; - - const result = - selected === "commit" - ? await handleCommit(false, false) - : selected === "pr" - ? await handlePR(false) - : await handleConfig(); - - if (result !== "back") return; + } finally { + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); } } -async function selectPlatform( - hostname: string, -): Promise { - if (!process.stdin.isTTY) { - console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`); - process.exit(1); - } +// ── Command Definitions ──────────────────────────────────────────────── - return selectOne({ - title: "Select remote platform", - subtitle: `Remote ${hostname} could not be auto-detected`, - items: [ - { label: "GitHub", value: "github" as Platform, description: "Use gh CLI" }, - { label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" }, - { label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" }, +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 ] [--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, + }, -async function handleCommit( - autoMode: boolean, - dryRun: boolean, -): Promise<"done" | "back"> { - if (!(await isGitRepo())) { - console.error(` ${RED}Error: Not a git repository.${RESET}`); - process.exit(1); - } + { + 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, + }, - const stagedFiles = await getStagedFiles(); - const unstagedFiles = await getUnstagedFiles(); + { + name: "config", + aliases: ["cfg"], + description: "Configure API settings", + usage: "gai config [get |set |list]", + flags: [], + examples: [ + "gai config", + "gai config list", + "gai config get model", + "gai config set model gpt-4o", + ], + handler: handleConfig, + }, - if (stagedFiles.length === 0 && unstagedFiles.length === 0) { - console.log(` ${DIM}Nothing to commit. No staged or unstaged changes.${RESET}`); - return "done"; - } + { + 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, + }, - if (unstagedFiles.length > 0) { - if (autoMode) { - await stageFiles(unstagedFiles.map((f) => f.path)); - console.log( - ` ${GREEN}Auto-staged ${unstagedFiles.length} file(s).${RESET}`, - ); - } else { - const selected = await selectFiles(stagedFiles, unstagedFiles); - if (selected === BACK) return "back"; - if (selected.length > 0) { - await stageFiles(selected); - console.log( - ` ${GREEN}Staged ${selected.length} file(s).${RESET}`, - ); - } - } - } + { + 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, + }, - const diff = await getStagedDiff(); - if (!diff) { - console.log(` ${DIM}Nothing to commit. No staged changes to commit.${RESET}`); - return "done"; - } + { + name: "changelog", + aliases: ["cl", "log"], + description: "Generate changelog from recent commits", + usage: "gai changelog [--from ] [--to ] [--count ]", + 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, + }, - const config = await loadConfig(); + { + 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, + }, +); - if (!config.apiKey) { - console.error( - ` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`, - ); - process.exit(1); - } +// Keep the defs accessible for help command +const allCommandDefs = [...commands.values()].filter( + (c, i, arr) => arr.findIndex((x) => x.name === c.name) === i, +); - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = - diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - const repoRoot = await getRepoRoot(); - const projectCtx = await collectProjectContext(repoRoot); - const recentCommits = await getRecentCommits(10); - - const userPrompt = buildPrompt({ - readme: projectCtx.readme, - packageDescription: projectCtx.packageDescription, - structure: projectCtx.structure, - recentCommits, - diff: truncatedDiff, - }); - - console.log("\n Generating commit message..."); - - let message: string; - try { - message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt); - } catch (err) { - console.error( - ` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - - if (dryRun) { - console.log(`\n ${BOLD}Generated commit message:${RESET}`); - console.log(` ${GREEN}${message}${RESET}`); - await copyToClipboard(message); - console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`); - return "done"; - } - - const action = await confirmCommit(message); - - if (action === "y") { - try { - const result = await commit(message); - printCommitResult(result, message); - } catch (err) { - console.error( - ` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - } else if (action === "e") { - const edited = await editMessage(message); - if (edited) { - try { - const result = await commit(edited); - printCommitResult(result, edited); - } catch (err) { - console.error( - ` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - } else { - console.log(" Aborted."); - } - } else { - const copied = await copyToClipboard(message); - console.log( - copied - ? ` Aborted. Message copied to clipboard.` - : ` Aborted. Message: ${message}`, - ); - } - - return "done"; -} - -async function handlePR(draft: boolean): Promise<"done" | "back"> { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error( - ` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`, - ); - process.exit(1); - } - - if (!(await isGitRepo())) { - console.error(` ${RED}Error: Not a git repository.${RESET}`); - process.exit(1); - } - - let platform = await detectPlatform(); - if (!platform) { - const hostname = (await getRemoteHostname()) || "unknown"; - const chosen = await selectPlatform(hostname); - if (chosen === BACK) return "back"; - if (!chosen) { - console.log(" Aborted."); - process.exit(0); - } - platform = chosen; - } - - const platformLabel = - platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea"; - console.log(` Using: ${CYAN}${platformLabel}${RESET}`); - - const baseBranch = await getDefaultBranch(); - const branchName = await getBranchName(); - - if (branchName === baseBranch) { - console.error( - ` ${RED}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET}`, - ); - process.exit(1); - } - - console.log( - ` Branch: ${CYAN}${branchName}${RESET} → base: ${CYAN}${baseBranch}${RESET}`, - ); - - const commits = await getBranchCommits(baseBranch); - - if (commits.length === 0) { - const choice = await selectOne({ - title: "No commits to compare", - subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`, - items: [{ label: "Back", value: "back" as const }], - }); - if (choice === null) process.exit(0); - return "done"; - } - - console.log( - ` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`, - ); - - const pushStatus = await getBranchPushStatus(); - if (!pushStatus.pushed) { - const target = pushStatus.upstream ?? `origin/${branchName}`; - const answer = await ask( - ` Branch is not pushed to ${CYAN}${target}${RESET}. Push now? [${GREEN}Y${RESET}/n] `, - ); - - if (answer.toLowerCase() === "n") { - console.log(" Aborted."); - return "done"; - } - - console.log(` Pushing ${CYAN}${branchName}${RESET}...`); - try { - await pushCurrentBranch(branchName, pushStatus.upstream); - console.log(` ${GREEN}Pushed ${branchName}.${RESET}`); - } catch (err) { - console.error( - ` ${RED}Push failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - } - - const diff = await getBranchDiff(baseBranch); - if (!diff) { - console.error(` ${RED}Error: No diff from base branch.${RESET}`); - process.exit(1); - } - - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = - diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - const repoRoot = await getRepoRoot(); - const projectCtx = await collectProjectContext(repoRoot); - - const userPrompt = buildPRPrompt({ - readme: projectCtx.readme, - packageDescription: projectCtx.packageDescription, - structure: projectCtx.structure, - branchName, - baseBranch, - branchCommits: commits, - diff: truncatedDiff, - }); - - console.log("\n Generating PR title..."); - - let title: string; - let body: string; - try { - const result = await generatePRMessage(config, PR_SYSTEM_PROMPT, userPrompt); - title = result.title; - body = result.body; - } catch (err) { - console.error( - ` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - - console.log(`\n ${BOLD}Generated PR:${RESET}`); - console.log(` Title: ${GREEN}${title}${RESET}`); - if (body) { - console.log( - ` Body: ${DIM}${body.replace(/\n/g, "\n ")}${RESET}`, - ); - } - console.log(""); - - const answer = await ask(` Create this PR? [${GREEN}Y${RESET}/n/e] `); - const lower = answer.toLowerCase(); - - if (lower === "n") { - console.log(" Aborted."); - return "done"; - } - - if (lower === "e") { - const newTitle = await ask(" Title: "); - const newBody = await ask(" Body (optional): "); - if (!newTitle.trim()) { - console.log(" Aborted."); - return "done"; - } - title = newTitle; - body = newBody; - } - - console.log(`\n Creating PR...`); - - try { - const url = await createPR(platform, title, body, baseBranch, draft); - console.log(` ${GREEN}${BOLD}✔ PR created!${RESET}`); - console.log(` ${CYAN}${url}${RESET}`); - } catch (err) { - console.error( - ` ${RED}PR creation failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - - return "done"; -} - -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 === "help") { - showHelp(); - 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 === "pr") { - const draft = args.includes("--draft"); - await handlePR(draft); - return; - } - - if (!subcommand) { - await showMenu(); - return; - } - - console.error(` ${RED}Unknown command: ${subcommand}${RESET}`); - showHelp(); - process.exit(1); -} +// ── Main ─────────────────────────────────────────────────────────────── process.on("SIGINT", () => { - process.stdout.write("\x1b[?25h"); + process.stdout.write("\x1b[?25h"); // ensure cursor is shown process.stdout.write("\n"); process.exit(130); }); -main().catch((err) => { - console.error(` ${RED}Unexpected error: ${err}${RESET}`); - process.exit(1); -}); +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); + }); diff --git a/package.json b/package.json index ad09ed6..849414d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gai", - "version": "0.1.2", - "description": "AI-powered git commit message generator", + "version": "0.1.3", + "description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more", "module": "index.ts", "type": "module", "bin": { diff --git a/src/ai.ts b/src/ai.ts index 4c96662..744da83 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,29 +1,20 @@ -import type { Config } from "./types"; +import type { Config, StreamCallbacks } from "./types"; interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } -interface ChatCompletionResponse { - choices?: Array<{ - message?: { - content?: string | null; - }; - finish_reason?: string; - }>; - error?: { - message?: string; - type?: string; - code?: string; - }; -} - const MAX_RETRIES = 3; -const RETRY_DELAY = 1000; +const RETRY_DELAY_MS = 1000; + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} function cleanMessage(raw: string): string { let msg = raw.trim(); + // Strip code fences if the whole response is wrapped if (msg.startsWith("```") && msg.endsWith("```")) { const lines = msg.split("\n"); if (lines.length > 2) { @@ -35,16 +26,14 @@ function cleanMessage(raw: string): string { return msg; } -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - export async function callAI( config: Config, systemPrompt: string, userPrompt: string, + callbacks?: StreamCallbacks, ): Promise { const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`; + const stream = callbacks != null; const messages: ChatMessage[] = [ { role: "system", content: systemPrompt }, @@ -64,45 +53,45 @@ export async function callAI( max_tokens: config.maxTokens, temperature: config.temperature, messages, + stream, }), }); if (!response.ok) { const text = await response.text(); if (response.status === 429 && attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY * attempt); + await sleep(RETRY_DELAY_MS * attempt); continue; } throw new Error(`API request failed (${response.status}): ${text}`); } - const data = (await response.json()) as ChatCompletionResponse; + if (stream && response.body) { + return await readStream(response.body, callbacks!); + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string | null }; finish_reason?: string }>; + error?: { message?: string; type?: string; code?: string }; + }; if (data.error) { - throw new Error( - `API error: ${data.error.message ?? JSON.stringify(data.error)}`, - ); + throw new Error(`API error: ${data.error.message ?? JSON.stringify(data.error)}`); } const raw = data.choices?.[0]?.message?.content; const finishReason = data.choices?.[0]?.finish_reason; - if (raw && raw.trim()) { - return raw; - } + if (raw && raw.trim()) return raw; if (finishReason === "length") { - throw new Error( - "Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.", - ); + throw new Error("Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS."); } - if (finishReason === "content_filter") { throw new Error("Response blocked by content filter."); } - if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY * attempt); + await sleep(RETRY_DELAY_MS * attempt); continue; } @@ -113,21 +102,70 @@ export async function callAI( if (attempt >= MAX_RETRIES) throw err; if (err instanceof Error && err.message.startsWith("API error")) throw err; if (err instanceof Error && err.message.includes("max_tokens")) throw err; - if (err instanceof Error && err.message.includes("content filter")) - throw err; - await sleep(RETRY_DELAY * attempt); + if (err instanceof Error && err.message.includes("content filter")) throw err; + await sleep(RETRY_DELAY_MS * attempt); } } throw new Error("Failed to generate response"); } +async function readStream(body: ReadableStream, callbacks: StreamCallbacks): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + // Keep the last potentially incomplete line + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + try { + const parsed = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>; + }; + const token = parsed.choices?.[0]?.delta?.content; + if (token) { + fullText += token; + callbacks.onToken?.(token); + } + const finishReason = parsed.choices?.[0]?.finish_reason; + if (finishReason === "length") { + callbacks.onError?.(new Error("Response truncated (max_tokens too low).")); + } + } catch { + // Skip unparseable SSE lines + } + } + } + } finally { + reader.releaseLock(); + } + + callbacks.onDone?.(fullText); + return fullText; +} + export async function generateCommitMessage( config: Config, systemPrompt: string, userPrompt: string, + callbacks?: StreamCallbacks, ): Promise { - const raw = await callAI(config, systemPrompt, userPrompt); + const raw = await callAI(config, systemPrompt, userPrompt, callbacks); return cleanMessage(raw); } @@ -135,8 +173,9 @@ export async function generatePRMessage( config: Config, systemPrompt: string, userPrompt: string, + callbacks?: StreamCallbacks, ): Promise<{ title: string; body: string }> { - const raw = await callAI(config, systemPrompt, userPrompt); + const raw = await callAI(config, systemPrompt, userPrompt, callbacks); const cleaned = cleanMessage(raw); const lines = cleaned.split("\n"); @@ -148,6 +187,5 @@ export async function generatePRMessage( } const body = lines.slice(bodyStart).join("\n").trim(); - return { title, body }; } diff --git a/src/brand.ts b/src/brand.ts new file mode 100644 index 0000000..1aa4454 --- /dev/null +++ b/src/brand.ts @@ -0,0 +1,22 @@ +// Brand banner and ASCII art logo for gai + +import { GREEN, CYAN, RESET } from "./terminal"; + +export const VERSION = "0.1.3"; + +export function showBanner(): string { + const G = GREEN(); + const C = CYAN(); + const R = RESET(); + + return [ + "", + `${G} ██████╗ █████╗ ██╗${R}`, + `${G} ██╔════╝ ██╔══██╗██║${R}`, + `${G} ██║ ██╗ ███████║██║${R}`, + `${G} ██║ ██║ ██╔══██║██║${R}`, + `${G} ╚██████╝ ██║ ██║██║${R} ${C}AI-powered git helper${R}`, + `${G} ╚═════╝ ╚═╝ ╚═╝╚═╝${R} ${C}v${VERSION}${R}`, + "", + ].join("\n"); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..1c10a3a --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,295 @@ +// Lightweight CLI argument parser aligned with mainstream CLI conventions. +// Supports: subcommands, short/long flags, flag values, positional args, --help, --version. + +export interface FlagDef { + long: string; // e.g. "dry-run" + short?: string; // e.g. "d" + type: "boolean" | "string"; + description: string; + default?: unknown; // boolean flags default to false +} + +export interface CommandDef { + name: string; + aliases?: string[]; + description: string; + usage?: string; // one-line usage, e.g. "gai commit [-a|--all] [-d|--dry-run]" + flags?: FlagDef[]; + examples?: string[]; + handler: (args: ParsedArgs) => Promise; // returns exit code +} + +export interface ParsedArgs { + command: string; // matched command name + flags: Record; // resolved flags by long name + positional: string[]; // remaining positional args + raw: string[]; // original argv + subcommand: CommandDef; +} + +// Global flags available to all commands +const GLOBAL_FLAGS: FlagDef[] = [ + { long: "help", short: "h", type: "boolean", description: "Show help for this command" }, + { long: "version", short: "V", type: "boolean", description: "Show version" }, + { long: "verbose", short: "v", type: "boolean", description: "Enable verbose output" }, + { long: "no-color", type: "boolean", description: "Disable colored output" }, +]; + +function buildFlagIndex(flags: FlagDef[]): Map { + const index = new Map(); + for (const f of flags) { + index.set("--" + f.long, f); + if (f.short) index.set("-" + f.short, f); + } + return index; +} + +function resolveFlagName(raw: string): { flag: FlagDef; value?: string } | null { + // "--key=value" + const eqIndex = raw.indexOf("="); + if (eqIndex !== -1) { + const name = raw.slice(0, eqIndex); + const value = raw.slice(eqIndex + 1); + const allFlags = buildFlagIndex([...GLOBAL_FLAGS]); // we'll rebuild in context + // We'll handle = syntax in the main parse loop with proper index + return null; // handled inline + } + return null; // handled inline +} + +function parseArgs( + rawArgs: string[], + commands: Map, +): ParsedArgs | { error: string } { + const args = [...rawArgs]; + let cmdName = ""; + + // Find subcommand + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg.startsWith("-")) continue; // skip flags before subcommand + cmdName = arg; + args.splice(i, 1); + break; + } + + // Resolve subcommand (including aliases) + let subcommand: CommandDef | undefined; + if (cmdName) { + subcommand = commands.get(cmdName); + if (!subcommand) { + // Try aliases + for (const [, cmd] of commands) { + if (cmd.aliases?.includes(cmdName)) { + subcommand = cmd; + cmdName = cmd.name; + break; + } + } + } + if (!subcommand) { + return { error: `Unknown command: ${cmdName}\nRun 'gai --help' for usage.` }; + } + } else { + // Default: interactive menu + subcommand = commands.get("")!; + } + + const flags = subcommand.flags ?? []; + const allFlags = [...GLOBAL_FLAGS, ...flags]; + const flagIndex = buildFlagIndex(allFlags); + + const resolved: Record = {}; + // Set defaults + for (const f of allFlags) { + if (f.type === "boolean") { + resolved[f.long] = f.default ?? false; + } else if (f.default !== undefined) { + resolved[f.long] = f.default; + } + } + + const positional: string[] = []; + + // Parse remaining args + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + + // Handle --flag=value + if (arg.startsWith("--") && arg.includes("=")) { + const eqIdx = arg.indexOf("="); + const name = arg.slice(0, eqIdx); + const value = arg.slice(eqIdx + 1); + const flag = flagIndex.get(name); + if (!flag) return { error: `Unknown flag: ${name}` }; + if (flag.type === "boolean") { + resolved[flag.long] = value === "true" || value === "1" || value === ""; + } else { + resolved[flag.long] = value; + } + continue; + } + + // Handle --flag or -f + if (arg.startsWith("-")) { + const flag = flagIndex.get(arg); + if (!flag) return { error: `Unknown flag: ${arg}` }; + if (flag.type === "boolean") { + resolved[flag.long] = true; + } else { + // Consume next arg as value + i++; + if (i >= args.length || args[i]!.startsWith("-")) { + return { error: `Flag ${arg} requires a value.` }; + } + resolved[flag.long] = args[i]!; + } + continue; + } + + // Positional + positional.push(arg); + } + + return { + command: cmdName || "", + flags: resolved, + positional, + raw: rawArgs, + subcommand, + }; +} + +export function registerCommands(...cmds: CommandDef[]): Map { + const map = new Map(); + for (const cmd of cmds) { + map.set(cmd.name, cmd); + if (cmd.aliases) { + for (const alias of cmd.aliases) { + if (!map.has(alias)) { + map.set(alias, cmd); + } + } + } + } + return map; +} + +export function formatHelp(commands: Map, cmdName?: string): string { + if (cmdName) { + const cmd = commands.get(cmdName); + if (!cmd) return `Unknown command: ${cmdName}`; + + const lines: string[] = []; + lines.push(""); + lines.push(` gai ${cmdName} — ${cmd.description}`); + lines.push(""); + + if (cmd.usage) { + lines.push(` Usage: ${cmd.usage}`); + lines.push(""); + } + + const flags = cmd.flags ?? []; + if (flags.length > 0) { + lines.push(" Flags:"); + for (const f of flags) { + const shorts = f.short ? `-${f.short}, ` : " "; + const typeHint = f.type === "string" ? " " : ""; + lines.push(` ${shorts}--${f.long}${typeHint}`); + lines.push(` ${f.description}`); + } + lines.push(""); + } + + lines.push(" Global flags:"); + for (const f of GLOBAL_FLAGS) { + const shorts = f.short ? `-${f.short}, ` : " "; + const typeHint = f.type === "string" ? " " : ""; + lines.push(` ${shorts}--${f.long}${typeHint}`); + lines.push(` ${f.description}`); + } + lines.push(""); + + if (cmd.examples && cmd.examples.length > 0) { + lines.push(" Examples:"); + for (const ex of cmd.examples) { + lines.push(` $ ${ex}`); + } + lines.push(""); + } + + return lines.join("\n"); + } + + // General help — deduplicate: show only canonical command names + const lines: string[] = []; + lines.push(""); + lines.push(" gai — AI-powered git commit and PR helper"); + lines.push(""); + lines.push(" Usage: gai [flags]"); + lines.push(""); + lines.push(" Commands:"); + + // Deduplicate: only show canonical names (not aliases) + const seen = new Set(); + let maxLen = 0; + const entries: { name: string; desc: string }[] = []; + for (const [name, cmd] of commands) { + if (!name) continue; // skip default + // Skip if this name is an alias of another command (i.e., not the canonical name) + if (cmd.name !== name) continue; + if (seen.has(name)) continue; + seen.add(name); + const aliases = cmd.aliases && cmd.aliases.length > 0 ? ` (${cmd.aliases.join(", ")})` : ""; + const label = ` ${name}${aliases}`; + entries.push({ name: label, desc: cmd.description }); + if (label.length > maxLen) maxLen = label.length; + } + maxLen += 4; + + for (const entry of entries) { + const padding = " ".repeat(Math.max(2, maxLen - entry.name.length)); + lines.push(`${entry.name}${padding}${entry.desc}`); + } + + lines.push(""); + lines.push(" Global flags:"); + for (const f of GLOBAL_FLAGS) { + const shorts = f.short ? `-${f.short}, ` : " "; + lines.push(` ${shorts}--${f.long} ${f.description}`); + } + lines.push(""); + lines.push(` Run 'gai help ' for command-specific help.`); + lines.push(""); + + return lines.join("\n"); +} + +export async function runCLI(rawArgs: string[], commands: Map): Promise { + const result = parseArgs(rawArgs, commands); + + if ("error" in result) { + console.error(`\n Error: ${result.error}\n`); + return 1; + } + + // Handle --help globally + if (result.flags["help"]) { + console.log(formatHelp(commands, result.command || undefined)); + return 0; + } + + // Handle --version globally + if (result.flags["version"]) { + console.log("gai v0.1.3"); + return 0; + } + + try { + return await result.subcommand.handler(result); + } catch (err) { + console.error(`\n Error: ${err instanceof Error ? err.message : err}\n`); + return 1; + } +} diff --git a/src/clipboard.ts b/src/clipboard.ts index b42c988..7611d41 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -1,26 +1,26 @@ export async function copyToClipboard(text: string): Promise { - const commands: string[][] = []; + const commands: string[][] = []; - if (process.platform === "darwin") { - commands.push(["pbcopy"]); - } else if (process.platform === "linux") { - commands.push(["xclip", "-selection", "clipboard"]); - commands.push(["xsel", "--clipboard", "--input"]); - } + if (process.platform === "darwin") { + commands.push(["pbcopy"]); + } else if (process.platform === "linux") { + commands.push(["xclip", "-selection", "clipboard"]); + commands.push(["xsel", "--clipboard", "--input"]); + } - for (const cmd of commands) { - try { - const proc = Bun.spawn(cmd, { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }); - proc.stdin.write(text); - proc.stdin.end(); - const exitCode = await proc.exited; - if (exitCode === 0) return true; - } catch {} - } + for (const cmd of commands) { + try { + const proc = Bun.spawn(cmd, { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }); + proc.stdin.write(text); + proc.stdin.end(); + const exitCode = await proc.exited; + if (exitCode === 0) return true; + } catch {} + } - return false; + return false; } diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts new file mode 100644 index 0000000..1ec76ca --- /dev/null +++ b/src/commands/changelog.ts @@ -0,0 +1,81 @@ +import { loadConfig } from "../config"; +import { isGitRepo, getRecentCommits } from "../git"; +import { CHANGELOG_SYSTEM_PROMPT, buildChangelogPrompt } from "../prompt"; +import { callAI } from "../ai"; +import { BOLD, RED, DIM, RESET, CYAN } from "../terminal"; +import { isStdinTTY } from "../tty"; +import type { StreamCallbacks } from "../types"; +import type { ParsedArgs } from "../cli"; + +export async function handleChangelog(args: ParsedArgs): Promise { + const config = await loadConfig(); + + if (!config.apiKey) { + console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); + return 1; + } + + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + + const from = args.flags["from"] as string | undefined; + const to = args.flags["to"] as string | undefined; + const countFlag = args.flags["count"] as number | undefined; + const verbose = args.flags["verbose"] as boolean; + + // Collect commits + let commits: string[]; + + if (from) { + // Range-based: get commits between from..to + const range = to ? `${from}..${to}` : `${from}..HEAD`; + try { + const result = await Bun.$`git log --oneline ${range}`.quiet().text(); + commits = result.trim().split("\n").filter(Boolean); + } catch { + console.error(`\n ${RED()}Error: Invalid range: ${range}${RESET()}\n`); + return 1; + } + } else { + // Count-based + const count = typeof countFlag === "number" ? countFlag : 20; + commits = await getRecentCommits(count); + } + + if (commits.length === 0) { + console.log(` ${DIM()}No commits found for the specified range.${RESET()}`); + return 0; + } + + if (verbose) { + console.log(` ${DIM()}Processing ${commits.length} commits${RESET()}`); + console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); + } + + const userPrompt = buildChangelogPrompt(commits, from, to); + + const tty = isStdinTTY(); + if (tty) { + console.log(`\n ${BOLD()}${CYAN()}Generating changelog from ${commits.length} commits...${RESET()}\n`); + } + + try { + const callbacks: StreamCallbacks | undefined = tty ? { + onToken: (token) => process.stdout.write(token), + } : undefined; + + const result = await callAI(config, CHANGELOG_SYSTEM_PROMPT, userPrompt, callbacks); + if (callbacks) { + process.stdout.write("\n"); + } else { + process.stdout.write(result + "\n"); + } + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} diff --git a/src/commands/commit.ts b/src/commands/commit.ts new file mode 100644 index 0000000..d8ff3e6 --- /dev/null +++ b/src/commands/commit.ts @@ -0,0 +1,433 @@ +import * as readline from "node:readline"; +import { + isGitRepo, + getRepoRoot, + getStagedFiles, + getUnstagedFiles, + getStagedDiff, + getRecentCommits, + stageFiles, + applyFileSelection, + commit, +} from "../git"; +import { selectFiles } from "../selector"; +import { BACK, SKIP_WAIT } from "../menu"; +import { collectProjectContext } from "../context"; +import { buildPrompt, SYSTEM_PROMPT } from "../prompt"; +import { generateCommitMessage } from "../ai"; +import { copyToClipboard } from "../clipboard"; +import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; +import { isStdinTTY } from "../tty"; +import type { Config, CommitResult, StreamCallbacks } from "../types"; +import { loadConfig } from "../config"; +import type { ParsedArgs } from "../cli"; + +function ask(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +function printCommitResult(result: CommitResult, 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(", ")}`); +} + +function printSelectionResult(result: { staged: string[]; unstaged: string[] }) { + const parts: string[] = []; + if (result.staged.length > 0) { + parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`); + } + if (result.unstaged.length > 0) { + parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`); + } + if (parts.length > 0) { + console.log(` Updated staging area: ${parts.join(", ")}`); + } +} + +async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { + console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); + console.log(` ${GREEN()}${message}${RESET()}\n`); + const answer = await ask(` Use this message? [${GREEN()}Y${RESET()}/n/e] `); + const lower = answer.toLowerCase(); + if (lower === "n") return "n"; + if (lower === "e") return "e"; + return "y"; +} + +async function editMessage(current: string): Promise { + if (!isStdinTTY()) return null; + process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`); + + const savedRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + + let buffer = current; + let cursor = current.length; + + function render() { + process.stdout.write("\x1b[2K\r > " + buffer); + if (cursor < buffer.length) { + process.stdout.write(`\x1b[${buffer.length - cursor}D`); + } + } + + process.stdout.write(" > "); + process.stdout.write(buffer); + + return new Promise((resolve) => { + let escapeBuf = ""; + + 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"); + process.stdout.write("\n"); + resolve(null); + 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 === "\r") { + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + process.stdout.write("\n"); + const result = buffer.trim(); + resolve(result || null); + return; + } + + if (key === "\x7f") { + if (cursor > 0) { + buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor); + cursor--; + render(); + } + return; + } + + if (key === "\x01") { if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } return; } + if (key === "\x05") { if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } return; } + if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; } + if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; } + + if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) { + buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor); + cursor++; + render(); + } + }); + + function handleSeq(seq: string) { + if (seq === "\x1b[D" || seq === "\x1bOD") { + if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); } + } else if (seq === "\x1b[C" || seq === "\x1bOC") { + if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); } + } else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") { + if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } + } else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") { + if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } + } else if (seq === "\x1b[3~") { + if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); } + } + } + }); +} + +export async function handleCommit(args: ParsedArgs): Promise { + const autoMode = args.flags["all"] as boolean || args.flags["auto"] as boolean; + const dryRun = args.flags["dry-run"] as boolean; + const amend = args.flags["amend"] as boolean; + const customMessage = args.flags["message"] as string | undefined; + const verbose = args.flags["verbose"] as boolean; + + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + + // If a custom message is provided, skip AI and commit directly + if (customMessage) { + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); + + if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) { + console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`); + return 1; + } + + if (!amend && autoMode && unstagedFiles.length > 0) { + await stageFiles(unstagedFiles.map((f) => f.path)); + console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); + } else if (!amend) { + if (isStdinTTY()) { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); + } else if (stagedFiles.length === 0 && unstagedFiles.length > 0) { + console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`); + return 1; + } + } + + const diff = await getStagedDiff(); + if (!diff && !amend) { + console.log(` ${DIM()}Nothing to commit.${RESET()}`); + return 0; + } + + try { + const result = await commit(customMessage); + printCommitResult(result, customMessage); + return 0; + } catch (err) { + console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + } + + // Handle amend + if (amend) { + return handleAmendCommit(args); + } + + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); + + if (stagedFiles.length === 0 && unstagedFiles.length === 0) { + console.log(` ${DIM()}Nothing to commit. No staged or unstaged changes.${RESET()}`); + return 0; + } + + if (autoMode && unstagedFiles.length > 0) { + await stageFiles(unstagedFiles.map((f) => f.path)); + console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); + } else { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); + } + + const diff = await getStagedDiff(); + if (!diff) { + console.log(` ${DIM()}Nothing to commit. No staged changes to commit.${RESET()}`); + return 0; + } + + const config = await loadConfig(); + + if (!config.apiKey) { + console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); + return 1; + } + + const MAX_DIFF_SIZE = 15000; + const truncatedDiff = diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" + : diff; + + const repoRoot = await getRepoRoot(); + const projectCtx = await collectProjectContext(repoRoot); + const recentCommits = await getRecentCommits(10); + + const userPrompt = buildPrompt({ + readme: projectCtx.readme, + packageDescription: projectCtx.packageDescription, + structure: projectCtx.structure, + recentCommits, + diff: truncatedDiff, + }); + + if (verbose) { + console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); + } + + const tty = isStdinTTY(); + if (tty) { + console.log("\n Generating commit message..."); + } + + let message: string; + try { + const callbacks: StreamCallbacks | undefined = tty ? { + onToken: (token) => process.stdout.write(token), + } : undefined; + + if (callbacks) process.stdout.write(" "); + + message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt, callbacks); + + if (callbacks) process.stdout.write("\n"); + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + if (dryRun) { + console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); + console.log(` ${GREEN()}${message}${RESET()}`); + await copyToClipboard(message); + console.log(`\n ${DIM()}(dry-run, message copied to clipboard)${RESET()}`); + return 0; + } + + const action = await confirmCommit(message); + + if (action === "y") { + try { + const result = await commit(message); + printCommitResult(result, message); + } catch (err) { + console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + } else if (action === "e") { + const edited = await editMessage(message); + if (edited) { + try { + const result = await commit(edited); + printCommitResult(result, edited); + } catch (err) { + console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + } else { + console.log(" Aborted."); + } + } else { + const copied = await copyToClipboard(message); + console.log(copied ? ` Aborted. Message copied to clipboard.` : ` Aborted. Message: ${message}`); + } + + return 0; +} + +async function handleAmendCommit(args: ParsedArgs): Promise { + const config = await loadConfig(); + if (!config.apiKey) { + console.error(`\n ${RED()}Error: API key not set.${RESET()}\n`); + return 1; + } + + // Get the diff of the last commit (what would be amended) + let diff: string; + try { + diff = await Bun.$`git diff HEAD~1..HEAD`.quiet().text(); + diff = diff.trim(); + } catch { + // If there's no previous commit (first commit), get staged diff + diff = await getStagedDiff(); + } + + if (!diff) { + // Try getting the commit message from HEAD + try { + const lastMsg = await Bun.$`git log -1 --format=%B`.quiet().text(); + if (lastMsg.trim()) { + console.log(` Last commit message: ${DIM()}${lastMsg.trim()}${RESET()}`); + const newMsg = await editMessage(lastMsg.trim()); + if (newMsg) { + await Bun.spawn(["git", "commit", "--amend", "-m", newMsg], { stdio: ["inherit", "inherit", "inherit"] }); + } + return 0; + } + } catch {} + console.log(` ${DIM()}No changes to amend.${RESET()}`); + return 0; + } + + const repoRoot = await getRepoRoot(); + const projectCtx = await collectProjectContext(repoRoot); + const recentCommits = await getRecentCommits(10); + + const MAX_DIFF_SIZE = 15000; + const truncatedDiff = diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" + : diff; + + const userPrompt = buildPrompt({ + readme: projectCtx.readme, + packageDescription: projectCtx.packageDescription, + structure: projectCtx.structure, + recentCommits, + diff: truncatedDiff, + }); + + console.log("\n Generating amended commit message..."); + + let message: string; + try { + message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt); + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + console.log(`\n ${BOLD()}Generated amended message:${RESET()}`); + console.log(` ${GREEN()}${message}${RESET()}\n`); + + const answer = await ask(` Amend commit with this message? [${GREEN()}Y${RESET()}/n/e] `); + const lower = answer.toLowerCase(); + + if (lower === "n") { + console.log(" Aborted."); + return 0; + } + + if (lower === "e") { + const edited = await editMessage(message); + if (!edited) { console.log(" Aborted."); return 0; } + message = edited; + } + + try { + const proc = Bun.spawn(["git", "commit", "--amend", "-m", message], { + stdio: ["inherit", "inherit", "inherit"], + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`git commit --amend failed (exit code ${exitCode})`); + } + } catch (err) { + console.error(`\n ${RED()}Amend failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..b692fab --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,333 @@ +import { loadConfig, saveConfig } from "../config"; +import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; +import { isStdinTTY } from "../tty"; +import { SKIP_WAIT } from "../menu"; +import type { Config } from "../types"; +import type { ParsedArgs } from "../cli"; + +type ConfigKey = keyof Config; + +interface ConfigField { + key: ConfigKey; + label: string; + format: (config: Config) => string; + initialEditValue: (config: Config) => string; + parse: (value: string) => { value: Config[ConfigKey] } | { error: string }; +} + +const CONFIG_FIELDS: ConfigField[] = [ + { + key: "apiKey", + label: "API Key", + format: (config) => + config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW()}(not set)${RESET()}`, + initialEditValue: () => "", + parse: (value) => ({ value }), + }, + { + key: "apiBase", + label: "API Base", + format: (config) => config.apiBase, + initialEditValue: (config) => config.apiBase, + parse: (value) => ({ value }), + }, + { + key: "model", + label: "Model", + format: (config) => config.model, + initialEditValue: (config) => config.model, + parse: (value) => ({ value }), + }, + { + key: "maxTokens", + label: "Max Tokens", + format: (config) => String(config.maxTokens), + initialEditValue: (config) => String(config.maxTokens), + parse: (value) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return { error: "Max Tokens must be a positive integer." }; + } + return { value: parsed }; + }, + }, + { + key: "temperature", + label: "Temperature", + format: (config) => String(config.temperature), + initialEditValue: (config) => String(config.temperature), + parse: (value) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return { error: "Temperature must be a finite number." }; + } + return { value: parsed }; + }, + }, +]; + +function visibleLength(value: string) { + return value.replace(/\x1b\[[0-9;]*m/g, "").length; +} + +function clearLine() { + process.stdout.write("\r\x1b[2K"); +} + +function moveUp(lines: number) { + if (lines > 0) process.stdout.write(`\x1b[${lines}A`); +} + +function renderConfigPage( + config: Config, + cursor: number, + previousLines: number, + status: string | null, + editState: { buffer: string; cursor: number } | null, +) { + if (previousLines > 0) { + for (let i = 0; i < previousLines; i++) { + clearLine(); + process.stdout.write("\n"); + } + moveUp(previousLines); + } + + const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2; + const lines = [ + "", + ` ${BOLD()}Configuration${RESET()}`, + editState + ? ` ${DIM()}editing · enter save · esc cancel · ctrl+c cancel${RESET()}` + : ` ${DIM()}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET()}`, + "", + ]; + + let activeValueOffset = 0; + for (let i = 0; i < CONFIG_FIELDS.length; i++) { + const field = CONFIG_FIELDS[i]!; + const active = i === cursor; + const pointer = active ? `${CYAN()}❯${RESET()}` : " "; + const marker = active ? `${GREEN()}●${RESET()}` : `${DIM()}○${RESET()}`; + const label = active ? `${BOLD()}${field.label}${RESET()}` : field.label; + const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label))); + const value = active && editState ? editState.buffer : field.format(config); + if (active && editState) { + activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`); + } + lines.push(` ${pointer} ${marker} ${label}${padding}${value}`); + } + + if (status) { + lines.push("", ` ${status}`); + } + + for (const line of lines) { + process.stdout.write(`${line}\n`); + } + if (editState) { + moveUp(lines.length - (4 + cursor)); + const column = activeValueOffset + editState.cursor; + process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`); + } else { + moveUp(lines.length); + } + return lines.length; +} + +async function interactiveConfig(): Promise<"done" | "back"> { + if (!isStdinTTY()) { + console.error(`\n ${RED()}Error: Interactive config requires a TTY.${RESET()}\n`); + process.exit(1); + } + + let config = await loadConfig(); + let cursor = 0; + let renderedLines = 0; + let status: string | null = null; + let editState: { buffer: string; cursor: number } | null = null; + let renderedCursorRow = 0; + const wasRaw = process.stdin.isRaw; + + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdout.write("\x1b[?25l"); + + const render = () => { + moveUp(renderedCursorRow); + renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState); + renderedCursorRow = editState ? 4 + cursor : 0; + process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l"); + }; + + render(); + + return new Promise((resolve) => { + const finish = (value: "done" | "back") => { + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + moveUp(renderedCursorRow); + for (let i = 0; i < renderedLines; i++) { + clearLine(); + process.stdout.write("\n"); + } + moveUp(renderedLines); + process.stdout.write("\x1b[?25h"); + resolve(value); + }; + + const saveEdit = async () => { + if (!editState) return; + const field = CONFIG_FIELDS[cursor]!; + const value = editState.buffer.trim(); + editState = null; + + if (value === "") { + status = `${DIM()}No changes.${RESET()}`; + } else { + const parsed = field.parse(value); + if ("error" in parsed) { + status = `${RED()}${parsed.error}${RESET()}`; + } else { + await saveConfig({ [field.key]: parsed.value } as Partial); + config = await loadConfig(); + status = `${GREEN()}${field.label} saved.${RESET()}`; + } + } + render(); + }; + + const onData = (data: Buffer) => { + const key = data.toString(); + + if (editState) { + if (key === "\x03" || key === "\x1b") { + editState = null; + status = `${DIM()}No changes.${RESET()}`; + render(); + return; + } + if (key === "\r") { void saveEdit(); return; } + if (key === "\x01") { editState.cursor = 0; render(); return; } + if (key === "\x05") { editState.cursor = editState.buffer.length; render(); return; } + if (key === "\x0b") { editState.buffer = editState.buffer.slice(0, editState.cursor); render(); return; } + if (key === "\x15") { editState.buffer = editState.buffer.slice(editState.cursor); editState.cursor = 0; render(); return; } + if (key === "\x7f") { + if (editState.cursor > 0) { + editState.buffer = editState.buffer.slice(0, editState.cursor - 1) + editState.buffer.slice(editState.cursor); + editState.cursor--; + render(); + } + return; + } + if (key === "\x1b[D" || key === "\x1bOD") { if (editState.cursor > 0) editState.cursor--; render(); return; } + if (key === "\x1b[C" || key === "\x1bOC") { if (editState.cursor < editState.buffer.length) editState.cursor++; render(); return; } + if (key === "\x1b[H" || key === "\x1b[1~") { editState.cursor = 0; render(); return; } + if (key === "\x1b[F" || key === "\x1b[4~") { editState.cursor = editState.buffer.length; render(); return; } + if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) { + editState.buffer = editState.buffer.slice(0, editState.cursor) + editState.buffer.slice(editState.cursor + 1); + render(); + return; + } + if (key >= " " && key !== "\x7f") { + editState.buffer = editState.buffer.slice(0, editState.cursor) + key + editState.buffer.slice(editState.cursor); + editState.cursor += key.length; + render(); + } + return; + } + + // Not editing + if (key === "\x03") return finish("done"); + if (key === "\x1b[A" || key === "\x1bOA") { + if (cursor > 0) { cursor--; status = null; render(); } + } else if (key === "\x1b[B" || key === "\x1bOB") { + if (cursor < CONFIG_FIELDS.length - 1) { cursor++; status = null; render(); } + } else if (key === "\x1b[D" || key === "\x1bOD" || key === "\x7f") { + return finish("back"); + } else if (key === " ") { + const value = CONFIG_FIELDS[cursor]!.initialEditValue(config); + editState = { buffer: value, cursor: value.length }; + status = null; + render(); + } + }; + + process.stdin.on("data", onData); + }); +} + +export async function handleConfig(args: ParsedArgs): Promise { + const positional = args.positional; + + // gai config get + if (positional[0] === "get") { + const key = positional[1]; + if (!key) { + console.error(`\n ${RED()}Error: Usage: gai config get ${RESET()}\n`); + return 1; + } + const config = await loadConfig(); + const field = CONFIG_FIELDS.find((f) => f.key === key); + if (!field) { + console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`); + console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`); + return 1; + } + // For apiKey, show masked value + if (key === "apiKey") { + console.log(config.apiKey || ""); + } else { + console.log(String(config[key as keyof Config])); + } + return 0; + } + + // gai config set + if (positional[0] === "set") { + const key = positional[1]; + const value = positional.slice(2).join(" "); + if (!key || value === undefined) { + console.error(`\n ${RED()}Error: Usage: gai config set ${RESET()}\n`); + return 1; + } + const field = CONFIG_FIELDS.find((f) => f.key === key); + if (!field) { + console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`); + console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`); + return 1; + } + const parsed = field.parse(value); + if ("error" in parsed) { + console.error(`\n ${RED()}Error: ${parsed.error}${RESET()}\n`); + return 1; + } + await saveConfig({ [field.key]: parsed.value } as Partial); + console.log(` ${GREEN()}${field.label} set.${RESET()}`); + return 0; + } + + // gai config list + if (positional[0] === "list" || positional[0] === "ls") { + const config = await loadConfig(); + const labelWidth = Math.max(...CONFIG_FIELDS.map((f) => f.label.length)) + 2; + console.log(""); + for (const field of CONFIG_FIELDS) { + const padding = " ".repeat(Math.max(1, labelWidth - field.label.length)); + console.log(` ${BOLD()}${field.label}${RESET()}${padding}${field.format(config)}`); + } + console.log(""); + return 0; + } + + // gai config (no args) → interactive + if (positional.length === 0) { + const result = await interactiveConfig(); + return result === "back" ? (SKIP_WAIT as unknown as number) : 0; + } + + console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`); + console.error(` Try: gai config [get|set|list]\n`); + return 1; +} diff --git a/src/commands/explain.ts b/src/commands/explain.ts new file mode 100644 index 0000000..15eb9ac --- /dev/null +++ b/src/commands/explain.ts @@ -0,0 +1,129 @@ +import { loadConfig } from "../config"; +import { + isGitRepo, + getStagedFiles, + getStagedDiff, + getUnstagedFiles, + getRepoRoot, + applyFileSelection, +} from "../git"; +import { selectFiles } from "../selector"; +import { BACK, SKIP_WAIT } from "../menu"; +import { collectProjectContext } from "../context"; +import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt"; +import { callAI } from "../ai"; +import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal"; +import { isStdinTTY } from "../tty"; +import type { StreamCallbacks } from "../types"; +import type { ParsedArgs } from "../cli"; + +export async function handleExplain(args: ParsedArgs): Promise { + const config = await loadConfig(); + + if (!config.apiKey) { + console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); + return 1; + } + + const unstaged = args.flags["unstaged"] as boolean; + const verbose = args.flags["verbose"] as boolean; + + // Determine which diff to explain + let diff: string; + let sourceLabel: string; + + if (unstaged) { + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + try { + diff = (await Bun.$`git diff`.quiet().text()).trim(); + } catch { + diff = ""; + } + sourceLabel = "unstaged changes"; + } else { + // Default: staged changes (or piped) + if (isStdinTTY()) { + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); + sourceLabel = "selected changes"; + + if (stagedFiles.length > 0 || unstagedFiles.length > 0) { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + await applyFileSelection(stagedFiles, unstagedFiles, selected); + } + diff = await getStagedDiff(); + } else { + // Read from pipe + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + diff = Buffer.concat(chunks).toString("utf-8").trim(); + sourceLabel = "piped input"; + } + } + + if (!diff) { + console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`); + return 0; + } + + if (args.flags["verbose"]) { + console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`); + } + + const MAX_DIFF_SIZE = 15000; + const truncatedDiff = diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" + : diff; + + // Collect project context for better explanations + let contextPrefix = ""; + try { + if (await isGitRepo()) { + const repoRoot = await getRepoRoot(); + const ctx = await collectProjectContext(repoRoot); + if (ctx.packageDescription) { + contextPrefix = `Project: ${ctx.packageDescription}\n\n`; + } + } + } catch {} + + const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff); + + if (verbose) { + console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); + } + + const tty = isStdinTTY(); + if (tty) { + console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`); + } + + try { + const callbacks: StreamCallbacks | undefined = tty ? { + onToken: (token) => process.stdout.write(token), + } : undefined; + + const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks); + if (callbacks) { + process.stdout.write("\n"); + } else { + // Non-TTY: print the result directly + process.stdout.write(explanation + "\n"); + } + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} diff --git a/src/commands/pr.ts b/src/commands/pr.ts new file mode 100644 index 0000000..304ac83 --- /dev/null +++ b/src/commands/pr.ts @@ -0,0 +1,209 @@ +import * as readline from "node:readline"; +import { loadConfig } from "../config"; +import { isGitRepo, getRepoRoot } from "../git"; +import { collectProjectContext } from "../context"; +import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt"; +import { generatePRMessage } from "../ai"; +import { BACK, SKIP_WAIT, selectOne } from "../menu"; +import { + getDefaultBranch, + getBranchName, + getBranchPushStatus, + pushCurrentBranch, + getBranchCommits, + getBranchDiff, + detectPlatform, + getRemoteHostname, + createPR, +} from "../pr"; +import type { Platform } from "../pr"; +import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; +import { isStdinTTY } from "../tty"; +import { copyToClipboard } from "../clipboard"; +import type { ParsedArgs } from "../cli"; + +function ask(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function selectPlatform(hostname: string): Promise { + if (!isStdinTTY()) { + console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`); + process.exit(1); + } + return selectOne({ + title: "Select remote platform", + subtitle: `Remote ${hostname} could not be auto-detected`, + items: [ + { label: "GitHub", value: "github" as Platform, description: "Use gh CLI" }, + { label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" }, + { label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" }, + ], + }); +} + +export async function handlePR(args: ParsedArgs): Promise { + const config = await loadConfig(); + + if (!config.apiKey) { + console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); + return 1; + } + + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + + const draft = args.flags["draft"] as boolean; + const verbose = args.flags["verbose"] as boolean; + + let platform = await detectPlatform(); + if (!platform) { + const hostname = (await getRemoteHostname()) || "unknown"; + const chosen = await selectPlatform(hostname); + if (chosen === BACK) return SKIP_WAIT as unknown as number; + if (!chosen) { + console.log(" Aborted."); + return 0; + } + platform = chosen; + } + + const platformLabel = platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea"; + console.log(` Using: ${CYAN()}${platformLabel}${RESET()}`); + + const baseBranch = await getDefaultBranch(); + const branchName = await getBranchName(); + + if (branchName === baseBranch) { + console.error(`\n ${RED()}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET()}\n`); + return 1; + } + + console.log(` Branch: ${CYAN()}${branchName}${RESET()} → base: ${CYAN()}${baseBranch}${RESET()}`); + + const commits = await getBranchCommits(baseBranch); + + if (commits.length === 0) { + const choice = await selectOne({ + title: "No commits to compare", + subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`, + items: [{ label: "Back", value: "back" as const }], + }); + if (choice === null) process.exit(0); + return SKIP_WAIT as unknown as number; + } + + console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`); + + if (verbose) { + console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); + } + + const pushStatus = await getBranchPushStatus(); + if (!pushStatus.pushed) { + const target = pushStatus.upstream ?? `origin/${branchName}`; + const answer = await ask( + ` Branch is not pushed to ${CYAN()}${target}${RESET()}. Push now? [${GREEN()}Y${RESET()}/n] `, + ); + + if (answer.toLowerCase() === "n") { + console.log(" Aborted."); + return 0; + } + + console.log(` Pushing ${CYAN()}${branchName}${RESET()}...`); + try { + await pushCurrentBranch(branchName, pushStatus.upstream); + console.log(` ${GREEN()}Pushed ${branchName}.${RESET()}`); + } catch (err) { + console.error(`\n ${RED()}Push failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + } + + const diff = await getBranchDiff(baseBranch); + if (!diff) { + console.error(`\n ${RED()}Error: No diff from base branch.${RESET()}\n`); + return 1; + } + + const MAX_DIFF_SIZE = 15000; + const truncatedDiff = diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" + : diff; + + const repoRoot = await getRepoRoot(); + const projectCtx = await collectProjectContext(repoRoot); + + const userPrompt = buildPRPrompt({ + readme: projectCtx.readme, + packageDescription: projectCtx.packageDescription, + structure: projectCtx.structure, + branchName, + baseBranch, + branchCommits: commits, + diff: truncatedDiff, + }); + + console.log("\n Generating PR title and description..."); + + let title: string; + let body: string; + try { + const result = await generatePRMessage(config, PR_SYSTEM_PROMPT, userPrompt); + title = result.title; + body = result.body; + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + console.log(`\n ${BOLD()}Generated PR:${RESET()}`); + console.log(` Title: ${GREEN()}${title}${RESET()}`); + if (body) { + console.log(` Body: ${DIM()}${body.replace(/\n/g, "\n ")}${RESET()}`); + } + console.log(""); + + const answer = await ask(` Create this PR? [${GREEN()}Y${RESET()}/n/e] `); + const lower = answer.toLowerCase(); + + if (lower === "n") { + console.log(" Aborted."); + await copyToClipboard(`${title}\n\n${body}`); + console.log(` ${DIM()}PR title copied to clipboard.${RESET()}`); + return 0; + } + + if (lower === "e") { + const newTitle = await ask(" Title: "); + const newBody = await ask(" Body (optional): "); + if (!newTitle.trim()) { + console.log(" Aborted."); + return 0; + } + title = newTitle; + body = newBody; + } + + console.log(`\n Creating PR...`); + + try { + const url = await createPR(platform, title, body, baseBranch, draft); + console.log(`\n ${GREEN()}${BOLD()}✔ PR created!${RESET()}`); + console.log(` ${CYAN()}${url}${RESET()}`); + } catch (err) { + console.error(`\n ${RED()}PR creation failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} diff --git a/src/commands/review.ts b/src/commands/review.ts new file mode 100644 index 0000000..754459e --- /dev/null +++ b/src/commands/review.ts @@ -0,0 +1,130 @@ +import { loadConfig } from "../config"; +import { + isGitRepo, + getStagedFiles, + getStagedDiff, + getUnstagedFiles, + getRepoRoot, + applyFileSelection, +} from "../git"; +import { selectFiles } from "../selector"; +import { BACK, SKIP_WAIT } from "../menu"; +import { collectProjectContext } from "../context"; +import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt"; +import { callAI } from "../ai"; +import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal"; +import { isStdinTTY } from "../tty"; +import type { StreamCallbacks } from "../types"; +import type { ParsedArgs } from "../cli"; + +export async function handleReview(args: ParsedArgs): Promise { + const config = await loadConfig(); + + if (!config.apiKey) { + console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); + return 1; + } + + const strictnessFlag = args.flags["strict"] as boolean + ? "strict" + : args.flags["lenient"] as boolean + ? "lenient" + : "normal"; + + const unstaged = args.flags["unstaged"] as boolean; + const verbose = args.flags["verbose"] as boolean; + + let diff: string; + let sourceLabel: string; + + if (unstaged) { + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + try { + diff = (await Bun.$`git diff`.quiet().text()).trim(); + } catch { + diff = ""; + } + sourceLabel = "unstaged changes"; + } else if (!isStdinTTY()) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + diff = Buffer.concat(chunks).toString("utf-8").trim(); + sourceLabel = "piped input"; + } else { + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); + sourceLabel = "selected changes"; + + if (stagedFiles.length > 0 || unstagedFiles.length > 0) { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + await applyFileSelection(stagedFiles, unstagedFiles, selected); + } + diff = await getStagedDiff(); + } + + if (!diff) { + console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`); + return 0; + } + + const MAX_DIFF_SIZE = 15000; + const truncatedDiff = diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" + : diff; + + let contextPrefix = ""; + try { + if (await isGitRepo()) { + const repoRoot = await getRepoRoot(); + const ctx = await collectProjectContext(repoRoot); + if (ctx.packageDescription) { + contextPrefix = `Project: ${ctx.packageDescription}\n\n`; + } + } + } catch {} + + const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag); + + const strictnessLabel = strictnessFlag === "strict" + ? `${RED()}strict${RESET()}` + : strictnessFlag === "lenient" + ? `${GREEN()}lenient${RESET()}` + : `${YELLOW()}normal${RESET()}`; + + if (verbose) { + console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`); + } + + const tty = isStdinTTY(); + if (tty) { + console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`); + } + + try { + const callbacks: StreamCallbacks | undefined = tty ? { + onToken: (token) => process.stdout.write(token), + } : undefined; + + const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks); + if (callbacks) { + process.stdout.write("\n"); + } else { + process.stdout.write(result + "\n"); + } + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts new file mode 100644 index 0000000..676051c --- /dev/null +++ b/src/commands/suggest.ts @@ -0,0 +1,146 @@ +import { loadConfig } from "../config"; +import { + isGitRepo, + getStagedFiles, + getStagedDiff, + getUnstagedFiles, + applyFileSelection, +} from "../git"; +import { selectFiles } from "../selector"; +import { BACK, SKIP_WAIT } from "../menu"; +import { + SUGGEST_SYSTEM_PROMPT, + buildSuggestBranchPrompt, + buildSuggestTypePrompt, +} from "../prompt"; +import { callAI } from "../ai"; +import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal"; +import { isStdinTTY } from "../tty"; +import type { Config } from "../types"; +import type { ParsedArgs } from "../cli"; + +export async function handleSuggest(args: ParsedArgs): Promise { + const config = await loadConfig(); + + if (!config.apiKey) { + console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); + return 1; + } + + const mode = args.positional[0] || "branch"; + const verbose = args.flags["verbose"] as boolean; + + if (mode !== "branch" && mode !== "type") { + console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`); + console.error(` Try: gai suggest branch | gai suggest type\n`); + return 1; + } + + // Get diff (staged, or unstaged if --unstaged, or piped) + let diff: string; + if (!isStdinTTY()) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + diff = Buffer.concat(chunks).toString("utf-8").trim(); + } else { + if (!(await isGitRepo())) { + console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); + return 1; + } + + if (args.flags["unstaged"] as boolean) { + try { + diff = (await Bun.$`git diff`.quiet().text()).trim(); + } catch { + diff = ""; + } + } else { + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); + + if (stagedFiles.length > 0 || unstagedFiles.length > 0) { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + await applyFileSelection(stagedFiles, unstagedFiles, selected); + } + diff = await getStagedDiff(); + } + } + + if (!diff) { + console.log(` ${DIM()}No changes to suggest from.${RESET()}`); + return 0; + } + + const MAX_DIFF_SIZE = 15000; + const truncatedDiff = diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" + : diff; + + if (verbose) { + console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); + } + + if (mode === "branch") { + return handleSuggestBranch(config, truncatedDiff); + } else { + return handleSuggestType(config, truncatedDiff); + } +} + +async function handleSuggestBranch(config: Config, diff: string): Promise { + const tty = isStdinTTY(); + if (tty) { + console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`); + } + + try { + const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff)); + const suggestions = raw + .split("\n") + .map((line) => line.replace(/^[\d.\s\-*]+/, "").trim()) + .filter(Boolean); + + if (suggestions.length === 0) { + console.log(` ${DIM()}No suggestions generated.${RESET()}`); + return 0; + } + + for (const s of suggestions) { + console.log(` ${GREEN()}${s}${RESET()}`); + } + console.log(""); + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} + +async function handleSuggestType(config: Config, diff: string): Promise { + const tty = isStdinTTY(); + if (tty) { + console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`); + } + + try { + const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff)); + const type = raw.trim().toLowerCase(); + const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]; + + if (validTypes.includes(type)) { + console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`); + } else { + console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`); + console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`); + } + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} diff --git a/src/git.ts b/src/git.ts index 6e09840..58cf11f 100644 --- a/src/git.ts +++ b/src/git.ts @@ -98,6 +98,33 @@ export async function stageFiles(paths: string[]): Promise { await Bun.$`git add -- ${paths}`; } +export async function unstageFiles(paths: string[]): Promise { + if (paths.length === 0) return; + try { + await Bun.$`git restore --staged -- ${paths}`.quiet(); + } catch { + await Bun.$`git rm --cached -r -- ${paths}`.quiet(); + } +} + +export async function applyFileSelection( + stagedFiles: FileEntry[], + unstagedFiles: FileEntry[], + selectedPaths: string[], +): Promise<{ staged: string[]; unstaged: string[] }> { + const selected = new Set(selectedPaths); + const stagedPaths = new Set(stagedFiles.map((file) => file.path)); + const unstagedPaths = new Set(unstagedFiles.map((file) => file.path)); + + const toUnstage = [...stagedPaths].filter((path) => !selected.has(path)); + const toStage = [...selected].filter((path) => unstagedPaths.has(path)); + + await unstageFiles(toUnstage); + await stageFiles(toStage); + + return { staged: toStage, unstaged: toUnstage }; +} + export async function commit( message: string, ): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> { diff --git a/src/menu.ts b/src/menu.ts index 4d59295..92158fe 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,4 +1,5 @@ import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; +import { isStdinTTY } from "./tty"; const UP = "\x1b[A"; const DOWN = "\x1b[B"; @@ -14,6 +15,10 @@ const BACKSPACE = "\x7f"; export const BACK = Symbol("prompt-back"); export type PromptBack = typeof BACK; +// Sent by command handlers to skip the "Press Enter to return" wait in the +// interactive menu when the user explicitly backed out of a sub-menu. +export const SKIP_WAIT = Symbol("skip-wait"); + export interface Choice { label: string; value: T; @@ -38,13 +43,8 @@ interface MultiPromptOptions extends BasePromptOptions { doneLabel?: string; } -function hideCursor() { - process.stdout.write("\x1b[?25l"); -} - -function showCursor() { - process.stdout.write("\x1b[?25h"); -} +function hideCursor() { process.stdout.write("\x1b[?25l"); } +function showCursor() { process.stdout.write("\x1b[?25h"); } function moveUp(lines: number) { if (lines > 0) process.stdout.write(`\x1b[${lines}A`); @@ -65,10 +65,10 @@ function padLabel(label: string, width: number) { function controls(mode: "single" | "multi", showBackHint = true) { if (mode === "single") { const backHint = showBackHint ? " · ←/backspace back" : ""; - return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`; + return `${DIM()}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET()}`; } const backHint = showBackHint ? " · ←/backspace back" : ""; - return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`; + return `${DIM()}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET()}`; } function renderPrompt(lines: string[], previousLines: number) { @@ -79,10 +79,7 @@ function renderPrompt(lines: string[], previousLines: number) { } moveUp(previousLines); } - - for (const line of lines) { - process.stdout.write(`${line}\n`); - } + for (const line of lines) process.stdout.write(`${line}\n`); moveUp(lines.length); return lines.length; } @@ -98,28 +95,19 @@ function clearPrompt(lines: number) { function normalizeKey(key: string, escapeBuf: string) { if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" }; - if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) { - return { action: "back", escapeBuf: "" }; - } + if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" }; if (key === SPACE) return { action: "space", escapeBuf: "" }; if (key === ENTER) return { action: "enter", escapeBuf: "" }; if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; - if (key === "\x1b" || key.startsWith("\x1b[")) { - return { action: null, escapeBuf: key }; - } + if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key }; if (escapeBuf) { const next = escapeBuf + key; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; - if (next === LEFT || next === ALT_LEFT) { - return { action: "back", escapeBuf: "" }; - } - return { - action: null, - escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next, - }; + if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" }; + return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next }; } return { action: null, escapeBuf: "" }; @@ -130,41 +118,35 @@ function createLines( mode: "single" | "multi", cursor: number, ) { - const labelWidth = Math.max( - ...options.items.map((item) => visibleLength(item.label)), - 0, - ) + 2; - + const labelWidth = Math.max(...options.items.map((item) => visibleLength(item.label)), 0) + 2; const lines = [ "", - ` ${BOLD}${options.title}${RESET}`, + ` ${BOLD()}${options.title}${RESET()}`, ]; - if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`); + if (options.subtitle) lines.push(` ${DIM()}${options.subtitle}${RESET()}`); lines.push(` ${controls(mode, options.allowBack !== false)}`, ""); for (let i = 0; i < options.items.length; i++) { const item = options.items[i]!; const active = i === cursor; - const pointer = active ? `${CYAN}❯${RESET}` : " "; + const pointer = active ? `${CYAN()}❯${RESET()}` : " "; const marker = mode === "single" - ? active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}` - : item.selected ? `${GREEN}◼${RESET}` : `${DIM}□${RESET}`; - const label = active ? `${BOLD}${item.label}${RESET}` : item.label; + ? active ? `${GREEN()}●${RESET()}` : `${DIM()}○${RESET()}` + : item.selected ? `${GREEN()}◼${RESET()}` : `${DIM()}□${RESET()}`; + const label = active ? `${BOLD()}${item.label}${RESET()}` : item.label; const description = item.description - ? active ? item.description : `${DIM}${item.description}${RESET}` + ? active ? item.description : `${DIM()}${item.description}${RESET()}` : ""; - lines.push( - ` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`, - ); + lines.push(` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`); } return lines; } function ensureTTY(title: string) { - if (process.stdin.isTTY !== true) { + if (!isStdinTTY()) { throw new Error(`${title} requires a TTY.`); } } @@ -184,12 +166,8 @@ export async function selectOne( hideCursor(); const render = () => { - renderedLines = renderPrompt( - createLines(options, "single", cursor), - renderedLines, - ); + renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines); }; - render(); return new Promise((resolve) => { @@ -210,16 +188,10 @@ export async function selectOne( escapeBuf = result.escapeBuf; if (result.action === "cancel") return finish(null); - if (result.action === "back" && options.allowBack !== false) { - return finish(BACK); - } - if (result.action === "up" && cursor > 0) { - cursor--; - render(); - } else if (result.action === "down" && cursor < options.items.length - 1) { - cursor++; - render(); - } else if (result.action === "space" || result.action === "enter") { + if (result.action === "back" && options.allowBack !== false) return finish(BACK); + if (result.action === "up" && cursor > 0) { cursor--; render(); } + else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); } + else if (result.action === "space" || result.action === "enter") { finish(options.items[cursor]!.value); } }; @@ -253,15 +225,13 @@ export async function selectMany( if (!options.selectAllLabel) return; items[0]!.selected = items.slice(1).every((item) => item.selected); }; + syncSelectAll(); const toggle = (index: number) => { const item = items[index]!; item.selected = !item.selected; - if (options.selectAllLabel && index === 0) { - for (let i = 1; i < items.length; i++) { - items[i]!.selected = item.selected; - } + for (let i = 1; i < items.length; i++) items[i]!.selected = item.selected; } else { syncSelectAll(); } @@ -269,19 +239,10 @@ export async function selectMany( const render = () => { renderedLines = renderPrompt( - createLines( - { - title: options.title, - subtitle: options.subtitle, - items, - }, - "multi", - cursor, - ), + createLines({ title: options.title, subtitle: options.subtitle, items }, "multi", cursor), renderedLines, ); }; - render(); return new Promise((resolve) => { @@ -302,24 +263,12 @@ export async function selectMany( escapeBuf = result.escapeBuf; if (result.action === "cancel") return finish(null); - if (result.action === "back" && options.allowBack !== false) { - return finish(BACK); - } - if (result.action === "up" && cursor > 0) { - cursor--; - render(); - } else if (result.action === "down" && cursor < items.length - 1) { - cursor++; - render(); - } else if (result.action === "space") { - toggle(cursor); - render(); - } else if (result.action === "enter") { - finish( - items - .filter((item) => item.selected && item.value !== null) - .map((item) => item.value as T), - ); + if (result.action === "back" && options.allowBack !== false) return finish(BACK); + if (result.action === "up" && cursor > 0) { cursor--; render(); } + else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); } + else if (result.action === "space") { toggle(cursor); render(); } + else if (result.action === "enter") { + finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T)); } }; diff --git a/src/prompt.ts b/src/prompt.ts index 4090f1a..16327e3 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,5 +1,7 @@ import type { PRContext, ProjectContext } from "./types"; +// ── Commit System Prompt ────────────────────────────────────────────── + export const SYSTEM_PROMPT = `You are an expert at writing concise, meaningful git commit messages following the Conventional Commits specification. Format: (): @@ -19,29 +21,17 @@ Rules: export function buildPrompt(context: ProjectContext): string { const parts: string[] = []; - if ( - context.packageDescription || - context.readme || - context.structure - ) { + if (context.packageDescription || context.readme || context.structure) { parts.push("## Project Context"); - if (context.packageDescription) { - parts.push(`Description: ${context.packageDescription}`); - } - if (context.structure) { - parts.push(`Structure: ${context.structure}`); - } - if (context.readme) { - parts.push(`README:\n${context.readme}`); - } + if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`); + if (context.structure) parts.push(`Structure: ${context.structure}`); + if (context.readme) parts.push(`README:\n${context.readme}`); parts.push(""); } if (context.recentCommits.length > 0) { parts.push("## Recent Commits (for style reference)"); - for (const c of context.recentCommits) { - parts.push(c); - } + for (const c of context.recentCommits) parts.push(c); parts.push(""); } @@ -55,6 +45,8 @@ export function buildPrompt(context: ProjectContext): string { return parts.join("\n"); } +// ── PR System Prompt ─────────────────────────────────────────────────── + export const PR_SYSTEM_PROMPT = `You are an expert at writing clear, concise pull request titles and descriptions. Format: @@ -74,21 +66,11 @@ Rules: export function buildPRPrompt(context: PRContext): string { const parts: string[] = []; - if ( - context.packageDescription || - context.readme || - context.structure - ) { + if (context.packageDescription || context.readme || context.structure) { parts.push("## Project Context"); - if (context.packageDescription) { - parts.push(`Description: ${context.packageDescription}`); - } - if (context.structure) { - parts.push(`Structure: ${context.structure}`); - } - if (context.readme) { - parts.push(`README:\n${context.readme}`); - } + if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`); + if (context.structure) parts.push(`Structure: ${context.structure}`); + if (context.readme) parts.push(`README:\n${context.readme}`); parts.push(""); } @@ -99,9 +81,7 @@ export function buildPRPrompt(context: PRContext): string { if (context.branchCommits.length > 0) { parts.push("## Commits on This Branch"); - for (const c of context.branchCommits) { - parts.push(c); - } + for (const c of context.branchCommits) parts.push(c); parts.push(""); } @@ -110,9 +90,141 @@ export function buildPRPrompt(context: PRContext): string { parts.push(context.diff); parts.push("```"); parts.push(""); - parts.push( - "Generate a pull request title and brief body for the above changes.", - ); + parts.push("Generate a pull request title and brief body for the above changes."); return parts.join("\n"); } + +// ── Explain Prompt ───────────────────────────────────────────────────── + +export const EXPLAIN_SYSTEM_PROMPT = `You are an expert software engineer explaining code changes in plain, accessible language. + +Given a git diff, explain: +1. WHAT changed at a high level (1 sentence summary) +2. WHY these changes matter (what problem they solve or what they enable) +3. A brief breakdown of the key changes (bullet points, one per file/module) + +Rules: +- Be concise but thorough +- Use plain language suitable for both junior and senior engineers +- Focus on the intent and impact, not just restating the diff +- Do NOT use markdown headings (no ##, ###). Use bold text markers like **Section:** instead. +- Keep each bullet point to 1-2 lines +- If the diff is trivial, say so and keep the explanation short`; + +export function buildExplainPrompt(diff: string): string { + const parts: string[] = []; + parts.push("## Changes to Explain"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("Explain these changes in plain language as described."); + return parts.join("\n"); +} + +// ── Review Prompt ────────────────────────────────────────────────────── + +export const REVIEW_SYSTEM_PROMPT = `You are a senior software engineer performing a thorough but friendly code review. + +Review the following code changes and provide feedback in these categories: +1. **Bugs & Logic Errors** — actual bugs, off-by-one errors, null safety, edge cases +2. **Code Quality** — readability, naming, duplication, complexity +3. **Performance** — inefficient patterns, unnecessary allocations, N+1 queries +4. **Security** — injection risks, exposed secrets, unsafe operations +5. **Suggestions** — concrete improvements with code snippets where helpful + +Rules: +- Be constructive, not harsh. Use "consider" and "suggest" instead of "you should". +- Prioritize by severity. Mention critical issues first. +- If the code looks great, say so! Don't fabricate issues. +- Keep feedback actionable — every issue should have a clear suggestion. +- Use **bold** for section headers and \`code\` for code references. +- Do NOT output a concluding summary paragraph. End with the last suggestion.`; + +export function buildReviewPrompt(diff: string, strictness: "lenient" | "normal" | "strict"): string { + const strictnessHints: Record = { + lenient: "Focus only on major issues. Skip minor style nits.", + normal: "Provide balanced feedback covering all categories.", + strict: "Be thorough. Flag even minor issues and style inconsistencies.", + }; + + const parts: string[] = []; + parts.push(`Review strictness: ${strictnessHints[strictness]}`); + parts.push(""); + parts.push("## Code Changes to Review"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("Please review the above changes."); + return parts.join("\n"); +} + +// ── Changelog Prompt ─────────────────────────────────────────────────── + +export const CHANGELOG_SYSTEM_PROMPT = `You are an expert at writing clear, user-facing changelogs from git commit history. + +Given a list of commits, generate a changelog organized by type: +- **Features** (feat commits) +- **Bug Fixes** (fix commits) +- **Improvements** (refactor, perf, style commits) +- **Documentation** (docs commits) +- **Chores & Maintenance** (chore, build, ci, test commits) + +Rules: +- Group by type, with the heading in **bold** +- Each entry should be a single line describing the change in user-friendly language +- Translate technical commit messages into language a user would understand +- Skip merge commits and trivial chore commits if they don't add value +- If a type has no entries, omit that section +- Output ONLY the changelog text, no preamble or markdown code blocks`; + +export function buildChangelogPrompt(commits: string[], from?: string, to?: string): string { + const parts: string[] = []; + const range = from ? `from ${from}${to ? ` to ${to}` : " to HEAD"}` : ""; + parts.push(range ? `Generate a changelog for commits ${range}.` : "Generate a changelog from the following commits."); + parts.push(""); + parts.push("## Commits"); + for (const c of commits) parts.push(c); + parts.push(""); + parts.push("Generate a changelog from these commits."); + return parts.join("\n"); +} + +// ── Suggest Prompt ───────────────────────────────────────────────────── + +export const SUGGEST_SYSTEM_PROMPT = `You are an expert at suggesting git branch names and commit types based on code changes. + +For branch name suggestions: +- Use format: / +- Types: feat, fix, refactor, docs, chore, perf, test +- Description should be 2-4 hyphenated words +- Provide exactly 3 suggestions, one per line + +For commit type suggestions: +- Return exactly one Conventional Commit type that best matches the changes +- Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert +- Output ONLY the type name`; + +export function buildSuggestBranchPrompt(diff: string): string { + const parts: string[] = []; + parts.push("## Changes"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("Suggest 3 branch names for these changes. Output one per line, no numbering."); + return parts.join("\n"); +} + +export function buildSuggestTypePrompt(diff: string): string { + const parts: string[] = []; + parts.push("## Changes"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("What Conventional Commit type best describes these changes? Output ONLY the type name."); + return parts.join("\n"); +} diff --git a/src/selector.ts b/src/selector.ts index 96019b1..c4e428b 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,43 +1,54 @@ import type { FileEntry } from "./types"; -import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; +import { isStdinTTY } from "./tty"; import { BACK, selectMany } from "./menu"; import type { PromptBack } from "./menu"; +function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) { + const files = new Map(); + + for (const file of stagedFiles) { + files.set(file.path, { ...file, staged: true, unstaged: false }); + } + + for (const file of unstagedFiles) { + const existing = files.get(file.path); + if (existing) { + existing.unstaged = true; + existing.label = existing.label === file.label + ? existing.label + : `${existing.label}, ${file.label}`; + } else { + files.set(file.path, { ...file, staged: false, unstaged: true }); + } + } + + return [...files.values()]; +} + export async function selectFiles( stagedFiles: FileEntry[], unstagedFiles: FileEntry[], ): Promise { - if (unstagedFiles.length === 0) return []; + const files = mergeFiles(stagedFiles, unstagedFiles); + if (files.length === 0) return []; - if (stagedFiles.length > 0) { - process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`); - for (const f of stagedFiles) { - process.stdout.write(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`); - } - } - - if (process.stdin.isTTY !== true) return []; + if (!isStdinTTY()) return stagedFiles.map((file) => file.path); const selected = await selectMany({ - title: "Select files to stage", - subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`, + title: "Select files for this action", + subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`, selectAllLabel: "Select all", cancelMessage: "Aborted.", - items: unstagedFiles.map((f) => ({ + items: files.map((f) => ({ label: f.path, value: f.path, description: f.label, + selected: f.staged, })), }); if (selected === null) process.exit(1); if (selected === BACK) return BACK; - if (selected.length > 0) { - process.stdout.write( - ` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`, - ); - } - return selected; } diff --git a/src/terminal.ts b/src/terminal.ts index 769649c..df203b3 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -1,7 +1,35 @@ -export const BOLD = "\x1b[1m"; -export const GREEN = "\x1b[32m"; -export const YELLOW = "\x1b[33m"; -export const CYAN = "\x1b[36m"; -export const RED = "\x1b[31m"; -export const DIM = "\x1b[2m"; -export const RESET = "\x1b[0m"; +// Terminal styling utilities. +// Respects NO_COLOR convention, --no-color flag, and TTY detection. + +import { isStdoutTTY } from "./tty"; + +let _enabled: boolean | null = null; + +export function setColorEnabled(enabled: boolean): void { + _enabled = enabled; +} + +export function isColorEnabled(): boolean { + if (_enabled !== null) return _enabled; + + // Respect NO_COLOR: https://no-color.org/ + if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") { + return false; + } + if (!isStdoutTTY()) return false; + if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true; + + return true; +} + +function s(code: string): string { + return isColorEnabled() ? code : ""; +} + +export const BOLD = () => s("\x1b[1m"); +export const DIM = () => s("\x1b[2m"); +export const GREEN = () => s("\x1b[32m"); +export const YELLOW = () => s("\x1b[33m"); +export const CYAN = () => s("\x1b[36m"); +export const RED = () => s("\x1b[31m"); +export const RESET = () => s("\x1b[0m"); diff --git a/src/tty.ts b/src/tty.ts new file mode 100644 index 0000000..704c2a0 --- /dev/null +++ b/src/tty.ts @@ -0,0 +1,36 @@ +// TTY detection for Bun compatibility. +// Bun does not set process.stdin.isTTY, so we use fs.fstatSync. + +import { fstatSync } from "node:fs"; + +let _stdinTTY: boolean | null = null; + +export function initTTY(): void { + if (_stdinTTY !== null) return; + + try { + // fd 0 = stdin. On Unix, a TTY is a character device. + const stat = fstatSync(0); + _stdinTTY = stat.isCharacterDevice(); + } catch { + _stdinTTY = false; + } +} + +export function isStdinTTY(): boolean { + if (_stdinTTY === null) initTTY(); + return _stdinTTY!; +} + +export function isStdoutTTY(): boolean { + // Use a heuristic for stdout — check if we're in a terminal + if (process.env.TERM || process.env.TERM_PROGRAM) return true; + if (process.env.NO_COLOR) return false; + // Try fstat on fd 1 (stdout) + try { + const stat = fstatSync(1); + return stat.isCharacterDevice(); + } catch { + return false; + } +} diff --git a/src/types.ts b/src/types.ts index b81f755..5a5c41f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,3 +29,17 @@ export interface PRContext { branchCommits: string[]; diff: string; } + +export interface CommitResult { + branch: string; + hash: string; + files: number; + insertions: number; + deletions: number; +} + +export interface StreamCallbacks { + onToken?: (token: string) => void; + onDone?: (fullText: string) => void; + onError?: (err: Error) => void; +} diff --git a/test/config.test.ts b/test/config.test.ts index 0664ec8..f5aec44 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -2,39 +2,39 @@ import { test, expect, describe } from "bun:test"; import { loadConfig } from "../src/config"; describe("config", () => { - test("loadConfig env variables override config file and defaults", async () => { - const origBase = process.env.GAI_API_BASE; - const origModel = process.env.GAI_MODEL; + test("loadConfig env variables override config file and defaults", async () => { + const origBase = process.env.GAI_API_BASE; + const origModel = process.env.GAI_MODEL; - process.env.GAI_API_BASE = "https://custom.api.com/v1"; - process.env.GAI_MODEL = "custom-model"; + process.env.GAI_API_BASE = "https://custom.api.com/v1"; + process.env.GAI_MODEL = "custom-model"; - const config = await loadConfig(); + const config = await loadConfig(); - expect(config.apiBase).toBe("https://custom.api.com/v1"); - expect(config.model).toBe("custom-model"); + expect(config.apiBase).toBe("https://custom.api.com/v1"); + expect(config.model).toBe("custom-model"); - if (origBase) process.env.GAI_API_BASE = origBase; - else delete process.env.GAI_API_BASE; - if (origModel) process.env.GAI_MODEL = origModel; - else delete process.env.GAI_MODEL; - }); + if (origBase) process.env.GAI_API_BASE = origBase; + else delete process.env.GAI_API_BASE; + if (origModel) process.env.GAI_MODEL = origModel; + else delete process.env.GAI_MODEL; + }); - test("loadConfig reads from environment variables", async () => { - const origBase = process.env.GAI_API_BASE; - const origModel = process.env.GAI_MODEL; + test("loadConfig reads from environment variables", async () => { + const origBase = process.env.GAI_API_BASE; + const origModel = process.env.GAI_MODEL; - process.env.GAI_API_BASE = "https://api.deepseek.com/v1"; - process.env.GAI_MODEL = "deepseek-v4-flash"; + process.env.GAI_API_BASE = "https://api.deepseek.com/v1"; + process.env.GAI_MODEL = "deepseek-v4-flash"; - const config = await loadConfig(); + const config = await loadConfig(); - expect(config.apiBase).toBe("https://api.deepseek.com/v1"); - expect(config.model).toBe("deepseek-v4-flash"); + expect(config.apiBase).toBe("https://api.deepseek.com/v1"); + expect(config.model).toBe("deepseek-v4-flash"); - if (origBase) process.env.GAI_API_BASE = origBase; - else delete process.env.GAI_API_BASE; - if (origModel) process.env.GAI_MODEL = origModel; - else delete process.env.GAI_MODEL; - }); + if (origBase) process.env.GAI_API_BASE = origBase; + else delete process.env.GAI_API_BASE; + if (origModel) process.env.GAI_MODEL = origModel; + else delete process.env.GAI_MODEL; + }); });