Compare commits
14 Commits
main
..
4b384a7581
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b384a7581 | |||
| 8b2babfa5d | |||
| e69b08ac01 | |||
| c0c3dfce7d | |||
| 8b21ab8d4a | |||
| 42e0fafaab | |||
| d0506381f5 | |||
| 14df49b110 | |||
| 962b76d20f | |||
| 12e71a0af7 | |||
| e1354e8651 | |||
| 7e662b25cc | |||
| 5bb2dc8e8a | |||
| 1dbfac7985 |
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
# gai
|
# gai
|
||||||
|
|
||||||
**AI-powered Git helper — commit messages, PRs, code review, changelogs, and more**
|
**AI-powered Git commit and pull request helper**
|
||||||
|
|
||||||
|
[](https://git.catpl.top/Mplan/gai/releases)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://bun.sh)
|
[](https://bun.sh)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
Generate **Conventional Commits** messages, pull request descriptions, code reviews, changelogs, and more — powered by AI with full project context.
|
Generate **Conventional Commits** messages and pull request descriptions using AI, based on your project context, code diff, branch changes, and commit history.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -16,18 +17,15 @@ Generate **Conventional Commits** messages, pull request descriptions, code revi
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **🤖 AI Commit Messages** — generate Conventional Commits from staged diffs with project context
|
- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter
|
||||||
- **🔀 AI Pull Requests** — create GitHub, Gitea, or GitLab PRs with AI-generated title and body
|
- **Context-aware commits** — reads project overview, staged diff, and recent commit history
|
||||||
- **📖 Explain Changes** — `gai explain` explains diffs in plain language
|
- **Conventional Commits** — `feat(scope): description` format by default
|
||||||
- **🔍 Code Review** — `gai review` provides thorough, constructive code review
|
- **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all"
|
||||||
- **📝 Changelog Generation** — `gai changelog` generates user-facing changelogs from commits
|
- **Inline editing** — edit AI-generated messages right in the terminal with cursor movement
|
||||||
- **💡 Smart Suggestions** — `gai suggest` recommends branch names or commit types
|
- **AI-generated PRs** — create GitHub, Gitea, or GitLab pull requests with generated title and body
|
||||||
- **✏️ Amend Commits** — `gai commit --amend` amends with AI-generated message
|
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more
|
||||||
- **⚡ Streaming Output** — AI responses stream token-by-token for instant feedback
|
- **Review before commit** — confirm, edit, or abort the generated message
|
||||||
- **📂 Interactive File Selection** — ↑/↓ navigate, space toggle, "Select all" support
|
- **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies
|
||||||
- **📋 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
|
## Quick Start
|
||||||
|
|
||||||
@@ -41,196 +39,85 @@ gai config
|
|||||||
# Open interactive menu
|
# Open interactive menu
|
||||||
gai
|
gai
|
||||||
|
|
||||||
# Generate a commit message
|
# Or directly generate a commit message
|
||||||
gai commit
|
gai commit
|
||||||
|
|
||||||
# Explain your changes
|
# Or create an AI-generated pull request
|
||||||
gai explain
|
gai pr
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
gai Open interactive menu
|
gai Open interactive menu
|
||||||
gai commit Generate AI commit message (interactive file selection)
|
gai commit Generate commit message (interactive file selection)
|
||||||
gai commit -a Auto-stage all changed files
|
gai commit --auto Auto-stage all changed files
|
||||||
gai commit -d Generate message without committing
|
gai commit -d Generate message without committing
|
||||||
gai commit -m "msg" Use custom message (skip AI)
|
gai pr Create a PR with AI-generated title and body
|
||||||
gai commit --amend Amend last commit with AI-generated message
|
gai pr --draft Create a draft PR
|
||||||
gai pr Create a PR with AI-generated title and body
|
gai config Configure API settings
|
||||||
gai pr --draft Create a draft PR
|
gai --help Show help
|
||||||
gai explain Explain staged changes in plain language
|
gai --version Show version
|
||||||
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 <key> Get a specific setting
|
|
||||||
gai config set <key> <v> Set a setting
|
|
||||||
gai help Show help
|
|
||||||
gai help <command> 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
|
### Interactive Menu
|
||||||
|
|
||||||
Run `gai` without arguments to open the mole-style interactive menu:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
gai v0.1.3
|
$ gai
|
||||||
AI-powered git helper for commits, PRs, reviews, and changelogs
|
|
||||||
────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
CREATE
|
gai
|
||||||
│ › 1 Commit Generate AI commit message
|
Choose a workflow
|
||||||
2 PR Create a PR with AI-generated title
|
↑/↓ navigate · enter/space select · ctrl+c cancel
|
||||||
3 Amend Amend last commit with AI message
|
|
||||||
|
|
||||||
INSPECT
|
❯ ● commit Generate AI commit message
|
||||||
4 Explain Explain staged changes in plain language
|
○ pr Create a PR with AI-generated title
|
||||||
5 Review AI code review of staged changes
|
○ config Configure API settings
|
||||||
6 Changelog Generate changelog from commits
|
|
||||||
7 Suggest Suggest branch name or commit type
|
|
||||||
|
|
||||||
PROJECT
|
|
||||||
8 Config Configure API settings
|
|
||||||
|
|
||||||
────────────────────────────────────────────────────────────────────────
|
|
||||||
↑/↓ navigate enter run 1-8 jump h help v version q quit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Number keys `1`–`8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu.
|
### Commit Flow
|
||||||
|
|
||||||
### Command Examples
|
```
|
||||||
|
$ gai commit
|
||||||
|
|
||||||
#### Commit
|
Staged files (will be included):
|
||||||
|
✓ src/git.ts (modified)
|
||||||
|
|
||||||
|
Select files to stage:
|
||||||
|
2 unstaged files available
|
||||||
|
↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel
|
||||||
|
|
||||||
|
❯ □ Select all
|
||||||
|
□ src/ai.ts modified
|
||||||
|
■ src/newfile.ts new
|
||||||
|
|
||||||
|
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(-)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pull Request Flow
|
||||||
|
|
||||||
|
`gai pr` detects the remote platform from `origin`:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
```bash
|
```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
|
gai pr
|
||||||
|
|
||||||
# Draft PR
|
|
||||||
gai pr --draft
|
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
|
## Configuration
|
||||||
|
|
||||||
### Via `gai config` (interactive)
|
### Via `gai config` (interactive)
|
||||||
@@ -239,15 +126,6 @@ gai suggest type
|
|||||||
gai config
|
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
|
### Via environment variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -315,14 +193,13 @@ GAI_MODEL=anthropic/claude-sonnet-4
|
|||||||
│ │
|
│ │
|
||||||
│ 2. Collect code changes │
|
│ 2. Collect code changes │
|
||||||
│ ├─ git diff --staged for commits │
|
│ ├─ git diff --staged for commits │
|
||||||
│ ├─ Branch diff for pull requests │
|
│ └─ branch diff for pull requests │
|
||||||
│ └─ Pipe support for external diffs │
|
|
||||||
│ │
|
│ │
|
||||||
│ 3. Collect commit history │
|
│ 3. Collect commit history │
|
||||||
│ ├─ git log --oneline -10 for commits │
|
│ ├─ git log --oneline -10 for commits │
|
||||||
│ └─ Branch commits for pull requests │
|
│ └─ branch commits for pull requests │
|
||||||
│ │
|
│ │
|
||||||
│ 4. Build prompt → Call AI API (streaming) │
|
│ 4. Build prompt → Call AI API │
|
||||||
│ │
|
│ │
|
||||||
│ 5. Review → Confirm → Commit or Create PR │
|
│ 5. Review → Confirm → Commit or Create PR │
|
||||||
└─────────────────────────────────────────────┘
|
└─────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
// gai — AI-powered git commit and PR helper
|
// gai — AI-powered git commit and PR helper
|
||||||
// v0.1.3
|
// v0.2.0
|
||||||
|
|
||||||
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
|
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
|
||||||
import { handleCommit } from "./src/commands/commit";
|
import { handleCommit } from "./src/commands/commit";
|
||||||
@@ -14,172 +14,9 @@ import { handleSuggest } from "./src/commands/suggest";
|
|||||||
import { setColorEnabled } from "./src/terminal";
|
import { setColorEnabled } from "./src/terminal";
|
||||||
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
|
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
|
||||||
import { isStdinTTY, initTTY } from "./src/tty";
|
import { isStdinTTY, initTTY } from "./src/tty";
|
||||||
import { VERSION } from "./src/brand";
|
import { BACK, selectOne } from "./src/menu";
|
||||||
import { SKIP_WAIT } from "./src/menu";
|
|
||||||
|
|
||||||
// ── Interactive Menu (mole-style) ─────────────────────────────────────
|
// ── Interactive Menu (default, no subcommand) ─────────────────────────
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
group: "Create" | "Inspect" | "Project";
|
|
||||||
}
|
|
||||||
|
|
||||||
const MENU_ITEMS: MenuItem[] = [
|
|
||||||
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
|
|
||||||
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
|
|
||||||
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
|
|
||||||
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
|
|
||||||
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
|
|
||||||
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
|
|
||||||
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
|
|
||||||
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
|
||||||
function showCursor() { process.stdout.write("\x1b[?25h"); }
|
|
||||||
|
|
||||||
function clearLine() { process.stdout.write("\r\x1b[2K"); }
|
|
||||||
|
|
||||||
async function readKey(): Promise<string> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const onData = (data: Buffer) => {
|
|
||||||
process.stdin.removeListener("data", onData);
|
|
||||||
resolve(data.toString());
|
|
||||||
};
|
|
||||||
process.stdin.once("data", onData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleLen(s: string): number {
|
|
||||||
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function padRight(value: string, width: number): string {
|
|
||||||
return value + " ".repeat(Math.max(0, width - visibleLen(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMenu(cursor: number): number {
|
|
||||||
process.stdout.write("\x1b[H"); // cursor home
|
|
||||||
|
|
||||||
let lineCount = 0;
|
|
||||||
const C = CYAN();
|
|
||||||
const D = DIM();
|
|
||||||
const G = GREEN();
|
|
||||||
const R = RESET();
|
|
||||||
const width = 72;
|
|
||||||
const separator = `${D}${"─".repeat(width)}${R}`;
|
|
||||||
|
|
||||||
const write = (line = "") => {
|
|
||||||
clearLine();
|
|
||||||
process.stdout.write(`${line}\n`);
|
|
||||||
lineCount++;
|
|
||||||
};
|
|
||||||
|
|
||||||
write("");
|
|
||||||
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
|
|
||||||
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
|
|
||||||
write(` ${separator}`);
|
|
||||||
write("");
|
|
||||||
|
|
||||||
const keyWidth = 3;
|
|
||||||
const labelWidth = Math.max(...MENU_ITEMS.map((m) => 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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
write("");
|
|
||||||
write(` ${separator}`);
|
|
||||||
write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
|
|
||||||
write("");
|
|
||||||
|
|
||||||
// Clear rest of screen
|
|
||||||
process.stdout.write("\x1b[J");
|
|
||||||
|
|
||||||
return lineCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchMenuAction(key: string): Promise<number> {
|
|
||||||
const fakeArgs: ParsedArgs = {
|
|
||||||
command: key,
|
|
||||||
flags: {},
|
|
||||||
positional: [],
|
|
||||||
raw: [],
|
|
||||||
subcommand: { name: key, description: "", handler: async () => 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (key === "amend") fakeArgs.flags["amend"] = true;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case "commit": return handleCommit(fakeArgs);
|
|
||||||
case "pr": return handlePR(fakeArgs);
|
|
||||||
case "config": return handleConfig(fakeArgs);
|
|
||||||
case "explain": return handleExplain(fakeArgs);
|
|
||||||
case "review": return handleReview(fakeArgs);
|
|
||||||
case "changelog": return handleChangelog(fakeArgs);
|
|
||||||
case "suggest": return handleSuggest(fakeArgs);
|
|
||||||
case "amend": return handleCommit(fakeArgs);
|
|
||||||
default: return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForEnter(): Promise<void> {
|
|
||||||
const D = DIM();
|
|
||||||
const R = RESET();
|
|
||||||
process.stdout.write(`\n ${D}Press Enter to return to menu...${R}`);
|
|
||||||
// Read a line from stdin (works in cooked mode — blocks until Enter)
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const onData = (data: Buffer) => {
|
|
||||||
process.stdin.removeListener("data", onData);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
process.stdin.on("data", onData);
|
|
||||||
// Resume stdin in case it was paused
|
|
||||||
process.stdin.resume();
|
|
||||||
});
|
|
||||||
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
|
|
||||||
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<number> {
|
async function showMenu(): Promise<number> {
|
||||||
if (!isStdinTTY()) {
|
if (!isStdinTTY()) {
|
||||||
@@ -187,96 +24,72 @@ async function showMenu(): Promise<number> {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cursor = 0;
|
while (true) {
|
||||||
const wasRaw = process.stdin.isRaw;
|
const selected = await selectOne({
|
||||||
|
title: "gai",
|
||||||
|
subtitle: "AI-powered git helper — choose a workflow",
|
||||||
|
allowBack: false,
|
||||||
|
items: [
|
||||||
|
{ label: "commit", value: "commit", description: "Generate AI commit message" },
|
||||||
|
{ label: "pr", value: "pr", description: "Create a PR with AI-generated title" },
|
||||||
|
{ label: "explain", value: "explain", description: "Explain staged changes in plain language" },
|
||||||
|
{ label: "review", value: "review", description: "AI code review of staged changes" },
|
||||||
|
{ label: "changelog", value: "changelog", description: "Generate changelog from commits" },
|
||||||
|
{ label: "suggest", value: "suggest", description: "Suggest branch name or commit type" },
|
||||||
|
{ label: "amend", value: "amend", description: "Amend last commit with AI message" },
|
||||||
|
{ label: "config", value: "config", description: "Configure API settings" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
if (selected === null || selected === BACK) return 0;
|
||||||
process.stdin.resume();
|
|
||||||
hideCursor();
|
|
||||||
|
|
||||||
// Initial render
|
// Build synthetic args for subcommand handlers
|
||||||
renderMenu(cursor);
|
const fakeArgs: ParsedArgs = {
|
||||||
|
command: selected,
|
||||||
|
flags: {},
|
||||||
|
positional: [],
|
||||||
|
raw: [],
|
||||||
|
subcommand: {
|
||||||
|
name: selected,
|
||||||
|
description: "",
|
||||||
|
handler: async () => 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
let result: number;
|
||||||
while (true) {
|
switch (selected) {
|
||||||
const raw = await readKey();
|
case "commit":
|
||||||
|
result = await handleCommit(fakeArgs);
|
||||||
// Escape sequences (arrows)
|
break;
|
||||||
if (raw === "\x1b[A" || raw === "\x1bOA") {
|
case "pr":
|
||||||
if (cursor > 0) { cursor--; renderMenu(cursor); }
|
result = await handlePR(fakeArgs);
|
||||||
continue;
|
break;
|
||||||
}
|
case "config":
|
||||||
if (raw === "\x1b[B" || raw === "\x1bOB") {
|
result = await handleConfig(fakeArgs);
|
||||||
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
|
break;
|
||||||
continue;
|
case "explain":
|
||||||
}
|
result = await handleExplain(fakeArgs);
|
||||||
|
break;
|
||||||
// Enter
|
case "review":
|
||||||
if (raw === "\r" || raw === "\n") {
|
result = await handleReview(fakeArgs);
|
||||||
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
|
break;
|
||||||
if (result !== 0) return result;
|
case "changelog":
|
||||||
hideCursor();
|
result = await handleChangelog(fakeArgs);
|
||||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
break;
|
||||||
process.stdin.resume();
|
case "suggest":
|
||||||
renderMenu(cursor);
|
result = await handleSuggest(fakeArgs);
|
||||||
continue;
|
break;
|
||||||
}
|
case "amend":
|
||||||
|
fakeArgs.flags["amend"] = true;
|
||||||
// Ctrl+C
|
result = await handleCommit(fakeArgs);
|
||||||
if (raw === "\x03") {
|
break;
|
||||||
showCursor();
|
default:
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
result = 0;
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number hotkeys (1-8)
|
|
||||||
if (raw >= "1" && raw <= "8") {
|
|
||||||
const idx = parseInt(raw) - 1;
|
|
||||||
if (idx < MENU_ITEMS.length) {
|
|
||||||
cursor = idx;
|
|
||||||
renderMenu(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Letter hotkeys
|
|
||||||
const lower = raw.toLowerCase();
|
|
||||||
if (lower === "h") {
|
|
||||||
showCursor();
|
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\x1b[2J\x1b[H");
|
|
||||||
console.log(formatHelp(commands));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (lower === "v") {
|
|
||||||
showCursor();
|
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\x1b[2J\x1b[H");
|
|
||||||
console.log(`gai v${VERSION}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (lower === "q") {
|
|
||||||
showCursor();
|
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
showCursor();
|
// Return to menu unless they explicitly chose "back"
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
if (result !== 0) return result;
|
||||||
process.stdin.pause();
|
// Loop back to menu for another action
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gai",
|
"name": "gai",
|
||||||
"version": "0.1.3",
|
"version": "0.1.2",
|
||||||
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
|
"description": "AI-powered git commit message generator",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
// 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");
|
|
||||||
}
|
|
||||||
+1
-1
@@ -282,7 +282,7 @@ export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef
|
|||||||
|
|
||||||
// Handle --version globally
|
// Handle --version globally
|
||||||
if (result.flags["version"]) {
|
if (result.flags["version"]) {
|
||||||
console.log("gai v0.1.3");
|
console.log("gai v0.2.0");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbacks: StreamCallbacks | undefined = tty ? {
|
const callbacks: StreamCallbacks = tty ? {
|
||||||
onToken: (token) => process.stdout.write(token),
|
onToken: (token) => process.stdout.write(token),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
|||||||
+21
-36
@@ -7,11 +7,10 @@ import {
|
|||||||
getStagedDiff,
|
getStagedDiff,
|
||||||
getRecentCommits,
|
getRecentCommits,
|
||||||
stageFiles,
|
stageFiles,
|
||||||
applyFileSelection,
|
|
||||||
commit,
|
commit,
|
||||||
} from "../git";
|
} from "../git";
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK, SKIP_WAIT } from "../menu";
|
import { BACK } from "../menu";
|
||||||
import { collectProjectContext } from "../context";
|
import { collectProjectContext } from "../context";
|
||||||
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
|
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
|
||||||
import { generateCommitMessage } from "../ai";
|
import { generateCommitMessage } from "../ai";
|
||||||
@@ -51,19 +50,6 @@ function printCommitResult(result: CommitResult, msg: string) {
|
|||||||
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
|
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"> {
|
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
|
||||||
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
||||||
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
||||||
@@ -187,22 +173,16 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
|
|||||||
const stagedFiles = await getStagedFiles();
|
const stagedFiles = await getStagedFiles();
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
|
||||||
if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
if (stagedFiles.length === 0 && !amend) {
|
||||||
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
|
if (autoMode && unstagedFiles.length > 0) {
|
||||||
return 1;
|
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||||
}
|
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||||
|
} else if (unstagedFiles.length > 0) {
|
||||||
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`);
|
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
|
} else {
|
||||||
|
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +215,18 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoMode && unstagedFiles.length > 0) {
|
if (unstagedFiles.length > 0) {
|
||||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
if (autoMode) {
|
||||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||||
} else {
|
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
} else {
|
||||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
|
if (selected === BACK) return 0;
|
||||||
|
if (selected.length > 0) {
|
||||||
|
await stageFiles(selected);
|
||||||
|
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = await getStagedDiff();
|
const diff = await getStagedDiff();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { loadConfig, saveConfig } from "../config";
|
import { loadConfig, saveConfig } from "../config";
|
||||||
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
||||||
import { isStdinTTY } from "../tty";
|
import { isStdinTTY } from "../tty";
|
||||||
import { SKIP_WAIT } from "../menu";
|
|
||||||
import type { Config } from "../types";
|
import type { Config } from "../types";
|
||||||
import type { ParsedArgs } from "../cli";
|
import type { ParsedArgs } from "../cli";
|
||||||
|
|
||||||
@@ -324,7 +323,7 @@ export async function handleConfig(args: ParsedArgs): Promise<number> {
|
|||||||
// gai config (no args) → interactive
|
// gai config (no args) → interactive
|
||||||
if (positional.length === 0) {
|
if (positional.length === 0) {
|
||||||
const result = await interactiveConfig();
|
const result = await interactiveConfig();
|
||||||
return result === "back" ? (SKIP_WAIT as unknown as number) : 0;
|
return result === "back" ? 0 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
|
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
|
||||||
|
|||||||
+19
-19
@@ -1,14 +1,7 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import {
|
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
|
||||||
isGitRepo,
|
|
||||||
getStagedFiles,
|
|
||||||
getStagedDiff,
|
|
||||||
getUnstagedFiles,
|
|
||||||
getRepoRoot,
|
|
||||||
applyFileSelection,
|
|
||||||
} from "../git";
|
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK, SKIP_WAIT } from "../menu";
|
import { BACK } from "../menu";
|
||||||
import { collectProjectContext } from "../context";
|
import { collectProjectContext } from "../context";
|
||||||
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
|
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
|
||||||
import { callAI } from "../ai";
|
import { callAI } from "../ai";
|
||||||
@@ -26,6 +19,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unstaged = args.flags["unstaged"] as boolean;
|
const unstaged = args.flags["unstaged"] as boolean;
|
||||||
|
const staged = args.flags["staged"] as boolean;
|
||||||
const verbose = args.flags["verbose"] as boolean;
|
const verbose = args.flags["verbose"] as boolean;
|
||||||
|
|
||||||
// Determine which diff to explain
|
// Determine which diff to explain
|
||||||
@@ -50,16 +44,22 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||||
return 1;
|
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();
|
diff = await getStagedDiff();
|
||||||
|
sourceLabel = "staged changes";
|
||||||
|
|
||||||
|
// If no staged changes, offer to stage unstaged files
|
||||||
|
if (!diff) {
|
||||||
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
if (unstagedFiles.length > 0) {
|
||||||
|
const selected = await selectFiles([], unstagedFiles);
|
||||||
|
if (selected === BACK) return 0;
|
||||||
|
if (selected.length > 0) {
|
||||||
|
await stageFiles(selected);
|
||||||
|
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
||||||
|
diff = await getStagedDiff();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Read from pipe
|
// Read from pipe
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -109,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbacks: StreamCallbacks | undefined = tty ? {
|
const callbacks: StreamCallbacks = tty ? {
|
||||||
onToken: (token) => process.stdout.write(token),
|
onToken: (token) => process.stdout.write(token),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@ import { isGitRepo, getRepoRoot } from "../git";
|
|||||||
import { collectProjectContext } from "../context";
|
import { collectProjectContext } from "../context";
|
||||||
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
|
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
|
||||||
import { generatePRMessage } from "../ai";
|
import { generatePRMessage } from "../ai";
|
||||||
import { BACK, SKIP_WAIT, selectOne } from "../menu";
|
import { BACK, selectOne } from "../menu";
|
||||||
import {
|
import {
|
||||||
getDefaultBranch,
|
getDefaultBranch,
|
||||||
getBranchName,
|
getBranchName,
|
||||||
@@ -68,7 +68,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
|
|||||||
if (!platform) {
|
if (!platform) {
|
||||||
const hostname = (await getRemoteHostname()) || "unknown";
|
const hostname = (await getRemoteHostname()) || "unknown";
|
||||||
const chosen = await selectPlatform(hostname);
|
const chosen = await selectPlatform(hostname);
|
||||||
if (chosen === BACK) return SKIP_WAIT as unknown as number;
|
if (chosen === BACK) return 0;
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
console.log(" Aborted.");
|
console.log(" Aborted.");
|
||||||
return 0;
|
return 0;
|
||||||
@@ -98,7 +98,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
|
|||||||
items: [{ label: "Back", value: "back" as const }],
|
items: [{ label: "Back", value: "back" as const }],
|
||||||
});
|
});
|
||||||
if (choice === null) process.exit(0);
|
if (choice === null) process.exit(0);
|
||||||
return SKIP_WAIT as unknown as number;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
|
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
|
||||||
|
|||||||
+18
-19
@@ -1,14 +1,7 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import {
|
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
|
||||||
isGitRepo,
|
|
||||||
getStagedFiles,
|
|
||||||
getStagedDiff,
|
|
||||||
getUnstagedFiles,
|
|
||||||
getRepoRoot,
|
|
||||||
applyFileSelection,
|
|
||||||
} from "../git";
|
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK, SKIP_WAIT } from "../menu";
|
import { BACK } from "../menu";
|
||||||
import { collectProjectContext } from "../context";
|
import { collectProjectContext } from "../context";
|
||||||
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
|
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
|
||||||
import { callAI } from "../ai";
|
import { callAI } from "../ai";
|
||||||
@@ -60,16 +53,22 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
|
|||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||||
return 1;
|
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();
|
diff = await getStagedDiff();
|
||||||
|
sourceLabel = "staged changes";
|
||||||
|
|
||||||
|
// If no staged changes, offer to stage unstaged files
|
||||||
|
if (!diff) {
|
||||||
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
if (unstagedFiles.length > 0) {
|
||||||
|
const selected = await selectFiles([], unstagedFiles);
|
||||||
|
if (selected === BACK) return 0;
|
||||||
|
if (selected.length > 0) {
|
||||||
|
await stageFiles(selected);
|
||||||
|
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
||||||
|
diff = await getStagedDiff();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!diff) {
|
if (!diff) {
|
||||||
@@ -111,7 +110,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbacks: StreamCallbacks | undefined = tty ? {
|
const callbacks: StreamCallbacks = tty ? {
|
||||||
onToken: (token) => process.stdout.write(token),
|
onToken: (token) => process.stdout.write(token),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -1,13 +1,7 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import {
|
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
|
||||||
isGitRepo,
|
|
||||||
getStagedFiles,
|
|
||||||
getStagedDiff,
|
|
||||||
getUnstagedFiles,
|
|
||||||
applyFileSelection,
|
|
||||||
} from "../git";
|
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK, SKIP_WAIT } from "../menu";
|
import { BACK } from "../menu";
|
||||||
import {
|
import {
|
||||||
SUGGEST_SYSTEM_PROMPT,
|
SUGGEST_SYSTEM_PROMPT,
|
||||||
buildSuggestBranchPrompt,
|
buildSuggestBranchPrompt,
|
||||||
@@ -57,15 +51,21 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
|||||||
diff = "";
|
diff = "";
|
||||||
}
|
}
|
||||||
} else {
|
} 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();
|
diff = await getStagedDiff();
|
||||||
|
|
||||||
|
// If no staged changes, offer to stage unstaged files
|
||||||
|
if (!diff) {
|
||||||
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
if (unstagedFiles.length > 0) {
|
||||||
|
const selected = await selectFiles([], unstagedFiles);
|
||||||
|
if (selected === BACK) return 0;
|
||||||
|
if (selected.length > 0) {
|
||||||
|
await stageFiles(selected);
|
||||||
|
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
||||||
|
diff = await getStagedDiff();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-27
@@ -98,33 +98,6 @@ export async function stageFiles(paths: string[]): Promise<void> {
|
|||||||
await Bun.$`git add -- ${paths}`;
|
await Bun.$`git add -- ${paths}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unstageFiles(paths: string[]): Promise<void> {
|
|
||||||
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(
|
export async function commit(
|
||||||
message: string,
|
message: string,
|
||||||
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
|
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ const BACKSPACE = "\x7f";
|
|||||||
export const BACK = Symbol("prompt-back");
|
export const BACK = Symbol("prompt-back");
|
||||||
export type PromptBack = typeof 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<T> {
|
export interface Choice<T> {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
@@ -225,7 +221,6 @@ export async function selectMany<T>(
|
|||||||
if (!options.selectAllLabel) return;
|
if (!options.selectAllLabel) return;
|
||||||
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
||||||
};
|
};
|
||||||
syncSelectAll();
|
|
||||||
|
|
||||||
const toggle = (index: number) => {
|
const toggle = (index: number) => {
|
||||||
const item = items[index]!;
|
const item = items[index]!;
|
||||||
|
|||||||
+19
-29
@@ -1,54 +1,44 @@
|
|||||||
import type { FileEntry } from "./types";
|
import type { FileEntry } from "./types";
|
||||||
|
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
|
||||||
import { isStdinTTY } from "./tty";
|
import { isStdinTTY } from "./tty";
|
||||||
import { BACK, selectMany } from "./menu";
|
import { BACK, selectMany } from "./menu";
|
||||||
import type { PromptBack } from "./menu";
|
import type { PromptBack } from "./menu";
|
||||||
|
|
||||||
function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) {
|
|
||||||
const files = new Map<string, FileEntry & { staged: boolean; unstaged: boolean }>();
|
|
||||||
|
|
||||||
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(
|
export async function selectFiles(
|
||||||
stagedFiles: FileEntry[],
|
stagedFiles: FileEntry[],
|
||||||
unstagedFiles: FileEntry[],
|
unstagedFiles: FileEntry[],
|
||||||
): Promise<string[] | PromptBack> {
|
): Promise<string[] | PromptBack> {
|
||||||
const files = mergeFiles(stagedFiles, unstagedFiles);
|
if (unstagedFiles.length === 0) return [];
|
||||||
if (files.length === 0) return [];
|
|
||||||
|
|
||||||
if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
|
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 (!isStdinTTY()) return [];
|
||||||
|
|
||||||
const selected = await selectMany({
|
const selected = await selectMany({
|
||||||
title: "Select files for this action",
|
title: "Select files to stage",
|
||||||
subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
|
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
|
||||||
selectAllLabel: "Select all",
|
selectAllLabel: "Select all",
|
||||||
cancelMessage: "Aborted.",
|
cancelMessage: "Aborted.",
|
||||||
items: files.map((f) => ({
|
items: unstagedFiles.map((f) => ({
|
||||||
label: f.path,
|
label: f.path,
|
||||||
value: f.path,
|
value: f.path,
|
||||||
description: f.label,
|
description: f.label,
|
||||||
selected: f.staged,
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selected === null) process.exit(1);
|
if (selected === null) process.exit(1);
|
||||||
if (selected === BACK) return BACK;
|
if (selected === BACK) return BACK;
|
||||||
|
|
||||||
|
if (selected.length > 0) {
|
||||||
|
process.stdout.write(
|
||||||
|
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-26
@@ -2,39 +2,39 @@ import { test, expect, describe } from "bun:test";
|
|||||||
import { loadConfig } from "../src/config";
|
import { loadConfig } from "../src/config";
|
||||||
|
|
||||||
describe("config", () => {
|
describe("config", () => {
|
||||||
test("loadConfig env variables override config file and defaults", async () => {
|
test("loadConfig env variables override config file and defaults", async () => {
|
||||||
const origBase = process.env.GAI_API_BASE;
|
const origBase = process.env.GAI_API_BASE;
|
||||||
const origModel = process.env.GAI_MODEL;
|
const origModel = process.env.GAI_MODEL;
|
||||||
|
|
||||||
process.env.GAI_API_BASE = "https://custom.api.com/v1";
|
process.env.GAI_API_BASE = "https://custom.api.com/v1";
|
||||||
process.env.GAI_MODEL = "custom-model";
|
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.apiBase).toBe("https://custom.api.com/v1");
|
||||||
expect(config.model).toBe("custom-model");
|
expect(config.model).toBe("custom-model");
|
||||||
|
|
||||||
if (origBase) process.env.GAI_API_BASE = origBase;
|
if (origBase) process.env.GAI_API_BASE = origBase;
|
||||||
else delete process.env.GAI_API_BASE;
|
else delete process.env.GAI_API_BASE;
|
||||||
if (origModel) process.env.GAI_MODEL = origModel;
|
if (origModel) process.env.GAI_MODEL = origModel;
|
||||||
else delete process.env.GAI_MODEL;
|
else delete process.env.GAI_MODEL;
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loadConfig reads from environment variables", async () => {
|
test("loadConfig reads from environment variables", async () => {
|
||||||
const origBase = process.env.GAI_API_BASE;
|
const origBase = process.env.GAI_API_BASE;
|
||||||
const origModel = process.env.GAI_MODEL;
|
const origModel = process.env.GAI_MODEL;
|
||||||
|
|
||||||
process.env.GAI_API_BASE = "https://api.deepseek.com/v1";
|
process.env.GAI_API_BASE = "https://api.deepseek.com/v1";
|
||||||
process.env.GAI_MODEL = "deepseek-v4-flash";
|
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.apiBase).toBe("https://api.deepseek.com/v1");
|
||||||
expect(config.model).toBe("deepseek-v4-flash");
|
expect(config.model).toBe("deepseek-v4-flash");
|
||||||
|
|
||||||
if (origBase) process.env.GAI_API_BASE = origBase;
|
if (origBase) process.env.GAI_API_BASE = origBase;
|
||||||
else delete process.env.GAI_API_BASE;
|
else delete process.env.GAI_API_BASE;
|
||||||
if (origModel) process.env.GAI_MODEL = origModel;
|
if (origModel) process.env.GAI_MODEL = origModel;
|
||||||
else delete process.env.GAI_MODEL;
|
else delete process.env.GAI_MODEL;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user