13 Commits

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

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

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

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

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

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

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

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

Also cache isColorEnabled result to avoid repeated env checks.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 01:53:31 +08:00
Mplan 586487d897 feat: overhaul CLI with new AI commands and mole-style menu (#6)
Build / bun-build (push) Successful in 1m20s
Major CLI redesign introducing a mole-style interactive menu with grouped categories. Added new AI-powered subcommands: explain, review, changelog, and suggest. Includes streaming output, pipe support, and updated brand logo. Refactored codebase into command modules for maintainability.

Reviewed-on: #6
2026-06-17 00:17:31 +08:00
Mplan 0d9c31ae3b Revert "feat(ui): redesign menu and add explain, review, changelog, suggest commands"
Build / bun-build (push) Has been cancelled
This reverts commit 1e370be8af.
2026-06-17 00:14:35 +08:00
Mplan 1e370be8af feat(ui): redesign menu and add explain, review, changelog, suggest commands
Build / bun-build (push) Has been cancelled
2026-06-17 00:11:58 +08:00
Mplan 55db09c973 feat(config): add interactive config editor and GitLab PR support (#4)
Build / bun-build (push) Successful in 32s
Revamp the configuration UI with an interactive editor that supports inline text editing, navigation, and field validation, replacing the previous sequential prompts. Add GitLab pull request creation support via the `glab` CLI, and extend back navigation to all interactive menus for a consistent user experience.

Reviewed-on: #4
2026-06-12 09:00:28 +08:00
23 changed files with 863 additions and 787 deletions
+18 -15
View File
@@ -121,23 +121,26 @@ git diff | gai suggest branch
Run `gai` without arguments to open the mole-style interactive menu: Run `gai` without arguments to open the mole-style interactive menu:
``` ```
██████╗ █████╗ ██╗ gai v0.1.3
██╔════╝ ██╔══██╗██║ AI-powered git helper for commits, PRs, reviews, and changelogs
██║ ██╗ ███████║██║ ────────────────────────────────────────────────────────────────────────
██║ ██║ ██╔══██║██║
╚██████╝ ██║ ██║██║ AI-powered git helper
╚═════╝ ╚═╝ ╚═╝╚═╝ v0.1.3
➤ 1. Commit Generate AI commit message CREATE
2. PR Create a PR with AI-generated title 1 Commit Generate AI commit message
3. Explain Explain staged changes in plain language 2 PR Create a PR with AI-generated title
4. Review AI code review of staged changes 3 Amend Amend last commit with AI message
5. Changelog Generate changelog from commits
6. Suggest Suggest branch name or commit type
7. Amend Amend last commit with AI message
8. Config Configure API settings
↑↓ | Enter | H Help | V Version | Q Quit INSPECT
4 Explain Explain staged changes in plain language
5 Review AI code review of staged changes
6 Changelog Generate changelog from commits
7 Suggest Suggest branch name or commit type
PROJECT
8 Config Configure API settings
────────────────────────────────────────────────────────────────────────
↑/↓ navigate enter run 1-8 jump h help v version q quit
``` ```
Number keys `1``8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu. Number keys `1``8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu.
+81 -84
View File
@@ -1,7 +1,6 @@
#!/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";
@@ -11,10 +10,9 @@ 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 } from "./src/terminal"; import { setColorEnabled, BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, clearScreen, visibleLength, padRight } 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 { showBanner } from "./src/brand"; import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu"; import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ───────────────────────────────────── // ── Interactive Menu (mole-style) ─────────────────────────────────────
@@ -23,24 +21,20 @@ 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" }, { key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title" }, { key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language" }, { key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
{ key: "review", label: "Review", description: "AI code review of staged changes" }, { key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits" }, { key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" }, { key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message" }, { key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
{ key: "config", label: "Config", description: "Configure API settings" }, { key: "config", label: "Config", description: "Configure API settings", group: "Project" },
]; ];
function hideCursor() { process.stdout.write("\x1b[?25l"); }
function showCursor() { process.stdout.write("\x1b[?25h"); }
function clearLine() { process.stdout.write("\r\x1b[2K"); }
async function readKey(): Promise<string> { async function readKey(): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
@@ -51,55 +45,66 @@ async function readKey(): Promise<string> {
}); });
} }
function visibleLen(s: string): number { function renderMenu(cursor: number): 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 ARROW = "➤"; const width = 72;
const separator = `${D}${"─".repeat(width)}${R}`;
// Calculate padding const write = (line = "") => {
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2; clearLine();
process.stdout.write(`${line}\n`);
lineCount++;
};
write("");
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
write(` ${separator}`);
write("");
const keyWidth = 3;
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLength(m.label))) + 2;
let currentGroup: MenuItem["group"] | null = null;
for (let i = 0; i < MENU_ITEMS.length; i++) { 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;
clearLine(); if (item.group !== currentGroup) {
if (active) { if (currentGroup !== null) write("");
const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label))); write(` ${D}${item.group.toUpperCase()}${R}`);
process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`); currentGroup = item.group;
} else { }
const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`); const pointer = active ? `${C}${R}` : " ";
const key = active ? `${G}${num}${R}` : `${D}${num}${R}`;
const label = active ? `${BOLD()}${item.label}${R}` : item.label;
const description = active ? item.description : `${D}${item.description}${R}`;
const row = [
pointer,
padRight(key, keyWidth),
padRight(label, labelWidth),
description,
].join(" ");
if (active) {
write(` ${C}${R} ${row}`);
} else {
write(` ${row}`);
} }
lineCount++;
} }
// Footer write("");
clearLine(); write(` ${separator}`);
process.stdout.write("\n"); write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
lineCount++; write("");
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");
@@ -145,14 +150,14 @@ 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();
}); });
process.stdout.write("\x1b[2J\x1b[H"); // clear screen clearScreen();
} }
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();
process.stdout.write("\x1b[2J\x1b[H"); // clear screen clearScreen();
const result = await dispatchMenuAction(item.key); const result = await dispatchMenuAction(item.key);
if (result === (SKIP_WAIT as unknown as number)) { if (result === (SKIP_WAIT as unknown as number)) {
return 0; // user explicitly backed out — skip "Press Enter" and return directly return 0; // user explicitly backed out — skip "Press Enter" and return directly
@@ -167,7 +172,6 @@ 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;
@@ -176,7 +180,14 @@ async function showMenu(): Promise<number> {
hideCursor(); hideCursor();
// Initial render // Initial render
renderMenu(banner, cursor); renderMenu(cursor);
const exitMenu = (exitCode: number): number => {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
return exitCode;
};
try { try {
while (true) { while (true) {
@@ -184,32 +195,29 @@ 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(banner, cursor); } if (cursor > 0) { cursor--; renderMenu(cursor); }
continue; continue;
} }
if (raw === "\x1b[B" || raw === "\x1bOB") { if (raw === "\x1b[B" || raw === "\x1bOB") {
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); } if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(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 result; if (result !== 0) return exitMenu(result);
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
renderMenu(banner, cursor); renderMenu(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 0; return exitMenu(0);
} }
// Number hotkeys (1-8) // Number hotkeys (1-8)
@@ -217,13 +225,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(banner, cursor); renderMenu(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 exitMenu(result);
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
renderMenu(banner, cursor); renderMenu(cursor);
continue; continue;
} }
} }
@@ -231,27 +239,18 @@ async function showMenu(): Promise<number> {
// Letter hotkeys // Letter hotkeys
const lower = raw.toLowerCase(); const lower = raw.toLowerCase();
if (lower === "h") { if (lower === "h") {
showCursor(); clearScreen();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log(formatHelp(commands)); console.log(formatHelp(commands));
return 0; return exitMenu(0);
} }
if (lower === "v") { if (lower === "v") {
showCursor(); clearScreen();
process.stdin.setRawMode(wasRaw === true); console.log(`gai v${VERSION}`);
process.stdin.pause(); return exitMenu(0);
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 0; return exitMenu(0);
} }
} }
} finally { } finally {
@@ -409,10 +408,8 @@ const commands = registerCommands(
}, },
); );
// Keep the defs accessible for help command // Keep canonical command defs accessible for help command (deduplicate by reference)
const allCommandDefs = [...commands.values()].filter( const allCommandDefs = [...new Set(commands.values())];
(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.3", "version": "0.1.4",
"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",
+7 -2
View File
@@ -123,7 +123,6 @@ 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) {
@@ -136,7 +135,12 @@ 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;
@@ -152,7 +156,8 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
} }
} }
} finally { } finally {
reader.releaseLock(); try { await reader.cancel(); } catch {}
// 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";
const VERSION = "0.1.3"; export const VERSION = "0.1.4";
export function showBanner(): string { export function showBanner(): string {
const G = GREEN(); const G = GREEN();
+3 -14
View File
@@ -1,6 +1,8 @@
// 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"
@@ -44,19 +46,6 @@ 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>,
@@ -282,7 +271,7 @@ export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef
// Handle --version globally // Handle --version globally
if (result.flags["version"]) { if (result.flags["version"]) {
console.log("gai v0.1.3"); console.log(`gai v${VERSION}`);
return 0; return 0;
} }
+23 -21
View File
@@ -1,26 +1,28 @@
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 = tty ? { const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token), onToken: (token) => process.stdout.write(token),
} : undefined; } : undefined;
+37 -123
View File
@@ -1,4 +1,3 @@
import * as readline from "node:readline";
import { import {
isGitRepo, isGitRepo,
getRepoRoot, getRepoRoot,
@@ -7,6 +6,7 @@ import {
getStagedDiff, getStagedDiff,
getRecentCommits, getRecentCommits,
stageFiles, stageFiles,
applyFileSelection,
commit, commit,
} from "../git"; } from "../git";
import { selectFiles } from "../selector"; import { selectFiles } from "../selector";
@@ -17,20 +17,11 @@ 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
@@ -50,6 +41,19 @@ function printCommitResult(result: CommitResult, msg: string) {
if (parts.length > 0) console.log(` ${parts.join(", ")}`); if (parts.length > 0) console.log(` ${parts.join(", ")}`);
} }
function printSelectionResult(result: { staged: string[]; unstaged: string[] }) {
const parts: string[] = [];
if (result.staged.length > 0) {
parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`);
}
if (result.unstaged.length > 0) {
parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`);
}
if (parts.length > 0) {
console.log(` Updated staging area: ${parts.join(", ")}`);
}
}
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}\n`); console.log(` ${GREEN()}${message}${RESET()}\n`);
@@ -61,99 +65,8 @@ 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> {
@@ -173,16 +86,22 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
const stagedFiles = await getStagedFiles(); const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles(); const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && !amend) { if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
if (autoMode && unstagedFiles.length > 0) { console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
await stageFiles(unstagedFiles.map((f) => f.path)); return 1;
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;
} }
} }
@@ -215,18 +134,13 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
return 0; return 0;
} }
if (unstagedFiles.length > 0) { if (autoMode && unstagedFiles.length > 0) {
if (autoMode) { await stageFiles(unstagedFiles.map((f) => f.path));
await stageFiles(unstagedFiles.map((f) => f.path)); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); } else {
} else { const selected = await selectFiles(stagedFiles, unstagedFiles);
const selected = await selectFiles(stagedFiles, unstagedFiles); if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected === BACK) return SKIP_WAIT as unknown as number; 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();
+7 -18
View File
@@ -1,7 +1,8 @@
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, hideCursor, showCursor, clearLine, moveUp, visibleLength } from "../terminal";
import { isStdinTTY } from "../tty"; import { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu"; 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";
@@ -66,18 +67,6 @@ 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,
@@ -151,29 +140,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();
process.stdout.write("\x1b[?25l"); hideCursor();
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;
process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l"); editState ? showCursor() : hideCursor();
}; };
render(); render();
return new Promise((resolve) => { return new Promise((resolve, reject) => {
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);
process.stdout.write("\x1b[?25h"); showCursor();
resolve(value); resolve(value);
}; };
+51 -106
View File
@@ -1,8 +1,6 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git"; import { collectDiff } from "../diff-source";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } 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";
@@ -11,119 +9,66 @@ 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 staged = args.flags["staged"] as boolean; const verbose = args.flags["verbose"] as boolean;
const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain let diff: string;
let diff: string; let sourceLabel: string;
let sourceLabel: string; let contextPrefix: string;
if (unstaged) { try {
if (!(await isGitRepo())) { const result = await collectDiff({ unstaged, includeProjectContext: true });
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); if (result.back) return SKIP_WAIT as unknown as number;
return 1; diff = result.diff;
} sourceLabel = result.sourceLabel;
try { contextPrefix = result.contextPrefix;
diff = (await Bun.$`git diff`.quiet().text()).trim(); } catch (err) {
} catch { console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
diff = ""; return 1;
} }
sourceLabel = "unstaged changes";
} else {
// Default: staged changes (or piped)
if (isStdinTTY()) {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files if (!diff) {
if (!diff) { console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
const unstagedFiles = await getUnstagedFiles(); return 0;
if (unstagedFiles.length > 0) { }
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
} else {
// Read from pipe
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
}
}
if (!diff) { if (verbose) {
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`); console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
return 0; }
}
if (args.flags["verbose"]) { const userPrompt = contextPrefix + buildExplainPrompt(diff);
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
}
const MAX_DIFF_SIZE = 15000; if (verbose) {
const truncatedDiff = diff.length > MAX_DIFF_SIZE console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" }
: diff;
// Collect project context for better explanations const tty = isStdinTTY();
let contextPrefix = ""; if (tty) {
try { console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
if (await isGitRepo()) { }
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff); try {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
if (verbose) { const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); if (callbacks) {
} process.stdout.write("\n");
} else {
process.stdout.write(explanation + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
const tty = isStdinTTY(); return 0;
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;
} }
+1 -11
View File
@@ -1,4 +1,3 @@
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";
@@ -19,19 +18,10 @@ 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 -105
View File
@@ -1,8 +1,6 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git"; import { collectDiff } from "../diff-source";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } 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";
@@ -11,119 +9,74 @@ 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;
if (unstaged) { try {
if (!(await isGitRepo())) { const result = await collectDiff({ unstaged, includeProjectContext: true });
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); if (result.back) return SKIP_WAIT as unknown as number;
return 1; diff = result.diff;
} sourceLabel = result.sourceLabel;
try { contextPrefix = result.contextPrefix;
diff = (await Bun.$`git diff`.quiet().text()).trim(); } catch (err) {
} catch { console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
diff = ""; return 1;
} }
sourceLabel = "unstaged changes";
} else if (!isStdinTTY()) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
} else {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files if (!diff) {
if (!diff) { console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
const unstagedFiles = await getUnstagedFiles(); return 0;
if (unstagedFiles.length > 0) { }
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
}
if (!diff) { const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
return 0;
}
const MAX_DIFF_SIZE = 15000; const strictnessLabel = strictnessFlag === "strict"
const truncatedDiff = diff.length > MAX_DIFF_SIZE ? `${RED()}strict${RESET()}`
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" : strictnessFlag === "lenient"
: diff; ? `${GREEN()}lenient${RESET()}`
: `${YELLOW()}normal${RESET()}`;
let contextPrefix = ""; if (verbose) {
try { console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
if (await isGitRepo()) { }
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag); const tty = isStdinTTY();
if (tty) {
console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
}
const strictnessLabel = strictnessFlag === "strict" try {
? `${RED()}strict${RESET()}` const callbacks: StreamCallbacks | undefined = tty ? {
: strictnessFlag === "lenient" onToken: (token) => process.stdout.write(token),
? `${GREEN()}lenient${RESET()}` } : undefined;
: `${YELLOW()}normal${RESET()}`;
if (verbose) { const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`); 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;
}
const tty = isStdinTTY(); return 0;
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;
} }
+79 -112
View File
@@ -1,11 +1,10 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git"; import { collectDiff } from "../diff-source";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } 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";
@@ -14,133 +13,101 @@ 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;
} }
// Get diff (staged, or unstaged if --unstaged, or piped) const unstaged = args.flags["unstaged"] as boolean;
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;
}
if (args.flags["unstaged"] as boolean) { let diff: string;
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
} else {
diff = await getStagedDiff();
// If no staged changes, offer to stage unstaged files try {
if (!diff) { const result = await collectDiff({ unstaged, includeProjectContext: false });
const unstagedFiles = await getUnstagedFiles(); if (result.back) return SKIP_WAIT as unknown as number;
if (unstagedFiles.length > 0) { diff = result.diff;
const selected = await selectFiles([], unstagedFiles); } catch (err) {
if (selected === BACK) return SKIP_WAIT as unknown as number; console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
if (selected.length > 0) { return 1;
await stageFiles(selected); }
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
diff = await getStagedDiff();
}
}
}
}
}
if (!diff) { if (!diff) {
console.log(` ${DIM()}No changes to suggest from.${RESET()}`); console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
return 0; return 0;
} }
const MAX_DIFF_SIZE = 15000; if (verbose) {
const truncatedDiff = diff.length > MAX_DIFF_SIZE console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" }
: diff;
if (verbose) { if (mode === "branch") {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); return handleSuggestBranch(config, diff);
} }
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
@@ -0,0 +1,98 @@
// Shared diff-source helper used by explain, review, suggest, and commit commands.
// Handles the common pattern of: detecting where to get a diff from
// (staged, unstaged, or piped stdin), optionally presenting an interactive
// file selector, applying truncation, and collecting project context.
import { isGitRepo, getStagedFiles, getStagedDiff, getUnstagedFiles, getRepoRoot, applyFileSelection } from "./git";
import { selectFiles } from "./selector";
import { BACK } from "./menu";
import { collectProjectContext } from "./context";
import { isStdinTTY } from "./tty";
export interface DiffSourceResult {
diff: string;
sourceLabel: string;
contextPrefix: string;
back: boolean;
}
const MAX_DIFF_SIZE = 15000;
/**
* Collect a diff from the appropriate source based on flags and TTY state.
*
* - If `unstaged` is true: uses `git diff` (unstaged changes)
* - If TTY (interactive): shows file selector for staged/unstaged, then uses staged diff
* - If piped (non-TTY): reads diff from stdin
*
* Returns the diff, a human-readable source label, project context prefix,
* and whether the user pressed back.
*/
export async function collectDiff(opts: {
unstaged?: boolean;
includeProjectContext?: boolean;
} = {}): Promise<DiffSourceResult> {
const { unstaged = false, includeProjectContext = true } = opts;
let diff: string;
let sourceLabel: string;
let contextPrefix = "";
if (unstaged) {
if (!(await isGitRepo())) {
throw new Error("Not a git repository.");
}
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
sourceLabel = "unstaged changes";
} else if (isStdinTTY()) {
if (!(await isGitRepo())) {
throw new Error("Not a git repository.");
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) {
return { diff: "", sourceLabel, contextPrefix, back: true };
}
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
} else {
// Piped input (non-TTY)
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
}
// Truncate large diffs
if (diff.length > MAX_DIFF_SIZE) {
diff = diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)";
}
// Collect project context for better AI responses
if (includeProjectContext && diff) {
try {
if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {
// Context collection is best-effort
}
}
return { diff, sourceLabel, contextPrefix, back: false };
}
+38 -6
View File
@@ -37,12 +37,17 @@ function parseNameStatus(output: string): FileEntry[] {
return output return output
.trim() .trim()
.split("\n") .split("\n")
.filter(Boolean) .filter((line) => line.trim())
.map((line) => { .map((line) => {
const [status, ...pathParts] = line.split("\t"); const tabIdx = line.indexOf("\t");
const path = pathParts[pathParts.length - 1] ?? ""; if (tabIdx === -1) return null;
return { path, status: status!, label: statusToLabel(status!) }; const status = line.slice(0, tabIdx);
}); // Join path parts back (paths may contain escaped chars but not tabs)
const path = line.slice(tabIdx + 1);
if (!status || !path) return null;
return { path, status, label: statusToLabel(status) };
})
.filter((entry): entry is FileEntry => entry !== null);
} }
export async function getStagedFiles(): Promise<FileEntry[]> { export async function getStagedFiles(): Promise<FileEntry[]> {
@@ -98,6 +103,33 @@ export async function stageFiles(paths: string[]): Promise<void> {
await Bun.$`git add -- ${paths}`; await Bun.$`git add -- ${paths}`;
} }
export async function unstageFiles(paths: string[]): Promise<void> {
if (paths.length === 0) return;
try {
await Bun.$`git restore --staged -- ${paths}`.quiet();
} catch {
await Bun.$`git rm --cached -r -- ${paths}`.quiet();
}
}
export async function applyFileSelection(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
selectedPaths: string[],
): Promise<{ staged: string[]; unstaged: string[] }> {
const selected = new Set(selectedPaths);
const stagedPaths = new Set(stagedFiles.map((file) => file.path));
const unstagedPaths = new Set(unstagedFiles.map((file) => file.path));
const toUnstage = [...stagedPaths].filter((path) => !selected.has(path));
const toStage = [...selected].filter((path) => unstagedPaths.has(path));
await unstageFiles(toUnstage);
await stageFiles(toStage);
return { staged: toStage, unstaged: toUnstage };
}
export async function commit( export async function commit(
message: string, message: string,
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> { ): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
@@ -113,7 +145,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+([0-9a-f]{7,})/); const branchHashMatch = stdout.match(/\[(\S+)\s+(?:\(root-commit\)\s+)?([0-9a-f]{7,})/);
const branch = branchHashMatch?.[1] ?? ""; const branch = branchHashMatch?.[1] ?? "";
const hash = branchHashMatch?.[2] ?? ""; const hash = branchHashMatch?.[2] ?? "";
+72 -49
View File
@@ -1,4 +1,4 @@
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; import { BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength, padRight } from "./terminal";
import { isStdinTTY } from "./tty"; import { isStdinTTY } from "./tty";
const UP = "\x1b[A"; const UP = "\x1b[A";
@@ -43,22 +43,7 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
doneLabel?: string; doneLabel?: string;
} }
function hideCursor() { process.stdout.write("\x1b[?25l"); } function padLabel(label: string, width: number): string {
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)));
} }
@@ -92,7 +77,8 @@ function clearPrompt(lines: number) {
moveUp(lines); moveUp(lines);
} }
function normalizeKey(key: string, escapeBuf: string) { function normalizeKey(key: string, escapeBuf: string): { action: string | null; escapeBuf: string } {
// Single-chunk actions
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === 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: "" };
@@ -100,14 +86,20 @@ function normalizeKey(key: string, escapeBuf: string) {
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: "" };
if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key }; // Start of an escape sequence — buffer it
if (key === "\x1b" || key.startsWith("\x1b[") || key.startsWith("\x1bO")) {
return { action: null, escapeBuf: key };
}
// Continue buffering an escape sequence
if (escapeBuf) { 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: "" };
return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next }; // If key is a terminal character (letter/digit/~) or buffer got too long, flush
if (/^[A-Za-z~0-9]$/.test(key) || next.length > 10) return { action: null, escapeBuf: "" };
return { action: null, escapeBuf: next };
} }
return { action: null, escapeBuf: "" }; return { action: null, escapeBuf: "" };
@@ -168,15 +160,25 @@ export async function selectOne<T>(
const render = () => { const render = () => {
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines); renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
}; };
render();
return new Promise((resolve) => { const cleanup = () => {
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);
clearPrompt(renderedLines); cleanup();
showCursor();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -184,15 +186,20 @@ export async function selectOne<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf); try {
escapeBuf = result.escapeBuf; const result = normalizeKey(data.toString(), 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);
} }
}; };
@@ -225,6 +232,7 @@ export async function selectMany<T>(
if (!options.selectAllLabel) return; if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected); items[0]!.selected = items.slice(1).every((item) => item.selected);
}; };
syncSelectAll();
const toggle = (index: number) => { const toggle = (index: number) => {
const item = items[index]!; const item = items[index]!;
@@ -242,15 +250,25 @@ export async function selectMany<T>(
renderedLines, renderedLines,
); );
}; };
render();
return new Promise((resolve) => { const cleanup = () => {
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);
clearPrompt(renderedLines); cleanup();
showCursor();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -258,16 +276,21 @@ export async function selectMany<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf); try {
escapeBuf = result.escapeBuf; const result = normalizeKey(data.toString(), 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);
} }
}; };
+33 -77
View File
@@ -124,6 +124,36 @@ export async function getRemoteHostname(): Promise<string | null> {
} }
} }
const PLATFORM_CLI: Record<Platform, {
bin: string;
args: (title: string, body: string, base: string, draft: boolean) => string[];
label: string;
}> = {
github: {
bin: "gh",
args: (title, body, base, draft) => {
const a = ["pr", "create", "--title", title, "--body", body, "--base", base];
if (draft) a.push("--draft");
return a;
},
label: "gh pr create",
},
gitlab: {
bin: "glab",
args: (title, body, base, draft) => {
const a = ["mr", "create", "--title", title, "--description", body, "--target-branch", base];
if (draft) a.push("--draft");
return a;
},
label: "glab mr create",
},
gitea: {
bin: "tea",
args: (title, body, base, _draft) => ["pulls", "create", "--title", title, "--description", body, "--base", base],
label: "tea pulls create",
},
};
export async function createPR( export async function createPR(
platform: Platform, platform: Platform,
title: string, title: string,
@@ -131,80 +161,8 @@ export async function createPR(
base: string, base: string,
draft: boolean, draft: boolean,
): Promise<string> { ): Promise<string> {
if (platform === "github") { const cli = PLATFORM_CLI[platform];
const args = [ const proc = Bun.spawn([cli.bin, ...cli.args(title, body, base, draft)], {
"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",
}); });
@@ -213,9 +171,7 @@ 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( throw new Error(stderr.trim() || `${cli.label} failed (exit code ${exitCode})`);
stderr.trim() || `tea pulls create failed (exit code ${exitCode})`,
);
} }
const match = stdout.match(/(https?:\/\/[^\s]+)/); const match = stdout.match(/(https?:\/\/[^\s]+)/);
+29 -19
View File
@@ -1,44 +1,54 @@
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty"; import { isStdinTTY } from "./tty";
import { BACK, selectMany } from "./menu"; import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu"; import type { PromptBack } from "./menu";
function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) {
const files = new Map<string, FileEntry & { staged: boolean; unstaged: boolean }>();
for (const file of stagedFiles) {
files.set(file.path, { ...file, staged: true, unstaged: false });
}
for (const file of unstagedFiles) {
const existing = files.get(file.path);
if (existing) {
existing.unstaged = true;
existing.label = existing.label === file.label
? existing.label
: `${existing.label}, ${file.label}`;
} else {
files.set(file.path, { ...file, staged: false, unstaged: true });
}
}
return [...files.values()];
}
export async function selectFiles( export async function selectFiles(
stagedFiles: FileEntry[], stagedFiles: FileEntry[],
unstagedFiles: FileEntry[], unstagedFiles: FileEntry[],
): Promise<string[] | PromptBack> { ): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return []; const files = mergeFiles(stagedFiles, unstagedFiles);
if (files.length === 0) return [];
if (stagedFiles.length > 0) { if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
for (const f of stagedFiles) {
process.stdout.write(` ${GREEN()}${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
}
}
if (!isStdinTTY()) return [];
const selected = await selectMany({ const selected = await selectMany({
title: "Select files to stage", title: "Select files for this action",
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`, subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
selectAllLabel: "Select all", selectAllLabel: "Select all",
cancelMessage: "Aborted.", cancelMessage: "Aborted.",
items: unstagedFiles.map((f) => ({ items: files.map((f) => ({
label: f.path, label: f.path,
value: f.path, value: f.path,
description: f.label, description: f.label,
selected: f.staged,
})), })),
}); });
if (selected === null) process.exit(1); if (selected === null) process.exit(1);
if (selected === BACK) return BACK; if (selected === BACK) return BACK;
if (selected.length > 0) {
process.stdout.write(
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
);
}
return selected; return selected;
} }
+53 -11
View File
@@ -1,29 +1,39 @@
// Terminal styling utilities. // Terminal styling and rendering 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 !== "") {
return false; _enabled = false;
} return false;
if (!isStdoutTTY()) return false; }
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true; if (!isStdoutTTY()) {
_enabled = false;
return false;
}
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
_enabled = true;
return true;
}
return true; _enabled = 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");
@@ -33,3 +43,35 @@ 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
@@ -0,0 +1,163 @@
// Shared TTY input utilities used by command handlers.
// Provides a simple line-input "ask" helper and a reusable inline
// raw-mode text editor (used by both commit message editing and
// interactive config editing).
import * as readline from "node:readline";
import { isStdinTTY } from "./tty";
// ── Simple line input (cooked mode) ──────────────────────────────────
export function ask(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// ── Inline raw-mode editor ────────────────────────────────────────────
//
// Provides a simple line editor that runs in raw mode and supports:
// - Left/Right arrows, Home/End for cursor movement
// - Backspace / Delete for character removal
// - Ctrl+A (beginning), Ctrl+E (end), Ctrl+K (kill to end), Ctrl+U (kill to start)
// - Enter to confirm, Ctrl+C / Esc to cancel
//
// Returns the edited string, or null if the user cancelled.
export interface EditResult {
value: string | null;
}
export async function editLine(initial: string): Promise<string | null> {
if (!isStdinTTY()) return null;
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
let buffer = initial;
let cursor = initial.length;
function render() {
// Clear line, move to start, show prompt + buffer, then reposition cursor
process.stdout.write("\r\x1b[2K > " + buffer);
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
}
}
process.stdout.write(" > " + buffer);
return new Promise((resolve) => {
let escapeBuf = "";
function finish(value: string | null) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
resolve(value);
}
function handleEscapeSeq(seq: string) {
switch (seq) {
case "\x1b[D": case "\x1bOD": // Left
if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); }
break;
case "\x1b[C": case "\x1bOC": // Right
if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); }
break;
case "\x1b[H": case "\x1b[1~": case "\x1bOH": // Home
if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
break;
case "\x1b[F": case "\x1b[4~": case "\x1bOF": // End
if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
break;
case "\x1b[3~": // Delete
if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); }
break;
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
// Ctrl+C
if (key === "\x03") { finish(null); return; }
// Esc
if (key === "\x1b") {
if (escapeBuf) {
// Already buffering — check if this completes a sequence
const next = escapeBuf + key;
if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(next)) {
handleEscapeSeq(next);
escapeBuf = "";
} else {
// Treat lone Esc as cancel
finish(null);
}
return;
}
escapeBuf = "\x1b";
return;
}
// Buffering an escape sequence
if (escapeBuf) {
escapeBuf += key;
// Check if this completes a valid sequence
if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(escapeBuf)) {
handleEscapeSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 10 || /^[A-Za-z~]$/.test(key)) {
// Timeout or terminator that didn't match — discard
escapeBuf = "";
}
return;
}
// Enter
if (key === "\r" || key === "\n") {
const result = buffer.trim();
finish(result || null);
return;
}
// Backspace
if (key === "\x7f") {
if (cursor > 0) {
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
cursor--;
render();
}
return;
}
// Ctrl+A → beginning of line
if (key === "\x01") {
if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
return;
}
// Ctrl+E → end of line
if (key === "\x05") {
if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
return;
}
// Ctrl+K → kill to end
if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; }
// Ctrl+U → kill to start
if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; }
// Printable characters
if (key >= " " && key !== "\x7f") {
buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
cursor += key.length;
render();
}
});
});
}
+3 -4
View File
@@ -23,14 +23,13 @@ export function isStdinTTY(): boolean {
} }
export function isStdoutTTY(): boolean { export function isStdoutTTY(): boolean {
// Use a heuristic for stdout — check if we're in a terminal // Primary check: fstat on fd 1 (stdout)most reliable
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;
} }
} }
+6 -7
View File
@@ -12,22 +12,21 @@ export interface FileEntry {
label: string; label: string;
} }
export interface ProjectContext { export interface BaseContext {
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 PRContext { export interface ProjectContext extends BaseContext {
readme: string | null; recentCommits: string[];
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 {