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>
This commit is contained in:
2026-06-18 01:56:36 +08:00
parent 4fbac6a6e1
commit f3b5c631de
6 changed files with 198 additions and 456 deletions
+2 -103
View File
@@ -1,4 +1,3 @@
import * as readline from "node:readline";
import { import {
isGitRepo, isGitRepo,
getRepoRoot, getRepoRoot,
@@ -18,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
@@ -75,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> {
+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,15 +1,6 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { collectDiff } from "../diff-source";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
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";
@@ -18,112 +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 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;
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) { if (!diff) {
const selected = await selectFiles(stagedFiles, unstagedFiles); console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
if (selected === BACK) return SKIP_WAIT as unknown as number; return 0;
await applyFileSelection(stagedFiles, unstagedFiles, selected); }
}
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 | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) {
process.stdout.write("\n");
} else {
// 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 -106
View File
@@ -1,15 +1,6 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { collectDiff } from "../diff-source";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
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";
@@ -18,113 +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;
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) { if (!diff) {
const selected = await selectFiles(stagedFiles, unstagedFiles); console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
if (selected === BACK) return SKIP_WAIT as unknown as number; return 0;
await applyFileSelection(stagedFiles, unstagedFiles, selected); }
}
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 | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
if (callbacks) {
process.stdout.write("\n");
} else {
process.stdout.write(result + "\n");
}
} catch (err) {
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
return 0;
} }
+79 -112
View File
@@ -1,17 +1,10 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { collectDiff } from "../diff-source";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
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";
@@ -20,127 +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 {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length > 0 || unstagedFiles.length > 0) { try {
const selected = await selectFiles(stagedFiles, unstagedFiles); const result = await collectDiff({ unstaged, includeProjectContext: false });
if (selected === BACK) return SKIP_WAIT as unknown as number; if (result.back) return SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected); diff = result.diff;
} } catch (err) {
diff = await getStagedDiff(); console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
} return 1;
} }
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;
} }