19 Commits

Author SHA1 Message Date
Mplan ab9a41ab83 feat(brand): redesign logo with bold box-drawing ASCII art; bump v0.1.3
Build / bun-build (push) Successful in 16m54s
Replace the simple ASCII logo with a bold 'GAI' box-drawing font
that renders cleanly in modern terminals. Fix version to v0.1.3
across all files (brand, cli, index, package.json).
2026-06-16 02:10:09 +08:00
Mplan 724d4d3b6b fix(ui): add 'Press Enter to return' pause after subcommand completes
After a subcommand returns (especially 'Nothing to commit' cases), the
menu no longer immediately redraws and hides the message. Instead, the
user sees the subcommand output plus a 'Press Enter to return to menu'
prompt, giving them time to read the result before returning.
2026-06-16 02:07:29 +08:00
Mplan 8ff481f630 feat(ui): adopt mole-style interactive menu with brand banner
- Add ASCII art brand banner (src/brand.ts)
- Redesign main menu to match mole's UI pattern:
  - Numbered items with ➤ arrow cursor (cyan for active, dim for inactive)
  - Label + description two-column layout
  - Number key hotkeys (1-8) for direct selection
  - Letter hotkeys: H for Help, V for Version, Q for Quit
  - Footer with controls hint: ↑↓ | Enter | H Help | V Version | Q Quit
  - Full-screen redraw using \033[H cursor home
  - Clear-screen transition when entering/exiting subcommands
- Keep selectOne/selectMany for other interactive dialogs (file selector,
  platform selector) unchanged
2026-06-16 02:04:34 +08:00
Mplan b9de1267e3 test: update config test for new settings format
Build / bun-build (push) Failing after 2m4s
2026-06-16 02:01:10 +08:00
Mplan 4572605f33 docs: update README and package.json for v0.2.0
- Full README rewrite with all new commands, pipe support, aliases,
  global flags, and configuration examples
- Bump version to 0.2.0, update description
2026-06-16 02:01:07 +08:00
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
13 changed files with 154 additions and 246 deletions
+12 -22
View File
@@ -118,33 +118,23 @@ 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 AI-powered git helper — 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 ○ explain Explain staged changes in plain language
6 Changelog Generate changelog from commits ○ review AI code review of staged changes
7 Suggest Suggest branch name or commit type ○ changelog Generate changelog from commits
○ suggest Suggest branch name or commit type
PROJECT ○ amend Amend last commit with AI message
8 Config Configure API settings ○ 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.
### Command Examples ### Command Examples
#### Commit #### Commit
+43 -66
View File
@@ -14,8 +14,7 @@ import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled } from "./src/terminal"; import { setColorEnabled } from "./src/terminal";
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal"; import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty"; import { isStdinTTY, initTTY } from "./src/tty";
import { VERSION } from "./src/brand"; import { showBanner } from "./src/brand";
import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ───────────────────────────────────── // ── Interactive Menu (mole-style) ─────────────────────────────────────
@@ -23,18 +22,17 @@ interface MenuItem {
key: string; key: string;
label: string; label: string;
description: string; description: string;
group: "Create" | "Inspect" | "Project";
} }
const MENU_ITEMS: MenuItem[] = [ const MENU_ITEMS: MenuItem[] = [
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" }, { key: "commit", label: "Commit", description: "Generate AI commit message" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" }, { key: "pr", label: "PR", description: "Create a PR with AI-generated title" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" }, { key: "explain", label: "Explain", description: "Explain staged changes in plain language" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" }, { key: "review", label: "Review", description: "AI code review of staged changes" },
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" }, { key: "changelog", label: "Changelog", description: "Generate changelog from commits" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" }, { key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" }, { key: "amend", label: "Amend", description: "Amend last commit with AI message" },
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" }, { key: "config", label: "Config", description: "Configure API settings" },
]; ];
function hideCursor() { process.stdout.write("\x1b[?25l"); } function hideCursor() { process.stdout.write("\x1b[?25l"); }
@@ -56,70 +54,51 @@ function visibleLen(s: string): number {
return s.replace(/\x1b\[[0-9;]*m/g, "").length; return s.replace(/\x1b\[[0-9;]*m/g, "").length;
} }
function padRight(value: string, width: number): string { function renderMenu(banner: string, cursor: number): number {
return value + " ".repeat(Math.max(0, width - visibleLen(value)));
}
function renderMenu(cursor: number): number {
process.stdout.write("\x1b[H"); // cursor home process.stdout.write("\x1b[H"); // cursor home
let lineCount = 0; let lineCount = 0;
for (const line of banner.split("\n")) {
clearLine();
process.stdout.write(line + "\n");
lineCount++;
}
const G = GREEN();
const C = CYAN(); const C = CYAN();
const D = DIM(); const D = DIM();
const G = GREEN();
const R = RESET(); const R = RESET();
const width = 72; const ARROW = "➤";
const separator = `${D}${"─".repeat(width)}${R}`;
const write = (line = "") => { // Calculate padding
clearLine();
process.stdout.write(`${line}\n`);
lineCount++;
};
write("");
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
write(` ${separator}`);
write("");
const keyWidth = 3;
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2; const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
let currentGroup: MenuItem["group"] | null = null;
for (let i = 0; i < MENU_ITEMS.length; i++) { for (let i = 0; i < MENU_ITEMS.length; i++) {
const item = MENU_ITEMS[i]!; const item = MENU_ITEMS[i]!;
const num = String(i + 1); const num = String(i + 1);
const active = i === cursor; const active = i === cursor;
if (item.group !== currentGroup) { clearLine();
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) { if (active) {
write(` ${C}${R} ${row}`); const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label)));
process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`);
} else { } else {
write(` ${row}`); const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`);
} }
lineCount++;
} }
write(""); // Footer
write(` ${separator}`); clearLine();
write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`); process.stdout.write("\n");
write(""); lineCount++;
clearLine();
process.stdout.write(` ${D}↑↓ | Enter | ${G}H${D} Help | ${G}V${D} Version | ${G}Q${D} Quit${R}\n`);
lineCount++;
clearLine();
process.stdout.write("\n");
lineCount++;
// Clear rest of screen // Clear rest of screen
process.stdout.write("\x1b[J"); process.stdout.write("\x1b[J");
@@ -174,9 +153,6 @@ async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number>
process.stdin.pause(); process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H"); // clear screen process.stdout.write("\x1b[2J\x1b[H"); // clear screen
const result = await dispatchMenuAction(item.key); 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(); await waitForEnter();
return result; return result;
} }
@@ -187,6 +163,7 @@ async function showMenu(): Promise<number> {
return 1; return 1;
} }
const banner = showBanner();
let cursor = 0; let cursor = 0;
const wasRaw = process.stdin.isRaw; const wasRaw = process.stdin.isRaw;
@@ -195,7 +172,7 @@ async function showMenu(): Promise<number> {
hideCursor(); hideCursor();
// Initial render // Initial render
renderMenu(cursor); renderMenu(banner, cursor);
try { try {
while (true) { while (true) {
@@ -203,11 +180,11 @@ async function showMenu(): Promise<number> {
// Escape sequences (arrows) // Escape sequences (arrows)
if (raw === "\x1b[A" || raw === "\x1bOA") { if (raw === "\x1b[A" || raw === "\x1bOA") {
if (cursor > 0) { cursor--; renderMenu(cursor); } if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
continue; continue;
} }
if (raw === "\x1b[B" || raw === "\x1bOB") { if (raw === "\x1b[B" || raw === "\x1bOB") {
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); } if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); }
continue; continue;
} }
@@ -218,7 +195,7 @@ async function showMenu(): Promise<number> {
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
renderMenu(cursor); renderMenu(banner, cursor);
continue; continue;
} }
@@ -236,13 +213,13 @@ async function showMenu(): Promise<number> {
const idx = parseInt(raw) - 1; const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) { if (idx < MENU_ITEMS.length) {
cursor = idx; cursor = idx;
renderMenu(cursor); renderMenu(banner, cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw); const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
if (result !== 0) return result; if (result !== 0) return result;
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
renderMenu(cursor); renderMenu(banner, cursor);
continue; continue;
} }
} }
@@ -262,7 +239,7 @@ async function showMenu(): Promise<number> {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H"); process.stdout.write("\x1b[2J\x1b[H");
console.log(`gai v${VERSION}`); console.log("gai v0.1.3");
return 0; return 0;
} }
if (lower === "q") { if (lower === "q") {
+1 -1
View File
@@ -2,7 +2,7 @@
import { GREEN, CYAN, RESET } from "./terminal"; import { GREEN, CYAN, RESET } from "./terminal";
export const VERSION = "0.1.3"; const VERSION = "0.1.3";
export function showBanner(): string { export function showBanner(): string {
const G = GREEN(); const G = GREEN();
+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;
+15 -30
View File
@@ -7,11 +7,10 @@ import {
getStagedDiff, getStagedDiff,
getRecentCommits, getRecentCommits,
stageFiles, stageFiles,
applyFileSelection,
commit, commit,
} from "../git"; } from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK } from "../menu";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
import { buildPrompt, SYSTEM_PROMPT } from "../prompt"; import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
import { generateCommitMessage } from "../ai"; import { generateCommitMessage } from "../ai";
@@ -51,19 +50,6 @@ function printCommitResult(result: CommitResult, msg: string) {
if (parts.length > 0) console.log(` ${parts.join(", ")}`); if (parts.length > 0) console.log(` ${parts.join(", ")}`);
} }
function printSelectionResult(result: { staged: string[]; unstaged: string[] }) {
const parts: string[] = [];
if (result.staged.length > 0) {
parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`);
}
if (result.unstaged.length > 0) {
parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`);
}
if (parts.length > 0) {
console.log(` Updated staging area: ${parts.join(", ")}`);
}
}
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}\n`); console.log(` ${GREEN()}${message}${RESET()}\n`);
@@ -187,22 +173,16 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
const stagedFiles = await getStagedFiles(); const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) { if (stagedFiles.length === 0 && !amend) {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`); if (autoMode && unstagedFiles.length > 0) {
return 1;
}
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;
} }
} }
@@ -235,13 +215,18 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
return 0; return 0;
} }
if (autoMode && unstagedFiles.length > 0) { if (unstagedFiles.length > 0) {
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();
+1 -2
View File
@@ -1,7 +1,6 @@
import { loadConfig, saveConfig } from "../config"; import { loadConfig, saveConfig } from "../config";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty"; import { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu";
import type { Config } from "../types"; import type { Config } from "../types";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
@@ -324,7 +323,7 @@ export async function handleConfig(args: ParsedArgs): Promise<number> {
// gai config (no args) → interactive // gai config (no args) → interactive
if (positional.length === 0) { if (positional.length === 0) {
const result = await interactiveConfig(); const result = await interactiveConfig();
return result === "back" ? (SKIP_WAIT as unknown as number) : 0; return result === "back" ? 0 : 0;
} }
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`); console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
+19 -19
View File
@@ -1,14 +1,7 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK } from "../menu";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt"; import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai"; import { callAI } from "../ai";
@@ -26,6 +19,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
} }
const unstaged = args.flags["unstaged"] as boolean; const unstaged = args.flags["unstaged"] as boolean;
const staged = args.flags["staged"] as boolean;
const verbose = args.flags["verbose"] as boolean; const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain // Determine which diff to explain
@@ -50,16 +44,22 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1; return 1;
} }
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff(); diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
} else { } else {
// Read from pipe // Read from pipe
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -109,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks | undefined = tty ? { const callbacks: StreamCallbacks = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+3 -3
View File
@@ -4,7 +4,7 @@ import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt"; import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
import { generatePRMessage } from "../ai"; import { generatePRMessage } from "../ai";
import { BACK, SKIP_WAIT, selectOne } from "../menu"; import { BACK, selectOne } from "../menu";
import { import {
getDefaultBranch, getDefaultBranch,
getBranchName, getBranchName,
@@ -68,7 +68,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
if (!platform) { if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown"; const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname); const chosen = await selectPlatform(hostname);
if (chosen === BACK) return SKIP_WAIT as unknown as number; if (chosen === BACK) return 0;
if (!chosen) { if (!chosen) {
console.log(" Aborted."); console.log(" Aborted.");
return 0; return 0;
@@ -98,7 +98,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
items: [{ label: "Back", value: "back" as const }], items: [{ label: "Back", value: "back" as const }],
}); });
if (choice === null) process.exit(0); if (choice === null) process.exit(0);
return SKIP_WAIT as unknown as number; return 0;
} }
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`); console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
+18 -19
View File
@@ -1,14 +1,7 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK } from "../menu";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt"; import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai"; import { callAI } from "../ai";
@@ -60,16 +53,22 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1; return 1;
} }
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff(); diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
} }
if (!diff) { if (!diff) {
@@ -111,7 +110,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
} }
try { try {
const callbacks: StreamCallbacks | undefined = tty ? { const callbacks: StreamCallbacks = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+16 -16
View File
@@ -1,13 +1,7 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK } from "../menu";
import { import {
SUGGEST_SYSTEM_PROMPT, SUGGEST_SYSTEM_PROMPT,
buildSuggestBranchPrompt, buildSuggestBranchPrompt,
@@ -57,15 +51,21 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
diff = ""; diff = "";
} }
} else { } else {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff(); diff = await getStagedDiff();
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
} }
} }
-27
View File
@@ -98,33 +98,6 @@ export async function stageFiles(paths: string[]): Promise<void> {
await Bun.$`git add -- ${paths}`; await Bun.$`git add -- ${paths}`;
} }
export async function unstageFiles(paths: string[]): Promise<void> {
if (paths.length === 0) return;
try {
await Bun.$`git restore --staged -- ${paths}`.quiet();
} catch {
await Bun.$`git rm --cached -r -- ${paths}`.quiet();
}
}
export async function applyFileSelection(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
selectedPaths: string[],
): Promise<{ staged: string[]; unstaged: string[] }> {
const selected = new Set(selectedPaths);
const stagedPaths = new Set(stagedFiles.map((file) => file.path));
const unstagedPaths = new Set(unstagedFiles.map((file) => file.path));
const toUnstage = [...stagedPaths].filter((path) => !selected.has(path));
const toStage = [...selected].filter((path) => unstagedPaths.has(path));
await unstageFiles(toUnstage);
await stageFiles(toStage);
return { staged: toStage, unstaged: toUnstage };
}
export async function commit( export async function commit(
message: string, message: string,
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> { ): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
-5
View File
@@ -15,10 +15,6 @@ const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-back"); export const BACK = Symbol("prompt-back");
export type PromptBack = typeof BACK; export type PromptBack = typeof BACK;
// Sent by command handlers to skip the "Press Enter to return" wait in the
// interactive menu when the user explicitly backed out of a sub-menu.
export const SKIP_WAIT = Symbol("skip-wait");
export interface Choice<T> { export interface Choice<T> {
label: string; label: string;
value: T; value: T;
@@ -225,7 +221,6 @@ export async function selectMany<T>(
if (!options.selectAllLabel) return; if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected); items[0]!.selected = items.slice(1).every((item) => item.selected);
}; };
syncSelectAll();
const toggle = (index: number) => { const toggle = (index: number) => {
const item = items[index]!; const item = items[index]!;
+19 -29
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;
} }