4 Commits

Author SHA1 Message Date
Mplan 586487d897 feat: overhaul CLI with new AI commands and mole-style menu (#6)
Build / bun-build (push) Successful in 1m20s
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: #6
2026-06-17 00:17:31 +08:00
Mplan 0d9c31ae3b Revert "feat(ui): redesign menu and add explain, review, changelog, suggest commands"
Build / bun-build (push) Has been cancelled
This reverts commit 1e370be8af.
2026-06-17 00:14:35 +08:00
Mplan 1e370be8af feat(ui): redesign menu and add explain, review, changelog, suggest commands
Build / bun-build (push) Has been cancelled
2026-06-17 00:11:58 +08:00
Mplan 55db09c973 feat(config): add interactive config editor and GitLab PR support (#4)
Build / bun-build (push) Successful in 32s
Revamp the configuration UI with an interactive editor that supports inline text editing, navigation, and field validation, replacing the previous sequential prompts. Add GitLab pull request creation support via the `glab` CLI, and extend back navigation to all interactive menus for a consistent user experience.

Reviewed-on: #4
2026-06-12 09:00:28 +08:00
16 changed files with 647 additions and 256 deletions
+190 -67
View File
@@ -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.
</div>
@@ -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 <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
```
$ 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 │
└─────────────────────────────────────────────┘
+252 -65
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
// gai — AI-powered git commit and PR helper
// v0.2.0
// v0.1.3
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
import { handleCommit } from "./src/commands/commit";
@@ -14,9 +14,172 @@ 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 { BACK, selectOne } from "./src/menu";
import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (default, no subcommand) ─────────────────────────
// ── Interactive Menu (mole-style) ─────────────────────────────────────
interface MenuItem {
key: string;
label: string;
description: string;
group: "Create" | "Inspect" | "Project";
}
const MENU_ITEMS: MenuItem[] = [
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
];
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> {
if (!isStdinTTY()) {
@@ -24,72 +187,96 @@ async function showMenu(): Promise<number> {
return 1;
}
while (true) {
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" },
],
});
let cursor = 0;
const wasRaw = process.stdin.isRaw;
if (selected === null || selected === BACK) return 0;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
// Build synthetic args for subcommand handlers
const fakeArgs: ParsedArgs = {
command: selected,
flags: {},
positional: [],
raw: [],
subcommand: {
name: selected,
description: "",
handler: async () => 0,
},
};
// Initial render
renderMenu(cursor);
let result: number;
switch (selected) {
case "commit":
result = await handleCommit(fakeArgs);
break;
case "pr":
result = await handlePR(fakeArgs);
break;
case "config":
result = await handleConfig(fakeArgs);
break;
case "explain":
result = await handleExplain(fakeArgs);
break;
case "review":
result = await handleReview(fakeArgs);
break;
case "changelog":
result = await handleChangelog(fakeArgs);
break;
case "suggest":
result = await handleSuggest(fakeArgs);
break;
case "amend":
fakeArgs.flags["amend"] = true;
result = await handleCommit(fakeArgs);
break;
default:
result = 0;
try {
while (true) {
const raw = await readKey();
// Escape sequences (arrows)
if (raw === "\x1b[A" || raw === "\x1bOA") {
if (cursor > 0) { cursor--; renderMenu(cursor); }
continue;
}
if (raw === "\x1b[B" || raw === "\x1bOB") {
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
continue;
}
// Enter
if (raw === "\r" || raw === "\n") {
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
if (result !== 0) return 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;
}
// 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;
}
}
// Return to menu unless they explicitly chose "back"
if (result !== 0) return result;
// Loop back to menu for another action
} finally {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
}
}
+2 -2
View File
@@ -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": {
+22
View File
@@ -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");
}
+1 -1
View File
@@ -282,7 +282,7 @@ export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef
// Handle --version globally
if (result.flags["version"]) {
console.log("gai v0.2.0");
console.log("gai v0.1.3");
return 0;
}
+1 -1
View File
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
}
try {
const callbacks: StreamCallbacks = tty ? {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
+36 -21
View File
@@ -7,10 +7,11 @@ import {
getStagedDiff,
getRecentCommits,
stageFiles,
applyFileSelection,
commit,
} from "../git";
import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
import { generateCommitMessage } from "../ai";
@@ -50,6 +51,19 @@ function printCommitResult(result: CommitResult, msg: string) {
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`);
@@ -173,16 +187,22 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && !amend) {
if (autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (unstagedFiles.length > 0) {
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;
} else {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
return 1;
}
}
@@ -215,18 +235,13 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
return 0;
}
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 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
}
}
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();
+2 -1
View File
@@ -1,6 +1,7 @@
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";
@@ -323,7 +324,7 @@ export async function handleConfig(args: ParsedArgs): Promise<number> {
// gai config (no args) → interactive
if (positional.length === 0) {
const result = await interactiveConfig();
return result === "back" ? 0 : 0;
return result === "back" ? (SKIP_WAIT as unknown as number) : 0;
}
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
+18 -18
View File
@@ -1,7 +1,14 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai";
@@ -19,7 +26,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
}
const unstaged = args.flags["unstaged"] as boolean;
const staged = args.flags["staged"] as boolean;
const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain
@@ -44,22 +50,16 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected 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 (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[] = [];
@@ -109,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
}
try {
const callbacks: StreamCallbacks = tty ? {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
+3 -3
View File
@@ -4,7 +4,7 @@ import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context";
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
import { generatePRMessage } from "../ai";
import { BACK, selectOne } from "../menu";
import { BACK, SKIP_WAIT, selectOne } from "../menu";
import {
getDefaultBranch,
getBranchName,
@@ -68,7 +68,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname);
if (chosen === BACK) return 0;
if (chosen === BACK) return SKIP_WAIT as unknown as number;
if (!chosen) {
console.log(" Aborted.");
return 0;
@@ -98,7 +98,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
items: [{ label: "Back", value: "back" as const }],
});
if (choice === null) process.exit(0);
return 0;
return SKIP_WAIT as unknown as number;
}
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
+18 -17
View File
@@ -1,7 +1,14 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai";
@@ -53,22 +60,16 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected 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 (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) {
@@ -110,7 +111,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
}
try {
const callbacks: StreamCallbacks = tty ? {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
+15 -15
View File
@@ -1,7 +1,13 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import {
SUGGEST_SYSTEM_PROMPT,
buildSuggestBranchPrompt,
@@ -51,21 +57,15 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
diff = "";
}
} else {
diff = await getStagedDiff();
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
// 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 (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();
}
}
+27
View File
@@ -98,6 +98,33 @@ export async function stageFiles(paths: string[]): Promise<void> {
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(
message: string,
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
+5
View File
@@ -15,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<T> {
label: string;
value: T;
@@ -221,6 +225,7 @@ export async function selectMany<T>(
if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected);
};
syncSelectAll();
const toggle = (index: number) => {
const item = items[index]!;
+29 -19
View File
@@ -1,44 +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<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(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
): Promise<string[] | PromptBack> {
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 (!isStdinTTY()) 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;
}
+26 -26
View File
@@ -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;
});
});