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 {
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user