Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 301f8f645d | |||
| 311d059e52 | |||
| f3b5c631de | |||
| 4fbac6a6e1 | |||
| d4ba4da9d5 | |||
| 10c7b9a688 | |||
| 5959d78e6c | |||
| a1151d7f76 | |||
| ff4e18ca8b |
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
// gai — AI-powered git commit and PR helper
|
// gai — AI-powered git commit and PR helper
|
||||||
// v0.1.3
|
|
||||||
|
|
||||||
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
|
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
|
||||||
import { handleCommit } from "./src/commands/commit";
|
import { handleCommit } from "./src/commands/commit";
|
||||||
@@ -11,8 +10,7 @@ import { handleExplain } from "./src/commands/explain";
|
|||||||
import { handleReview } from "./src/commands/review";
|
import { handleReview } from "./src/commands/review";
|
||||||
import { handleChangelog } from "./src/commands/changelog";
|
import { handleChangelog } from "./src/commands/changelog";
|
||||||
import { handleSuggest } from "./src/commands/suggest";
|
import { handleSuggest } from "./src/commands/suggest";
|
||||||
import { setColorEnabled } from "./src/terminal";
|
import { setColorEnabled, BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, clearScreen, visibleLength, padRight } from "./src/terminal";
|
||||||
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
|
|
||||||
import { isStdinTTY, initTTY } from "./src/tty";
|
import { isStdinTTY, initTTY } from "./src/tty";
|
||||||
import { VERSION } from "./src/brand";
|
import { VERSION } from "./src/brand";
|
||||||
import { SKIP_WAIT } from "./src/menu";
|
import { SKIP_WAIT } from "./src/menu";
|
||||||
@@ -37,11 +35,6 @@ const MENU_ITEMS: MenuItem[] = [
|
|||||||
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
|
{ 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> {
|
async function readKey(): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const onData = (data: Buffer) => {
|
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 {
|
function renderMenu(cursor: number): number {
|
||||||
process.stdout.write("\x1b[H"); // cursor home
|
process.stdout.write("\x1b[H"); // cursor home
|
||||||
|
|
||||||
@@ -84,7 +69,7 @@ function renderMenu(cursor: number): number {
|
|||||||
write("");
|
write("");
|
||||||
|
|
||||||
const keyWidth = 3;
|
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;
|
let currentGroup: MenuItem["group"] | null = null;
|
||||||
|
|
||||||
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
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
|
// Resume stdin in case it was paused
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
});
|
});
|
||||||
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
|
clearScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
|
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
|
||||||
showCursor();
|
showCursor();
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
|
clearScreen();
|
||||||
const result = await dispatchMenuAction(item.key);
|
const result = await dispatchMenuAction(item.key);
|
||||||
if (result === (SKIP_WAIT as unknown as number)) {
|
if (result === (SKIP_WAIT as unknown as number)) {
|
||||||
return 0; // user explicitly backed out — skip "Press Enter" and return directly
|
return 0; // user explicitly backed out — skip "Press Enter" and return directly
|
||||||
@@ -197,6 +182,13 @@ async function showMenu(): Promise<number> {
|
|||||||
// Initial render
|
// Initial render
|
||||||
renderMenu(cursor);
|
renderMenu(cursor);
|
||||||
|
|
||||||
|
const exitMenu = (exitCode: number): number => {
|
||||||
|
showCursor();
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
return exitCode;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const raw = await readKey();
|
const raw = await readKey();
|
||||||
@@ -214,7 +206,7 @@ async function showMenu(): Promise<number> {
|
|||||||
// Enter
|
// Enter
|
||||||
if (raw === "\r" || raw === "\n") {
|
if (raw === "\r" || raw === "\n") {
|
||||||
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
|
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
|
||||||
if (result !== 0) return result;
|
if (result !== 0) return exitMenu(result);
|
||||||
hideCursor();
|
hideCursor();
|
||||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
@@ -224,11 +216,8 @@ async function showMenu(): Promise<number> {
|
|||||||
|
|
||||||
// Ctrl+C
|
// Ctrl+C
|
||||||
if (raw === "\x03") {
|
if (raw === "\x03") {
|
||||||
showCursor();
|
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\n");
|
process.stdout.write("\n");
|
||||||
return 0;
|
return exitMenu(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Number hotkeys (1-8)
|
// Number hotkeys (1-8)
|
||||||
@@ -238,7 +227,7 @@ async function showMenu(): Promise<number> {
|
|||||||
cursor = idx;
|
cursor = idx;
|
||||||
renderMenu(cursor);
|
renderMenu(cursor);
|
||||||
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
|
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
|
||||||
if (result !== 0) return result;
|
if (result !== 0) return exitMenu(result);
|
||||||
hideCursor();
|
hideCursor();
|
||||||
if (wasRaw !== true) process.stdin.setRawMode(true);
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
@@ -250,27 +239,18 @@ async function showMenu(): Promise<number> {
|
|||||||
// Letter hotkeys
|
// Letter hotkeys
|
||||||
const lower = raw.toLowerCase();
|
const lower = raw.toLowerCase();
|
||||||
if (lower === "h") {
|
if (lower === "h") {
|
||||||
showCursor();
|
clearScreen();
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\x1b[2J\x1b[H");
|
|
||||||
console.log(formatHelp(commands));
|
console.log(formatHelp(commands));
|
||||||
return 0;
|
return exitMenu(0);
|
||||||
}
|
}
|
||||||
if (lower === "v") {
|
if (lower === "v") {
|
||||||
showCursor();
|
clearScreen();
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\x1b[2J\x1b[H");
|
|
||||||
console.log(`gai v${VERSION}`);
|
console.log(`gai v${VERSION}`);
|
||||||
return 0;
|
return exitMenu(0);
|
||||||
}
|
}
|
||||||
if (lower === "q") {
|
if (lower === "q") {
|
||||||
showCursor();
|
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdout.write("\n");
|
process.stdout.write("\n");
|
||||||
return 0;
|
return exitMenu(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -428,10 +408,8 @@ const commands = registerCommands(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the defs accessible for help command
|
// Keep canonical command defs accessible for help command (deduplicate by reference)
|
||||||
const allCommandDefs = [...commands.values()].filter(
|
const allCommandDefs = [...new Set(commands.values())];
|
||||||
(c, i, arr) => arr.findIndex((x) => x.name === c.name) === i,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────────
|
// ── Main ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gai",
|
"name": "gai",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
|
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
|
|||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const lines = buffer.split("\n");
|
const lines = buffer.split("\n");
|
||||||
// Keep the last potentially incomplete line
|
|
||||||
buffer = lines.pop() ?? "";
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -136,7 +135,12 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data) as {
|
const parsed = JSON.parse(data) as {
|
||||||
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>;
|
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;
|
const token = parsed.choices?.[0]?.delta?.content;
|
||||||
if (token) {
|
if (token) {
|
||||||
fullText += token;
|
fullText += token;
|
||||||
@@ -152,7 +156,8 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reader.releaseLock();
|
try { await reader.cancel(); } catch {}
|
||||||
|
// releaseLock is not needed after cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
callbacks.onDone?.(fullText);
|
callbacks.onDone?.(fullText);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { GREEN, CYAN, RESET } from "./terminal";
|
import { GREEN, CYAN, RESET } from "./terminal";
|
||||||
|
|
||||||
export const VERSION = "0.1.3";
|
export const VERSION = "0.1.4";
|
||||||
|
|
||||||
export function showBanner(): string {
|
export function showBanner(): string {
|
||||||
const G = GREEN();
|
const G = GREEN();
|
||||||
|
|||||||
+3
-14
@@ -1,6 +1,8 @@
|
|||||||
// Lightweight CLI argument parser aligned with mainstream CLI conventions.
|
// Lightweight CLI argument parser aligned with mainstream CLI conventions.
|
||||||
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
|
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
|
||||||
|
|
||||||
|
import { VERSION } from "./brand";
|
||||||
|
|
||||||
export interface FlagDef {
|
export interface FlagDef {
|
||||||
long: string; // e.g. "dry-run"
|
long: string; // e.g. "dry-run"
|
||||||
short?: string; // e.g. "d"
|
short?: string; // e.g. "d"
|
||||||
@@ -44,19 +46,6 @@ function buildFlagIndex(flags: FlagDef[]): Map<string, FlagDef> {
|
|||||||
return index;
|
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(
|
function parseArgs(
|
||||||
rawArgs: string[],
|
rawArgs: string[],
|
||||||
commands: Map<string, CommandDef>,
|
commands: Map<string, CommandDef>,
|
||||||
@@ -282,7 +271,7 @@ export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef
|
|||||||
|
|
||||||
// Handle --version globally
|
// Handle --version globally
|
||||||
if (result.flags["version"]) {
|
if (result.flags["version"]) {
|
||||||
console.log("gai v0.1.3");
|
console.log(`gai v${VERSION}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -19,7 +19,9 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
|||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
const exitCode = await proc.exited;
|
const exitCode = await proc.exited;
|
||||||
if (exitCode === 0) return true;
|
if (exitCode === 0) return true;
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Try next command
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+11
-66
@@ -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";
|
||||||
@@ -28,76 +19,31 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
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) {
|
|
||||||
if (!(await isGitRepo())) {
|
|
||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
const result = await collectDiff({ unstaged, includeProjectContext: true });
|
||||||
} catch {
|
if (result.back) return SKIP_WAIT as unknown as number;
|
||||||
diff = "";
|
diff = result.diff;
|
||||||
}
|
sourceLabel = result.sourceLabel;
|
||||||
sourceLabel = "unstaged changes";
|
contextPrefix = result.contextPrefix;
|
||||||
} else {
|
} catch (err) {
|
||||||
// Default: staged changes (or piped)
|
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
if (isStdinTTY()) {
|
|
||||||
if (!(await isGitRepo())) {
|
|
||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
|
||||||
return 1;
|
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) {
|
if (!diff) {
|
||||||
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
|
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.flags["verbose"]) {
|
if (verbose) {
|
||||||
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
|
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_DIFF_SIZE = 15000;
|
const userPrompt = contextPrefix + buildExplainPrompt(diff);
|
||||||
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);
|
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||||
@@ -117,7 +63,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
|
|||||||
if (callbacks) {
|
if (callbacks) {
|
||||||
process.stdout.write("\n");
|
process.stdout.write("\n");
|
||||||
} else {
|
} else {
|
||||||
// Non-TTY: print the result directly
|
|
||||||
process.stdout.write(explanation + "\n");
|
process.stdout.write(explanation + "\n");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+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`);
|
||||||
|
|||||||
+10
-58
@@ -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";
|
||||||
@@ -36,64 +27,25 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
|
|||||||
|
|
||||||
let diff: string;
|
let diff: string;
|
||||||
let sourceLabel: 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 {
|
try {
|
||||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
const result = await collectDiff({ unstaged, includeProjectContext: true });
|
||||||
} catch {
|
if (result.back) return SKIP_WAIT as unknown as number;
|
||||||
diff = "";
|
diff = result.diff;
|
||||||
}
|
sourceLabel = result.sourceLabel;
|
||||||
sourceLabel = "unstaged changes";
|
contextPrefix = result.contextPrefix;
|
||||||
} else if (!isStdinTTY()) {
|
} catch (err) {
|
||||||
const chunks: Buffer[] = [];
|
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
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;
|
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) {
|
if (!diff) {
|
||||||
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
|
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_DIFF_SIZE = 15000;
|
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
|
||||||
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 strictnessLabel = strictnessFlag === "strict"
|
const strictnessLabel = strictnessFlag === "strict"
|
||||||
? `${RED()}strict${RESET()}`
|
? `${RED()}strict${RESET()}`
|
||||||
|
|||||||
+11
-44
@@ -1,12 +1,5 @@
|
|||||||
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,
|
||||||
@@ -36,37 +29,17 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get diff (staged, or unstaged if --unstaged, or piped)
|
const unstaged = args.flags["unstaged"] as boolean;
|
||||||
|
|
||||||
let diff: string;
|
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 {
|
try {
|
||||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
const result = await collectDiff({ unstaged, includeProjectContext: false });
|
||||||
} catch {
|
if (result.back) return SKIP_WAIT as unknown as number;
|
||||||
diff = "";
|
diff = result.diff;
|
||||||
}
|
} catch (err) {
|
||||||
} else {
|
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
const stagedFiles = await getStagedFiles();
|
return 1;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!diff) {
|
if (!diff) {
|
||||||
@@ -74,20 +47,14 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
|||||||
return 0;
|
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) {
|
if (verbose) {
|
||||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "branch") {
|
if (mode === "branch") {
|
||||||
return handleSuggestBranch(config, truncatedDiff);
|
return handleSuggestBranch(config, diff);
|
||||||
} else {
|
|
||||||
return handleSuggestType(config, truncatedDiff);
|
|
||||||
}
|
}
|
||||||
|
return handleSuggestType(config, diff);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
|
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
|
return output
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean)
|
.filter((line) => line.trim())
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const [status, ...pathParts] = line.split("\t");
|
const tabIdx = line.indexOf("\t");
|
||||||
const path = pathParts[pathParts.length - 1] ?? "";
|
if (tabIdx === -1) return null;
|
||||||
return { path, status: status!, label: statusToLabel(status!) };
|
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[]> {
|
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})`);
|
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 branch = branchHashMatch?.[1] ?? "";
|
||||||
const hash = branchHashMatch?.[2] ?? "";
|
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";
|
import { isStdinTTY } from "./tty";
|
||||||
|
|
||||||
const UP = "\x1b[A";
|
const UP = "\x1b[A";
|
||||||
@@ -43,22 +43,7 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
|
|||||||
doneLabel?: string;
|
doneLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
function padLabel(label: string, width: number): string {
|
||||||
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) {
|
|
||||||
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
|
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +77,8 @@ function clearPrompt(lines: number) {
|
|||||||
moveUp(lines);
|
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 === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||||
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||||
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", 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 === ENTER) return { action: "enter", escapeBuf: "" };
|
||||||
if (key === CTRL_C) return { action: "cancel", 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) {
|
if (escapeBuf) {
|
||||||
const next = escapeBuf + key;
|
const next = escapeBuf + key;
|
||||||
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
|
||||||
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
|
||||||
if (next === LEFT || next === ALT_LEFT) return { action: "back", 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: "" };
|
return { action: null, escapeBuf: "" };
|
||||||
@@ -168,15 +160,25 @@ export async function selectOne<T>(
|
|||||||
const render = () => {
|
const render = () => {
|
||||||
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
|
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
|
||||||
};
|
};
|
||||||
render();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const cleanup = () => {
|
||||||
const finish = (value: T | null | PromptBack) => {
|
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
process.stdin.removeListener("data", onData);
|
|
||||||
clearPrompt(renderedLines);
|
clearPrompt(renderedLines);
|
||||||
showCursor();
|
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) {
|
if (value === null && options.cancelMessage) {
|
||||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||||
}
|
}
|
||||||
@@ -184,6 +186,7 @@ export async function selectOne<T>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onData = (data: Buffer) => {
|
const onData = (data: Buffer) => {
|
||||||
|
try {
|
||||||
const result = normalizeKey(data.toString(), escapeBuf);
|
const result = normalizeKey(data.toString(), escapeBuf);
|
||||||
escapeBuf = result.escapeBuf;
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
@@ -194,6 +197,10 @@ export async function selectOne<T>(
|
|||||||
else if (result.action === "space" || result.action === "enter") {
|
else if (result.action === "space" || result.action === "enter") {
|
||||||
finish(options.items[cursor]!.value);
|
finish(options.items[cursor]!.value);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
process.stdin.on("data", onData);
|
process.stdin.on("data", onData);
|
||||||
@@ -243,15 +250,25 @@ export async function selectMany<T>(
|
|||||||
renderedLines,
|
renderedLines,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
render();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const cleanup = () => {
|
||||||
const finish = (value: T[] | null | PromptBack) => {
|
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
process.stdin.removeListener("data", onData);
|
|
||||||
clearPrompt(renderedLines);
|
clearPrompt(renderedLines);
|
||||||
showCursor();
|
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) {
|
if (value === null && options.cancelMessage) {
|
||||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||||
}
|
}
|
||||||
@@ -259,6 +276,7 @@ export async function selectMany<T>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onData = (data: Buffer) => {
|
const onData = (data: Buffer) => {
|
||||||
|
try {
|
||||||
const result = normalizeKey(data.toString(), escapeBuf);
|
const result = normalizeKey(data.toString(), escapeBuf);
|
||||||
escapeBuf = result.escapeBuf;
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
@@ -270,6 +288,10 @@ export async function selectMany<T>(
|
|||||||
else if (result.action === "enter") {
|
else if (result.action === "enter") {
|
||||||
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
|
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);
|
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(
|
export async function createPR(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -131,20 +161,8 @@ export async function createPR(
|
|||||||
base: string,
|
base: string,
|
||||||
draft: boolean,
|
draft: boolean,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (platform === "github") {
|
const cli = PLATFORM_CLI[platform];
|
||||||
const args = [
|
const proc = Bun.spawn([cli.bin, ...cli.args(title, body, base, draft)], {
|
||||||
"pr",
|
|
||||||
"create",
|
|
||||||
"--title",
|
|
||||||
title,
|
|
||||||
"--body",
|
|
||||||
body,
|
|
||||||
"--base",
|
|
||||||
base,
|
|
||||||
];
|
|
||||||
if (draft) args.push("--draft");
|
|
||||||
|
|
||||||
const proc = Bun.spawn(["gh", ...args], {
|
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
@@ -153,69 +171,7 @@ export async function createPR(
|
|||||||
const stderr = await new Response(proc.stderr).text();
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
throw new Error(
|
throw new Error(stderr.trim() || `${cli.label} failed (exit code ${exitCode})`);
|
||||||
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})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
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.
|
// Respects NO_COLOR convention, --no-color flag, and TTY detection.
|
||||||
|
|
||||||
import { isStdoutTTY } from "./tty";
|
import { isStdoutTTY } from "./tty";
|
||||||
|
|
||||||
|
// ── Color support ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _enabled: boolean | null = null;
|
let _enabled: boolean | null = null;
|
||||||
|
|
||||||
export function setColorEnabled(enabled: boolean): void {
|
export function setColorEnabled(enabled: boolean): void {
|
||||||
@@ -14,11 +16,19 @@ export function isColorEnabled(): boolean {
|
|||||||
|
|
||||||
// Respect NO_COLOR: https://no-color.org/
|
// Respect NO_COLOR: https://no-color.org/
|
||||||
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
|
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
|
||||||
|
_enabled = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!isStdoutTTY()) return false;
|
if (!isStdoutTTY()) {
|
||||||
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
_enabled = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
|
||||||
|
_enabled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_enabled = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,3 +43,35 @@ export const YELLOW = () => s("\x1b[33m");
|
|||||||
export const CYAN = () => s("\x1b[36m");
|
export const CYAN = () => s("\x1b[36m");
|
||||||
export const RED = () => s("\x1b[31m");
|
export const RED = () => s("\x1b[31m");
|
||||||
export const RESET = () => s("\x1b[0m");
|
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 {
|
export function isStdoutTTY(): boolean {
|
||||||
// Use a heuristic for stdout — check if we're in a terminal
|
// Primary check: fstat on fd 1 (stdout) — most reliable
|
||||||
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
|
|
||||||
if (process.env.NO_COLOR) return false;
|
|
||||||
// Try fstat on fd 1 (stdout)
|
|
||||||
try {
|
try {
|
||||||
const stat = fstatSync(1);
|
const stat = fstatSync(1);
|
||||||
return stat.isCharacterDevice();
|
return stat.isCharacterDevice();
|
||||||
} catch {
|
} catch {
|
||||||
|
// Fall back to TERM heuristic only when fstat fails
|
||||||
|
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-7
@@ -12,22 +12,21 @@ export interface FileEntry {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectContext {
|
export interface BaseContext {
|
||||||
readme: string | null;
|
readme: string | null;
|
||||||
packageDescription: string | null;
|
packageDescription: string | null;
|
||||||
structure: string | null;
|
structure: string | null;
|
||||||
recentCommits: string[];
|
|
||||||
diff: string;
|
diff: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PRContext {
|
export interface ProjectContext extends BaseContext {
|
||||||
readme: string | null;
|
recentCommits: string[];
|
||||||
packageDescription: string | null;
|
}
|
||||||
structure: string | null;
|
|
||||||
|
export interface PRContext extends BaseContext {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
branchCommits: string[];
|
branchCommits: string[];
|
||||||
diff: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommitResult {
|
export interface CommitResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user