13 Commits

Author SHA1 Message Date
Mplan 301f8f645d chore: bump version to 0.1.4 and clean up cli
Build / bun-build (push) Successful in 36s
- Set VERSION = '0.1.4' in brand.ts as single source of truth
- Import VERSION in cli.ts for --version output
- Remove dead code resolveFlagName from cli.ts
- Remove hardcoded version comment from index.ts

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:57:18 +08:00
Mplan 311d059e52 refactor: clean up main entry point
- Use shared terminal helpers (hideCursor, showCursor, clearLine,
  clearScreen, visibleLength, padRight) from terminal.ts
- Simplify allCommandDefs dedup with new Set()
- Centralize terminal state restore into exitMenu() closure + finally

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:57:01 +08:00
Mplan f3b5c631de refactor: use shared helpers in all command handlers
- commit.ts, pr.ts: use ask() and editLine() from tty-input module
- config.ts: use shared terminal helpers and escape buffering
- explain.ts, review.ts, suggest.ts: use collectDiff() from
  diff-source module, eliminating ~400 lines of duplicated logic

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:56:36 +08:00
Mplan 4fbac6a6e1 refactor: simplify createPR and improve type hierarchy
- Replace 3 nearly-identical createPR branches (70+ lines each)
  with a single function using PLATFORM_CLI lookup table
- Add BaseContext interface and have PRContext/ProjectContext
  extend it to eliminate field duplication

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:56:13 +08:00
Mplan d4ba4da9d5 refactor: improve menu with shared helpers and error boundaries
- Use shared terminal helpers (hideCursor, showCursor, clearLine,
  moveUp, visibleLength, padRight) from terminal.ts
- Fix normalizeKey to properly buffer SS3 escape sequences (\x1bO)
- Add try/catch+cleanup error boundaries in selectOne and selectMany
  to prevent terminal state leaks (raw mode, hidden cursor) on errors

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:55:39 +08:00
Mplan 10c7b9a688 feat: add shared diff-source helper (collectDiff)
Unify duplicated diff-collection logic from explain.ts, review.ts,
suggest.ts into a single collectDiff() that handles staged, unstaged,
and piped stdin sources, interactive file selection, diff truncation,
and project context collection.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:55:25 +08:00
Mplan 5959d78e6c feat: add shared tty-input module (ask, editLine)
Extract duplicated ask() from commit.ts and pr.ts, and extract
the 100+ line inline raw-mode editor editMessage() from commit.ts
into a reusable editLine() with proper escape sequence buffering.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:55:04 +08:00
Mplan a1151d7f76 refactor: add shared terminal rendering helpers to terminal.ts
Add hideCursor, showCursor, clearLine, moveUp, clearScreen,
visibleLength, padRight as centralized exports to eliminate
duplication across menu.ts, config.ts, and index.ts.

