feat: overhaul CLI with new AI commands and mole-style menu #6
@@ -0,0 +1,418 @@
|
||||
import * as readline from "node:readline";
|
||||
import {
|
||||
isGitRepo,
|
||||
getRepoRoot,
|
||||
getStagedFiles,
|
||||
getUnstagedFiles,
|
||||
getStagedDiff,
|
||||
getRecentCommits,
|
||||
stageFiles,
|
||||
commit,
|
||||
} from "../git";
|
||||
import { selectFiles } from "../selector";
|
||||
import { BACK } from "../menu";
|
||||
import { collectProjectContext } from "../context";
|
||||
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
|
||||
import { generateCommitMessage } from "../ai";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
||||
import { isStdinTTY } from "../tty";
|
||||
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
|
||||
? `${YELLOW()}[${result.branch} ${result.hash}]${RESET()}`
|
||||
: result.hash
|
||||
? `${YELLOW()}${result.hash}${RESET()}`
|
||||
: "";
|
||||
console.log(` ${id} ${msg}`);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (result.files > 0)
|
||||
parts.push(`${YELLOW()}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET()}`);
|
||||
if (result.insertions > 0)
|
||||
parts.push(`${GREEN()}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET()}`);
|
||||
if (result.deletions > 0)
|
||||
parts.push(`${RED()}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET()}`);
|
||||
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
|
||||
}
|
||||
|
||||
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
|
||||
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
||||
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
||||
const answer = await ask(` Use this message? [${GREEN()}Y${RESET()}/n/e] `);
|
||||
const lower = answer.toLowerCase();
|
||||
if (lower === "n") return "n";
|
||||
if (lower === "e") return "e";
|
||||
return "y";
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCommit(args: ParsedArgs): Promise<number> {
|
||||
const autoMode = args.flags["all"] as boolean || args.flags["auto"] as boolean;
|
||||
const dryRun = args.flags["dry-run"] as boolean;
|
||||
const amend = args.flags["amend"] as boolean;
|
||||
const customMessage = args.flags["message"] as string | undefined;
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
|
||||
if (!(await isGitRepo())) {
|
||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If a custom message is provided, skip AI and commit directly
|
||||
if (customMessage) {
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
|
||||
if (stagedFiles.length === 0 && !amend) {
|
||||
if (autoMode && unstagedFiles.length > 0) {
|
||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||
} else if (unstagedFiles.length > 0) {
|
||||
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
|
||||
return 1;
|
||||
} else {
|
||||
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const diff = await getStagedDiff();
|
||||
if (!diff && !amend) {
|
||||
console.log(` ${DIM()}Nothing to commit.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await commit(customMessage);
|
||||
printCommitResult(result, customMessage);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle amend
|
||||
if (amend) {
|
||||
return handleAmendCommit(args);
|
||||
}
|
||||
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
|
||||
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
||||
console.log(` ${DIM()}Nothing to commit. No staged or unstaged changes.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (unstagedFiles.length > 0) {
|
||||
if (autoMode) {
|
||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||
} else {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) return 0;
|
||||
if (selected.length > 0) {
|
||||
await stageFiles(selected);
|
||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff = await getStagedDiff();
|
||||
if (!diff) {
|
||||
console.log(` ${DIM()}Nothing to commit. No staged changes to commit.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
|
||||
const repoRoot = await getRepoRoot();
|
||||
const projectCtx = await collectProjectContext(repoRoot);
|
||||
const recentCommits = await getRecentCommits(10);
|
||||
|
||||
const userPrompt = buildPrompt({
|
||||
readme: projectCtx.readme,
|
||||
packageDescription: projectCtx.packageDescription,
|
||||
structure: projectCtx.structure,
|
||||
recentCommits,
|
||||
diff: truncatedDiff,
|
||||
});
|
||||
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||
}
|
||||
|
||||
const tty = isStdinTTY();
|
||||
if (tty) {
|
||||
console.log("\n Generating commit message...");
|
||||
}
|
||||
|
||||
let message: string;
|
||||
try {
|
||||
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||
onToken: (token) => process.stdout.write(token),
|
||||
} : undefined;
|
||||
|
||||
if (callbacks) process.stdout.write(" ");
|
||||
|
||||
message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt, callbacks);
|
||||
|
||||
if (callbacks) process.stdout.write("\n");
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
||||
console.log(` ${GREEN()}${message}${RESET()}`);
|
||||
await copyToClipboard(message);
|
||||
console.log(`\n ${DIM()}(dry-run, message copied to clipboard)${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const action = await confirmCommit(message);
|
||||
|
||||
if (action === "y") {
|
||||
try {
|
||||
const result = await commit(message);
|
||||
printCommitResult(result, message);
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
} else if (action === "e") {
|
||||
const edited = await editMessage(message);
|
||||
if (edited) {
|
||||
try {
|
||||
const result = await commit(edited);
|
||||
printCommitResult(result, edited);
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
console.log(" Aborted.");
|
||||
}
|
||||
} else {
|
||||
const copied = await copyToClipboard(message);
|
||||
console.log(copied ? ` Aborted. Message copied to clipboard.` : ` Aborted. Message: ${message}`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleAmendCommit(args: ParsedArgs): Promise<number> {
|
||||
const config = await loadConfig();
|
||||
if (!config.apiKey) {
|
||||
console.error(`\n ${RED()}Error: API key not set.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the diff of the last commit (what would be amended)
|
||||
let diff: string;
|
||||
try {
|
||||
diff = await Bun.$`git diff HEAD~1..HEAD`.quiet().text();
|
||||
diff = diff.trim();
|
||||
} catch {
|
||||
// If there's no previous commit (first commit), get staged diff
|
||||
diff = await getStagedDiff();
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
// Try getting the commit message from HEAD
|
||||
try {
|
||||
const lastMsg = await Bun.$`git log -1 --format=%B`.quiet().text();
|
||||
if (lastMsg.trim()) {
|
||||
console.log(` Last commit message: ${DIM()}${lastMsg.trim()}${RESET()}`);
|
||||
const newMsg = await editMessage(lastMsg.trim());
|
||||
if (newMsg) {
|
||||
await Bun.spawn(["git", "commit", "--amend", "-m", newMsg], { stdio: ["inherit", "inherit", "inherit"] });
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
} catch {}
|
||||
console.log(` ${DIM()}No changes to amend.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const repoRoot = await getRepoRoot();
|
||||
const projectCtx = await collectProjectContext(repoRoot);
|
||||
const recentCommits = await getRecentCommits(10);
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
|
||||
const userPrompt = buildPrompt({
|
||||
readme: projectCtx.readme,
|
||||
packageDescription: projectCtx.packageDescription,
|
||||
structure: projectCtx.structure,
|
||||
recentCommits,
|
||||
diff: truncatedDiff,
|
||||
});
|
||||
|
||||
console.log("\n Generating amended commit message...");
|
||||
|
||||
let message: string;
|
||||
try {
|
||||
message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt);
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(`\n ${BOLD()}Generated amended message:${RESET()}`);
|
||||
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
||||
|
||||
const answer = await ask(` Amend commit with this message? [${GREEN()}Y${RESET()}/n/e] `);
|
||||
const lower = answer.toLowerCase();
|
||||
|
||||
if (lower === "n") {
|
||||
console.log(" Aborted.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (lower === "e") {
|
||||
const edited = await editMessage(message);
|
||||
if (!edited) { console.log(" Aborted."); return 0; }
|
||||
message = edited;
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(["git", "commit", "--amend", "-m", message], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`git commit --amend failed (exit code ${exitCode})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Amend failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { loadConfig, saveConfig } from "../config";
|
||||
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
||||
import { isStdinTTY } from "../tty";
|
||||
import type { Config } from "../types";
|
||||
import type { ParsedArgs } from "../cli";
|
||||
|
||||
type ConfigKey = keyof Config;
|
||||
|
||||
interface ConfigField {
|
||||
key: ConfigKey;
|
||||
label: string;
|
||||
format: (config: Config) => string;
|
||||
initialEditValue: (config: Config) => string;
|
||||
parse: (value: string) => { value: Config[ConfigKey] } | { error: string };
|
||||
}
|
||||
|
||||
const CONFIG_FIELDS: ConfigField[] = [
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "API Key",
|
||||
format: (config) =>
|
||||
config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW()}(not set)${RESET()}`,
|
||||
initialEditValue: () => "",
|
||||
parse: (value) => ({ value }),
|
||||
},
|
||||
{
|
||||
key: "apiBase",
|
||||
label: "API Base",
|
||||
format: (config) => config.apiBase,
|
||||
initialEditValue: (config) => config.apiBase,
|
||||
parse: (value) => ({ value }),
|
||||
},
|
||||
{
|
||||
key: "model",
|
||||
label: "Model",
|
||||
format: (config) => config.model,
|
||||
initialEditValue: (config) => config.model,
|
||||
parse: (value) => ({ value }),
|
||||
},
|
||||
{
|
||||
key: "maxTokens",
|
||||
label: "Max Tokens",
|
||||
format: (config) => String(config.maxTokens),
|
||||
initialEditValue: (config) => String(config.maxTokens),
|
||||
parse: (value) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return { error: "Max Tokens must be a positive integer." };
|
||||
}
|
||||
return { value: parsed };
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "temperature",
|
||||
label: "Temperature",
|
||||
format: (config) => String(config.temperature),
|
||||
initialEditValue: (config) => String(config.temperature),
|
||||
parse: (value) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return { error: "Temperature must be a finite number." };
|
||||
}
|
||||
return { value: parsed };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
previousLines: number,
|
||||
status: string | null,
|
||||
editState: { buffer: string; cursor: number } | null,
|
||||
) {
|
||||
if (previousLines > 0) {
|
||||
for (let i = 0; i < previousLines; i++) {
|
||||
clearLine();
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
moveUp(previousLines);
|
||||
}
|
||||
|
||||
const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2;
|
||||
const lines = [
|
||||
"",
|
||||
` ${BOLD()}Configuration${RESET()}`,
|
||||
editState
|
||||
? ` ${DIM()}editing · enter save · esc cancel · ctrl+c cancel${RESET()}`
|
||||
: ` ${DIM()}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET()}`,
|
||||
"",
|
||||
];
|
||||
|
||||
let activeValueOffset = 0;
|
||||
for (let i = 0; i < CONFIG_FIELDS.length; i++) {
|
||||
const field = CONFIG_FIELDS[i]!;
|
||||
const active = i === cursor;
|
||||
const pointer = active ? `${CYAN()}❯${RESET()}` : " ";
|
||||
const marker = active ? `${GREEN()}●${RESET()}` : `${DIM()}○${RESET()}`;
|
||||
const label = active ? `${BOLD()}${field.label}${RESET()}` : field.label;
|
||||
const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label)));
|
||||
const value = active && editState ? editState.buffer : field.format(config);
|
||||
if (active && editState) {
|
||||
activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`);
|
||||
}
|
||||
lines.push(` ${pointer} ${marker} ${label}${padding}${value}`);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
lines.push("", ` ${status}`);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
if (editState) {
|
||||
moveUp(lines.length - (4 + cursor));
|
||||
const column = activeValueOffset + editState.cursor;
|
||||
process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`);
|
||||
} else {
|
||||
moveUp(lines.length);
|
||||
}
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
async function interactiveConfig(): Promise<"done" | "back"> {
|
||||
if (!isStdinTTY()) {
|
||||
console.error(`\n ${RED()}Error: Interactive config requires a TTY.${RESET()}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let config = await loadConfig();
|
||||
let cursor = 0;
|
||||
let renderedLines = 0;
|
||||
let status: string | null = null;
|
||||
let editState: { buffer: string; cursor: number } | null = null;
|
||||
let renderedCursorRow = 0;
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
|
||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdout.write("\x1b[?25l");
|
||||
|
||||
const render = () => {
|
||||
moveUp(renderedCursorRow);
|
||||
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
|
||||
renderedCursorRow = editState ? 4 + cursor : 0;
|
||||
process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l");
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const finish = (value: "done" | "back") => {
|
||||
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");
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editState) return;
|
||||
const field = CONFIG_FIELDS[cursor]!;
|
||||
const value = editState.buffer.trim();
|
||||
editState = null;
|
||||
|
||||
if (value === "") {
|
||||
status = `${DIM()}No changes.${RESET()}`;
|
||||
} else {
|
||||
const parsed = field.parse(value);
|
||||
if ("error" in parsed) {
|
||||
status = `${RED()}${parsed.error}${RESET()}`;
|
||||
} else {
|
||||
await saveConfig({ [field.key]: parsed.value } as Partial<Config>);
|
||||
config = await loadConfig();
|
||||
status = `${GREEN()}${field.label} saved.${RESET()}`;
|
||||
}
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const key = data.toString();
|
||||
|
||||
if (editState) {
|
||||
if (key === "\x03" || key === "\x1b") {
|
||||
editState = null;
|
||||
status = `${DIM()}No changes.${RESET()}`;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (key === "\r") { void saveEdit(); return; }
|
||||
if (key === "\x01") { editState.cursor = 0; render(); return; }
|
||||
if (key === "\x05") { editState.cursor = editState.buffer.length; render(); return; }
|
||||
if (key === "\x0b") { editState.buffer = editState.buffer.slice(0, editState.cursor); render(); return; }
|
||||
if (key === "\x15") { editState.buffer = editState.buffer.slice(editState.cursor); editState.cursor = 0; render(); return; }
|
||||
if (key === "\x7f") {
|
||||
if (editState.cursor > 0) {
|
||||
editState.buffer = editState.buffer.slice(0, editState.cursor - 1) + editState.buffer.slice(editState.cursor);
|
||||
editState.cursor--;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === "\x1b[D" || key === "\x1bOD") { if (editState.cursor > 0) editState.cursor--; render(); return; }
|
||||
if (key === "\x1b[C" || key === "\x1bOC") { if (editState.cursor < editState.buffer.length) editState.cursor++; render(); return; }
|
||||
if (key === "\x1b[H" || key === "\x1b[1~") { editState.cursor = 0; render(); return; }
|
||||
if (key === "\x1b[F" || key === "\x1b[4~") { editState.cursor = editState.buffer.length; render(); return; }
|
||||
if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) {
|
||||
editState.buffer = editState.buffer.slice(0, editState.cursor) + editState.buffer.slice(editState.cursor + 1);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (key >= " " && key !== "\x7f") {
|
||||
editState.buffer = editState.buffer.slice(0, editState.cursor) + key + editState.buffer.slice(editState.cursor);
|
||||
editState.cursor += key.length;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not editing
|
||||
if (key === "\x03") return finish("done");
|
||||
if (key === "\x1b[A" || key === "\x1bOA") {
|
||||
if (cursor > 0) { cursor--; status = null; render(); }
|
||||
} else if (key === "\x1b[B" || key === "\x1bOB") {
|
||||
if (cursor < CONFIG_FIELDS.length - 1) { cursor++; status = null; render(); }
|
||||
} else if (key === "\x1b[D" || key === "\x1bOD" || key === "\x7f") {
|
||||
return finish("back");
|
||||
} else if (key === " ") {
|
||||
const value = CONFIG_FIELDS[cursor]!.initialEditValue(config);
|
||||
editState = { buffer: value, cursor: value.length };
|
||||
status = null;
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleConfig(args: ParsedArgs): Promise<number> {
|
||||
const positional = args.positional;
|
||||
|
||||
// gai config get <key>
|
||||
if (positional[0] === "get") {
|
||||
const key = positional[1];
|
||||
if (!key) {
|
||||
console.error(`\n ${RED()}Error: Usage: gai config get <key>${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
const config = await loadConfig();
|
||||
const field = CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field) {
|
||||
console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`);
|
||||
console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`);
|
||||
return 1;
|
||||
}
|
||||
// For apiKey, show masked value
|
||||
if (key === "apiKey") {
|
||||
console.log(config.apiKey || "");
|
||||
} else {
|
||||
console.log(String(config[key as keyof Config]));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// gai config set <key> <value>
|
||||
if (positional[0] === "set") {
|
||||
const key = positional[1];
|
||||
const value = positional.slice(2).join(" ");
|
||||
if (!key || value === undefined) {
|
||||
console.error(`\n ${RED()}Error: Usage: gai config set <key> <value>${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
const field = CONFIG_FIELDS.find((f) => f.key === key);
|
||||
if (!field) {
|
||||
console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`);
|
||||
console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`);
|
||||
return 1;
|
||||
}
|
||||
const parsed = field.parse(value);
|
||||
if ("error" in parsed) {
|
||||
console.error(`\n ${RED()}Error: ${parsed.error}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
await saveConfig({ [field.key]: parsed.value } as Partial<Config>);
|
||||
console.log(` ${GREEN()}${field.label} set.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// gai config list
|
||||
if (positional[0] === "list" || positional[0] === "ls") {
|
||||
const config = await loadConfig();
|
||||
const labelWidth = Math.max(...CONFIG_FIELDS.map((f) => f.label.length)) + 2;
|
||||
console.log("");
|
||||
for (const field of CONFIG_FIELDS) {
|
||||
const padding = " ".repeat(Math.max(1, labelWidth - field.label.length));
|
||||
console.log(` ${BOLD()}${field.label}${RESET()}${padding}${field.format(config)}`);
|
||||
}
|
||||
console.log("");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// gai config (no args) → interactive
|
||||
if (positional.length === 0) {
|
||||
const result = await interactiveConfig();
|
||||
return result === "back" ? 0 : 0;
|
||||
}
|
||||
|
||||
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
|
||||
console.error(` Try: gai config [get|set|list]\n`);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import * as readline from "node:readline";
|
||||
import { loadConfig } from "../config";
|
||||
import { isGitRepo, getRepoRoot } from "../git";
|
||||
import { collectProjectContext } from "../context";
|
||||
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
|
||||
import { generatePRMessage } from "../ai";
|
||||
import { BACK, selectOne } from "../menu";
|
||||
import {
|
||||
getDefaultBranch,
|
||||
getBranchName,
|
||||
getBranchPushStatus,
|
||||
pushCurrentBranch,
|
||||
getBranchCommits,
|
||||
getBranchDiff,
|
||||
detectPlatform,
|
||||
getRemoteHostname,
|
||||
createPR,
|
||||
} from "../pr";
|
||||
import type { Platform } from "../pr";
|
||||
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
|
||||
import { isStdinTTY } from "../tty";
|
||||
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`);
|
||||
process.exit(1);
|
||||
}
|
||||
return selectOne({
|
||||
title: "Select remote platform",
|
||||
subtitle: `Remote ${hostname} could not be auto-detected`,
|
||||
items: [
|
||||
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
|
||||
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" },
|
||||
{ label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePR(args: ParsedArgs): Promise<number> {
|
||||
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 (!(await isGitRepo())) {
|
||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const draft = args.flags["draft"] as boolean;
|
||||
const verbose = args.flags["verbose"] as boolean;
|
||||
|
||||
let platform = await detectPlatform();
|
||||
if (!platform) {
|
||||
const hostname = (await getRemoteHostname()) || "unknown";
|
||||
const chosen = await selectPlatform(hostname);
|
||||
if (chosen === BACK) return 0;
|
||||
if (!chosen) {
|
||||
console.log(" Aborted.");
|
||||
return 0;
|
||||
}
|
||||
platform = chosen;
|
||||
}
|
||||
|
||||
const platformLabel = platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea";
|
||||
console.log(` Using: ${CYAN()}${platformLabel}${RESET()}`);
|
||||
|
||||
const baseBranch = await getDefaultBranch();
|
||||
const branchName = await getBranchName();
|
||||
|
||||
if (branchName === baseBranch) {
|
||||
console.error(`\n ${RED()}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(` Branch: ${CYAN()}${branchName}${RESET()} → base: ${CYAN()}${baseBranch}${RESET()}`);
|
||||
|
||||
const commits = await getBranchCommits(baseBranch);
|
||||
|
||||
if (commits.length === 0) {
|
||||
const choice = await selectOne({
|
||||
title: "No commits to compare",
|
||||
subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`,
|
||||
items: [{ label: "Back", value: "back" as const }],
|
||||
});
|
||||
if (choice === null) process.exit(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
|
||||
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||
}
|
||||
|
||||
const pushStatus = await getBranchPushStatus();
|
||||
if (!pushStatus.pushed) {
|
||||
const target = pushStatus.upstream ?? `origin/${branchName}`;
|
||||
const answer = await ask(
|
||||
` Branch is not pushed to ${CYAN()}${target}${RESET()}. Push now? [${GREEN()}Y${RESET()}/n] `,
|
||||
);
|
||||
|
||||
if (answer.toLowerCase() === "n") {
|
||||
console.log(" Aborted.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(` Pushing ${CYAN()}${branchName}${RESET()}...`);
|
||||
try {
|
||||
await pushCurrentBranch(branchName, pushStatus.upstream);
|
||||
console.log(` ${GREEN()}Pushed ${branchName}.${RESET()}`);
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}Push failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const diff = await getBranchDiff(baseBranch);
|
||||
if (!diff) {
|
||||
console.error(`\n ${RED()}Error: No diff from base branch.${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
|
||||
const repoRoot = await getRepoRoot();
|
||||
const projectCtx = await collectProjectContext(repoRoot);
|
||||
|
||||
const userPrompt = buildPRPrompt({
|
||||
readme: projectCtx.readme,
|
||||
packageDescription: projectCtx.packageDescription,
|
||||
structure: projectCtx.structure,
|
||||
branchName,
|
||||
baseBranch,
|
||||
branchCommits: commits,
|
||||
diff: truncatedDiff,
|
||||
});
|
||||
|
||||
console.log("\n Generating PR title and description...");
|
||||
|
||||
let title: string;
|
||||
let body: string;
|
||||
try {
|
||||
const result = await generatePRMessage(config, PR_SYSTEM_PROMPT, userPrompt);
|
||||
title = result.title;
|
||||
body = result.body;
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(`\n ${BOLD()}Generated PR:${RESET()}`);
|
||||
console.log(` Title: ${GREEN()}${title}${RESET()}`);
|
||||
if (body) {
|
||||
console.log(` Body: ${DIM()}${body.replace(/\n/g, "\n ")}${RESET()}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
const answer = await ask(` Create this PR? [${GREEN()}Y${RESET()}/n/e] `);
|
||||
const lower = answer.toLowerCase();
|
||||
|
||||
if (lower === "n") {
|
||||
console.log(" Aborted.");
|
||||
await copyToClipboard(`${title}\n\n${body}`);
|
||||
console.log(` ${DIM()}PR title copied to clipboard.${RESET()}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (lower === "e") {
|
||||
const newTitle = await ask(" Title: ");
|
||||
const newBody = await ask(" Body (optional): ");
|
||||
if (!newTitle.trim()) {
|
||||
console.log(" Aborted.");
|
||||
return 0;
|
||||
}
|
||||
title = newTitle;
|
||||
body = newBody;
|
||||
}
|
||||
|
||||
console.log(`\n Creating PR...`);
|
||||
|
||||
try {
|
||||
const url = await createPR(platform, title, body, baseBranch, draft);
|
||||
console.log(`\n ${GREEN()}${BOLD()}✔ PR created!${RESET()}`);
|
||||
console.log(` ${CYAN()}${url}${RESET()}`);
|
||||
} catch (err) {
|
||||
console.error(`\n ${RED()}PR creation failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user