Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 301f8f645d | |||
| 311d059e52 | |||
| f3b5c631de | |||
| 4fbac6a6e1 | |||
| d4ba4da9d5 | |||
| 10c7b9a688 | |||
| 5959d78e6c | |||
| a1151d7f76 | |||
| ff4e18ca8b |
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
// gai — AI-powered git commit and PR helper
|
||||
// v0.1.3
|
||||
|
||||
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
|
||||
import { handleCommit } from "./src/commands/commit";
|
||||
@@ -11,8 +10,7 @@ import { handleExplain } from "./src/commands/explain";
|
||||
import { handleReview } from "./src/commands/review";
|
||||
import { handleChangelog } from "./src/commands/changelog";
|
||||
import { handleSuggest } from "./src/commands/suggest";
|
||||
import { setColorEnabled } from "./src/terminal";
|
||||
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
|
||||
import { setColorEnabled, BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, clearScreen, visibleLength, padRight } from "./src/terminal";
|
||||
import { isStdinTTY, initTTY } from "./src/tty";
|
||||
import { VERSION } from "./src/brand";
|
||||
import { SKIP_WAIT } from "./src/menu";
|
||||
@@ -37,11 +35,6 @@ const MENU_ITEMS: MenuItem[] = [
|
||||
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
|
||||
];
|
||||
|
||||
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
||||
function showCursor() { process.stdout.write("\x1b[?25h"); }
|
||||
|
||||
function clearLine() { process.stdout.write("\r\x1b[2K"); }
|
||||
|
||||
async function readKey(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const onData = (data: Buffer) => {
|
||||
@@ -52,14 +45,6 @@ async function readKey(): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function visibleLen(s: string): number {
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
|
||||
function padRight(value: string, width: number): string {
|
||||
return value + " ".repeat(Math.max(0, width - visibleLen(value)));
|
||||
}
|
||||
|
||||
function renderMenu(cursor: number): number {
|
||||
process.stdout.write("\x1b[H"); // cursor home
|
||||
|
||||
@@ -84,7 +69,7 @@ function renderMenu(cursor: number): number {
|
||||
write("");
|
||||
|
||||
const keyWidth = 3;
|
||||
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
|
||||
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLength(m.label))) + 2;
|
||||
let currentGroup: MenuItem["group"] | null = null;
|
||||
|
||||
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
||||
@@ -165,14 +150,14 @@ async function waitForEnter(): Promise<void> {
|
||||
// Resume stdin in case it was paused
|
||||
process.stdin.resume();
|
||||
});
|
||||
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
|
||||
clearScreen();
|
||||
}
|
||||
|
||||
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
|
||||
showCursor();
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
|
||||
clearScreen();
|
||||
const result = await dispatchMenuAction(item.key);
|
||||
if (result === (SKIP_WAIT as unknown as number)) {
|
||||
return 0; // user explicitly backed out — skip "Press Enter" and return directly
|
||||
@@ -197,6 +182,13 @@ async function showMenu(): Promise<number> {
|
||||
// Initial render
|
||||
renderMenu(cursor);
|
||||
|
||||
const exitMenu = (exitCode: number): number => {
|
||||
showCursor();
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
return exitCode;
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const raw = await readKey();
|
||||
@@ -214,7 +206,7 @@ async function showMenu(): Promise<number> {
|
||||
// Enter
|
||||
if (raw === "\r" || raw === "\n") {
|
||||
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
|
||||
if (result !== 0) return result;
|
||||
if (result !== 0) return exitMenu(result);
|
||||
hideCursor();
|
||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
@@ -224,11 +216,8 @@ async function showMenu(): Promise<number> {
|
||||
|
||||
// Ctrl+C
|
||||
if (raw === "\x03") {
|
||||
showCursor();
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdout.write("\n");
|
||||
return 0;
|
||||
return exitMenu(0);
|
||||
}
|
||||
|
||||
// Number hotkeys (1-8)
|
||||
@@ -238,7 +227,7 @@ async function showMenu(): Promise<number> {
|
||||
cursor = idx;
|
||||
renderMenu(cursor);
|
||||
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
|
||||
if (result !== 0) return result;
|
||||
if (result !== 0) return exitMenu(result);
|
||||
hideCursor();
|
||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
@@ -250,27 +239,18 @@ async function showMenu(): Promise<number> {
|
||||
// Letter hotkeys
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower === "h") {
|
||||
showCursor();
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdout.write("\x1b[2J\x1b[H");
|
||||
clearScreen();
|
||||
console.log(formatHelp(commands));
|
||||
return 0;
|
||||
return exitMenu(0);
|
||||
}
|
||||
if (lower === "v") {
|
||||
showCursor();
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdout.write("\x1b[2J\x1b[H");
|
||||
clearScreen();
|
||||
console.log(`gai v${VERSION}`);
|
||||
return 0;
|
||||
return exitMenu(0);
|
||||
}
|
||||
if (lower === "q") {
|
||||
showCursor();
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdout.write("\n");
|
||||
return 0;
|
||||
return exitMenu(0);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -428,10 +408,8 @@ const commands = registerCommands(
|
||||
},
|
||||
);
|
||||
|
||||
// Keep the defs accessible for help command
|
||||
const allCommandDefs = [...commands.values()].filter(
|
||||
(c, i, arr) => arr.findIndex((x) => x.name === c.name) === i,
|
||||
);
|
||||
// Keep canonical command defs accessible for help command (deduplicate by reference)
|
||||
const allCommandDefs = [...new Set(commands.values())];
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gai",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
|
||||
@@ -123,7 +123,6 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
// Keep the last potentially incomplete line
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -136,7 +135,12 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (parsed.error) {
|
||||
callbacks.onError?.(new Error(`Stream error: ${parsed.error.message ?? "unknown"}`));
|
||||
continue;
|
||||
}
|
||||
const token = parsed.choices?.[0]?.delta?.content;
|
||||
if (token) {
|
||||
fullText += token;
|
||||
@@ -152,7 +156,8 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
try { await reader.cancel(); } catch {}
|
||||
// releaseLock is not needed after cancel
|
||||
}
|
||||
|
||||
callbacks.onDone?.(fullText);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { GREEN, CYAN, RESET } from "./terminal";
|
||||
|
||||
export const VERSION = "0.1.3";
|
||||
export const VERSION = "0.1.4";
|
||||
|
||||
export function showBanner(): string {
|
||||
const G = GREEN();
|
||||
|
||||
+3
-14
@@ -1,6 +1,8 @@
|
||||
// Lightweight CLI argument parser aligned with mainstream CLI conventions.
|
||||
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
|
||||
|
||||
import { VERSION } from "./brand";
|
||||
|
||||
export interface FlagDef {
|
||||
long: string; // e.g. "dry-run"
|
||||
short?: string; // e.g. "d"
|
||||
@@ -44,19 +46,6 @@ function buildFlagIndex(flags: FlagDef[]): Map<string, FlagDef> {
|
||||
return index;
|
||||
}
|
||||
|
||||
function resolveFlagName(raw: string): { flag: FlagDef; value?: string } | null {
|
||||
// "--key=value"
|
||||
const eqIndex = raw.indexOf("=");
|
||||
if (eqIndex !== -1) {
|
||||
const name = raw.slice(0, eqIndex);
|
||||
const value = raw.slice(eqIndex + 1);
|
||||
const allFlags = buildFlagIndex([...GLOBAL_FLAGS]); // we'll rebuild in context
|
||||
// We'll handle = syntax in the main parse loop with proper index
|
||||
return null; // handled inline
|
||||
}
|
||||
return null; // handled inline
|
||||
}
|
||||
|
||||
function parseArgs(
|
||||
rawArgs: string[],
|
||||
commands: Map<string, CommandDef>,
|
||||
@@ -282,7 +271,7 @@ export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef
|
||||
|
||||
// Handle --version globally
|
||||
if (result.flags["version"]) {
|
||||
console.log("gai v0.1.3");
|
||||
console.log(`gai v${VERSION}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -19,7 +19,9 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
proc.stdin.end();
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode === 0) return true;
|
||||
} catch {}
|
||||
} catch {
|
||||
// Try next command
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+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);
|
||||
};
|
||||
|
||||
|
||||
+11
-66
@@ -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";
|
||||
@@ -28,76 +19,31 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
||||
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 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`);
|
||||
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;
|
||||
}
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
sourceLabel = "selected changes";
|
||||
|
||||
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) return 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 (args.flags["verbose"]) {
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
|
||||
}
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
|
||||
// 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 userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
|
||||
const userPrompt = contextPrefix + buildExplainPrompt(diff);
|
||||
|
||||
if (verbose) {
|
||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||
@@ -117,7 +63,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
||||
if (callbacks) {
|
||||
process.stdout.write("\n");
|
||||
} else {
|
||||
// Non-TTY: print the result directly
|
||||
process.stdout.write(explanation + "\n");
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+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`);
|
||||
|
||||
+10
-58
@@ -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";
|
||||
@@ -36,64 +27,25 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
|
||||
|
||||
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`);
|
||||
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;
|
||||
}
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
sourceLabel = "selected changes";
|
||||
|
||||
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) return 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;
|
||||
}
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
||||
: diff;
|
||||
|
||||
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 userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag);
|
||||
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
|
||||
|
||||
const strictnessLabel = strictnessFlag === "strict"
|
||||
? `${RED()}strict${RESET()}`
|
||||
|
||||
+11
-44
@@ -1,12 +1,5 @@
|
||||
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,
|
||||
@@ -36,37 +29,17 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
||||
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) {
|
||||
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) {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||
}
|
||||
diff = await getStagedDiff();
|
||||
}
|
||||
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) {
|
||||
@@ -74,20 +47,14 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
||||
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 (mode === "branch") {
|
||||
return handleSuggestBranch(config, truncatedDiff);
|
||||
} else {
|
||||
return handleSuggestType(config, truncatedDiff);
|
||||
return handleSuggestBranch(config, diff);
|
||||
}
|
||||
return handleSuggestType(config, diff);
|
||||
}
|
||||
|
||||
async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Shared diff-source helper used by explain, review, suggest, and commit commands.
|
||||
// Handles the common pattern of: detecting where to get a diff from
|
||||
// (staged, unstaged, or piped stdin), optionally presenting an interactive
|
||||
// file selector, applying truncation, and collecting project context.
|
||||
|
||||
import { isGitRepo, getStagedFiles, getStagedDiff, getUnstagedFiles, getRepoRoot, applyFileSelection } from "./git";
|
||||
import { selectFiles } from "./selector";
|
||||
import { BACK } from "./menu";
|
||||
import { collectProjectContext } from "./context";
|
||||
import { isStdinTTY } from "./tty";
|
||||
|
||||
export interface DiffSourceResult {
|
||||
diff: string;
|
||||
sourceLabel: string;
|
||||
contextPrefix: string;
|
||||
back: boolean;
|
||||
}
|
||||
|
||||
const MAX_DIFF_SIZE = 15000;
|
||||
|
||||
/**
|
||||
* Collect a diff from the appropriate source based on flags and TTY state.
|
||||
*
|
||||
* - If `unstaged` is true: uses `git diff` (unstaged changes)
|
||||
* - If TTY (interactive): shows file selector for staged/unstaged, then uses staged diff
|
||||
* - If piped (non-TTY): reads diff from stdin
|
||||
*
|
||||
* Returns the diff, a human-readable source label, project context prefix,
|
||||
* and whether the user pressed back.
|
||||
*/
|
||||
export async function collectDiff(opts: {
|
||||
unstaged?: boolean;
|
||||
includeProjectContext?: boolean;
|
||||
} = {}): Promise<DiffSourceResult> {
|
||||
const { unstaged = false, includeProjectContext = true } = opts;
|
||||
|
||||
let diff: string;
|
||||
let sourceLabel: string;
|
||||
let contextPrefix = "";
|
||||
|
||||
if (unstaged) {
|
||||
if (!(await isGitRepo())) {
|
||||
throw new Error("Not a git repository.");
|
||||
}
|
||||
try {
|
||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
||||
} catch {
|
||||
diff = "";
|
||||
}
|
||||
sourceLabel = "unstaged changes";
|
||||
} else if (isStdinTTY()) {
|
||||
if (!(await isGitRepo())) {
|
||||
throw new Error("Not a git repository.");
|
||||
}
|
||||
const stagedFiles = await getStagedFiles();
|
||||
const unstagedFiles = await getUnstagedFiles();
|
||||
sourceLabel = "selected changes";
|
||||
|
||||
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
|
||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||
if (selected === BACK) {
|
||||
return { diff: "", sourceLabel, contextPrefix, back: true };
|
||||
}
|
||||
await applyFileSelection(stagedFiles, unstagedFiles, selected);
|
||||
}
|
||||
diff = await getStagedDiff();
|
||||
} else {
|
||||
// Piped input (non-TTY)
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
diff = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
sourceLabel = "piped input";
|
||||
}
|
||||
|
||||
// Truncate large diffs
|
||||
if (diff.length > MAX_DIFF_SIZE) {
|
||||
diff = diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)";
|
||||
}
|
||||
|
||||
// Collect project context for better AI responses
|
||||
if (includeProjectContext && diff) {
|
||||
try {
|
||||
if (await isGitRepo()) {
|
||||
const repoRoot = await getRepoRoot();
|
||||
const ctx = await collectProjectContext(repoRoot);
|
||||
if (ctx.packageDescription) {
|
||||
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Context collection is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
return { diff, sourceLabel, contextPrefix, back: false };
|
||||
}
|
||||
+11
-6
@@ -37,12 +37,17 @@ function parseNameStatus(output: string): FileEntry[] {
|
||||
return output
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
const [status, ...pathParts] = line.split("\t");
|
||||
const path = pathParts[pathParts.length - 1] ?? "";
|
||||
return { path, status: status!, label: statusToLabel(status!) };
|
||||
});
|
||||
const tabIdx = line.indexOf("\t");
|
||||
if (tabIdx === -1) return null;
|
||||
const status = line.slice(0, tabIdx);
|
||||
// Join path parts back (paths may contain escaped chars but not tabs)
|
||||
const path = line.slice(tabIdx + 1);
|
||||
if (!status || !path) return null;
|
||||
return { path, status, label: statusToLabel(status) };
|
||||
})
|
||||
.filter((entry): entry is FileEntry => entry !== null);
|
||||
}
|
||||
|
||||
export async function getStagedFiles(): Promise<FileEntry[]> {
|
||||
@@ -140,7 +145,7 @@ export async function commit(
|
||||
throw new Error(stderr.trim() || `git commit failed (exit code ${exitCode})`);
|
||||
}
|
||||
|
||||
const branchHashMatch = stdout.match(/\[(\S+)\s+([0-9a-f]{7,})/);
|
||||
const branchHashMatch = stdout.match(/\[(\S+)\s+(?:\(root-commit\)\s+)?([0-9a-f]{7,})/);
|
||||
const branch = branchHashMatch?.[1] ?? "";
|
||||
const hash = branchHashMatch?.[2] ?? "";
|
||||
|
||||
|
||||
+50
-28
@@ -1,4 +1,4 @@
|
||||
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
|
||||
import { BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength, padRight } from "./terminal";
|
||||
import { isStdinTTY } from "./tty";
|
||||
|
||||
const UP = "\x1b[A";
|
||||
@@ -43,22 +43,7 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
|
||||
doneLabel?: string;
|
||||
}
|
||||
|
||||
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
||||
function showCursor() { process.stdout.write("\x1b[?25h"); }
|
||||
|
||||
function moveUp(lines: number) {
|
||||
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
||||
}
|
||||
|
||||
function clearLine() {
|
||||
process.stdout.write("\r\x1b[2K");
|
||||
}
|
||||
|
||||
function visibleLength(value: string) {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
|
||||
function padLabel(label: string, width: number) {
|
||||
function padLabel(label: string, width: number): string {
|
||||
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
|
||||
}
|
||||
|
||||
@@ -92,7 +77,8 @@ function clearPrompt(lines: number) {
|
||||
moveUp(lines);
|
||||
}
|
||||
|
||||
function normalizeKey(key: string, escapeBuf: string) {
|
||||
function normalizeKey(key: string, escapeBuf: string): { action: string | null; escapeBuf: string } {
|
||||
// Single-chunk actions
|
||||
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" };
|
||||
@@ -100,14 +86,20 @@ function normalizeKey(key: string, escapeBuf: string) {
|
||||
if (key === ENTER) return { action: "enter", escapeBuf: "" };
|
||||
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
|
||||
|
||||
if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key };
|
||||
// Start of an escape sequence — buffer it
|
||||
if (key === "\x1b" || key.startsWith("\x1b[") || key.startsWith("\x1bO")) {
|
||||
return { action: null, escapeBuf: key };
|
||||
}
|
||||
|
||||
// Continue buffering an escape sequence
|
||||
if (escapeBuf) {
|
||||
const next = escapeBuf + key;
|
||||
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||
if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" };
|
||||
return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next };
|
||||
// If key is a terminal character (letter/digit/~) or buffer got too long, flush
|
||||
if (/^[A-Za-z~0-9]$/.test(key) || next.length > 10) return { action: null, escapeBuf: "" };
|
||||
return { action: null, escapeBuf: next };
|
||||
}
|
||||
|
||||
return { action: null, escapeBuf: "" };
|
||||
@@ -168,15 +160,25 @@ export async function selectOne<T>(
|
||||
const render = () => {
|
||||
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
|
||||
};
|
||||
render();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const finish = (value: T | null | PromptBack) => {
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
clearPrompt(renderedLines);
|
||||
showCursor();
|
||||
};
|
||||
|
||||
try {
|
||||
render();
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (value: T | null | PromptBack) => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
cleanup();
|
||||
if (value === null && options.cancelMessage) {
|
||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||
}
|
||||
@@ -184,6 +186,7 @@ export async function selectOne<T>(
|
||||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
try {
|
||||
const result = normalizeKey(data.toString(), escapeBuf);
|
||||
escapeBuf = result.escapeBuf;
|
||||
|
||||
@@ -194,6 +197,10 @@ export async function selectOne<T>(
|
||||
else if (result.action === "space" || result.action === "enter") {
|
||||
finish(options.items[cursor]!.value);
|
||||
}
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on("data", onData);
|
||||
@@ -243,15 +250,25 @@ export async function selectMany<T>(
|
||||
renderedLines,
|
||||
);
|
||||
};
|
||||
render();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const finish = (value: T[] | null | PromptBack) => {
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
clearPrompt(renderedLines);
|
||||
showCursor();
|
||||
};
|
||||
|
||||
try {
|
||||
render();
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (value: T[] | null | PromptBack) => {
|
||||
process.stdin.removeListener("data", onData);
|
||||
cleanup();
|
||||
if (value === null && options.cancelMessage) {
|
||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||
}
|
||||
@@ -259,6 +276,7 @@ export async function selectMany<T>(
|
||||
};
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
try {
|
||||
const result = normalizeKey(data.toString(), escapeBuf);
|
||||
escapeBuf = result.escapeBuf;
|
||||
|
||||
@@ -270,6 +288,10 @@ export async function selectMany<T>(
|
||||
else if (result.action === "enter") {
|
||||
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
|
||||
}
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on("data", onData);
|
||||
|
||||
@@ -124,6 +124,36 @@ export async function getRemoteHostname(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
const PLATFORM_CLI: Record<Platform, {
|
||||
bin: string;
|
||||
args: (title: string, body: string, base: string, draft: boolean) => string[];
|
||||
label: string;
|
||||
}> = {
|
||||
github: {
|
||||
bin: "gh",
|
||||
args: (title, body, base, draft) => {
|
||||
const a = ["pr", "create", "--title", title, "--body", body, "--base", base];
|
||||
if (draft) a.push("--draft");
|
||||
return a;
|
||||
},
|
||||
label: "gh pr create",
|
||||
},
|
||||
gitlab: {
|
||||
bin: "glab",
|
||||
args: (title, body, base, draft) => {
|
||||
const a = ["mr", "create", "--title", title, "--description", body, "--target-branch", base];
|
||||
if (draft) a.push("--draft");
|
||||
return a;
|
||||
},
|
||||
label: "glab mr create",
|
||||
},
|
||||
gitea: {
|
||||
bin: "tea",
|
||||
args: (title, body, base, _draft) => ["pulls", "create", "--title", title, "--description", body, "--base", base],
|
||||
label: "tea pulls create",
|
||||
},
|
||||
};
|
||||
|
||||
export async function createPR(
|
||||
platform: Platform,
|
||||
title: string,
|
||||
@@ -131,20 +161,8 @@ export async function createPR(
|
||||
base: string,
|
||||
draft: boolean,
|
||||
): Promise<string> {
|
||||
if (platform === "github") {
|
||||
const args = [
|
||||
"pr",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--body",
|
||||
body,
|
||||
"--base",
|
||||
base,
|
||||
];
|
||||
if (draft) args.push("--draft");
|
||||
|
||||
const proc = Bun.spawn(["gh", ...args], {
|
||||
const cli = PLATFORM_CLI[platform];
|
||||
const proc = Bun.spawn([cli.bin, ...cli.args(title, body, base, draft)], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
@@ -153,69 +171,7 @@ export async function createPR(
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
stderr.trim() || `gh pr create failed (exit code ${exitCode})`,
|
||||
);
|
||||
}
|
||||
|
||||
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
||||
return match?.[1] ?? stdout.trim();
|
||||
}
|
||||
|
||||
if (platform === "gitlab") {
|
||||
const args = [
|
||||
"mr",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--description",
|
||||
body,
|
||||
"--target-branch",
|
||||
base,
|
||||
];
|
||||
if (draft) args.push("--draft");
|
||||
|
||||
const proc = Bun.spawn(["glab", ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
stderr.trim() || `glab mr create failed (exit code ${exitCode})`,
|
||||
);
|
||||
}
|
||||
|
||||
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
||||
return match?.[1] ?? stdout.trim();
|
||||
}
|
||||
|
||||
const args = [
|
||||
"pulls",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--description",
|
||||
body,
|
||||
"--base",
|
||||
base,
|
||||
];
|
||||
|
||||
const proc = Bun.spawn(["tea", ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
stderr.trim() || `tea pulls create failed (exit code ${exitCode})`,
|
||||
);
|
||||
throw new Error(stderr.trim() || `${cli.label} failed (exit code ${exitCode})`);
|
||||
}
|
||||
|
||||
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
||||
|
||||
+45
-3
@@ -1,8 +1,10 @@
|
||||
// Terminal styling utilities.
|
||||
// Terminal styling and rendering utilities.
|
||||
// Respects NO_COLOR convention, --no-color flag, and TTY detection.
|
||||
|
||||
import { isStdoutTTY } from "./tty";
|
||||
|
||||
// ── Color support ─────────────────────────────────────────────────────
|
||||
|
||||
let _enabled: boolean | null = null;
|
||||
|
||||
export function setColorEnabled(enabled: boolean): void {
|
||||
@@ -14,11 +16,19 @@ export function isColorEnabled(): boolean {
|
||||
|
||||
// Respect NO_COLOR: https://no-color.org/
|
||||
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
|
||||
_enabled = false;
|
||||
return false;
|
||||
}
|
||||
if (!isStdoutTTY()) return false;
|
||||
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
||||
if (!isStdoutTTY()) {
|
||||
_enabled = false;
|
||||
return false;
|
||||
}
|
||||
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
|
||||
_enabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
_enabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -33,3 +43,35 @@ export const YELLOW = () => s("\x1b[33m");
|
||||
export const CYAN = () => s("\x1b[36m");
|
||||
export const RED = () => s("\x1b[31m");
|
||||
export const RESET = () => s("\x1b[0m");
|
||||
|
||||
// ── Terminal rendering helpers ───────────────────────────────────────
|
||||
|
||||
export function hideCursor(): void {
|
||||
process.stdout.write("\x1b[?25l");
|
||||
}
|
||||
|
||||
export function showCursor(): void {
|
||||
process.stdout.write("\x1b[?25h");
|
||||
}
|
||||
|
||||
export function clearLine(): void {
|
||||
process.stdout.write("\r\x1b[2K");
|
||||
}
|
||||
|
||||
export function moveUp(lines: number): void {
|
||||
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
|
||||
}
|
||||
|
||||
export function clearScreen(): void {
|
||||
process.stdout.write("\x1b[2J\x1b[H");
|
||||
}
|
||||
|
||||
/** Calculate visible length of a string, stripping ANSI escape codes. */
|
||||
export function visibleLength(value: string): number {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
}
|
||||
|
||||
/** Pad a string to the given visible width (accounting for ANSI codes). */
|
||||
export function padRight(value: string, width: number): string {
|
||||
return value + " ".repeat(Math.max(0, width - visibleLength(value)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Shared TTY input utilities used by command handlers.
|
||||
// Provides a simple line-input "ask" helper and a reusable inline
|
||||
// raw-mode text editor (used by both commit message editing and
|
||||
// interactive config editing).
|
||||
|
||||
import * as readline from "node:readline";
|
||||
import { isStdinTTY } from "./tty";
|
||||
|
||||
// ── Simple line input (cooked mode) ──────────────────────────────────
|
||||
|
||||
export function ask(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Inline raw-mode editor ────────────────────────────────────────────
|
||||
//
|
||||
// Provides a simple line editor that runs in raw mode and supports:
|
||||
// - Left/Right arrows, Home/End for cursor movement
|
||||
// - Backspace / Delete for character removal
|
||||
// - Ctrl+A (beginning), Ctrl+E (end), Ctrl+K (kill to end), Ctrl+U (kill to start)
|
||||
// - Enter to confirm, Ctrl+C / Esc to cancel
|
||||
//
|
||||
// Returns the edited string, or null if the user cancelled.
|
||||
|
||||
export interface EditResult {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export async function editLine(initial: string): Promise<string | null> {
|
||||
if (!isStdinTTY()) return null;
|
||||
|
||||
const savedRaw = process.stdin.isRaw;
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
|
||||
let buffer = initial;
|
||||
let cursor = initial.length;
|
||||
|
||||
function render() {
|
||||
// Clear line, move to start, show prompt + buffer, then reposition cursor
|
||||
process.stdout.write("\r\x1b[2K > " + buffer);
|
||||
if (cursor < buffer.length) {
|
||||
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(" > " + buffer);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let escapeBuf = "";
|
||||
|
||||
function finish(value: string | null) {
|
||||
process.stdin.setRawMode(savedRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeAllListeners("data");
|
||||
process.stdout.write("\n");
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
function handleEscapeSeq(seq: string) {
|
||||
switch (seq) {
|
||||
case "\x1b[D": case "\x1bOD": // Left
|
||||
if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); }
|
||||
break;
|
||||
case "\x1b[C": case "\x1bOC": // Right
|
||||
if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); }
|
||||
break;
|
||||
case "\x1b[H": case "\x1b[1~": case "\x1bOH": // Home
|
||||
if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
|
||||
break;
|
||||
case "\x1b[F": case "\x1b[4~": case "\x1bOF": // End
|
||||
if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
|
||||
break;
|
||||
case "\x1b[3~": // Delete
|
||||
if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on("data", (data: Buffer) => {
|
||||
const key = data.toString();
|
||||
|
||||
// Ctrl+C
|
||||
if (key === "\x03") { finish(null); return; }
|
||||
// Esc
|
||||
if (key === "\x1b") {
|
||||
if (escapeBuf) {
|
||||
// Already buffering — check if this completes a sequence
|
||||
const next = escapeBuf + key;
|
||||
if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(next)) {
|
||||
handleEscapeSeq(next);
|
||||
escapeBuf = "";
|
||||
} else {
|
||||
// Treat lone Esc as cancel
|
||||
finish(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
escapeBuf = "\x1b";
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffering an escape sequence
|
||||
if (escapeBuf) {
|
||||
escapeBuf += key;
|
||||
// Check if this completes a valid sequence
|
||||
if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(escapeBuf)) {
|
||||
handleEscapeSeq(escapeBuf);
|
||||
escapeBuf = "";
|
||||
} else if (escapeBuf.length > 10 || /^[A-Za-z~]$/.test(key)) {
|
||||
// Timeout or terminator that didn't match — discard
|
||||
escapeBuf = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter
|
||||
if (key === "\r" || key === "\n") {
|
||||
const result = buffer.trim();
|
||||
finish(result || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (key === "\x7f") {
|
||||
if (cursor > 0) {
|
||||
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
|
||||
cursor--;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A → beginning of line
|
||||
if (key === "\x01") {
|
||||
if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
|
||||
return;
|
||||
}
|
||||
// Ctrl+E → end of line
|
||||
if (key === "\x05") {
|
||||
if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
|
||||
return;
|
||||
}
|
||||
// Ctrl+K → kill to end
|
||||
if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; }
|
||||
// Ctrl+U → kill to start
|
||||
if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; }
|
||||
|
||||
// Printable characters
|
||||
if (key >= " " && key !== "\x7f") {
|
||||
buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
|
||||
cursor += key.length;
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+3
-4
@@ -23,14 +23,13 @@ export function isStdinTTY(): boolean {
|
||||
}
|
||||
|
||||
export function isStdoutTTY(): boolean {
|
||||
// Use a heuristic for stdout — check if we're in a terminal
|
||||
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
|
||||
if (process.env.NO_COLOR) return false;
|
||||
// Try fstat on fd 1 (stdout)
|
||||
// Primary check: fstat on fd 1 (stdout) — most reliable
|
||||
try {
|
||||
const stat = fstatSync(1);
|
||||
return stat.isCharacterDevice();
|
||||
} catch {
|
||||
// Fall back to TERM heuristic only when fstat fails
|
||||
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -12,22 +12,21 @@ export interface FileEntry {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProjectContext {
|
||||
export interface BaseContext {
|
||||
readme: string | null;
|
||||
packageDescription: string | null;
|
||||
structure: string | null;
|
||||
recentCommits: string[];
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export interface PRContext {
|
||||
readme: string | null;
|
||||
packageDescription: string | null;
|
||||
structure: string | null;
|
||||
export interface ProjectContext extends BaseContext {
|
||||
recentCommits: string[];
|
||||
}
|
||||
|
||||
export interface PRContext extends BaseContext {
|
||||
branchName: string;
|
||||
baseBranch: string;
|
||||
branchCommits: string[];
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
|
||||
Reference in New Issue
Block a user