Also cache isColorEnabled result to avoid repeated env checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:54:37 +08:00
Mplan ff4e18ca8b fix: fix bugs in core modules
- tty: fix isStdoutTTY to use fstat first, fall back to TERM heuristic
- git: fix commit regex to handle root-commit output format
- git: fix parseNameStatus to handle edge cases (empty lines, missing tabs)
- ai: fix readStream to cancel reader instead of releaseLock
- cli: remove dead code resolveFlagName function
- clipboard: fix inconsistent indentation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:53:31 +08:00
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
26 changed files with 2998 additions and 1363 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 │
└─────────────────────────────────────────────┘
+381 -934
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "gai",
"version": "0.1.2",
"description": "AI-powered git commit message generator",
"version": "0.1.4",
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
"module": "index.ts",
"type": "module",
"bin": {
+83 -40
View File
@@ -1,29 +1,20 @@
import type { Config } from "./types";
import type { Config, StreamCallbacks } from "./types";
interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface ChatCompletionResponse {
choices?: Array<{
message?: {
content?: string | null;
};
finish_reason?: string;
}>;
error?: {
message?: string;
type?: string;
code?: string;
};
}
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
const RETRY_DELAY_MS = 1000;
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function cleanMessage(raw: string): string {
let msg = raw.trim();
// Strip code fences if the whole response is wrapped
if (msg.startsWith("```") && msg.endsWith("```")) {
const lines = msg.split("\n");
if (lines.length > 2) {
@@ -35,16 +26,14 @@ function cleanMessage(raw: string): string {
return msg;
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function callAI(
config: Config,
systemPrompt: string,
userPrompt: string,
callbacks?: StreamCallbacks,
): Promise<string> {
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
const stream = callbacks != null;
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
@@ -64,45 +53,45 @@ export async function callAI(
max_tokens: config.maxTokens,
temperature: config.temperature,
messages,
stream,
}),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 429 && attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY * attempt);
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
throw new Error(`API request failed (${response.status}): ${text}`);
}
const data = (await response.json()) as ChatCompletionResponse;
if (stream && response.body) {
return await readStream(response.body, callbacks!);
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string | null }; finish_reason?: string }>;
error?: { message?: string; type?: string; code?: string };
};
if (data.error) {
throw new Error(
`API error: ${data.error.message ?? JSON.stringify(data.error)}`,
);
throw new Error(`API error: ${data.error.message ?? JSON.stringify(data.error)}`);
}
const raw = data.choices?.[0]?.message?.content;
const finishReason = data.choices?.[0]?.finish_reason;
if (raw && raw.trim()) {
return raw;
}
if (raw && raw.trim()) return raw;
if (finishReason === "length") {
throw new Error(
"Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.",
);
throw new Error("Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.");
}
if (finishReason === "content_filter") {
throw new Error("Response blocked by content filter.");
}
if (attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY * attempt);
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
@@ -113,21 +102,75 @@ export async function callAI(
if (attempt >= MAX_RETRIES) throw err;
if (err instanceof Error && err.message.startsWith("API error")) throw err;
if (err instanceof Error && err.message.includes("max_tokens")) throw err;
if (err instanceof Error && err.message.includes("content filter"))
throw err;
await sleep(RETRY_DELAY * attempt);
if (err instanceof Error && err.message.includes("content filter")) throw err;
await sleep(RETRY_DELAY_MS * attempt);
}
}
throw new Error("Failed to generate response");
}
async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCallbacks): Promise<string> {
const reader = body.getReader();
const decoder = new TextDecoder();
let fullText = "";
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>;
error?: { message?: string };
};
if (parsed.error) {
callbacks.onError?.(new Error(`Stream error: ${parsed.error.message ?? "unknown"}`));
continue;
}
const token = parsed.choices?.[0]?.delta?.content;
if (token) {
fullText += token;
callbacks.onToken?.(token);
}
const finishReason = parsed.choices?.[0]?.finish_reason;
if (finishReason === "length") {
callbacks.onError?.(new Error("Response truncated (max_tokens too low)."));
}
} catch {
// Skip unparseable SSE lines
}
}
}
} finally {
try { await reader.cancel(); } catch {}
// releaseLock is not needed after cancel
}
callbacks.onDone?.(fullText);
return fullText;
}
export async function generateCommitMessage(
config: Config,
systemPrompt: string,
userPrompt: string,
callbacks?: StreamCallbacks,
): Promise<string> {
const raw = await callAI(config, systemPrompt, userPrompt);
const raw = await callAI(config, systemPrompt, userPrompt, callbacks);
return cleanMessage(raw);
}
@@ -135,8 +178,9 @@ export async function generatePRMessage(
config: Config,
systemPrompt: string,
userPrompt: string,
callbacks?: StreamCallbacks,
): Promise<{ title: string; body: string }> {
const raw = await callAI(config, systemPrompt, userPrompt);
const raw = await callAI(config, systemPrompt, userPrompt, callbacks);
const cleaned = cleanMessage(raw);
const lines = cleaned.split("\n");
@@ -148,6 +192,5 @@ export async function generatePRMessage(
}
const body = lines.slice(bodyStart).join("\n").trim();
return { title, body };
}
+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.4";
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");
}
+284
View File
@@ -0,0 +1,284 @@
// Lightweight CLI argument parser aligned with mainstream CLI conventions.
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
import { VERSION } from "./brand";
export interface FlagDef {
long: string; // e.g. "dry-run"
short?: string; // e.g. "d"
type: "boolean" | "string";
description: string;
default?: unknown; // boolean flags default to false
}
export interface CommandDef {
name: string;
aliases?: string[];
description: string;
usage?: string; // one-line usage, e.g. "gai commit [-a|--all] [-d|--dry-run]"
flags?: FlagDef[];
examples?: string[];
handler: (args: ParsedArgs) => Promise<number>; // returns exit code
}
export interface ParsedArgs {
command: string; // matched command name
flags: Record<string, unknown>; // resolved flags by long name
positional: string[]; // remaining positional args
raw: string[]; // original argv
subcommand: CommandDef;
}
// Global flags available to all commands
const GLOBAL_FLAGS: FlagDef[] = [
{ long: "help", short: "h", type: "boolean", description: "Show help for this command" },
{ long: "version", short: "V", type: "boolean", description: "Show version" },
{ long: "verbose", short: "v", type: "boolean", description: "Enable verbose output" },
{ long: "no-color", type: "boolean", description: "Disable colored output" },
];
function buildFlagIndex(flags: FlagDef[]): Map<string, FlagDef> {
const index = new Map<string, FlagDef>();
for (const f of flags) {
index.set("--" + f.long, f);
if (f.short) index.set("-" + f.short, f);
}
return index;
}
function parseArgs(
rawArgs: string[],
commands: Map<string, CommandDef>,
): ParsedArgs | { error: string } {
const args = [...rawArgs];
let cmdName = "";
// Find subcommand
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg.startsWith("-")) continue; // skip flags before subcommand
cmdName = arg;
args.splice(i, 1);
break;
}
// Resolve subcommand (including aliases)
let subcommand: CommandDef | undefined;
if (cmdName) {
subcommand = commands.get(cmdName);
if (!subcommand) {
// Try aliases
for (const [, cmd] of commands) {
if (cmd.aliases?.includes(cmdName)) {
subcommand = cmd;
cmdName = cmd.name;
break;
}
}
}
if (!subcommand) {
return { error: `Unknown command: ${cmdName}\nRun 'gai --help' for usage.` };
}
} else {
// Default: interactive menu
subcommand = commands.get("")!;
}
const flags = subcommand.flags ?? [];
const allFlags = [...GLOBAL_FLAGS, ...flags];
const flagIndex = buildFlagIndex(allFlags);
const resolved: Record<string, unknown> = {};
// Set defaults
for (const f of allFlags) {
if (f.type === "boolean") {
resolved[f.long] = f.default ?? false;
} else if (f.default !== undefined) {
resolved[f.long] = f.default;
}
}
const positional: string[] = [];
// Parse remaining args
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
// Handle --flag=value
if (arg.startsWith("--") && arg.includes("=")) {
const eqIdx = arg.indexOf("=");
const name = arg.slice(0, eqIdx);
const value = arg.slice(eqIdx + 1);
const flag = flagIndex.get(name);
if (!flag) return { error: `Unknown flag: ${name}` };
if (flag.type === "boolean") {
resolved[flag.long] = value === "true" || value === "1" || value === "";
} else {
resolved[flag.long] = value;
}
continue;
}
// Handle --flag or -f
if (arg.startsWith("-")) {
const flag = flagIndex.get(arg);
if (!flag) return { error: `Unknown flag: ${arg}` };
if (flag.type === "boolean") {
resolved[flag.long] = true;
} else {
// Consume next arg as value
i++;
if (i >= args.length || args[i]!.startsWith("-")) {
return { error: `Flag ${arg} requires a value.` };
}
resolved[flag.long] = args[i]!;
}
continue;
}
// Positional
positional.push(arg);
}
return {
command: cmdName || "",
flags: resolved,
positional,
raw: rawArgs,
subcommand,
};
}
export function registerCommands(...cmds: CommandDef[]): Map<string, CommandDef> {
const map = new Map<string, CommandDef>();
for (const cmd of cmds) {
map.set(cmd.name, cmd);
if (cmd.aliases) {
for (const alias of cmd.aliases) {
if (!map.has(alias)) {
map.set(alias, cmd);
}
}
}
}
return map;
}
export function formatHelp(commands: Map<string, CommandDef>, cmdName?: string): string {
if (cmdName) {
const cmd = commands.get(cmdName);
if (!cmd) return `Unknown command: ${cmdName}`;
const lines: string[] = [];
lines.push("");
lines.push(` gai ${cmdName}${cmd.description}`);
lines.push("");
if (cmd.usage) {
lines.push(` Usage: ${cmd.usage}`);
lines.push("");
}
const flags = cmd.flags ?? [];
if (flags.length > 0) {
lines.push(" Flags:");
for (const f of flags) {
const shorts = f.short ? `-${f.short}, ` : " ";
const typeHint = f.type === "string" ? " <value>" : "";
lines.push(` ${shorts}--${f.long}${typeHint}`);
lines.push(` ${f.description}`);
}
lines.push("");
}
lines.push(" Global flags:");
for (const f of GLOBAL_FLAGS) {
const shorts = f.short ? `-${f.short}, ` : " ";
const typeHint = f.type === "string" ? " <value>" : "";
lines.push(` ${shorts}--${f.long}${typeHint}`);
lines.push(` ${f.description}`);
}
lines.push("");
if (cmd.examples && cmd.examples.length > 0) {
lines.push(" Examples:");
for (const ex of cmd.examples) {
lines.push(` $ ${ex}`);
}
lines.push("");
}
return lines.join("\n");
}
// General help — deduplicate: show only canonical command names
const lines: string[] = [];
lines.push("");
lines.push(" gai — AI-powered git commit and PR helper");
lines.push("");
lines.push(" Usage: gai <command> [flags]");
lines.push("");
lines.push(" Commands:");
// Deduplicate: only show canonical names (not aliases)
const seen = new Set<string>();
let maxLen = 0;
const entries: { name: string; desc: string }[] = [];
for (const [name, cmd] of commands) {
if (!name) continue; // skip default
// Skip if this name is an alias of another command (i.e., not the canonical name)
if (cmd.name !== name) continue;
if (seen.has(name)) continue;
seen.add(name);
const aliases = cmd.aliases && cmd.aliases.length > 0 ? ` (${cmd.aliases.join(", ")})` : "";
const label = ` ${name}${aliases}`;
entries.push({ name: label, desc: cmd.description });
if (label.length > maxLen) maxLen = label.length;
}
maxLen += 4;
for (const entry of entries) {
const padding = " ".repeat(Math.max(2, maxLen - entry.name.length));
lines.push(`${entry.name}${padding}${entry.desc}`);
}
lines.push("");
lines.push(" Global flags:");
for (const f of GLOBAL_FLAGS) {
const shorts = f.short ? `-${f.short}, ` : " ";
lines.push(` ${shorts}--${f.long} ${f.description}`);
}
lines.push("");
lines.push(` Run 'gai help <command>' for command-specific help.`);
lines.push("");
return lines.join("\n");
}
export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef>): Promise<number> {
const result = parseArgs(rawArgs, commands);
if ("error" in result) {
console.error(`\n Error: ${result.error}\n`);
return 1;
}
// Handle --help globally
if (result.flags["help"]) {
console.log(formatHelp(commands, result.command || undefined));
return 0;
}
// Handle --version globally
if (result.flags["version"]) {
console.log(`gai v${VERSION}`);
return 0;
}
try {
return await result.subcommand.handler(result);
} catch (err) {
console.error(`\n Error: ${err instanceof Error ? err.message : err}\n`);
return 1;
}
}
+23 -21
View File
@@ -1,26 +1,28 @@
export async function copyToClipboard(text: string): Promise<boolean> {
const commands: string[][] = [];
const commands: string[][] = [];
if (process.platform === "darwin") {
commands.push(["pbcopy"]);
} else if (process.platform === "linux") {
commands.push(["xclip", "-selection", "clipboard"]);
commands.push(["xsel", "--clipboard", "--input"]);
}
if (process.platform === "darwin") {
commands.push(["pbcopy"]);
} else if (process.platform === "linux") {
commands.push(["xclip", "-selection", "clipboard"]);
commands.push(["xsel", "--clipboard", "--input"]);
}
for (const cmd of commands) {
try {
const proc = Bun.spawn(cmd, {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
});
proc.stdin.write(text);
proc.stdin.end();
const exitCode = await proc.exited;
if (exitCode === 0) return true;
} catch {}
}
for (const cmd of commands) {
try {
const proc = Bun.spawn(cmd, {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
});
proc.stdin.write(text);
proc.stdin.end();
const exitCode = await proc.exited;
if (exitCode === 0) return true;
} catch {
// Try next command
}
}
return false;
return false;
}
+81
View File
@@ -0,0 +1,81 @@
import { loadConfig } from "../config";
import { isGitRepo, getRecentCommits } from "../git";
import { CHANGELOG_SYSTEM_PROMPT, buildChangelogPrompt } from "../prompt";
import { callAI } from "../ai";
import { BOLD, RED, DIM, RESET, CYAN } from "../terminal";
import { isStdinTTY } from "../tty";
import type { StreamCallbacks } from "../types";
import type { ParsedArgs } from "../cli";
export async function handleChangelog(args: ParsedArgs): Promise<number> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1;
}
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
const from = args.flags["from"] as string | undefined;
const to = args.flags["to"] as string | undefined;
const countFlag = args.flags["count"] as number | undefined;
const verbose = args.flags["verbose"] as boolean;
// Collect commits
let commits: string[];
if (from) {
// Range-based: get commits between from..to
const range = to ? `${from}..${to}` : `${from}..HEAD`;
try {
const result = await Bun.$`git log --oneline ${range}`.quiet().text();
commits = result.trim().split("\n").filter(Boolean);
} catch {
console.error(`\n ${RED()}Error: Invalid range: ${range}${RESET()}\n`);
return 1;
}
} else {
// Count-based
const count = typeof countFlag === "number" ? countFlag : 20;
commits = await getRecentCommits(count);
}
if (commits.length === 0) {
console.log(` ${DIM()}No commits found for the specified range.${RESET()}`);
return 0;
}
if (verbose) {
console.log(` ${DIM()}Processing ${commits.length} commits${RESET()}`);
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
}
const userPrompt = buildChangelogPrompt(commits, from, to);
const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Generating changelog from ${commits.length} commits...${RESET()}\n`);
}
try {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const result = await callAI(config, CHANGELOG_SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) {
process.stdout.write("\n");
} else {
process.stdout.write(result + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
}
+332
View File
@@ -0,0 +1,332 @@
import {
isGitRepo,
getRepoRoot,
getStagedFiles,
getUnstagedFiles,
getStagedDiff,
getRecentCommits,
stageFiles,
applyFileSelection,
commit,
} from "../git";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
import { generateCommitMessage } from "../ai";
import { copyToClipboard } from "../clipboard";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
import { ask, editLine } from "../tty-input";
import type { Config, CommitResult, StreamCallbacks } from "../types";
import { loadConfig } from "../config";
import type { ParsedArgs } from "../cli";
function printCommitResult(result: CommitResult, msg: string) {
console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`);
const id = result.branch && result.hash
? `${YELLOW()}[${result.branch} ${result.hash}]${RESET()}`
: result.hash
? `${YELLOW()}${result.hash}${RESET()}`
: "";
console.log(` ${id} ${msg}`);
const parts: string[] = [];
if (result.files > 0)
parts.push(`${YELLOW()}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET()}`);
if (result.insertions > 0)
parts.push(`${GREEN()}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET()}`);
if (result.deletions > 0)
parts.push(`${RED()}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET()}`);
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
}
function printSelectionResult(result: { staged: string[]; unstaged: string[] }) {
const parts: string[] = [];
if (result.staged.length > 0) {
parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`);
}
if (result.unstaged.length > 0) {
parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`);
}
if (parts.length > 0) {
console.log(` Updated staging area: ${parts.join(", ")}`);
}
}
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}\n`);
const answer = await ask(` Use this message? [${GREEN()}Y${RESET()}/n/e] `);
const lower = answer.toLowerCase();
if (lower === "n") return "n";
if (lower === "e") return "e";
return "y";
}
async function editMessage(current: string): Promise<string | null> {
process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`);
return editLine(current);
}
export async function handleCommit(args: ParsedArgs): Promise<number> {
const autoMode = args.flags["all"] as boolean || args.flags["auto"] as boolean;
const dryRun = args.flags["dry-run"] as boolean;
const amend = args.flags["amend"] as boolean;
const customMessage = args.flags["message"] as string | undefined;
const verbose = args.flags["verbose"] as boolean;
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
// If a custom message is provided, skip AI and commit directly
if (customMessage) {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
return 1;
}
if (!amend && autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (!amend) {
if (isStdinTTY()) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
} else if (stagedFiles.length === 0 && unstagedFiles.length > 0) {
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
return 1;
}
}
const diff = await getStagedDiff();
if (!diff && !amend) {
console.log(` ${DIM()}Nothing to commit.${RESET()}`);
return 0;
}
try {
const result = await commit(customMessage);
printCommitResult(result, customMessage);
return 0;
} catch (err) {
console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
}
// Handle amend
if (amend) {
return handleAmendCommit(args);
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.log(` ${DIM()}Nothing to commit. No staged or unstaged changes.${RESET()}`);
return 0;
}
if (autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
}
const diff = await getStagedDiff();
if (!diff) {
console.log(` ${DIM()}Nothing to commit. No staged changes to commit.${RESET()}`);
return 0;
}
const config = await loadConfig();
if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1;
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const repoRoot = await getRepoRoot();
const projectCtx = await collectProjectContext(repoRoot);
const recentCommits = await getRecentCommits(10);
const userPrompt = buildPrompt({
readme: projectCtx.readme,
packageDescription: projectCtx.packageDescription,
structure: projectCtx.structure,
recentCommits,
diff: truncatedDiff,
});
if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
}
const tty = isStdinTTY();
if (tty) {
console.log("\n Generating commit message...");
}
let message: string;
try {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
if (callbacks) process.stdout.write(" ");
message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) process.stdout.write("\n");
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
if (dryRun) {
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}`);
await copyToClipboard(message);
console.log(`\n ${DIM()}(dry-run, message copied to clipboard)${RESET()}`);
return 0;
}
const action = await confirmCommit(message);
if (action === "y") {
try {
const result = await commit(message);
printCommitResult(result, message);
} catch (err) {
console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
} else if (action === "e") {
const edited = await editMessage(message);
if (edited) {
try {
const result = await commit(edited);
printCommitResult(result, edited);
} catch (err) {
console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
} else {
console.log(" Aborted.");
}
} else {
const copied = await copyToClipboard(message);
console.log(copied ? ` Aborted. Message copied to clipboard.` : ` Aborted. Message: ${message}`);
}
return 0;
}
async function handleAmendCommit(args: ParsedArgs): Promise<number> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set.${RESET()}\n`);
return 1;
}
// Get the diff of the last commit (what would be amended)
let diff: string;
try {
diff = await Bun.$`git diff HEAD~1..HEAD`.quiet().text();
diff = diff.trim();
} catch {
// If there's no previous commit (first commit), get staged diff
diff = await getStagedDiff();
}
if (!diff) {
// Try getting the commit message from HEAD
try {
const lastMsg = await Bun.$`git log -1 --format=%B`.quiet().text();
if (lastMsg.trim()) {
console.log(` Last commit message: ${DIM()}${lastMsg.trim()}${RESET()}`);
const newMsg = await editMessage(lastMsg.trim());
if (newMsg) {
await Bun.spawn(["git", "commit", "--amend", "-m", newMsg], { stdio: ["inherit", "inherit", "inherit"] });
}
return 0;
}
} catch {}
console.log(` ${DIM()}No changes to amend.${RESET()}`);
return 0;
}
const repoRoot = await getRepoRoot();
const projectCtx = await collectProjectContext(repoRoot);
const recentCommits = await getRecentCommits(10);
const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const userPrompt = buildPrompt({
readme: projectCtx.readme,
packageDescription: projectCtx.packageDescription,
structure: projectCtx.structure,
recentCommits,
diff: truncatedDiff,
});
console.log("\n Generating amended commit message...");
let message: string;
try {
message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt);
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
console.log(`\n ${BOLD()}Generated amended message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}\n`);
const answer = await ask(` Amend commit with this message? [${GREEN()}Y${RESET()}/n/e] `);
const lower = answer.toLowerCase();
if (lower === "n") {
console.log(" Aborted.");
return 0;
}
if (lower === "e") {
const edited = await editMessage(message);
if (!edited) { console.log(" Aborted."); return 0; }
message = edited;
}
try {
const proc = Bun.spawn(["git", "commit", "--amend", "-m", message], {
stdio: ["inherit", "inherit", "inherit"],
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`git commit --amend failed (exit code ${exitCode})`);
}
} catch (err) {
console.error(`\n ${RED()}Amend failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
}
+322
View File
@@ -0,0 +1,322 @@
import { loadConfig, saveConfig } from "../config";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength } from "../terminal";
import { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu";
import { editLine } from "../tty-input";
import type { Config } from "../types";
import type { ParsedArgs } from "../cli";
type ConfigKey = keyof Config;
interface ConfigField {
key: ConfigKey;
label: string;
format: (config: Config) => string;
initialEditValue: (config: Config) => string;
parse: (value: string) => { value: Config[ConfigKey] } | { error: string };
}
const CONFIG_FIELDS: ConfigField[] = [
{
key: "apiKey",
label: "API Key",
format: (config) =>
config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW()}(not set)${RESET()}`,
initialEditValue: () => "",
parse: (value) => ({ value }),
},
{
key: "apiBase",
label: "API Base",
format: (config) => config.apiBase,
initialEditValue: (config) => config.apiBase,
parse: (value) => ({ value }),
},
{
key: "model",
label: "Model",
format: (config) => config.model,
initialEditValue: (config) => config.model,
parse: (value) => ({ value }),
},
{
key: "maxTokens",
label: "Max Tokens",
format: (config) => String(config.maxTokens),
initialEditValue: (config) => String(config.maxTokens),
parse: (value) => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
return { error: "Max Tokens must be a positive integer." };
}
return { value: parsed };
},
},
{
key: "temperature",
label: "Temperature",
format: (config) => String(config.temperature),
initialEditValue: (config) => String(config.temperature),
parse: (value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return { error: "Temperature must be a finite number." };
}
return { value: parsed };
},
},
];
function renderConfigPage(
config: Config,
cursor: number,
previousLines: number,
status: string | null,
editState: { buffer: string; cursor: number } | null,
) {
if (previousLines > 0) {
for (let i = 0; i < previousLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(previousLines);
}
const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2;
const lines = [
"",
` ${BOLD()}Configuration${RESET()}`,
editState
? ` ${DIM()}editing · enter save · esc cancel · ctrl+c cancel${RESET()}`
: ` ${DIM()}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET()}`,
"",
];
let activeValueOffset = 0;
for (let i = 0; i < CONFIG_FIELDS.length; i++) {
const field = CONFIG_FIELDS[i]!;
const active = i === cursor;
const pointer = active ? `${CYAN()}${RESET()}` : " ";
const marker = active ? `${GREEN()}${RESET()}` : `${DIM()}${RESET()}`;
const label = active ? `${BOLD()}${field.label}${RESET()}` : field.label;
const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label)));
const value = active && editState ? editState.buffer : field.format(config);
if (active && editState) {
activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`);
}
lines.push(` ${pointer} ${marker} ${label}${padding}${value}`);
}
if (status) {
lines.push("", ` ${status}`);
}
for (const line of lines) {
process.stdout.write(`${line}\n`);
}
if (editState) {
moveUp(lines.length - (4 + cursor));
const column = activeValueOffset + editState.cursor;
process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`);
} else {
moveUp(lines.length);
}
return lines.length;
}
async function interactiveConfig(): Promise<"done" | "back"> {
if (!isStdinTTY()) {
console.error(`\n ${RED()}Error: Interactive config requires a TTY.${RESET()}\n`);
process.exit(1);
}
let config = await loadConfig();
let cursor = 0;
let renderedLines = 0;
let status: string | null = null;
let editState: { buffer: string; cursor: number } | null = null;
let renderedCursorRow = 0;
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
const render = () => {
moveUp(renderedCursorRow);
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
renderedCursorRow = editState ? 4 + cursor : 0;
editState ? showCursor() : hideCursor();
};
render();
return new Promise((resolve, reject) => {
const finish = (value: "done" | "back") => {
process.stdin.removeListener("data", onData);
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
moveUp(renderedCursorRow);
for (let i = 0; i < renderedLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(renderedLines);
showCursor();
resolve(value);
};
const saveEdit = async () => {
if (!editState) return;
const field = CONFIG_FIELDS[cursor]!;
const value = editState.buffer.trim();
editState = null;
if (value === "") {
status = `${DIM()}No changes.${RESET()}`;
} else {
const parsed = field.parse(value);
if ("error" in parsed) {
status = `${RED()}${parsed.error}${RESET()}`;
} else {
await saveConfig({ [field.key]: parsed.value } as Partial<Config>);
config = await loadConfig();
status = `${GREEN()}${field.label} saved.${RESET()}`;
}
}
render();
};
const onData = (data: Buffer) => {
const key = data.toString();
if (editState) {
if (key === "\x03" || key === "\x1b") {
editState = null;
status = `${DIM()}No changes.${RESET()}`;
render();
return;
}
if (key === "\r") { void saveEdit(); return; }
if (key === "\x01") { editState.cursor = 0; render(); return; }
if (key === "\x05") { editState.cursor = editState.buffer.length; render(); return; }
if (key === "\x0b") { editState.buffer = editState.buffer.slice(0, editState.cursor); render(); return; }
if (key === "\x15") { editState.buffer = editState.buffer.slice(editState.cursor); editState.cursor = 0; render(); return; }
if (key === "\x7f") {
if (editState.cursor > 0) {
editState.buffer = editState.buffer.slice(0, editState.cursor - 1) + editState.buffer.slice(editState.cursor);
editState.cursor--;
render();
}
return;
}
if (key === "\x1b[D" || key === "\x1bOD") { if (editState.cursor > 0) editState.cursor--; render(); return; }
if (key === "\x1b[C" || key === "\x1bOC") { if (editState.cursor < editState.buffer.length) editState.cursor++; render(); return; }
if (key === "\x1b[H" || key === "\x1b[1~") { editState.cursor = 0; render(); return; }
if (key === "\x1b[F" || key === "\x1b[4~") { editState.cursor = editState.buffer.length; render(); return; }
if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) {
editState.buffer = editState.buffer.slice(0, editState.cursor) + editState.buffer.slice(editState.cursor + 1);
render();
return;
}
if (key >= " " && key !== "\x7f") {
editState.buffer = editState.buffer.slice(0, editState.cursor) + key + editState.buffer.slice(editState.cursor);
editState.cursor += key.length;
render();
}
return;
}
// Not editing
if (key === "\x03") return finish("done");
if (key === "\x1b[A" || key === "\x1bOA") {
if (cursor > 0) { cursor--; status = null; render(); }
} else if (key === "\x1b[B" || key === "\x1bOB") {
if (cursor < CONFIG_FIELDS.length - 1) { cursor++; status = null; render(); }
} else if (key === "\x1b[D" || key === "\x1bOD" || key === "\x7f") {
return finish("back");
} else if (key === " ") {
const value = CONFIG_FIELDS[cursor]!.initialEditValue(config);
editState = { buffer: value, cursor: value.length };
status = null;
render();
}
};
process.stdin.on("data", onData);
});
}
export async function handleConfig(args: ParsedArgs): Promise<number> {
const positional = args.positional;
// gai config get <key>
if (positional[0] === "get") {
const key = positional[1];
if (!key) {
console.error(`\n ${RED()}Error: Usage: gai config get <key>${RESET()}\n`);
return 1;
}
const config = await loadConfig();
const field = CONFIG_FIELDS.find((f) => f.key === key);
if (!field) {
console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`);
console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`);
return 1;
}
// For apiKey, show masked value
if (key === "apiKey") {
console.log(config.apiKey || "");
} else {
console.log(String(config[key as keyof Config]));
}
return 0;
}
// gai config set <key> <value>
if (positional[0] === "set") {
const key = positional[1];
const value = positional.slice(2).join(" ");
if (!key || value === undefined) {
console.error(`\n ${RED()}Error: Usage: gai config set <key> <value>${RESET()}\n`);
return 1;
}
const field = CONFIG_FIELDS.find((f) => f.key === key);
if (!field) {
console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`);
console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`);
return 1;
}
const parsed = field.parse(value);
if ("error" in parsed) {
console.error(`\n ${RED()}Error: ${parsed.error}${RESET()}\n`);
return 1;
}
await saveConfig({ [field.key]: parsed.value } as Partial<Config>);
console.log(` ${GREEN()}${field.label} set.${RESET()}`);
return 0;
}
// gai config list
if (positional[0] === "list" || positional[0] === "ls") {
const config = await loadConfig();
const labelWidth = Math.max(...CONFIG_FIELDS.map((f) => f.label.length)) + 2;
console.log("");
for (const field of CONFIG_FIELDS) {
const padding = " ".repeat(Math.max(1, labelWidth - field.label.length));
console.log(` ${BOLD()}${field.label}${RESET()}${padding}${field.format(config)}`);
}
console.log("");
return 0;
}
// gai config (no args) → interactive
if (positional.length === 0) {
const result = await interactiveConfig();
return result === "back" ? (SKIP_WAIT as unknown as number) : 0;
}
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
console.error(` Try: gai config [get|set|list]\n`);
return 1;
}
+74
View File
@@ -0,0 +1,74 @@
import { loadConfig } from "../config";
import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal";
import { isStdinTTY } from "../tty";
import type { StreamCallbacks } from "../types";
import type { ParsedArgs } from "../cli";
export async function handleExplain(args: ParsedArgs): Promise<number> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1;
}
const unstaged = args.flags["unstaged"] as boolean;
const verbose = args.flags["verbose"] as boolean;
let diff: string;
let sourceLabel: string;
let contextPrefix: string;
try {
const result = await collectDiff({ unstaged, includeProjectContext: true });
if (result.back) return SKIP_WAIT as unknown as number;
diff = result.diff;
sourceLabel = result.sourceLabel;
contextPrefix = result.contextPrefix;
} catch (err) {
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
return 0;
}
if (verbose) {
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
}
const userPrompt = contextPrefix + buildExplainPrompt(diff);
if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
}
const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
}
try {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) {
process.stdout.write("\n");
} else {
process.stdout.write(explanation + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
}
+199
View File
@@ -0,0 +1,199 @@
import { loadConfig } from "../config";
import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context";
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
import { generatePRMessage } from "../ai";
import { BACK, SKIP_WAIT, selectOne } from "../menu";
import {
getDefaultBranch,
getBranchName,
getBranchPushStatus,
pushCurrentBranch,
getBranchCommits,
getBranchDiff,
detectPlatform,
getRemoteHostname,
createPR,
} from "../pr";
import type { Platform } from "../pr";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
import { ask } from "../tty-input";
import { copyToClipboard } from "../clipboard";
import type { ParsedArgs } from "../cli";
async function selectPlatform(hostname: string): Promise<Platform | null | typeof BACK> {
if (!isStdinTTY()) {
console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`);
process.exit(1);
}
return selectOne({
title: "Select remote platform",
subtitle: `Remote ${hostname} could not be auto-detected`,
items: [
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" },
{ label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" },
],
});
}
export async function handlePR(args: ParsedArgs): Promise<number> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1;
}
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
const draft = args.flags["draft"] as boolean;
const verbose = args.flags["verbose"] as boolean;
let platform = await detectPlatform();
if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname);
if (chosen === BACK) return SKIP_WAIT as unknown as number;
if (!chosen) {
console.log(" Aborted.");
return 0;
}
platform = chosen;
}
const platformLabel = platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea";
console.log(` Using: ${CYAN()}${platformLabel}${RESET()}`);
const baseBranch = await getDefaultBranch();
const branchName = await getBranchName();
if (branchName === baseBranch) {
console.error(`\n ${RED()}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET()}\n`);
return 1;
}
console.log(` Branch: ${CYAN()}${branchName}${RESET()} → base: ${CYAN()}${baseBranch}${RESET()}`);
const commits = await getBranchCommits(baseBranch);
if (commits.length === 0) {
const choice = await selectOne({
title: "No commits to compare",
subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`,
items: [{ label: "Back", value: "back" as const }],
});
if (choice === null) process.exit(0);
return SKIP_WAIT as unknown as number;
}
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
}
const pushStatus = await getBranchPushStatus();
if (!pushStatus.pushed) {
const target = pushStatus.upstream ?? `origin/${branchName}`;
const answer = await ask(
` Branch is not pushed to ${CYAN()}${target}${RESET()}. Push now? [${GREEN()}Y${RESET()}/n] `,
);
if (answer.toLowerCase() === "n") {
console.log(" Aborted.");
return 0;
}
console.log(` Pushing ${CYAN()}${branchName}${RESET()}...`);
try {
await pushCurrentBranch(branchName, pushStatus.upstream);
console.log(` ${GREEN()}Pushed ${branchName}.${RESET()}`);
} catch (err) {
console.error(`\n ${RED()}Push failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
}
const diff = await getBranchDiff(baseBranch);
if (!diff) {
console.error(`\n ${RED()}Error: No diff from base branch.${RESET()}\n`);
return 1;
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const repoRoot = await getRepoRoot();
const projectCtx = await collectProjectContext(repoRoot);
const userPrompt = buildPRPrompt({
readme: projectCtx.readme,
packageDescription: projectCtx.packageDescription,
structure: projectCtx.structure,
branchName,
baseBranch,
branchCommits: commits,
diff: truncatedDiff,
});
console.log("\n Generating PR title and description...");
let title: string;
let body: string;
try {
const result = await generatePRMessage(config, PR_SYSTEM_PROMPT, userPrompt);
title = result.title;
body = result.body;
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
console.log(`\n ${BOLD()}Generated PR:${RESET()}`);
console.log(` Title: ${GREEN()}${title}${RESET()}`);
if (body) {
console.log(` Body: ${DIM()}${body.replace(/\n/g, "\n ")}${RESET()}`);
}
console.log("");
const answer = await ask(` Create this PR? [${GREEN()}Y${RESET()}/n/e] `);
const lower = answer.toLowerCase();
if (lower === "n") {
console.log(" Aborted.");
await copyToClipboard(`${title}\n\n${body}`);
console.log(` ${DIM()}PR title copied to clipboard.${RESET()}`);
return 0;
}
if (lower === "e") {
const newTitle = await ask(" Title: ");
const newBody = await ask(" Body (optional): ");
if (!newTitle.trim()) {
console.log(" Aborted.");
return 0;
}
title = newTitle;
body = newBody;
}
console.log(`\n Creating PR...`);
try {
const url = await createPR(platform, title, body, baseBranch, draft);
console.log(`\n ${GREEN()}${BOLD()}✔ PR created!${RESET()}`);
console.log(` ${CYAN()}${url}${RESET()}`);
} catch (err) {
console.error(`\n ${RED()}PR creation failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
}
+82
View File
@@ -0,0 +1,82 @@
import { loadConfig } from "../config";
import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
import { isStdinTTY } from "../tty";
import type { StreamCallbacks } from "../types";
import type { ParsedArgs } from "../cli";
export async function handleReview(args: ParsedArgs): Promise<number> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1;
}
const strictnessFlag = args.flags["strict"] as boolean
? "strict"
: args.flags["lenient"] as boolean
? "lenient"
: "normal";
const unstaged = args.flags["unstaged"] as boolean;
const verbose = args.flags["verbose"] as boolean;
let diff: string;
let sourceLabel: string;
let contextPrefix: string;
try {
const result = await collectDiff({ unstaged, includeProjectContext: true });
if (result.back) return SKIP_WAIT as unknown as number;
diff = result.diff;
sourceLabel = result.sourceLabel;
contextPrefix = result.contextPrefix;
} catch (err) {
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
return 0;
}
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
const strictnessLabel = strictnessFlag === "strict"
? `${RED()}strict${RESET()}`
: strictnessFlag === "lenient"
? `${GREEN()}lenient${RESET()}`
: `${YELLOW()}normal${RESET()}`;
if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
}
const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
}
try {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) {
process.stdout.write("\n");
} else {
process.stdout.write(result + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
}
+113
View File
@@ -0,0 +1,113 @@
import { loadConfig } from "../config";
import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
import {
SUGGEST_SYSTEM_PROMPT,
buildSuggestBranchPrompt,
buildSuggestTypePrompt,
} from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
import { isStdinTTY } from "../tty";
import type { Config } from "../types";
import type { ParsedArgs } from "../cli";
export async function handleSuggest(args: ParsedArgs): Promise<number> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1;
}
const mode = args.positional[0] || "branch";
const verbose = args.flags["verbose"] as boolean;
if (mode !== "branch" && mode !== "type") {
console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
console.error(` Try: gai suggest branch | gai suggest type\n`);
return 1;
}
const unstaged = args.flags["unstaged"] as boolean;
let diff: string;
try {
const result = await collectDiff({ unstaged, includeProjectContext: false });
if (result.back) return SKIP_WAIT as unknown as number;
diff = result.diff;
} catch (err) {
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
if (!diff) {
console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
return 0;
}
if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
}
if (mode === "branch") {
return handleSuggestBranch(config, diff);
}
return handleSuggestType(config, diff);
}
async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
}
try {
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
const suggestions = raw
.split("\n")
.map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
.filter(Boolean);
if (suggestions.length === 0) {
console.log(` ${DIM()}No suggestions generated.${RESET()}`);
return 0;
}
for (const s of suggestions) {
console.log(` ${GREEN()}${s}${RESET()}`);
}
console.log("");
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
}
async function handleSuggestType(config: Config, diff: string): Promise<number> {
const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
}
try {
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
const type = raw.trim().toLowerCase();
const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
if (validTypes.includes(type)) {
console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
} else {
console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
}
+98
View File
@@ -0,0 +1,98 @@
// Shared diff-source helper used by explain, review, suggest, and commit commands.
// Handles the common pattern of: detecting where to get a diff from
// (staged, unstaged, or piped stdin), optionally presenting an interactive
// file selector, applying truncation, and collecting project context.
import { isGitRepo, getStagedFiles, getStagedDiff, getUnstagedFiles, getRepoRoot, applyFileSelection } from "./git";
import { selectFiles } from "./selector";
import { BACK } from "./menu";
import { collectProjectContext } from "./context";
import { isStdinTTY } from "./tty";
export interface DiffSourceResult {
diff: string;
sourceLabel: string;
contextPrefix: string;
back: boolean;
}
const MAX_DIFF_SIZE = 15000;
/**
* Collect a diff from the appropriate source based on flags and TTY state.
*
* - If `unstaged` is true: uses `git diff` (unstaged changes)
* - If TTY (interactive): shows file selector for staged/unstaged, then uses staged diff
* - If piped (non-TTY): reads diff from stdin
*
* Returns the diff, a human-readable source label, project context prefix,
* and whether the user pressed back.
*/
export async function collectDiff(opts: {
unstaged?: boolean;
includeProjectContext?: boolean;
} = {}): Promise<DiffSourceResult> {
const { unstaged = false, includeProjectContext = true } = opts;
let diff: string;
let sourceLabel: string;
let contextPrefix = "";
if (unstaged) {
if (!(await isGitRepo())) {
throw new Error("Not a git repository.");
}
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
sourceLabel = "unstaged changes";
} else if (isStdinTTY()) {
if (!(await isGitRepo())) {
throw new Error("Not a git repository.");
}
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 { diff: "", sourceLabel, contextPrefix, back: true };
}
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
} else {
// Piped input (non-TTY)
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
}
// Truncate large diffs
if (diff.length > MAX_DIFF_SIZE) {
diff = diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)";
}
// Collect project context for better AI responses
if (includeProjectContext && diff) {
try {
if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {
// Context collection is best-effort
}
}
return { diff, sourceLabel, contextPrefix, back: false };
}
+38 -6
View File
@@ -37,12 +37,17 @@ function parseNameStatus(output: string): FileEntry[] {
return output
.trim()
.split("\n")
.filter(Boolean)
.filter((line) => line.trim())
.map((line) => {
const [status, ...pathParts] = line.split("\t");
const path = pathParts[pathParts.length - 1] ?? "";
return { path, status: status!, label: statusToLabel(status!) };
});
const tabIdx = line.indexOf("\t");
if (tabIdx === -1) return null;
const status = line.slice(0, tabIdx);
// Join path parts back (paths may contain escaped chars but not tabs)
const path = line.slice(tabIdx + 1);
if (!status || !path) return null;
return { path, status, label: statusToLabel(status) };
})
.filter((entry): entry is FileEntry => entry !== null);
}
export async function getStagedFiles(): Promise<FileEntry[]> {
@@ -98,6 +103,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 }> {
@@ -113,7 +145,7 @@ export async function commit(
throw new Error(stderr.trim() || `git commit failed (exit code ${exitCode})`);
}
const branchHashMatch = stdout.match(/\[(\S+)\s+([0-9a-f]{7,})/);
const branchHashMatch = stdout.match(/\[(\S+)\s+(?:\(root-commit\)\s+)?([0-9a-f]{7,})/);
const branch = branchHashMatch?.[1] ?? "";
const hash = branchHashMatch?.[2] ?? "";
+91 -120
View File
@@ -1,4 +1,5 @@
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
import { BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength, padRight } from "./terminal";
import { isStdinTTY } from "./tty";
const UP = "\x1b[A";
const DOWN = "\x1b[B";
@@ -14,6 +15,10 @@ const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-back");
export type PromptBack = typeof BACK;
// Sent by command handlers to skip the "Press Enter to return" wait in the
// interactive menu when the user explicitly backed out of a sub-menu.
export const SKIP_WAIT = Symbol("skip-wait");
export interface Choice<T> {
label: string;
value: T;
@@ -38,37 +43,17 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
doneLabel?: string;
}
function hideCursor() {
process.stdout.write("\x1b[?25l");
}
function showCursor() {
process.stdout.write("\x1b[?25h");
}
function moveUp(lines: number) {
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
}
function clearLine() {
process.stdout.write("\r\x1b[2K");
}
function visibleLength(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function padLabel(label: string, width: number) {
function padLabel(label: string, width: number): string {
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
}
function controls(mode: "single" | "multi", showBackHint = true) {
if (mode === "single") {
const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`;
return `${DIM()}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET()}`;
}
const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`;
return `${DIM()}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET()}`;
}
function renderPrompt(lines: string[], previousLines: number) {
@@ -79,10 +64,7 @@ function renderPrompt(lines: string[], previousLines: number) {
}
moveUp(previousLines);
}
for (const line of lines) {
process.stdout.write(`${line}\n`);
}
for (const line of lines) process.stdout.write(`${line}\n`);
moveUp(lines.length);
return lines.length;
}
@@ -95,31 +77,29 @@ function clearPrompt(lines: number) {
moveUp(lines);
}
function normalizeKey(key: string, escapeBuf: string) {
function normalizeKey(key: string, escapeBuf: string): { action: string | null; escapeBuf: string } {
// Single-chunk actions
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) {
return { action: "back", escapeBuf: "" };
}
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" };
if (key === SPACE) return { action: "space", escapeBuf: "" };
if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
if (key === "\x1b" || key.startsWith("\x1b[")) {
// Start of an escape sequence — buffer it
if (key === "\x1b" || key.startsWith("\x1b[") || key.startsWith("\x1bO")) {
return { action: null, escapeBuf: key };
}
// Continue buffering an escape sequence
if (escapeBuf) {
const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) {
return { action: "back", escapeBuf: "" };
}
return {
action: null,
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
};
if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" };
// If key is a terminal character (letter/digit/~) or buffer got too long, flush
if (/^[A-Za-z~0-9]$/.test(key) || next.length > 10) return { action: null, escapeBuf: "" };
return { action: null, escapeBuf: next };
}
return { action: null, escapeBuf: "" };
@@ -130,41 +110,35 @@ function createLines<T>(
mode: "single" | "multi",
cursor: number,
) {
const labelWidth = Math.max(
...options.items.map((item) => visibleLength(item.label)),
0,
) + 2;
const labelWidth = Math.max(...options.items.map((item) => visibleLength(item.label)), 0) + 2;
const lines = [
"",
` ${BOLD}${options.title}${RESET}`,
` ${BOLD()}${options.title}${RESET()}`,
];
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`);
if (options.subtitle) lines.push(` ${DIM()}${options.subtitle}${RESET()}`);
lines.push(` ${controls(mode, options.allowBack !== false)}`, "");
for (let i = 0; i < options.items.length; i++) {
const item = options.items[i]!;
const active = i === cursor;
const pointer = active ? `${CYAN}${RESET}` : " ";
const pointer = active ? `${CYAN()}${RESET()}` : " ";
const marker = mode === "single"
? active ? `${GREEN}${RESET}` : `${DIM}${RESET}`
: item.selected ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const label = active ? `${BOLD}${item.label}${RESET}` : item.label;
? active ? `${GREEN()}${RESET()}` : `${DIM()}${RESET()}`
: item.selected ? `${GREEN()}${RESET()}` : `${DIM()}${RESET()}`;
const label = active ? `${BOLD()}${item.label}${RESET()}` : item.label;
const description = item.description
? active ? item.description : `${DIM}${item.description}${RESET}`
? active ? item.description : `${DIM()}${item.description}${RESET()}`
: "";
lines.push(
` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`,
);
lines.push(` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`);
}
return lines;
}
function ensureTTY(title: string) {
if (process.stdin.isTTY !== true) {
if (!isStdinTTY()) {
throw new Error(`${title} requires a TTY.`);
}
}
@@ -184,21 +158,27 @@ export async function selectOne<T>(
hideCursor();
const render = () => {
renderedLines = renderPrompt(
createLines(options, "single", cursor),
renderedLines,
);
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
};
render();
const cleanup = () => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
clearPrompt(renderedLines);
showCursor();
};
return new Promise((resolve) => {
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
cleanup();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
@@ -206,21 +186,20 @@ export async function selectOne<T>(
};
const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
try {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) {
cursor--;
render();
} else if (result.action === "down" && cursor < options.items.length - 1) {
cursor++;
render();
} else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value);
if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
if (result.action === "up" && cursor > 0) { cursor--; render(); }
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value);
}
} catch (err) {
cleanup();
reject(err);
}
};
@@ -253,15 +232,13 @@ 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]!;
item.selected = !item.selected;
if (options.selectAllLabel && index === 0) {
for (let i = 1; i < items.length; i++) {
items[i]!.selected = item.selected;
}
for (let i = 1; i < items.length; i++) items[i]!.selected = item.selected;
} else {
syncSelectAll();
}
@@ -269,28 +246,29 @@ export async function selectMany<T>(
const render = () => {
renderedLines = renderPrompt(
createLines(
{
title: options.title,
subtitle: options.subtitle,
items,
},
"multi",
cursor,
),
createLines({ title: options.title, subtitle: options.subtitle, items }, "multi", cursor),
renderedLines,
);
};
render();
const cleanup = () => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
clearPrompt(renderedLines);
showCursor();
};
return new Promise((resolve) => {
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
cleanup();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
@@ -298,28 +276,21 @@ export async function selectMany<T>(
};
const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
try {
const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) {
cursor--;
render();
} else if (result.action === "down" && cursor < items.length - 1) {
cursor++;
render();
} else if (result.action === "space") {
toggle(cursor);
render();
} else if (result.action === "enter") {
finish(
items
.filter((item) => item.selected && item.value !== null)
.map((item) => item.value as T),
);
if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
if (result.action === "up" && cursor > 0) { cursor--; render(); }
else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
else if (result.action === "space") { toggle(cursor); render(); }
else if (result.action === "enter") {
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
}
} catch (err) {
cleanup();
reject(err);
}
};
+33 -77
View File
@@ -124,6 +124,36 @@ export async function getRemoteHostname(): Promise<string | null> {
}
}
const PLATFORM_CLI: Record<Platform, {
bin: string;
args: (title: string, body: string, base: string, draft: boolean) => string[];
label: string;
}> = {
github: {
bin: "gh",
args: (title, body, base, draft) => {
const a = ["pr", "create", "--title", title, "--body", body, "--base", base];
if (draft) a.push("--draft");
return a;
},
label: "gh pr create",
},
gitlab: {
bin: "glab",
args: (title, body, base, draft) => {
const a = ["mr", "create", "--title", title, "--description", body, "--target-branch", base];
if (draft) a.push("--draft");
return a;
},
label: "glab mr create",
},
gitea: {
bin: "tea",
args: (title, body, base, _draft) => ["pulls", "create", "--title", title, "--description", body, "--base", base],
label: "tea pulls create",
},
};
export async function createPR(
platform: Platform,
title: string,
@@ -131,80 +161,8 @@ export async function createPR(
base: string,
draft: boolean,
): Promise<string> {
if (platform === "github") {
const args = [
"pr",
"create",
"--title",
title,
"--body",
body,
"--base",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["gh", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `gh pr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
if (platform === "gitlab") {
const args = [
"mr",
"create",
"--title",
title,
"--description",
body,
"--target-branch",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["glab", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `glab mr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
const args = [
"pulls",
"create",
"--title",
title,
"--description",
body,
"--base",
base,
];
const proc = Bun.spawn(["tea", ...args], {
const cli = PLATFORM_CLI[platform];
const proc = Bun.spawn([cli.bin, ...cli.args(title, body, base, draft)], {
stdout: "pipe",
stderr: "pipe",
});
@@ -213,9 +171,7 @@ export async function createPR(
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `tea pulls create failed (exit code ${exitCode})`,
);
throw new Error(stderr.trim() || `${cli.label} failed (exit code ${exitCode})`);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
+149 -37
View File
@@ -1,5 +1,7 @@
import type { PRContext, ProjectContext } from "./types";
// ── Commit System Prompt ──────────────────────────────────────────────
export const SYSTEM_PROMPT = `You are an expert at writing concise, meaningful git commit messages following the Conventional Commits specification.
Format: <type>(<scope>): <description>
@@ -19,29 +21,17 @@ Rules:
export function buildPrompt(context: ProjectContext): string {
const parts: string[] = [];
if (
context.packageDescription ||
context.readme ||
context.structure
) {
if (context.packageDescription || context.readme || context.structure) {
parts.push("## Project Context");
if (context.packageDescription) {
parts.push(`Description: ${context.packageDescription}`);
}
if (context.structure) {
parts.push(`Structure: ${context.structure}`);
}
if (context.readme) {
parts.push(`README:\n${context.readme}`);
}
if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`);
if (context.structure) parts.push(`Structure: ${context.structure}`);
if (context.readme) parts.push(`README:\n${context.readme}`);
parts.push("");
}
if (context.recentCommits.length > 0) {
parts.push("## Recent Commits (for style reference)");
for (const c of context.recentCommits) {
parts.push(c);
}
for (const c of context.recentCommits) parts.push(c);
parts.push("");
}
@@ -55,6 +45,8 @@ export function buildPrompt(context: ProjectContext): string {
return parts.join("\n");
}
// ── PR System Prompt ───────────────────────────────────────────────────
export const PR_SYSTEM_PROMPT = `You are an expert at writing clear, concise pull request titles and descriptions.
Format:
@@ -74,21 +66,11 @@ Rules:
export function buildPRPrompt(context: PRContext): string {
const parts: string[] = [];
if (
context.packageDescription ||
context.readme ||
context.structure
) {
if (context.packageDescription || context.readme || context.structure) {
parts.push("## Project Context");
if (context.packageDescription) {
parts.push(`Description: ${context.packageDescription}`);
}
if (context.structure) {
parts.push(`Structure: ${context.structure}`);
}
if (context.readme) {
parts.push(`README:\n${context.readme}`);
}
if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`);
if (context.structure) parts.push(`Structure: ${context.structure}`);
if (context.readme) parts.push(`README:\n${context.readme}`);
parts.push("");
}
@@ -99,9 +81,7 @@ export function buildPRPrompt(context: PRContext): string {
if (context.branchCommits.length > 0) {
parts.push("## Commits on This Branch");
for (const c of context.branchCommits) {
parts.push(c);
}
for (const c of context.branchCommits) parts.push(c);
parts.push("");
}
@@ -110,9 +90,141 @@ export function buildPRPrompt(context: PRContext): string {
parts.push(context.diff);
parts.push("```");
parts.push("");
parts.push(
"Generate a pull request title and brief body for the above changes.",
);
parts.push("Generate a pull request title and brief body for the above changes.");
return parts.join("\n");
}
// ── Explain Prompt ─────────────────────────────────────────────────────
export const EXPLAIN_SYSTEM_PROMPT = `You are an expert software engineer explaining code changes in plain, accessible language.
Given a git diff, explain:
1. WHAT changed at a high level (1 sentence summary)
2. WHY these changes matter (what problem they solve or what they enable)
3. A brief breakdown of the key changes (bullet points, one per file/module)
Rules:
- Be concise but thorough
- Use plain language suitable for both junior and senior engineers
- Focus on the intent and impact, not just restating the diff
- Do NOT use markdown headings (no ##, ###). Use bold text markers like **Section:** instead.
- Keep each bullet point to 1-2 lines
- If the diff is trivial, say so and keep the explanation short`;
export function buildExplainPrompt(diff: string): string {
const parts: string[] = [];
parts.push("## Changes to Explain");
parts.push("```diff");
parts.push(diff);
parts.push("```");
parts.push("");
parts.push("Explain these changes in plain language as described.");
return parts.join("\n");
}
// ── Review Prompt ──────────────────────────────────────────────────────
export const REVIEW_SYSTEM_PROMPT = `You are a senior software engineer performing a thorough but friendly code review.
Review the following code changes and provide feedback in these categories:
1. **Bugs & Logic Errors** — actual bugs, off-by-one errors, null safety, edge cases
2. **Code Quality** — readability, naming, duplication, complexity
3. **Performance** — inefficient patterns, unnecessary allocations, N+1 queries
4. **Security** — injection risks, exposed secrets, unsafe operations
5. **Suggestions** — concrete improvements with code snippets where helpful
Rules:
- Be constructive, not harsh. Use "consider" and "suggest" instead of "you should".
- Prioritize by severity. Mention critical issues first.
- If the code looks great, say so! Don't fabricate issues.
- Keep feedback actionable — every issue should have a clear suggestion.
- Use **bold** for section headers and \`code\` for code references.
- Do NOT output a concluding summary paragraph. End with the last suggestion.`;
export function buildReviewPrompt(diff: string, strictness: "lenient" | "normal" | "strict"): string {
const strictnessHints: Record<string, string> = {
lenient: "Focus only on major issues. Skip minor style nits.",
normal: "Provide balanced feedback covering all categories.",
strict: "Be thorough. Flag even minor issues and style inconsistencies.",
};
const parts: string[] = [];
parts.push(`Review strictness: ${strictnessHints[strictness]}`);
parts.push("");
parts.push("## Code Changes to Review");
parts.push("```diff");
parts.push(diff);
parts.push("```");
parts.push("");
parts.push("Please review the above changes.");
return parts.join("\n");
}
// ── Changelog Prompt ───────────────────────────────────────────────────
export const CHANGELOG_SYSTEM_PROMPT = `You are an expert at writing clear, user-facing changelogs from git commit history.
Given a list of commits, generate a changelog organized by type:
- **Features** (feat commits)
- **Bug Fixes** (fix commits)
- **Improvements** (refactor, perf, style commits)
- **Documentation** (docs commits)
- **Chores & Maintenance** (chore, build, ci, test commits)
Rules:
- Group by type, with the heading in **bold**
- Each entry should be a single line describing the change in user-friendly language
- Translate technical commit messages into language a user would understand
- Skip merge commits and trivial chore commits if they don't add value
- If a type has no entries, omit that section
- Output ONLY the changelog text, no preamble or markdown code blocks`;
export function buildChangelogPrompt(commits: string[], from?: string, to?: string): string {
const parts: string[] = [];
const range = from ? `from ${from}${to ? ` to ${to}` : " to HEAD"}` : "";
parts.push(range ? `Generate a changelog for commits ${range}.` : "Generate a changelog from the following commits.");
parts.push("");
parts.push("## Commits");
for (const c of commits) parts.push(c);
parts.push("");
parts.push("Generate a changelog from these commits.");
return parts.join("\n");
}
// ── Suggest Prompt ─────────────────────────────────────────────────────
export const SUGGEST_SYSTEM_PROMPT = `You are an expert at suggesting git branch names and commit types based on code changes.
For branch name suggestions:
- Use format: <type>/<short-description>
- Types: feat, fix, refactor, docs, chore, perf, test
- Description should be 2-4 hyphenated words
- Provide exactly 3 suggestions, one per line
For commit type suggestions:
- Return exactly one Conventional Commit type that best matches the changes
- Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
- Output ONLY the type name`;
export function buildSuggestBranchPrompt(diff: string): string {
const parts: string[] = [];
parts.push("## Changes");
parts.push("```diff");
parts.push(diff);
parts.push("```");
parts.push("");
parts.push("Suggest 3 branch names for these changes. Output one per line, no numbering.");
return parts.join("\n");
}
export function buildSuggestTypePrompt(diff: string): string {
const parts: string[] = [];
parts.push("## Changes");
parts.push("```diff");
parts.push(diff);
parts.push("```");
parts.push("");
parts.push("What Conventional Commit type best describes these changes? Output ONLY the type name.");
return parts.join("\n");
}
+30 -19
View File
@@ -1,43 +1,54 @@
import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty";
import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu";
function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) {
const files = new Map<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 (process.stdin.isTTY !== true) return [];
if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
const selected = await selectMany({
title: "Select files to stage",
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
title: "Select files for this action",
subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
selectAllLabel: "Select all",
cancelMessage: "Aborted.",
items: unstagedFiles.map((f) => ({
items: files.map((f) => ({
label: f.path,
value: f.path,
description: f.label,
selected: f.staged,
})),
});
if (selected === null) process.exit(1);
if (selected === BACK) return BACK;
if (selected.length > 0) {
process.stdout.write(
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
);
}
return selected;
}
+77 -7
View File
@@ -1,7 +1,77 @@
export const BOLD = "\x1b[1m";
export const GREEN = "\x1b[32m";
export const YELLOW = "\x1b[33m";
export const CYAN = "\x1b[36m";
export const RED = "\x1b[31m";
export const DIM = "\x1b[2m";
export const RESET = "\x1b[0m";
// Terminal styling and rendering utilities.
// Respects NO_COLOR convention, --no-color flag, and TTY detection.
import { isStdoutTTY } from "./tty";
// ── Color support ─────────────────────────────────────────────────────
let _enabled: boolean | null = null;
export function setColorEnabled(enabled: boolean): void {
_enabled = enabled;
}
export function isColorEnabled(): boolean {
if (_enabled !== null) return _enabled;
// Respect NO_COLOR: https://no-color.org/
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
_enabled = false;
return false;
}
if (!isStdoutTTY()) {
_enabled = false;
return false;
}
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
_enabled = true;
return true;
}
_enabled = true;
return true;
}
function s(code: string): string {
return isColorEnabled() ? code : "";
}
export const BOLD = () => s("\x1b[1m");
export const DIM = () => s("\x1b[2m");
export const GREEN = () => s("\x1b[32m");
export const YELLOW = () => s("\x1b[33m");
export const CYAN = () => s("\x1b[36m");
export const RED = () => s("\x1b[31m");
export const RESET = () => s("\x1b[0m");
// ── Terminal rendering helpers ───────────────────────────────────────
export function hideCursor(): void {
process.stdout.write("\x1b[?25l");
}
export function showCursor(): void {
process.stdout.write("\x1b[?25h");
}
export function clearLine(): void {
process.stdout.write("\r\x1b[2K");
}
export function moveUp(lines: number): void {
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
}
export function clearScreen(): void {
process.stdout.write("\x1b[2J\x1b[H");
}
/** Calculate visible length of a string, stripping ANSI escape codes. */
export function visibleLength(value: string): number {
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
}
/** Pad a string to the given visible width (accounting for ANSI codes). */
export function padRight(value: string, width: number): string {
return value + " ".repeat(Math.max(0, width - visibleLength(value)));
}
+163
View File
@@ -0,0 +1,163 @@
// Shared TTY input utilities used by command handlers.
// Provides a simple line-input "ask" helper and a reusable inline
// raw-mode text editor (used by both commit message editing and
// interactive config editing).
import * as readline from "node:readline";
import { isStdinTTY } from "./tty";
// ── Simple line input (cooked mode) ──────────────────────────────────
export function ask(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// ── Inline raw-mode editor ────────────────────────────────────────────
//
// Provides a simple line editor that runs in raw mode and supports:
// - Left/Right arrows, Home/End for cursor movement
// - Backspace / Delete for character removal
// - Ctrl+A (beginning), Ctrl+E (end), Ctrl+K (kill to end), Ctrl+U (kill to start)
// - Enter to confirm, Ctrl+C / Esc to cancel
//
// Returns the edited string, or null if the user cancelled.
export interface EditResult {
value: string | null;
}
export async function editLine(initial: string): Promise<string | null> {
if (!isStdinTTY()) return null;
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
let buffer = initial;
let cursor = initial.length;
function render() {
// Clear line, move to start, show prompt + buffer, then reposition cursor
process.stdout.write("\r\x1b[2K > " + buffer);
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
}
}
process.stdout.write(" > " + buffer);
return new Promise((resolve) => {
let escapeBuf = "";
function finish(value: string | null) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
resolve(value);
}
function handleEscapeSeq(seq: string) {
switch (seq) {
case "\x1b[D": case "\x1bOD": // Left
if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); }
break;
case "\x1b[C": case "\x1bOC": // Right
if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); }
break;
case "\x1b[H": case "\x1b[1~": case "\x1bOH": // Home
if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
break;
case "\x1b[F": case "\x1b[4~": case "\x1bOF": // End
if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
break;
case "\x1b[3~": // Delete
if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); }
break;
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
// Ctrl+C
if (key === "\x03") { finish(null); return; }
// Esc
if (key === "\x1b") {
if (escapeBuf) {
// Already buffering — check if this completes a sequence
const next = escapeBuf + key;
if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(next)) {
handleEscapeSeq(next);
escapeBuf = "";
} else {
// Treat lone Esc as cancel
finish(null);
}
return;
}
escapeBuf = "\x1b";
return;
}
// Buffering an escape sequence
if (escapeBuf) {
escapeBuf += key;
// Check if this completes a valid sequence
if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(escapeBuf)) {
handleEscapeSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 10 || /^[A-Za-z~]$/.test(key)) {
// Timeout or terminator that didn't match — discard
escapeBuf = "";
}
return;
}
// Enter
if (key === "\r" || key === "\n") {
const result = buffer.trim();
finish(result || null);
return;
}
// Backspace
if (key === "\x7f") {
if (cursor > 0) {
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
cursor--;
render();
}
return;
}
// Ctrl+A → beginning of line
if (key === "\x01") {
if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
return;
}
// Ctrl+E → end of line
if (key === "\x05") {
if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
return;
}
// Ctrl+K → kill to end
if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; }
// Ctrl+U → kill to start
if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; }
// Printable characters
if (key >= " " && key !== "\x7f") {
buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
cursor += key.length;
render();
}
});
});
}
+35
View File
@@ -0,0 +1,35 @@
// TTY detection for Bun compatibility.
// Bun does not set process.stdin.isTTY, so we use fs.fstatSync.
import { fstatSync } from "node:fs";
let _stdinTTY: boolean | null = null;
export function initTTY(): void {
if (_stdinTTY !== null) return;
try {
// fd 0 = stdin. On Unix, a TTY is a character device.
const stat = fstatSync(0);
_stdinTTY = stat.isCharacterDevice();
} catch {
_stdinTTY = false;
}
}
export function isStdinTTY(): boolean {
if (_stdinTTY === null) initTTY();
return _stdinTTY!;
}
export function isStdoutTTY(): boolean {
// Primary check: fstat on fd 1 (stdout) — most reliable
try {
const stat = fstatSync(1);
return stat.isCharacterDevice();
} catch {
// Fall back to TERM heuristic only when fstat fails
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
return false;
}
}
+20 -7
View File
@@ -12,20 +12,33 @@ export interface FileEntry {
label: string;
}
export interface ProjectContext {
export interface BaseContext {
readme: string | null;
packageDescription: string | null;
structure: string | null;
recentCommits: string[];
diff: string;
}
export interface PRContext {
readme: string | null;
packageDescription: string | null;
structure: string | null;
export interface ProjectContext extends BaseContext {
recentCommits: string[];
}
export interface PRContext extends BaseContext {
branchName: string;
baseBranch: string;
branchCommits: string[];
diff: string;
}
export interface CommitResult {
branch: string;
hash: string;
files: number;
insertions: number;
deletions: number;
}
export interface StreamCallbacks {
onToken?: (token: string) => void;
onDone?: (fullText: string) => void;
onError?: (err: Error) => void;
}
+50
View File
@@ -0,0 +1,50 @@
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test, expect, describe } from "bun:test";
async function run(command: string[], cwd: string, env: Record<string, string> = {}) {
const proc = Bun.spawn(command, {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
PATH: process.env.PATH ?? "",
HOME: env.HOME ?? process.env.HOME ?? "",
...env,
},
});
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
return { exitCode, stdout, stderr };
}
describe("commit command", () => {
test("clean repository exits without requiring API key", async () => {
const repo = mkdtempSync(join(tmpdir(), "gai-clean-repo-"));
const home = mkdtempSync(join(tmpdir(), "gai-empty-home-"));
const init = await run(["git", "init"], repo, { HOME: home });
expect(init.exitCode).toBe(0);
const result = await run(
["bun", "run", join(import.meta.dir, "..", "index.ts"), "commit"],
repo,
{
HOME: home,
GAI_API_KEY: "",
},
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Nothing to commit");
expect(result.stdout).toContain("No staged or unstaged changes");
expect(result.stderr).not.toContain("API key not set");
expect(result.stderr).not.toContain("requires a TTY");
});
});
+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;
});
});