14 Commits

Author SHA1 Message Date
Mplan 4b384a7581 refactor(ui): update menu, selector for TTY detection; thin dispatcher
- Update menu.ts and selector.ts to use isStdinTTY() and function-based
  terminal colors
- Refactor index.ts from 995-line monolith to ~270-line dispatcher that
  registers all commands via the CLI parser and delegates to modules
- Add initTTY() call at startup for correct pipe/TTY detection
- Interactive menu expanded to include new commands (explain, review,
  changelog, suggest, amend)
2026-06-16 02:01:05 +08:00
Mplan 8b2babfa5d feat: add explain, review, changelog, and suggest commands
- gai explain: plain-language diff explanation with pipe support
- gai review: AI code review with strict/normal/lenient modes
- gai changelog: generate user-facing changelog from commits,
  supports --from/--to ranges and -n count
- gai suggest: suggest branch names or commit type from diff
- All commands support pipe input and auto-stage on empty staging area
2026-06-16 02:01:02 +08:00
Mplan e69b08ac01 refactor: extract commit, pr, config into command modules
- commit: add --amend, -m/--message, streaming output, auto-stage flow
- pr: add clipboard copy on abort, TTY-safe platform selection
- config: add get/set/list subcommands for non-interactive use
- All modules use dynamic terminal colors and proper TTY detection
2026-06-16 02:00:59 +08:00
Mplan c0c3dfce7d feat(ai): add streaming SSE support and new prompt templates
- Stream AI responses token-by-token via SSE for instant feedback
- Add EXPLAIN, REVIEW, CHANGELOG, SUGGEST system prompts
- Add buildExplainPrompt, buildReviewPrompt, buildChangelogPrompt,
  buildSuggestBranchPrompt, buildSuggestTypePrompt functions
