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
+185 -62
View File
@@ -2,14 +2,13 @@
# gai # 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) [![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) [![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/) [![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> </div>
@@ -17,15 +16,18 @@ Generate **Conventional Commits** messages and pull request descriptions using A
## Features ## Features
- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter - **🤖 AI Commit Messages** — generate Conventional Commits from staged diffs with project context
- **Context-aware commits** — reads project overview, staged diff, and recent commit history - **🔀 AI Pull Requests** — create GitHub, Gitea, or GitLab PRs with AI-generated title and body
- **Conventional Commits** — `feat(scope): description` format by default - **📖 Explain Changes** — `gai explain` explains diffs in plain language
- **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all" - **🔍 Code Review** — `gai review` provides thorough, constructive code review
- **Inline editing** — edit AI-generated messages right in the terminal with cursor movement - **📝 Changelog Generation** — `gai changelog` generates user-facing changelogs from commits
- **AI-generated PRs** — create GitHub, Gitea, or GitLab pull requests with generated title and body - **💡 Smart Suggestions** — `gai suggest` recommends branch names or commit types
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more - **✏️ Amend Commits** — `gai commit --amend` amends with AI-generated message
- **Review before commit** — confirm, edit, or abort the generated message - **⚡ Streaming Output** — AI responses stream token-by-token for instant feedback
- **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies - **📂 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 ## Quick Start
@@ -39,85 +41,196 @@ gai config
# Open interactive menu # Open interactive menu
gai gai
# Or directly generate a commit message # Generate a commit message
gai commit gai commit
# Or create an AI-generated pull request # Explain your changes
gai pr gai explain
``` ```
## Usage ## Usage
``` ```
gai Open interactive menu gai Open interactive menu
gai commit Generate commit message (interactive file selection) gai commit Generate AI commit message (interactive file selection)
gai commit --auto Auto-stage all changed files gai commit -a 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 commit --amend Amend last commit with AI-generated message
gai pr Create a PR with AI-generated title and body gai pr Create a PR with AI-generated title and body
gai pr --draft Create a draft PR gai pr --draft Create a draft PR
gai config Configure API settings gai explain Explain staged changes in plain language
gai --help Show help 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 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
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
``` ```
$ gai commit gai v0.1.3
AI-powered git helper for commits, PRs, reviews, and changelogs
────────────────────────────────────────────────────────────────────────
Staged files (will be included): CREATE
✓ src/git.ts (modified) 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: INSPECT
2 unstaged files available 4 Explain Explain staged changes in plain language
↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel 5 Review AI code review of staged changes
6 Changelog Generate changelog from commits
7 Suggest Suggest branch name or commit type
□ Select all PROJECT
□ src/ai.ts modified 8 Config Configure API settings
■ src/newfile.ts new
Generating commit message... ────────────────────────────────────────────────────────────────────────
↑/↓ navigate enter run 1-8 jump h help v version q quit
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 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 #### Commit
- 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)
@@ -126,6 +239,15 @@ gai pr --draft
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 |
@@ -193,13 +315,14 @@ 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 │ 4. Build prompt → Call AI API (streaming)
│ │ │ │
│ 5. Review → Confirm → Commit or Create PR │ │ 5. Review → Confirm → Commit or Create PR │
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
+251 -64
View File
@@ -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.2.0 // v0.1.3
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,9 +14,172 @@ 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 { 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> { async function showMenu(): Promise<number> {
if (!isStdinTTY()) { if (!isStdinTTY()) {
@@ -24,72 +187,96 @@ async function showMenu(): Promise<number> {
return 1; return 1;
} }
let cursor = 0;
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
// Initial render
renderMenu(cursor);
try {
while (true) { while (true) {
const selected = await selectOne({ const raw = await readKey();
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 (selected === null || selected === BACK) return 0; // Escape sequences (arrows)
if (raw === "\x1b[A" || raw === "\x1bOA") {
// Build synthetic args for subcommand handlers if (cursor > 0) { cursor--; renderMenu(cursor); }
const fakeArgs: ParsedArgs = { continue;
command: selected, }
flags: {}, if (raw === "\x1b[B" || raw === "\x1bOB") {
positional: [], if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
raw: [], continue;
subcommand: {
name: selected,
description: "",
handler: async () => 0,
},
};
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;
} }
// Return to menu unless they explicitly chose "back" // Enter
if (raw === "\r" || raw === "\n") {
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
if (result !== 0) return result; if (result !== 0) return result;
// Loop back to menu for another action 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;
}
}
} finally {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "gai", "name": "gai",
"version": "0.1.2", "version": "0.1.3",
"description": "AI-powered git commit message generator", "description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"bin": { "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 // Handle --version globally
if (result.flags["version"]) { if (result.flags["version"]) {
console.log("gai v0.2.0"); console.log("gai v0.1.3");
return 0; return 0;
} }
+1 -1
View File
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks = tty ? { const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+30 -15
View File
@@ -7,10 +7,11 @@ import {
getStagedDiff, getStagedDiff,
getRecentCommits, getRecentCommits,
stageFiles, stageFiles,
applyFileSelection,
commit, commit,
} from "../git"; } from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK } from "../menu"; import { BACK, SKIP_WAIT } 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";
@@ -50,6 +51,19 @@ 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`);
@@ -173,16 +187,22 @@ 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 (stagedFiles.length === 0 && !amend) { if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
if (autoMode && 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)); await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (unstagedFiles.length > 0) { } 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;
} }
} }
@@ -215,18 +235,13 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
return 0; return 0;
} }
if (unstagedFiles.length > 0) { if (autoMode && unstagedFiles.length > 0) {
if (autoMode) {
await stageFiles(unstagedFiles.map((f) => f.path)); await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else { } else {
const selected = await selectFiles(stagedFiles, unstagedFiles); const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return 0; if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) { printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
}
}
} }
const diff = await getStagedDiff(); const diff = await getStagedDiff();
+2 -1
View File
@@ -1,6 +1,7 @@
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";
@@ -323,7 +324,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" ? 0 : 0; return result === "back" ? (SKIP_WAIT as unknown as number) : 0;
} }
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`); console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
+18 -18
View File
@@ -1,7 +1,14 @@
import { loadConfig } from "../config"; 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 { selectFiles } from "../selector";
import { BACK } from "../menu"; import { BACK, SKIP_WAIT } 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";
@@ -19,7 +26,6 @@ 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
@@ -44,22 +50,16 @@ 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;
} }
diff = await getStagedDiff(); const stagedFiles = await getStagedFiles();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) { sourceLabel = "selected changes";
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0; if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
if (selected.length > 0) { const selected = await selectFiles(stagedFiles, unstagedFiles);
await stageFiles(selected); if (selected === BACK) return SKIP_WAIT as unknown as number;
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`); await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff(); 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 = tty ? { const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+3 -3
View File
@@ -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, selectOne } from "../menu"; import { BACK, SKIP_WAIT, 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 0; if (chosen === BACK) return SKIP_WAIT as unknown as number;
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 0; return SKIP_WAIT as unknown as number;
} }
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 -17
View File
@@ -1,7 +1,14 @@
import { loadConfig } from "../config"; 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 { selectFiles } from "../selector";
import { BACK } from "../menu"; import { BACK, SKIP_WAIT } 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";
@@ -53,23 +60,17 @@ 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;
} }
diff = await getStagedDiff(); const stagedFiles = await getStagedFiles();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) { sourceLabel = "selected changes";
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0; if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
if (selected.length > 0) { const selected = await selectFiles(stagedFiles, unstagedFiles);
await stageFiles(selected); if (selected === BACK) return SKIP_WAIT as unknown as number;
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`); await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff(); diff = await getStagedDiff();
} }
}
}
}
if (!diff) { if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`); console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
@@ -110,7 +111,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks = tty ? { const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+15 -15
View File
@@ -1,7 +1,13 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git"; import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK } from "../menu"; import { BACK, SKIP_WAIT } from "../menu";
import { import {
SUGGEST_SYSTEM_PROMPT, SUGGEST_SYSTEM_PROMPT,
buildSuggestBranchPrompt, buildSuggestBranchPrompt,
@@ -51,23 +57,17 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
diff = ""; diff = "";
} }
} else { } else {
diff = await getStagedDiff(); const stagedFiles = await getStagedFiles();
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles); if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
if (selected === BACK) return 0; const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected.length > 0) { if (selected === BACK) return SKIP_WAIT as unknown as number;
await stageFiles(selected); await applyFileSelection(stagedFiles, unstagedFiles, selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`); }
diff = await getStagedDiff(); diff = await getStagedDiff();
} }
} }
}
}
}
if (!diff) { if (!diff) {
console.log(` ${DIM()}No changes to suggest from.${RESET()}`); console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
+27
View File
@@ -98,6 +98,33 @@ 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 }> {
+5
View File
@@ -15,6 +15,10 @@ 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;
@@ -221,6 +225,7 @@ 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]!;
+29 -19
View File
@@ -1,44 +1,54 @@
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> {
if (unstagedFiles.length === 0) return []; const files = mergeFiles(stagedFiles, unstagedFiles);
if (files.length === 0) return [];
if (stagedFiles.length > 0) { if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
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 to stage", title: "Select files for this action",
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`, subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
selectAllLabel: "Select all", selectAllLabel: "Select all",
cancelMessage: "Aborted.", cancelMessage: "Aborted.",
items: unstagedFiles.map((f) => ({ items: files.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;
} }