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:
+2
-103
@@ -1,4 +1,3 @@
|
||||
import * as readline from "node:readline";
|
||||
import {
|
||||
isGitRepo,
|
||||
getRepoRoot,
|
||||
@@ -18,20 +17,11 @@ import { generateCommitMessage } from "../ai";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
||||
import { isStdinTTY } from "../tty";
|
||||
import { ask, editLine } from "../tty-input";
|
||||
import type { Config, CommitResult, StreamCallbacks } from "../types";
|
||||
import { loadConfig } from "../config";
|
||||
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) {
|
||||
console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`);
|
||||
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> {
|
||||
if (!isStdinTTY()) return null;
|
||||
process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`);
|
||||
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
return editLine(current);
|
||||
}
|
||||
|
||||
export async function handleCommit(args: ParsedArgs): Promise<number> {
|
||||
|
||||
+7
-18
@@ -1,7 +1,8 @@
|
||||
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 { SKIP_WAIT } from "../menu";
|
||||
import { editLine } from "../tty-input";
|
||||
import type { Config } from "../types";
|
||||
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(
|
||||
config: Config,
|
||||
cursor: number,
|
||||
@@ -151,29 +140,29 @@ async function interactiveConfig(): Promise<"done" | "back"> {
|
||||
|
||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdout.write("\x1b[?25l");
|
||||
hideCursor();
|
||||
|
||||
const render = () => {
|
||||
moveUp(renderedCursorRow);
|
||||
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
|
||||
renderedCursorRow = editState ? 4 + cursor : 0;
|
||||
process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l");
|
||||
editState ? showCursor() : hideCursor();
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (value: "done" | "back") => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
moveUp(renderedCursorRow);
|
||||
for (let i = 0; i < renderedLines; i++) {
|
||||
clearLine();
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
moveUp(renderedLines);
|
||||
process.stdout.write("\x1b[?25h");
|
||||
showCursor();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
|
||||
+51
-106
@@ -1,15 +1,6 @@
|
||||
import { loadConfig } from "../config";
|
||||
import {
|
||||
isGitRepo,
|
||||
getStagedFiles,
|
||||
getStagedDiff,
|
||||
getUnstagedFiles,
|
||||
getRepoRoot,
|
||||
applyFileSelection,
|
||||
} from "../git";
|
||||
import { selectFiles } from "../selector";
|
||||
import { collectDiff } from "../diff-source";
|
||||
import { BACK, SKIP_WAIT } from "../menu";
|
||||
import { collectProjectContext } from "../context";
|
||||
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
|
||||
import { callAI } from "../ai";
|
||||
import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal";
|
||||
@@ -18,112 +9,66 @@ import type { StreamCallbacks } from "../types";
|
||||
import type { ParsedArgs } from "../cli";
|
||||
|
||||
export async function handleExplain(args: ParsedArgs): Promise<number> {
|
||||
const config = await loadConfig();
|
||||
const config = await loadConfig();
|
||||
|
||||
if (!config.apiKey) {
|
||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (!config.apiKey) {
|
||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const unstaged = args.flags["unstaged"] as boolean;
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
const unstaged = args.flags["unstaged"] as boolean;
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
|
||||
// Determine which diff to explain
|
||||
let diff: string;
|
||||
let sourceLabel: string;
|
||||
let diff: string;
|
||||
let sourceLabel: string;
|
||||
let contextPrefix: string;
|
||||
|
||||
if (unstaged) {
|
||||
if (!(await isGitRepo())) {
|
||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
||||
} catch {
|
||||
diff = "";
|
||||
}
|
||||
sourceLabel = "unstaged changes";
|
||||
} else {
|
||||
// Default: staged changes (or piped)
|
||||
if (isStdinTTY()) {
|
||||
if (!(await isGitRepo())) {
|
||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
sourceLabel = "selected changes";
|
||||
try {
|
||||
const result = await collectDiff({ unstaged, includeProjectContext: true });
|
||||
if (result.back) return SKIP_WAIT as unknown as number;
|
||||
diff = result.diff;
|
||||
sourceLabel = result.sourceLabel;
|
||||
contextPrefix = result.contextPrefix;
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||
}
|
||||
diff = await getStagedDiff();
|
||||
} 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) {
|
||||
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
|
||||
}
|
||||
|
||||
if (args.flags["verbose"]) {
|
||||
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
|
||||
}
|
||||
const userPrompt = contextPrefix + buildExplainPrompt(diff);
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||
}
|
||||
|
||||
// Collect project context for better explanations
|
||||
let contextPrefix = "";
|
||||
try {
|
||||
if (await isGitRepo()) {
|
||||
const repoRoot = await getRepoRoot();
|
||||
const ctx = await collectProjectContext(repoRoot);
|
||||
if (ctx.packageDescription) {
|
||||
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const tty = isStdinTTY();
|
||||
if (tty) {
|
||||
console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
|
||||
}
|
||||
|
||||
const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
|
||||
try {
|
||||
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||
onToken: (token) => process.stdout.write(token),
|
||||
} : undefined;
|
||||
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||
}
|
||||
const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
|
||||
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();
|
||||
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;
|
||||
return 0;
|
||||
}
|
||||
|
||||
+1
-11
@@ -1,4 +1,3 @@
|
||||
import * as readline from "node:readline";
|
||||
import { loadConfig } from "../config";
|
||||
import { isGitRepo, getRepoRoot } from "../git";
|
||||
import { collectProjectContext } from "../context";
|
||||
@@ -19,19 +18,10 @@ import {
|
||||
import type { Platform } from "../pr";
|
||||
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
||||
import { isStdinTTY } from "../tty";
|
||||
import { ask } from "../tty-input";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
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> {
|
||||
if (!isStdinTTY()) {
|
||||
console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`);
|
||||
|
||||
+58
-106
@@ -1,15 +1,6 @@
|
||||
import { loadConfig } from "../config";
|
||||
import {
|
||||
isGitRepo,
|
||||
getStagedFiles,
|
||||
getStagedDiff,
|
||||
getUnstagedFiles,
|
||||
getRepoRoot,
|
||||
applyFileSelection,
|
||||
} from "../git";
|
||||
import { selectFiles } from "../selector";
|
||||
import { collectDiff } from "../diff-source";
|
||||
import { BACK, SKIP_WAIT } from "../menu";
|
||||
import { collectProjectContext } from "../context";
|
||||
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
|
||||
import { callAI } from "../ai";
|
||||
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
|
||||
@@ -18,113 +9,74 @@ import type { StreamCallbacks } from "../types";
|
||||
import type { ParsedArgs } from "../cli";
|
||||
|
||||
export async function handleReview(args: ParsedArgs): Promise<number> {
|
||||
const config = await loadConfig();
|
||||
const config = await loadConfig();
|
||||
|
||||
if (!config.apiKey) {
|
||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (!config.apiKey) {
|
||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const strictnessFlag = args.flags["strict"] as boolean
|
||||
? "strict"
|
||||
: args.flags["lenient"] as boolean
|
||||
? "lenient"
|
||||
: "normal";
|
||||
const strictnessFlag = args.flags["strict"] as boolean
|
||||
? "strict"
|
||||
: args.flags["lenient"] as boolean
|
||||
? "lenient"
|
||||
: "normal";
|
||||
|
||||
const unstaged = args.flags["unstaged"] as boolean;
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
const unstaged = args.flags["unstaged"] as boolean;
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
|
||||
let diff: string;
|
||||
let sourceLabel: string;
|
||||
let diff: string;
|
||||
let sourceLabel: string;
|
||||
let contextPrefix: string;
|
||||
|
||||
if (unstaged) {
|
||||
if (!(await isGitRepo())) {
|
||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
||||
} catch {
|
||||
diff = "";
|
||||
}
|
||||
sourceLabel = "unstaged changes";
|
||||
} else if (!isStdinTTY()) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
diff = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
sourceLabel = "piped input";
|
||||
} else {
|
||||
if (!(await isGitRepo())) {
|
||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
sourceLabel = "selected changes";
|
||||
try {
|
||||
const result = await collectDiff({ unstaged, includeProjectContext: true });
|
||||
if (result.back) return SKIP_WAIT as unknown as number;
|
||||
diff = result.diff;
|
||||
sourceLabel = result.sourceLabel;
|
||||
contextPrefix = result.contextPrefix;
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||
}
|
||||
diff = await getStagedDiff();
|
||||
}
|
||||
if (!diff) {
|
||||
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
const strictnessLabel = strictnessFlag === "strict"
|
||||
? `${RED()}strict${RESET()}`
|
||||
: strictnessFlag === "lenient"
|
||||
? `${GREEN()}lenient${RESET()}`
|
||||
: `${YELLOW()}normal${RESET()}`;
|
||||
|
||||
let contextPrefix = "";
|
||||
try {
|
||||
if (await isGitRepo()) {
|
||||
const repoRoot = await getRepoRoot();
|
||||
const ctx = await collectProjectContext(repoRoot);
|
||||
if (ctx.packageDescription) {
|
||||
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
|
||||
}
|
||||
|
||||
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"
|
||||
? `${RED()}strict${RESET()}`
|
||||
: strictnessFlag === "lenient"
|
||||
? `${GREEN()}lenient${RESET()}`
|
||||
: `${YELLOW()}normal${RESET()}`;
|
||||
try {
|
||||
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||
onToken: (token) => process.stdout.write(token),
|
||||
} : undefined;
|
||||
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
const tty = isStdinTTY();
|
||||
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;
|
||||
return 0;
|
||||
}
|
||||
|
||||
+79
-112
@@ -1,17 +1,10 @@
|
||||
import { loadConfig } from "../config";
|
||||
import {
|
||||
isGitRepo,
|
||||
getStagedFiles,
|
||||
getStagedDiff,
|
||||
getUnstagedFiles,
|
||||
applyFileSelection,
|
||||
} from "../git";
|
||||
import { selectFiles } from "../selector";
|
||||
import { collectDiff } from "../diff-source";
|
||||
import { BACK, SKIP_WAIT } from "../menu";
|
||||
import {
|
||||
SUGGEST_SYSTEM_PROMPT,
|
||||
buildSuggestBranchPrompt,
|
||||
buildSuggestTypePrompt,
|
||||
SUGGEST_SYSTEM_PROMPT,
|
||||
buildSuggestBranchPrompt,
|
||||
buildSuggestTypePrompt,
|
||||
} from "../prompt";
|
||||
import { callAI } from "../ai";
|
||||
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
|
||||
@@ -20,127 +13,101 @@ import type { Config } from "../types";
|
||||
import type { ParsedArgs } from "../cli";
|
||||
|
||||
export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
||||
const config = await loadConfig();
|
||||
const config = await loadConfig();
|
||||
|
||||
if (!config.apiKey) {
|
||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (!config.apiKey) {
|
||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const mode = args.positional[0] || "branch";
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
const mode = args.positional[0] || "branch";
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
|
||||
if (mode !== "branch" && mode !== "type") {
|
||||
console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
|
||||
console.error(` Try: gai suggest branch | gai suggest type\n`);
|
||||
return 1;
|
||||
}
|
||||
if (mode !== "branch" && mode !== "type") {
|
||||
console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
|
||||
console.error(` Try: gai suggest branch | gai suggest type\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get diff (staged, or unstaged if --unstaged, or piped)
|
||||
let diff: string;
|
||||
if (!isStdinTTY()) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
diff = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
} else {
|
||||
if (!(await isGitRepo())) {
|
||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
const unstaged = args.flags["unstaged"] as boolean;
|
||||
|
||||
if (args.flags["unstaged"] as boolean) {
|
||||
try {
|
||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
||||
} catch {
|
||||
diff = "";
|
||||
}
|
||||
} else {
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
let diff: string;
|
||||
|
||||
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||
}
|
||||
diff = await getStagedDiff();
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await collectDiff({ unstaged, includeProjectContext: false });
|
||||
if (result.back) return SKIP_WAIT as unknown as number;
|
||||
diff = result.diff;
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
if (!diff) {
|
||||
console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||
}
|
||||
|
||||
if (mode === "branch") {
|
||||
return handleSuggestBranch(config, truncatedDiff);
|
||||
} else {
|
||||
return handleSuggestType(config, truncatedDiff);
|
||||
}
|
||||
if (mode === "branch") {
|
||||
return handleSuggestBranch(config, diff);
|
||||
}
|
||||
return handleSuggestType(config, diff);
|
||||
}
|
||||
|
||||
async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
|
||||
const tty = isStdinTTY();
|
||||
if (tty) {
|
||||
console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
|
||||
}
|
||||
const tty = isStdinTTY();
|
||||
if (tty) {
|
||||
console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
|
||||
const suggestions = raw
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
|
||||
const suggestions = raw
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
console.log(` ${DIM()}No suggestions generated.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
if (suggestions.length === 0) {
|
||||
console.log(` ${DIM()}No suggestions generated.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const s of suggestions) {
|
||||
console.log(` ${GREEN()}${s}${RESET()}`);
|
||||
}
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
for (const s of suggestions) {
|
||||
console.log(` ${GREEN()}${s}${RESET()}`);
|
||||
}
|
||||
console.log("");
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleSuggestType(config: Config, diff: string): Promise<number> {
|
||||
const tty = isStdinTTY();
|
||||
if (tty) {
|
||||
console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
|
||||
}
|
||||
const tty = isStdinTTY();
|
||||
if (tty) {
|
||||
console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
|
||||
const type = raw.trim().toLowerCase();
|
||||
const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
|
||||
try {
|
||||
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
|
||||
const type = raw.trim().toLowerCase();
|
||||
const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
|
||||
|
||||
if (validTypes.includes(type)) {
|
||||
console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
|
||||
} else {
|
||||
console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
|
||||
console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (validTypes.includes(type)) {
|
||||
console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
|
||||
} else {
|
||||
console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
|
||||
console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user