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
23 changed files with 790 additions and 882 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
+82 -83
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.1.3
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli"; import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
import { handleCommit } from "./src/commands/commit"; import { handleCommit } from "./src/commands/commit";
@@ -10,10 +11,10 @@ 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 { showBanner } from "./src/brand";
import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ───────────────────────────────────── // ── Interactive Menu (mole-style) ─────────────────────────────────────
@@ -21,20 +22,24 @@ 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 showCursor() { process.stdout.write("\x1b[?25h"); }
function clearLine() { process.stdout.write("\r\x1b[2K"); }
async function readKey(): Promise<string> { async function readKey(): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
@@ -45,66 +50,55 @@ async function readKey(): Promise<string> {
}); });
} }
function renderMenu(cursor: number): number { function visibleLen(s: string): number {
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function renderMenu(banner: string, 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(); const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
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++) { 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");
@@ -150,18 +144,15 @@ async function waitForEnter(): Promise<void> {
// Resume stdin in case it was paused // Resume stdin in case it was paused
process.stdin.resume(); process.stdin.resume();
}); });
clearScreen(); process.stdout.write("\x1b[2J\x1b[H"); // clear screen
} }
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> { async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
showCursor(); showCursor();
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
clearScreen(); 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;
} }
@@ -172,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;
@@ -180,14 +172,7 @@ async function showMenu(): Promise<number> {
hideCursor(); hideCursor();
// Initial render // Initial render
renderMenu(cursor); renderMenu(banner, cursor);
const exitMenu = (exitCode: number): number => {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
return exitCode;
};
try { try {
while (true) { while (true) {
@@ -195,29 +180,32 @@ 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;
} }
// Enter // Enter
if (raw === "\r" || raw === "\n") { if (raw === "\r" || raw === "\n") {
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw); const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
if (result !== 0) return exitMenu(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;
} }
// Ctrl+C // Ctrl+C
if (raw === "\x03") { if (raw === "\x03") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\n"); process.stdout.write("\n");
return exitMenu(0); return 0;
} }
// Number hotkeys (1-8) // Number hotkeys (1-8)
@@ -225,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 exitMenu(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;
} }
} }
@@ -239,18 +227,27 @@ async function showMenu(): Promise<number> {
// Letter hotkeys // Letter hotkeys
const lower = raw.toLowerCase(); const lower = raw.toLowerCase();
if (lower === "h") { if (lower === "h") {
clearScreen(); showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log(formatHelp(commands)); console.log(formatHelp(commands));
return exitMenu(0); return 0;
} }
if (lower === "v") { if (lower === "v") {
clearScreen(); showCursor();
console.log(`gai v${VERSION}`); process.stdin.setRawMode(wasRaw === true);
return exitMenu(0); process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log("gai v0.1.3");
return 0;
} }
if (lower === "q") { if (lower === "q") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\n"); process.stdout.write("\n");
return exitMenu(0); return 0;
} }
} }
} finally { } finally {
@@ -408,8 +405,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 ───────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gai", "name": "gai",
"version": "0.1.4", "version": "0.1.3",
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more", "description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
+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);
+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.4"; const VERSION = "0.1.3";
export function showBanner(): string { export function showBanner(): string {
const G = GREEN(); const G = GREEN();
+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.1.3");
return 0; return 0;
} }
+21 -23
View File
@@ -1,28 +1,26 @@
export async function copyToClipboard(text: string): Promise<boolean> { export async function copyToClipboard(text: string): Promise<boolean> {
const commands: string[][] = []; const commands: string[][] = [];
if (process.platform === "darwin") { if (process.platform === "darwin") {
commands.push(["pbcopy"]); commands.push(["pbcopy"]);
} else if (process.platform === "linux") { } else if (process.platform === "linux") {
commands.push(["xclip", "-selection", "clipboard"]); commands.push(["xclip", "-selection", "clipboard"]);
commands.push(["xsel", "--clipboard", "--input"]); commands.push(["xsel", "--clipboard", "--input"]);
} }
for (const cmd of commands) { for (const cmd of commands) {
try { try {
const proc = Bun.spawn(cmd, { const proc = Bun.spawn(cmd, {
stdin: "pipe", stdin: "pipe",
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}); });
proc.stdin.write(text); proc.stdin.write(text);
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;
+124 -38
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; await stageFiles(unstagedFiles.map((f) => f.path));
} console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (unstagedFiles.length > 0) {
if (!amend && autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (!amend) {
if (isStdinTTY()) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
} else if (stagedFiles.length === 0 && unstagedFiles.length > 0) {
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`); 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) {
await stageFiles(unstagedFiles.map((f) => f.path)); if (autoMode) {
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); await stageFiles(unstagedFiles.map((f) => f.path));
} else { console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
const selected = await selectFiles(stagedFiles, unstagedFiles); } else {
if (selected === BACK) return SKIP_WAIT as unknown as number; const selected = await selectFiles(stagedFiles, unstagedFiles);
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); if (selected === BACK) return 0;
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()}`);
+107 -52
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";
@@ -9,66 +11,119 @@ import type { StreamCallbacks } from "../types";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
export async function handleExplain(args: ParsedArgs): Promise<number> { export async function handleExplain(args: ParsedArgs): Promise<number> {
const config = await loadConfig(); const config = await loadConfig();
if (!config.apiKey) { if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1; return 1;
} }
const unstaged = args.flags["unstaged"] as boolean; const unstaged = args.flags["unstaged"] as boolean;
const verbose = args.flags["verbose"] as boolean; const staged = args.flags["staged"] as boolean;
const verbose = args.flags["verbose"] as boolean;
let diff: string; // Determine which diff to explain
let sourceLabel: string; let diff: string;
let contextPrefix: string; let sourceLabel: 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; return 1;
sourceLabel = result.sourceLabel; }
contextPrefix = result.contextPrefix; try {
} catch (err) { diff = (await Bun.$`git diff`.quiet().text()).trim();
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`); } catch {
return 1; 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 (!diff) { // If no staged changes, offer to stage unstaged files
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`); if (!diff) {
return 0; 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 (verbose) { if (!diff) {
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`); console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
} return 0;
}
const userPrompt = contextPrefix + buildExplainPrompt(diff); if (args.flags["verbose"]) {
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
}
if (verbose) { const MAX_DIFF_SIZE = 15000;
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); const truncatedDiff = diff.length > MAX_DIFF_SIZE
} ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const tty = isStdinTTY(); // Collect project context for better explanations
if (tty) { let contextPrefix = "";
console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`); try {
} if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
try { const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks); if (verbose) {
if (callbacks) { console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
process.stdout.write("\n"); }
} else {
process.stdout.write(explanation + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0; const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
}
try {
const callbacks: StreamCallbacks = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) {
process.stdout.write("\n");
} else {
// Non-TTY: print the result directly
process.stdout.write(explanation + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
} }
+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`);
+106 -59
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";
@@ -9,74 +11,119 @@ import type { StreamCallbacks } from "../types";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
export async function handleReview(args: ParsedArgs): Promise<number> { export async function handleReview(args: ParsedArgs): Promise<number> {
const config = await loadConfig(); const config = await loadConfig();
if (!config.apiKey) { if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1; return 1;
} }
const strictnessFlag = args.flags["strict"] as boolean const strictnessFlag = args.flags["strict"] as boolean
? "strict" ? "strict"
: args.flags["lenient"] as boolean : args.flags["lenient"] as boolean
? "lenient" ? "lenient"
: "normal"; : "normal";
const unstaged = args.flags["unstaged"] as boolean; const unstaged = args.flags["unstaged"] as boolean;
const verbose = args.flags["verbose"] as boolean; const verbose = args.flags["verbose"] as boolean;
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; return 1;
sourceLabel = result.sourceLabel; }
contextPrefix = result.contextPrefix; try {
} catch (err) { diff = (await Bun.$`git diff`.quiet().text()).trim();
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`); } catch {
return 1; 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 (!diff) { // If no staged changes, offer to stage unstaged files
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`); if (!diff) {
return 0; 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();
}
}
}
}
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag); if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
return 0;
}
const strictnessLabel = strictnessFlag === "strict" const MAX_DIFF_SIZE = 15000;
? `${RED()}strict${RESET()}` const truncatedDiff = diff.length > MAX_DIFF_SIZE
: strictnessFlag === "lenient" ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
? `${GREEN()}lenient${RESET()}` : diff;
: `${YELLOW()}normal${RESET()}`;
if (verbose) { let contextPrefix = "";
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`); try {
} if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
const tty = isStdinTTY(); const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag);
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
}
try { const strictnessLabel = strictnessFlag === "strict"
const callbacks: StreamCallbacks | undefined = tty ? { ? `${RED()}strict${RESET()}`
onToken: (token) => process.stdout.write(token), : strictnessFlag === "lenient"
} : undefined; ? `${GREEN()}lenient${RESET()}`
: `${YELLOW()}normal${RESET()}`;
const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks); if (verbose) {
if (callbacks) { console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
process.stdout.write("\n"); }
} else {
process.stdout.write(result + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0; const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
}
try {
const callbacks: StreamCallbacks = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) {
process.stdout.write("\n");
} else {
process.stdout.write(result + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
} }
+113 -80
View File
@@ -1,10 +1,11 @@
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,
buildSuggestTypePrompt, buildSuggestTypePrompt,
} from "../prompt"; } 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";
@@ -13,101 +14,133 @@ import type { Config } from "../types";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
export async function handleSuggest(args: ParsedArgs): Promise<number> { export async function handleSuggest(args: ParsedArgs): Promise<number> {
const config = await loadConfig(); const config = await loadConfig();
if (!config.apiKey) { if (!config.apiKey) {
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
return 1; return 1;
} }
const mode = args.positional[0] || "branch"; const mode = args.positional[0] || "branch";
const verbose = args.flags["verbose"] as boolean; const verbose = args.flags["verbose"] as boolean;
if (mode !== "branch" && mode !== "type") { if (mode !== "branch" && mode !== "type") {
console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`); console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
console.error(` Try: gai suggest branch | gai suggest type\n`); console.error(` Try: gai suggest branch | gai suggest type\n`);
return 1; return 1;
} }
const unstaged = args.flags["unstaged"] as boolean; // Get diff (staged, or unstaged if --unstaged, or piped)
let diff: string;
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();
} else {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
let diff: string; if (args.flags["unstaged"] as boolean) {
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
} else {
diff = await getStagedDiff();
try { // If no staged changes, offer to stage unstaged files
const result = await collectDiff({ unstaged, includeProjectContext: false }); if (!diff) {
if (result.back) return SKIP_WAIT as unknown as number; const unstagedFiles = await getUnstagedFiles();
diff = result.diff; if (unstagedFiles.length > 0) {
} catch (err) { const selected = await selectFiles([], unstagedFiles);
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`); if (selected === BACK) return 0;
return 1; 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;
} }
if (verbose) { const MAX_DIFF_SIZE = 15000;
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); const truncatedDiff = diff.length > MAX_DIFF_SIZE
} ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
if (mode === "branch") { if (verbose) {
return handleSuggestBranch(config, diff); console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
} }
return handleSuggestType(config, diff);
if (mode === "branch") {
return handleSuggestBranch(config, truncatedDiff);
} else {
return handleSuggestType(config, truncatedDiff);
}
} }
async function handleSuggestBranch(config: Config, diff: string): Promise<number> { async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
const tty = isStdinTTY(); const tty = isStdinTTY();
if (tty) { if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`); console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
} }
try { try {
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff)); const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
const suggestions = raw const suggestions = raw
.split("\n") .split("\n")
.map((line) => line.replace(/^[\d.\s\-*]+/, "").trim()) .map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
.filter(Boolean); .filter(Boolean);
if (suggestions.length === 0) { if (suggestions.length === 0) {
console.log(` ${DIM()}No suggestions generated.${RESET()}`); console.log(` ${DIM()}No suggestions generated.${RESET()}`);
return 0; return 0;
} }
for (const s of suggestions) { for (const s of suggestions) {
console.log(` ${GREEN()}${s}${RESET()}`); console.log(` ${GREEN()}${s}${RESET()}`);
} }
console.log(""); console.log("");
} catch (err) { } catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1; return 1;
} }
return 0; return 0;
} }
async function handleSuggestType(config: Config, diff: string): Promise<number> { async function handleSuggestType(config: Config, diff: string): Promise<number> {
const tty = isStdinTTY(); const tty = isStdinTTY();
if (tty) { if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`); console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
} }
try { try {
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff)); const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
const type = raw.trim().toLowerCase(); const type = raw.trim().toLowerCase();
const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]; const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
if (validTypes.includes(type)) { if (validTypes.includes(type)) {
console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`); console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
} else { } else {
console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`); console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`); console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
} }
} catch (err) { } catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1; return 1;
} }
return 0; return 0;
} }
-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] ?? "";
+49 -76
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) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
clearPrompt(renderedLines);
showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T | null | PromptBack) => { const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
cleanup(); clearPrompt(renderedLines);
showCursor();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -186,20 +180,15 @@ 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;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) return finish(BACK); if (result.action === "back" && options.allowBack !== false) return finish(BACK);
if (result.action === "up" && cursor > 0) { cursor--; render(); } if (result.action === "up" && cursor > 0) { cursor--; render(); }
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); } else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
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);
} }
}; };
@@ -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) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
clearPrompt(renderedLines);
showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T[] | null | PromptBack) => { const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
cleanup(); clearPrompt(renderedLines);
showCursor();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -276,21 +254,16 @@ 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;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) return finish(BACK); if (result.action === "back" && options.allowBack !== false) return finish(BACK);
if (result.action === "up" && cursor > 0) { cursor--; render(); } if (result.action === "up" && cursor > 0) { cursor--; render(); }
else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); } else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
else if (result.action === "space") { toggle(cursor); render(); } else if (result.action === "space") { toggle(cursor); render(); }
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);
} }
}; };
+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,80 @@ 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",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `gh pr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
if (platform === "gitlab") {
const args = [
"mr",
"create",
"--title",
title,
"--description",
body,
"--target-branch",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["glab", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `glab mr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
const args = [
"pulls",
"create",
"--title",
title,
"--description",
body,
"--base",
base,
];
const proc = Bun.spawn(["tea", ...args], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}); });
@@ -171,7 +213,9 @@ 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() || `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;
} }
+11 -53
View File
@@ -1,39 +1,29 @@
// 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 {
_enabled = enabled; _enabled = enabled;
} }
export function isColorEnabled(): boolean { export function isColorEnabled(): boolean {
if (_enabled !== null) return _enabled; if (_enabled !== null) return _enabled;
// 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()) return false;
if (!isStdoutTTY()) { if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
_enabled = false;
return false;
}
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
_enabled = true;
return true;
}
_enabled = true; return true;
return true;
} }
function s(code: string): string { function s(code: string): string {
return isColorEnabled() ? code : ""; return isColorEnabled() ? code : "";
} }
export const BOLD = () => s("\x1b[1m"); export const BOLD = () => s("\x1b[1m");
@@ -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 {