- Review prompt supports strict/lenient/normal modes
2026-06-16 02:00:56 +08:00
Mplan 8b21ab8d4a feat(terminal): add dynamic color with NO_COLOR/FORCE_COLOR support
- Convert static ANSI constants to functions for dynamic color control
- Respect NO_COLOR convention (https://no-color.org/)
- Support FORCE_COLOR for CI/CD environments
- setColorEnabled() API for --no-color flag integration
2026-06-16 02:00:53 +08:00
Mplan 42e0fafaab feat(cli): add argument parser and TTY detection
- New CLI argument parser supporting subcommands, short/long flags,
  flag values, positional args, aliases, and --help per command
- TTY detection via fstatSync (Bun compat: process.stdin.isTTY is
  undefined in Bun 1.3.x)
- Extended types: CommitResult, StreamCallbacks
2026-06-16 02:00:51 +08:00
Mplan d0506381f5 refactor(cli): remove interactive prompts for empty states and add clean repo test
Build / bun-build (push) Successful in 29s
Build / bun-build (pull_request) Successful in 37s
2026-06-12 00:42:49 +08:00
Mplan 14df49b110 refactor(cli): replace process.exit prompts with interactive selection for empty states 2026-06-12 00:36:01 +08:00
Mplan 962b76d20f refactor(cli): show empty state prompt in current page
Empty commit/PR states no longer auto-return to the menu. Instead, the
user is shown an in-page prompt with a Back option, mirroring the rest
of the CLI: pressing Enter on Back (or ←/backspace) closes the prompt
and returns control to the previous step.
2026-06-12 00:35:23 +08:00
Mplan 12e71a0af7 feat(config): add interactive editor with inline editing and navigation
Build / bun-build (push) Successful in 9m25s
2026-06-11 21:18:34 +08:00
Mplan e1354e8651 feat(menu): add back key navigation
Build / bun-build (push) Successful in 21s
2026-06-11 20:10:34 +08:00
Mplan 7e662b25cc refactor(pr): rely on selected CLI for PR creation 2026-06-11 19:43:19 +08:00
Mplan 5bb2dc8e8a chore(config): bump version to 0.1.2 2026-06-11 19:39:50 +08:00
Mplan 1dbfac7985 feat(pr): add GitLab support 2026-06-11 19:38:36 +08:00
24 changed files with 863 additions and 1254 deletions
+65 -188
View File
@@ -2,13 +2,14 @@
# gai # gai
**AI-powered Git helper — commit messages, PRs, code review, changelogs, and more** **AI-powered Git commit and pull request helper**
[![Release](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgit.catpl.top%2Fapi%2Fv1%2Frepos%2FMplan%2Fgai%2Freleases%2Flatest&query=%24.tag_name&label=release&color=blue)](https://git.catpl.top/Mplan/gai/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![Bun](https://img.shields.io/badge/runtime-Bun-f9d71c.svg)](https://bun.sh) [![Bun](https://img.shields.io/badge/runtime-Bun-f9d71c.svg)](https://bun.sh)
[![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6.svg)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6.svg)](https://www.typescriptlang.org/)
Generate **Conventional Commits** messages, pull request descriptions, code reviews, changelogs, and more — powered by AI with full project context. Generate **Conventional Commits** messages and pull request descriptions using AI, based on your project context, code diff, branch changes, and commit history.
</div> </div>
@@ -16,18 +17,15 @@ Generate **Conventional Commits** messages, pull request descriptions, code revi
## Features ## Features
- **🤖 AI Commit Messages** — generate Conventional Commits from staged diffs with project context - **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter
- **🔀 AI Pull Requests** — create GitHub, Gitea, or GitLab PRs with AI-generated title and body - **Context-aware commits** — reads project overview, staged diff, and recent commit history
- **📖 Explain Changes** — `gai explain` explains diffs in plain language - **Conventional Commits** — `feat(scope): description` format by default
- **🔍 Code Review** — `gai review` provides thorough, constructive code review - **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all"
- **📝 Changelog Generation** — `gai changelog` generates user-facing changelogs from commits - **Inline editing** — edit AI-generated messages right in the terminal with cursor movement
- **💡 Smart Suggestions** — `gai suggest` recommends branch names or commit types - **AI-generated PRs** — create GitHub, Gitea, or GitLab pull requests with generated title and body
- **✏️ Amend Commits** — `gai commit --amend` amends with AI-generated message - **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more
- **⚡ Streaming Output** — AI responses stream token-by-token for instant feedback - **Review before commit** — confirm, edit, or abort the generated message
- **📂 Interactive File Selection** — ↑/↓ navigate, space toggle, "Select all" support - **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies
- **📋 Pipe Support** — pipe diffs directly: `git diff | gai explain`
- **🎨 Mainstream CLI UX** — proper flags, aliases, `--help` per command, `--no-color`
- **🔧 OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more
## Quick Start ## Quick Start
@@ -41,196 +39,85 @@ gai config
# Open interactive menu # Open interactive menu
gai gai
# Generate a commit message # Or directly generate a commit message
gai commit gai commit
# Explain your changes # Or create an AI-generated pull request
gai explain gai pr
``` ```
## Usage ## Usage
``` ```
gai Open interactive menu gai Open interactive menu
gai commit Generate AI commit message (interactive file selection) gai commit Generate commit message (interactive file selection)
gai commit -a Auto-stage all changed files gai commit --auto Auto-stage all changed files
gai commit -d Generate message without committing gai commit -d Generate message without committing
gai commit -m "msg" Use custom message (skip AI)
gai commit --amend Amend last commit with AI-generated message
gai pr Create a PR with AI-generated title and body gai pr Create a PR with AI-generated title and body
gai pr --draft Create a draft PR gai pr --draft Create a draft PR
gai explain Explain staged changes in plain language gai config Configure API settings
gai explain --unstaged Explain unstaged changes gai --help Show help
gai review AI code review of staged changes
gai review --strict Thorough review, flag minor issues
gai review --lenient Focus only on major issues
gai changelog Generate changelog from recent commits
gai changelog --from v1.0 --to v1.1 Range-based changelog
gai suggest branch Suggest branch names for current changes
gai suggest type Suggest Conventional Commit type
gai config Configure API settings (interactive)
gai config list List all settings
gai config get <key> Get a specific setting
gai config set <key> <v> Set a setting
gai help Show help
gai help <command> Show command-specific help
gai --version Show version gai --version Show version
``` ```
### Global Flags
| Flag | Description |
|---|---|
| `-h, --help` | Show help |
| `-V, --version` | Show version |
| `-v, --verbose` | Verbose output |
| `--no-color` | Disable colored output |
Also respects the `NO_COLOR` and `FORCE_COLOR` environment variables.
### Subcommand Aliases
| Command | Aliases |
|---|---|
| `commit` | `c`, `ci` |
| `pr` | `p` |
| `config` | `cfg` |
| `explain` | `x` |
| `review` | `r`, `rv` |
| `changelog` | `cl`, `log` |
| `suggest` | `sg` |
| `help` | `h` |
### Pipe Support
All AI commands accept piped input — no git repository required:
```bash
# Explain any diff
git diff main..feature | gai explain
# Review changes from a PR
curl https://patch-diff.githubusercontent.com/... | gai review
# Suggest branch name for a diff
git diff | gai suggest branch
```
### Interactive Menu ### Interactive Menu
Run `gai` without arguments to open the mole-style interactive menu:
``` ```
gai v0.1.3 $ gai
AI-powered git helper for commits, PRs, reviews, and changelogs
────────────────────────────────────────────────────────────────────────
CREATE gai
1 Commit Generate AI commit message Choose a workflow
2 PR Create a PR with AI-generated title ↑/↓ navigate · enter/space select · ctrl+c cancel
3 Amend Amend last commit with AI message
INSPECT ● commit Generate AI commit message
4 Explain Explain staged changes in plain language ○ pr Create a PR with AI-generated title
5 Review AI code review of staged changes ○ config Configure API settings
6 Changelog Generate changelog from commits
7 Suggest Suggest branch name or commit type
PROJECT
8 Config Configure API settings
────────────────────────────────────────────────────────────────────────
↑/↓ navigate enter run 1-8 jump h help v version q quit
``` ```
Number keys `1``8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu. ### Commit Flow
### Command Examples ```
$ gai commit
#### Commit Staged files (will be included):
✓ src/git.ts (modified)
Select files to stage:
2 unstaged files available
↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel
□ Select all
□ src/ai.ts modified
■ src/newfile.ts new
Generating commit message...
Generated commit message:
feat(git): add interactive file staging and commit wrapper
Use this message? [Y/n/e] Y
✔ Committed successfully!
[main a3f7c2b] feat(git): add interactive file staging and commit wrapper
1 file changed, 45 insertions(+), 12 deletions(-)
```
### Pull Request Flow
`gai pr` detects the remote platform from `origin`:
- GitHub remotes use the `gh` CLI
- Gitea remotes use the `tea` CLI
- GitLab remotes use the `glab` CLI
- Unknown remotes prompt you to choose a platform
The command compares your current branch against the default branch, pushes the branch if needed, generates a PR title/body from the branch commits and diff, then asks for confirmation before creating the PR.
```bash ```bash
# Interactive file selection
gai commit
# Auto-stage everything
gai commit -a
# Dry-run (no commit)
gai commit -d
# Custom message (skip AI)
gai commit -m "fix: correct typo in README"
# Amend last commit with AI message
gai commit --amend
# Pipe diff for commit message
git diff --staged | gai commit
```
#### PR
```bash
# Create PR
gai pr gai pr
# Draft PR
gai pr --draft gai pr --draft
``` ```
#### Explain
```bash
# Explain staged changes
gai explain
# Explain unstaged changes
gai explain --unstaged
# Pipe any diff
git diff HEAD~3 | gai explain
```
#### Review
```bash
# Review staged changes (normal strictness)
gai review
# Thorough review
gai review --strict
# Lenient review
gai review --lenient
# Review unstaged changes
gai review --unstaged
```
#### Changelog
```bash
# From last 20 commits
gai changelog
# Custom count
gai changelog -n 50
# Between tags
gai changelog --from v1.0.0 --to v1.1.0
```
#### Suggest
```bash
# Suggest branch name
gai suggest branch
# Suggest commit type
gai suggest type
```
## Configuration ## Configuration
### Via `gai config` (interactive) ### Via `gai config` (interactive)
@@ -239,15 +126,6 @@ gai suggest type
gai config gai config
``` ```
### CLI-based config
```bash
gai config list
gai config get model
gai config set model gpt-4o
gai config set temperature 0.3
```
### Via environment variables ### Via environment variables
| Variable | Default | Description | | Variable | Default | Description |
@@ -315,14 +193,13 @@ GAI_MODEL=anthropic/claude-sonnet-4
│ │ │ │
│ 2. Collect code changes │ │ 2. Collect code changes │
│ ├─ git diff --staged for commits │ │ ├─ git diff --staged for commits │
Branch diff for pull requests │ branch diff for pull requests │
│ └─ Pipe support for external diffs │
│ │ │ │
│ 3. Collect commit history │ │ 3. Collect commit history │
│ ├─ git log --oneline -10 for commits │ │ ├─ git log --oneline -10 for commits │
│ └─ Branch commits for pull requests │ │ └─ branch commits for pull requests │
│ │ │ │
│ 4. Build prompt → Call AI API (streaming) │ 4. Build prompt → Call AI API
│ │ │ │
│ 5. Review → Confirm → Commit or Create PR │ │ 5. Review → Confirm → Commit or Create PR │
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
+69 -234
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun #!/usr/bin/env bun
// gai — AI-powered git commit and PR helper // gai — AI-powered git commit and PR helper
// v0.2.0
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli"; import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
import { handleCommit } from "./src/commands/commit"; import { handleCommit } from "./src/commands/commit";
@@ -10,161 +11,12 @@ import { handleExplain } from "./src/commands/explain";
import { handleReview } from "./src/commands/review"; import { handleReview } from "./src/commands/review";
import { handleChangelog } from "./src/commands/changelog"; import { handleChangelog } from "./src/commands/changelog";
import { handleSuggest } from "./src/commands/suggest"; import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled, BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, clearScreen, visibleLength, padRight } from "./src/terminal"; import { setColorEnabled } from "./src/terminal";
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty"; import { isStdinTTY, initTTY } from "./src/tty";
import { VERSION } from "./src/brand"; import { BACK, selectOne } from "./src/menu";
import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ───────────────────────────────────── // ── Interactive Menu (default, no subcommand) ─────────────────────────
interface MenuItem {
key: string;
label: string;
description: string;
group: "Create" | "Inspect" | "Project";
}
const MENU_ITEMS: MenuItem[] = [
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
];
async function readKey(): Promise<string> {
return new Promise((resolve) => {
const onData = (data: Buffer) => {
process.stdin.removeListener("data", onData);
resolve(data.toString());
};
process.stdin.once("data", onData);
});
}
function renderMenu(cursor: number): number {
process.stdout.write("\x1b[H"); // cursor home
let lineCount = 0;
const C = CYAN();
const D = DIM();
const G = GREEN();
const R = RESET();
const width = 72;
const separator = `${D}${"─".repeat(width)}${R}`;
const write = (line = "") => {
clearLine();
process.stdout.write(`${line}\n`);
lineCount++;
};
write("");
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
write(` ${separator}`);
write("");
const keyWidth = 3;
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLength(m.label))) + 2;
let currentGroup: MenuItem["group"] | null = null;
for (let i = 0; i < MENU_ITEMS.length; i++) {
const item = MENU_ITEMS[i]!;
const num = String(i + 1);
const active = i === cursor;
if (item.group !== currentGroup) {
if (currentGroup !== null) write("");
write(` ${D}${item.group.toUpperCase()}${R}`);
currentGroup = item.group;
}
const pointer = active ? `${C}${R}` : " ";
const key = active ? `${G}${num}${R}` : `${D}${num}${R}`;
const label = active ? `${BOLD()}${item.label}${R}` : item.label;
const description = active ? item.description : `${D}${item.description}${R}`;
const row = [
pointer,
padRight(key, keyWidth),
padRight(label, labelWidth),
description,
].join(" ");
if (active) {
write(` ${C}${R} ${row}`);
} else {
write(` ${row}`);
}
}
write("");
write(` ${separator}`);
write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
write("");
// Clear rest of screen
process.stdout.write("\x1b[J");
return lineCount;
}
async function dispatchMenuAction(key: string): Promise<number> {
const fakeArgs: ParsedArgs = {
command: key,
flags: {},
positional: [],
raw: [],
subcommand: { name: key, description: "", handler: async () => 0 },
};
if (key === "amend") fakeArgs.flags["amend"] = true;
switch (key) {
case "commit": return handleCommit(fakeArgs);
case "pr": return handlePR(fakeArgs);
case "config": return handleConfig(fakeArgs);
case "explain": return handleExplain(fakeArgs);
case "review": return handleReview(fakeArgs);
case "changelog": return handleChangelog(fakeArgs);
case "suggest": return handleSuggest(fakeArgs);
case "amend": return handleCommit(fakeArgs);
default: return 0;
}
}
async function waitForEnter(): Promise<void> {
const D = DIM();
const R = RESET();
process.stdout.write(`\n ${D}Press Enter to return to menu...${R}`);
// Read a line from stdin (works in cooked mode — blocks until Enter)
await new Promise<void>((resolve) => {
const onData = (data: Buffer) => {
process.stdin.removeListener("data", onData);
resolve();
};
process.stdin.on("data", onData);
// Resume stdin in case it was paused
process.stdin.resume();
});
clearScreen();
}
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
clearScreen();
const result = await dispatchMenuAction(item.key);
if (result === (SKIP_WAIT as unknown as number)) {
return 0; // user explicitly backed out — skip "Press Enter" and return directly
}
await waitForEnter();
return result;
}
async function showMenu(): Promise<number> { async function showMenu(): Promise<number> {
if (!isStdinTTY()) { if (!isStdinTTY()) {
@@ -172,91 +24,72 @@ async function showMenu(): Promise<number> {
return 1; return 1;
} }
let cursor = 0; while (true) {
const wasRaw = process.stdin.isRaw; const selected = await selectOne({
title: "gai",
subtitle: "AI-powered git helper — choose a workflow",
allowBack: false,
items: [
{ label: "commit", value: "commit", description: "Generate AI commit message" },
{ label: "pr", value: "pr", description: "Create a PR with AI-generated title" },
{ label: "explain", value: "explain", description: "Explain staged changes in plain language" },
{ label: "review", value: "review", description: "AI code review of staged changes" },
{ label: "changelog", value: "changelog", description: "Generate changelog from commits" },
{ label: "suggest", value: "suggest", description: "Suggest branch name or commit type" },
{ label: "amend", value: "amend", description: "Amend last commit with AI message" },
{ label: "config", value: "config", description: "Configure API settings" },
],
});
if (wasRaw !== true) process.stdin.setRawMode(true); if (selected === null || selected === BACK) return 0;
process.stdin.resume();
hideCursor();
// Initial render // Build synthetic args for subcommand handlers
renderMenu(cursor); const fakeArgs: ParsedArgs = {
command: selected,
const exitMenu = (exitCode: number): number => { flags: {},
showCursor(); positional: [],
process.stdin.setRawMode(wasRaw === true); raw: [],
process.stdin.pause(); subcommand: {
return exitCode; name: selected,
description: "",
handler: async () => 0,
},
}; };
try { let result: number;
while (true) { switch (selected) {
const raw = await readKey(); case "commit":
result = await handleCommit(fakeArgs);
// Escape sequences (arrows) break;
if (raw === "\x1b[A" || raw === "\x1bOA") { case "pr":
if (cursor > 0) { cursor--; renderMenu(cursor); } result = await handlePR(fakeArgs);
continue; break;
} case "config":
if (raw === "\x1b[B" || raw === "\x1bOB") { result = await handleConfig(fakeArgs);
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); } break;
continue; case "explain":
result = await handleExplain(fakeArgs);
break;
case "review":
result = await handleReview(fakeArgs);
break;
case "changelog":
result = await handleChangelog(fakeArgs);
break;
case "suggest":
result = await handleSuggest(fakeArgs);
break;
case "amend":
fakeArgs.flags["amend"] = true;
result = await handleCommit(fakeArgs);
break;
default:
result = 0;
} }
// Enter // Return to menu unless they explicitly chose "back"
if (raw === "\r" || raw === "\n") { if (result !== 0) return result;
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw); // Loop back to menu for another action
if (result !== 0) return exitMenu(result);
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(cursor);
continue;
}
// Ctrl+C
if (raw === "\x03") {
process.stdout.write("\n");
return exitMenu(0);
}
// Number hotkeys (1-8)
if (raw >= "1" && raw <= "8") {
const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) {
cursor = idx;
renderMenu(cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
if (result !== 0) return exitMenu(result);
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(cursor);
continue;
}
}
// Letter hotkeys
const lower = raw.toLowerCase();
if (lower === "h") {
clearScreen();
console.log(formatHelp(commands));
return exitMenu(0);
}
if (lower === "v") {
clearScreen();
console.log(`gai v${VERSION}`);
return exitMenu(0);
}
if (lower === "q") {
process.stdout.write("\n");
return exitMenu(0);
}
}
} finally {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
} }
} }
@@ -408,8 +241,10 @@ const commands = registerCommands(
}, },
); );
// Keep canonical command defs accessible for help command (deduplicate by reference) // Keep the defs accessible for help command
const allCommandDefs = [...new Set(commands.values())]; const allCommandDefs = [...commands.values()].filter(
(c, i, arr) => arr.findIndex((x) => x.name === c.name) === i,
);
// ── Main ─────────────────────────────────────────────────────────────── // ── Main ───────────────────────────────────────────────────────────────
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "gai", "name": "gai",
"version": "0.1.4", "version": "0.1.2",
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more", "description": "AI-powered git commit message generator",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"bin": { "bin": {
+2 -7
View File
@@ -123,6 +123,7 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); const lines = buffer.split("\n");
// Keep the last potentially incomplete line
buffer = lines.pop() ?? ""; buffer = lines.pop() ?? "";
for (const line of lines) { for (const line of lines) {
@@ -135,12 +136,7 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
try { try {
const parsed = JSON.parse(data) as { const parsed = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>; 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; const token = parsed.choices?.[0]?.delta?.content;
if (token) { if (token) {
fullText += token; fullText += token;
@@ -156,8 +152,7 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
} }
} }
} finally { } finally {
try { await reader.cancel(); } catch {} reader.releaseLock();
// releaseLock is not needed after cancel
} }
callbacks.onDone?.(fullText); callbacks.onDone?.(fullText);
-22
View File
@@ -1,22 +0,0 @@
// 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");
}
+14 -3
View File
@@ -1,8 +1,6 @@
// Lightweight CLI argument parser aligned with mainstream CLI conventions. // Lightweight CLI argument parser aligned with mainstream CLI conventions.
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version. // Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
import { VERSION } from "./brand";
export interface FlagDef { export interface FlagDef {
long: string; // e.g. "dry-run" long: string; // e.g. "dry-run"
short?: string; // e.g. "d" short?: string; // e.g. "d"
@@ -46,6 +44,19 @@ function buildFlagIndex(flags: FlagDef[]): Map<string, FlagDef> {
return index; return index;
} }
function resolveFlagName(raw: string): { flag: FlagDef; value?: string } | null {
// "--key=value"
const eqIndex = raw.indexOf("=");
if (eqIndex !== -1) {
const name = raw.slice(0, eqIndex);
const value = raw.slice(eqIndex + 1);
const allFlags = buildFlagIndex([...GLOBAL_FLAGS]); // we'll rebuild in context
// We'll handle = syntax in the main parse loop with proper index
return null; // handled inline
}
return null; // handled inline
}
function parseArgs( function parseArgs(
rawArgs: string[], rawArgs: string[],
commands: Map<string, CommandDef>, commands: Map<string, CommandDef>,
@@ -271,7 +282,7 @@ export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef
// Handle --version globally // Handle --version globally
if (result.flags["version"]) { if (result.flags["version"]) {
console.log(`gai v${VERSION}`); console.log("gai v0.2.0");
return 0; return 0;
} }
+1 -3
View File
@@ -19,9 +19,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
proc.stdin.end(); proc.stdin.end();
const exitCode = await proc.exited; const exitCode = await proc.exited;
if (exitCode === 0) return true; if (exitCode === 0) return true;
} catch { } catch {}
// Try next command
}
} }
return false; return false;
+1 -1
View File
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks | undefined = tty ? { const callbacks: StreamCallbacks = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+118 -32
View File
@@ -1,3 +1,4 @@
import * as readline from "node:readline";
import { import {
isGitRepo, isGitRepo,
getRepoRoot, getRepoRoot,
@@ -6,22 +7,30 @@ import {
getStagedDiff, getStagedDiff,
getRecentCommits, getRecentCommits,
stageFiles, stageFiles,
applyFileSelection,
commit, commit,
} from "../git"; } from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK } from "../menu";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
import { buildPrompt, SYSTEM_PROMPT } from "../prompt"; import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
import { generateCommitMessage } from "../ai"; import { generateCommitMessage } from "../ai";
import { copyToClipboard } from "../clipboard"; import { copyToClipboard } from "../clipboard";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty"; import { isStdinTTY } from "../tty";
import { ask, editLine } from "../tty-input";
import type { Config, CommitResult, StreamCallbacks } from "../types"; import type { Config, CommitResult, StreamCallbacks } from "../types";
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
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());
});
});
}
function printCommitResult(result: CommitResult, msg: string) { function printCommitResult(result: CommitResult, msg: string) {
console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`); console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`);
const id = result.branch && result.hash const id = result.branch && result.hash
@@ -41,19 +50,6 @@ function printCommitResult(result: CommitResult, msg: string) {
if (parts.length > 0) console.log(` ${parts.join(", ")}`); if (parts.length > 0) console.log(` ${parts.join(", ")}`);
} }
function printSelectionResult(result: { staged: string[]; unstaged: string[] }) {
const parts: string[] = [];
if (result.staged.length > 0) {
parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`);
}
if (result.unstaged.length > 0) {
parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`);
}
if (parts.length > 0) {
console.log(` Updated staging area: ${parts.join(", ")}`);
}
}
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}\n`); console.log(` ${GREEN()}${message}${RESET()}\n`);
@@ -65,8 +61,99 @@ async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
} }
async function editMessage(current: string): Promise<string | null> { async function editMessage(current: string): Promise<string | null> {
if (!isStdinTTY()) return null;
process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`); process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`);
return editLine(current);
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
let buffer = current;
let cursor = current.length;
function render() {
process.stdout.write("\x1b[2K\r > " + buffer);
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
}
}
process.stdout.write(" > ");
process.stdout.write(buffer);
return new Promise((resolve) => {
let escapeBuf = "";
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === "\x03") {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
resolve(null);
return;
}
if (key === "\x1b" || key.startsWith("\x1b[")) {
escapeBuf = key;
if (key.length >= 3) { handleSeq(key); escapeBuf = ""; }
return;
}
if (escapeBuf) {
escapeBuf += key;
if (/^[A-Za-z~]$/.test(key)) { handleSeq(escapeBuf); escapeBuf = ""; }
else if (escapeBuf.length > 8) escapeBuf = "";
return;
}
if (key === "\r") {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
const result = buffer.trim();
resolve(result || null);
return;
}
if (key === "\x7f") {
if (cursor > 0) {
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
cursor--;
render();
}
return;
}
if (key === "\x01") { if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } return; }
if (key === "\x05") { if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } return; }
if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; }
if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; }
if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
cursor++;
render();
}
});
function handleSeq(seq: string) {
if (seq === "\x1b[D" || seq === "\x1bOD") {
if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); }
} else if (seq === "\x1b[C" || seq === "\x1bOC") {
if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); }
} else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") {
if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
} else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") {
if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
} else if (seq === "\x1b[3~") {
if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); }
}
}
});
} }
export async function handleCommit(args: ParsedArgs): Promise<number> { export async function handleCommit(args: ParsedArgs): Promise<number> {
@@ -86,22 +173,16 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
const stagedFiles = await getStagedFiles(); const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) { if (stagedFiles.length === 0 && !amend) {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`); if (autoMode && unstagedFiles.length > 0) {
return 1;
}
if (!amend && autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path)); await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (!amend) { } else if (unstagedFiles.length > 0) {
if (isStdinTTY()) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
} else if (stagedFiles.length === 0 && unstagedFiles.length > 0) {
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`); console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
return 1; return 1;
} else {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
return 1;
} }
} }
@@ -134,13 +215,18 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
return 0; return 0;
} }
if (autoMode && unstagedFiles.length > 0) { if (unstagedFiles.length > 0) {
if (autoMode) {
await stageFiles(unstagedFiles.map((f) => f.path)); await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else { } else {
const selected = await selectFiles(stagedFiles, unstagedFiles); const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number; if (selected === BACK) return 0;
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
}
}
} }
const diff = await getStagedDiff(); const diff = await getStagedDiff();
+19 -9
View File
@@ -1,8 +1,6 @@
import { loadConfig, saveConfig } from "../config"; import { loadConfig, saveConfig } from "../config";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength } from "../terminal"; import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty"; import { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu";
import { editLine } from "../tty-input";
import type { Config } from "../types"; import type { Config } from "../types";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
@@ -67,6 +65,18 @@ const CONFIG_FIELDS: ConfigField[] = [
}, },
]; ];
function visibleLength(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function clearLine() {
process.stdout.write("\r\x1b[2K");
}
function moveUp(lines: number) {
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
}
function renderConfigPage( function renderConfigPage(
config: Config, config: Config,
cursor: number, cursor: number,
@@ -140,29 +150,29 @@ async function interactiveConfig(): Promise<"done" | "back"> {
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
hideCursor(); process.stdout.write("\x1b[?25l");
const render = () => { const render = () => {
moveUp(renderedCursorRow); moveUp(renderedCursorRow);
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState); renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
renderedCursorRow = editState ? 4 + cursor : 0; renderedCursorRow = editState ? 4 + cursor : 0;
editState ? showCursor() : hideCursor(); process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l");
}; };
render(); render();
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const finish = (value: "done" | "back") => { const finish = (value: "done" | "back") => {
process.stdin.removeListener("data", onData);
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData);
moveUp(renderedCursorRow); moveUp(renderedCursorRow);
for (let i = 0; i < renderedLines; i++) { for (let i = 0; i < renderedLines; i++) {
clearLine(); clearLine();
process.stdout.write("\n"); process.stdout.write("\n");
} }
moveUp(renderedLines); moveUp(renderedLines);
showCursor(); process.stdout.write("\x1b[?25h");
resolve(value); resolve(value);
}; };
@@ -313,7 +323,7 @@ export async function handleConfig(args: ParsedArgs): Promise<number> {
// gai config (no args) → interactive // gai config (no args) → interactive
if (positional.length === 0) { if (positional.length === 0) {
const result = await interactiveConfig(); const result = await interactiveConfig();
return result === "back" ? (SKIP_WAIT as unknown as number) : 0; return result === "back" ? 0 : 0;
} }
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`); console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
+69 -14
View File
@@ -1,6 +1,8 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { collectDiff } from "../diff-source"; import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import { BACK, SKIP_WAIT } from "../menu"; import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { collectProjectContext } from "../context";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt"; import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai"; import { callAI } from "../ai";
import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal"; import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal";
@@ -17,33 +19,85 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
} }
const unstaged = args.flags["unstaged"] as boolean; const unstaged = args.flags["unstaged"] as boolean;
const staged = args.flags["staged"] as boolean;
const verbose = args.flags["verbose"] as boolean; const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain
let diff: string; let diff: string;
let sourceLabel: string; let sourceLabel: string;
let contextPrefix: string;
try { if (unstaged) {
const result = await collectDiff({ unstaged, includeProjectContext: true }); if (!(await isGitRepo())) {
if (result.back) return SKIP_WAIT as unknown as number; console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
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; return 1;
} }
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
sourceLabel = "unstaged changes";
} else {
// Default: staged changes (or piped)
if (isStdinTTY()) {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
} else {
// Read from pipe
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
}
}
if (!diff) { if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`); console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
return 0; return 0;
} }
if (verbose) { if (args.flags["verbose"]) {
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`); console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
} }
const userPrompt = contextPrefix + buildExplainPrompt(diff); const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
// Collect project context for better explanations
let contextPrefix = "";
try {
if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
if (verbose) { if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
@@ -55,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks | undefined = tty ? { const callbacks: StreamCallbacks = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
@@ -63,6 +117,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
if (callbacks) { if (callbacks) {
process.stdout.write("\n"); process.stdout.write("\n");
} else { } else {
// Non-TTY: print the result directly
process.stdout.write(explanation + "\n"); process.stdout.write(explanation + "\n");
} }
} catch (err) { } catch (err) {
+14 -4
View File
@@ -1,9 +1,10 @@
import * as readline from "node:readline";
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getRepoRoot } from "../git"; import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt"; import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
import { generatePRMessage } from "../ai"; import { generatePRMessage } from "../ai";
import { BACK, SKIP_WAIT, selectOne } from "../menu"; import { BACK, selectOne } from "../menu";
import { import {
getDefaultBranch, getDefaultBranch,
getBranchName, getBranchName,
@@ -18,10 +19,19 @@ import {
import type { Platform } from "../pr"; import type { Platform } from "../pr";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty"; import { isStdinTTY } from "../tty";
import { ask } from "../tty-input";
import { copyToClipboard } from "../clipboard"; import { copyToClipboard } from "../clipboard";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
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());
});
});
}
async function selectPlatform(hostname: string): Promise<Platform | null | typeof BACK> { async function selectPlatform(hostname: string): Promise<Platform | null | typeof BACK> {
if (!isStdinTTY()) { if (!isStdinTTY()) {
console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`); console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`);
@@ -58,7 +68,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
if (!platform) { if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown"; const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname); const chosen = await selectPlatform(hostname);
if (chosen === BACK) return SKIP_WAIT as unknown as number; if (chosen === BACK) return 0;
if (!chosen) { if (!chosen) {
console.log(" Aborted."); console.log(" Aborted.");
return 0; return 0;
@@ -88,7 +98,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
items: [{ label: "Back", value: "back" as const }], items: [{ label: "Back", value: "back" as const }],
}); });
if (choice === null) process.exit(0); if (choice === null) process.exit(0);
return SKIP_WAIT as unknown as number; return 0;
} }
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`); console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
+60 -13
View File
@@ -1,6 +1,8 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { collectDiff } from "../diff-source"; import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import { BACK, SKIP_WAIT } from "../menu"; import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { collectProjectContext } from "../context";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt"; import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai"; import { callAI } from "../ai";
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal"; import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
@@ -27,25 +29,70 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
let diff: string; let diff: string;
let sourceLabel: string; let sourceLabel: string;
let contextPrefix: string;
try { if (unstaged) {
const result = await collectDiff({ unstaged, includeProjectContext: true }); if (!(await isGitRepo())) {
if (result.back) return SKIP_WAIT as unknown as number; console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
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; return 1;
} }
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
sourceLabel = "unstaged changes";
} else if (!isStdinTTY()) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
} else {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
}
if (!diff) { if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`); console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
return 0; return 0;
} }
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag); const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
let contextPrefix = "";
try {
if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag);
const strictnessLabel = strictnessFlag === "strict" const strictnessLabel = strictnessFlag === "strict"
? `${RED()}strict${RESET()}` ? `${RED()}strict${RESET()}`
@@ -63,7 +110,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks | undefined = tty ? { const callbacks: StreamCallbacks = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+46 -13
View File
@@ -1,6 +1,7 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { collectDiff } from "../diff-source"; import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
import { BACK, SKIP_WAIT } from "../menu"; import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { import {
SUGGEST_SYSTEM_PROMPT, SUGGEST_SYSTEM_PROMPT,
buildSuggestBranchPrompt, buildSuggestBranchPrompt,
@@ -29,32 +30,64 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
return 1; return 1;
} }
const unstaged = args.flags["unstaged"] as boolean; // Get diff (staged, or unstaged if --unstaged, or piped)
let diff: string; let diff: string;
if (!isStdinTTY()) {
try { const chunks: Buffer[] = [];
const result = await collectDiff({ unstaged, includeProjectContext: false }); for await (const chunk of process.stdin) {
if (result.back) return SKIP_WAIT as unknown as number; chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
diff = result.diff; }
} catch (err) { diff = Buffer.concat(chunks).toString("utf-8").trim();
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`); } else {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1; return 1;
} }
if (args.flags["unstaged"] as boolean) {
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
} else {
diff = await getStagedDiff();
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
}
}
if (!diff) { if (!diff) {
console.log(` ${DIM()}No changes to suggest from.${RESET()}`); console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
return 0; return 0;
} }
const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
if (verbose) { if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
} }
if (mode === "branch") { if (mode === "branch") {
return handleSuggestBranch(config, diff); return handleSuggestBranch(config, truncatedDiff);
} else {
return handleSuggestType(config, truncatedDiff);
} }
return handleSuggestType(config, diff);
} }
async function handleSuggestBranch(config: Config, diff: string): Promise<number> { async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
-98
View File
@@ -1,98 +0,0 @@
// 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 };
}
+6 -38
View File
@@ -37,17 +37,12 @@ function parseNameStatus(output: string): FileEntry[] {
return output return output
.trim() .trim()
.split("\n") .split("\n")
.filter((line) => line.trim()) .filter(Boolean)
.map((line) => { .map((line) => {
const tabIdx = line.indexOf("\t"); const [status, ...pathParts] = line.split("\t");
if (tabIdx === -1) return null; const path = pathParts[pathParts.length - 1] ?? "";
const status = line.slice(0, tabIdx); return { path, status: status!, label: statusToLabel(status!) };
// 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[]> { export async function getStagedFiles(): Promise<FileEntry[]> {
@@ -103,33 +98,6 @@ export async function stageFiles(paths: string[]): Promise<void> {
await Bun.$`git add -- ${paths}`; await Bun.$`git add -- ${paths}`;
} }
export async function unstageFiles(paths: string[]): Promise<void> {
if (paths.length === 0) return;
try {
await Bun.$`git restore --staged -- ${paths}`.quiet();
} catch {
await Bun.$`git rm --cached -r -- ${paths}`.quiet();
}
}
export async function applyFileSelection(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
selectedPaths: string[],
): Promise<{ staged: string[]; unstaged: string[] }> {
const selected = new Set(selectedPaths);
const stagedPaths = new Set(stagedFiles.map((file) => file.path));
const unstagedPaths = new Set(unstagedFiles.map((file) => file.path));
const toUnstage = [...stagedPaths].filter((path) => !selected.has(path));
const toStage = [...selected].filter((path) => unstagedPaths.has(path));
await unstageFiles(toUnstage);
await stageFiles(toStage);
return { staged: toStage, unstaged: toUnstage };
}
export async function commit( export async function commit(
message: string, message: string,
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> { ): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
@@ -145,7 +113,7 @@ export async function commit(
throw new Error(stderr.trim() || `git commit failed (exit code ${exitCode})`); throw new Error(stderr.trim() || `git commit failed (exit code ${exitCode})`);
} }
const branchHashMatch = stdout.match(/\[(\S+)\s+(?:\(root-commit\)\s+)?([0-9a-f]{7,})/); const branchHashMatch = stdout.match(/\[(\S+)\s+([0-9a-f]{7,})/);
const branch = branchHashMatch?.[1] ?? ""; const branch = branchHashMatch?.[1] ?? "";
const hash = branchHashMatch?.[2] ?? ""; const hash = branchHashMatch?.[2] ?? "";
+28 -55
View File
@@ -1,4 +1,4 @@
import { BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength, padRight } from "./terminal"; import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
import { isStdinTTY } from "./tty"; import { isStdinTTY } from "./tty";
const UP = "\x1b[A"; const UP = "\x1b[A";
@@ -15,10 +15,6 @@ const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-back"); export const BACK = Symbol("prompt-back");
export type PromptBack = typeof BACK; export type PromptBack = typeof BACK;
// Sent by command handlers to skip the "Press Enter to return" wait in the
// interactive menu when the user explicitly backed out of a sub-menu.
export const SKIP_WAIT = Symbol("skip-wait");
export interface Choice<T> { export interface Choice<T> {
label: string; label: string;
value: T; value: T;
@@ -43,7 +39,22 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
doneLabel?: string; doneLabel?: string;
} }
function padLabel(label: string, width: number): 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) {
return label + " ".repeat(Math.max(1, width - visibleLength(label))); return label + " ".repeat(Math.max(1, width - visibleLength(label)));
} }
@@ -77,8 +88,7 @@ function clearPrompt(lines: number) {
moveUp(lines); moveUp(lines);
} }
function normalizeKey(key: string, escapeBuf: string): { action: string | null; escapeBuf: string } { function normalizeKey(key: string, escapeBuf: string) {
// Single-chunk actions
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", 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: "" };
@@ -86,20 +96,14 @@ function normalizeKey(key: string, escapeBuf: string): { action: string | null;
if (key === ENTER) return { action: "enter", escapeBuf: "" }; if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
// Start of an escape sequence — buffer it if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key };
if (key === "\x1b" || key.startsWith("\x1b[") || key.startsWith("\x1bO")) {
return { action: null, escapeBuf: key };
}
// Continue buffering an escape sequence
if (escapeBuf) { if (escapeBuf) {
const next = escapeBuf + key; const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" }; if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" };
// If key is a terminal character (letter/digit/~) or buffer got too long, flush return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next };
if (/^[A-Za-z~0-9]$/.test(key) || next.length > 10) return { action: null, escapeBuf: "" };
return { action: null, escapeBuf: next };
} }
return { action: null, escapeBuf: "" }; return { action: null, escapeBuf: "" };
@@ -160,25 +164,15 @@ export async function selectOne<T>(
const render = () => { const render = () => {
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines); renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
}; };
render();
const cleanup = () => { return new Promise((resolve) => {
const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines); clearPrompt(renderedLines);
showCursor(); showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T | null | PromptBack) => {
process.stdin.removeListener("data", onData);
cleanup();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -186,7 +180,6 @@ export async function selectOne<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
try {
const result = normalizeKey(data.toString(), escapeBuf); const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
@@ -197,10 +190,6 @@ export async function selectOne<T>(
else if (result.action === "space" || result.action === "enter") { else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value); finish(options.items[cursor]!.value);
} }
} catch (err) {
cleanup();
reject(err);
}
}; };
process.stdin.on("data", onData); process.stdin.on("data", onData);
@@ -232,7 +221,6 @@ export async function selectMany<T>(
if (!options.selectAllLabel) return; if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected); items[0]!.selected = items.slice(1).every((item) => item.selected);
}; };
syncSelectAll();
const toggle = (index: number) => { const toggle = (index: number) => {
const item = items[index]!; const item = items[index]!;
@@ -250,25 +238,15 @@ export async function selectMany<T>(
renderedLines, renderedLines,
); );
}; };
render();
const cleanup = () => { return new Promise((resolve) => {
const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines); clearPrompt(renderedLines);
showCursor(); showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T[] | null | PromptBack) => {
process.stdin.removeListener("data", onData);
cleanup();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -276,7 +254,6 @@ export async function selectMany<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
try {
const result = normalizeKey(data.toString(), escapeBuf); const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
@@ -288,10 +265,6 @@ export async function selectMany<T>(
else if (result.action === "enter") { else if (result.action === "enter") {
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T)); finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
} }
} catch (err) {
cleanup();
reject(err);
}
}; };
process.stdin.on("data", onData); process.stdin.on("data", onData);
+77 -33
View File
@@ -124,36 +124,6 @@ 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( export async function createPR(
platform: Platform, platform: Platform,
title: string, title: string,
@@ -161,8 +131,20 @@ export async function createPR(
base: string, base: string,
draft: boolean, draft: boolean,
): Promise<string> { ): Promise<string> {
const cli = PLATFORM_CLI[platform]; if (platform === "github") {
const proc = Bun.spawn([cli.bin, ...cli.args(title, body, base, draft)], { const args = [
"pr",
"create",
"--title",
title,
"--body",
body,
"--base",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["gh", ...args], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}); });
@@ -171,7 +153,69 @@ export async function createPR(
const stderr = await new Response(proc.stderr).text(); const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error(stderr.trim() || `${cli.label} failed (exit code ${exitCode})`); 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], {
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() || `tea pulls create failed (exit code ${exitCode})`,
);
} }
const match = stdout.match(/(https?:\/\/[^\s]+)/); const match = stdout.match(/(https?:\/\/[^\s]+)/);
+19 -29
View File
@@ -1,54 +1,44 @@
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty"; import { isStdinTTY } from "./tty";
import { BACK, selectMany } from "./menu"; import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu"; import type { PromptBack } from "./menu";
function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) {
const files = new Map<string, FileEntry & { staged: boolean; unstaged: boolean }>();
for (const file of stagedFiles) {
files.set(file.path, { ...file, staged: true, unstaged: false });
}
for (const file of unstagedFiles) {
const existing = files.get(file.path);
if (existing) {
existing.unstaged = true;
existing.label = existing.label === file.label
? existing.label
: `${existing.label}, ${file.label}`;
} else {
files.set(file.path, { ...file, staged: false, unstaged: true });
}
}
return [...files.values()];
}
export async function selectFiles( export async function selectFiles(
stagedFiles: FileEntry[], stagedFiles: FileEntry[],
unstagedFiles: FileEntry[], unstagedFiles: FileEntry[],
): Promise<string[] | PromptBack> { ): Promise<string[] | PromptBack> {
const files = mergeFiles(stagedFiles, unstagedFiles); if (unstagedFiles.length === 0) return [];
if (files.length === 0) return [];
if (!isStdinTTY()) return stagedFiles.map((file) => file.path); if (stagedFiles.length > 0) {
process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
for (const f of stagedFiles) {
process.stdout.write(` ${GREEN()}${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
}
}
if (!isStdinTTY()) return [];
const selected = await selectMany({ const selected = await selectMany({
title: "Select files for this action", title: "Select files to stage",
subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`, subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
selectAllLabel: "Select all", selectAllLabel: "Select all",
cancelMessage: "Aborted.", cancelMessage: "Aborted.",
items: files.map((f) => ({ items: unstagedFiles.map((f) => ({
label: f.path, label: f.path,
value: f.path, value: f.path,
description: f.label, description: f.label,
selected: f.staged,
})), })),
}); });
if (selected === null) process.exit(1); if (selected === null) process.exit(1);
if (selected === BACK) return BACK; if (selected === BACK) return BACK;
if (selected.length > 0) {
process.stdout.write(
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
);
}
return selected; return selected;
} }
+3 -45
View File
@@ -1,10 +1,8 @@
// Terminal styling and rendering utilities. // Terminal styling utilities.
// Respects NO_COLOR convention, --no-color flag, and TTY detection. // Respects NO_COLOR convention, --no-color flag, and TTY detection.
import { isStdoutTTY } from "./tty"; import { isStdoutTTY } from "./tty";
// ── Color support ─────────────────────────────────────────────────────
let _enabled: boolean | null = null; let _enabled: boolean | null = null;
export function setColorEnabled(enabled: boolean): void { export function setColorEnabled(enabled: boolean): void {
@@ -16,19 +14,11 @@ export function isColorEnabled(): boolean {
// Respect NO_COLOR: https://no-color.org/ // Respect NO_COLOR: https://no-color.org/
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") { if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
_enabled = false;
return false; return false;
} }
if (!isStdoutTTY()) { if (!isStdoutTTY()) return false;
_enabled = false; if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
return false;
}
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
_enabled = true;
return true;
}
_enabled = true;
return true; return true;
} }
@@ -43,35 +33,3 @@ export const YELLOW = () => s("\x1b[33m");
export const CYAN = () => s("\x1b[36m"); export const CYAN = () => s("\x1b[36m");
export const RED = () => s("\x1b[31m"); export const RED = () => s("\x1b[31m");
export const RESET = () => s("\x1b[0m"); 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
@@ -1,163 +0,0 @@
// 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();
}
});
});
}
+4 -3
View File
@@ -23,13 +23,14 @@ export function isStdinTTY(): boolean {
} }
export function isStdoutTTY(): boolean { export function isStdoutTTY(): boolean {
// Primary check: fstat on fd 1 (stdout)most reliable // Use a heuristic for stdout — check if we're in a terminal
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
if (process.env.NO_COLOR) return false;
// Try fstat on fd 1 (stdout)
try { try {
const stat = fstatSync(1); const stat = fstatSync(1);
return stat.isCharacterDevice(); return stat.isCharacterDevice();
} catch { } catch {
// Fall back to TERM heuristic only when fstat fails
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
return false; return false;
} }
} }
+7 -6
View File
@@ -12,21 +12,22 @@ export interface FileEntry {
label: string; label: string;
} }
export interface BaseContext { export interface ProjectContext {
readme: string | null; readme: string | null;
packageDescription: string | null; packageDescription: string | null;
structure: string | null; structure: string | null;
recentCommits: string[];
diff: string; diff: string;
} }
export interface ProjectContext extends BaseContext { export interface PRContext {
recentCommits: string[]; readme: string | null;
} packageDescription: string | null;
structure: string | null;
export interface PRContext extends BaseContext {
branchName: string; branchName: string;
baseBranch: string; baseBranch: string;
branchCommits: string[]; branchCommits: string[];
diff: string;
} }
export interface CommitResult { export interface CommitResult {