Compare commits
13 Commits
ab9a41ab83
..
v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 301f8f645d | |||
| 311d059e52 | |||
| f3b5c631de | |||
| 4fbac6a6e1 | |||
| d4ba4da9d5 | |||
| 10c7b9a688 | |||
| 5959d78e6c | |||
| a1151d7f76 | |||
| ff4e18ca8b | |||
| 586487d897 | |||
| 0d9c31ae3b | |||
| 1e370be8af | |||
| 55db09c973 |
@@ -118,22 +118,32 @@ git diff | gai suggest branch
|
|||||||
|
|
||||||
### Interactive Menu
|
### Interactive Menu
|
||||||
|
|
||||||
```
|
Run `gai` without arguments to open the mole-style interactive menu:
|
||||||
$ gai
|
|
||||||
|
|
||||||
gai
|
|
||||||
AI-powered git helper — choose a workflow
|
|
||||||
↑/↓ navigate · enter/space select · ctrl+c cancel
|
|
||||||
|
|
||||||
❯ ● commit Generate AI commit message
|
|
||||||
○ pr Create a PR with AI-generated title
|
|
||||||
○ explain Explain staged changes in plain language
|
|
||||||
○ review AI code review of staged changes
|
|
||||||
○ changelog Generate changelog from commits
|
|
||||||
○ suggest Suggest branch name or commit type
|
|
||||||
○ amend Amend last commit with AI message
|
|
||||||
○ config Configure API settings
|
|
||||||
```
|
```
|
||||||
|
gai v0.1.3
|
||||||
|
AI-powered git helper for commits, PRs, reviews, and changelogs
|
||||||
|
────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE
|
||||||
|
│ › 1 Commit Generate AI commit message
|
||||||
|
2 PR Create a PR with AI-generated title
|
||||||
|
3 Amend Amend last commit with AI message
|
||||||
|
|
||||||
|
INSPECT
|
||||||
|
4 Explain Explain staged changes in plain language
|
||||||
|
5 Review AI code review of staged changes
|
||||||
|
6 Changelog Generate changelog from commits
|
||||||
|
7 Suggest Suggest branch name or commit type
|
||||||
|
|
||||||
|
PROJECT
|
||||||
|
8 Config Configure API settings
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────
|
||||||
|
↑/↓ navigate enter run 1-8 jump h help v version q quit
|
||||||
|
```
|
||||||
|
|
||||||
|
Number keys `1`–`8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu.
|
||||||
|
|
||||||
### Command Examples
|
### Command Examples
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +10,10 @@ 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 { showBanner } from "./src/brand";
|
import { VERSION } from "./src/brand";
|
||||||
|
import { SKIP_WAIT } from "./src/menu";
|
||||||
|
|
||||||
// ── Interactive Menu (mole-style) ─────────────────────────────────────
|
// ── Interactive Menu (mole-style) ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,24 +21,20 @@ interface MenuItem {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
group: "Create" | "Inspect" | "Project";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MENU_ITEMS: MenuItem[] = [
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
{ key: "commit", label: "Commit", description: "Generate AI commit message" },
|
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
|
||||||
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title" },
|
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
|
||||||
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language" },
|
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
|
||||||
{ key: "review", label: "Review", description: "AI code review of staged changes" },
|
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
|
||||||
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits" },
|
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
|
||||||
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" },
|
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
|
||||||
{ key: "amend", label: "Amend", description: "Amend last commit with AI message" },
|
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
|
||||||
{ key: "config", label: "Config", description: "Configure API settings" },
|
{ 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) => {
|
||||||
@@ -50,55 +45,66 @@ async function readKey(): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleLen(s: string): number {
|
function renderMenu(cursor: number): number {
|
||||||
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMenu(banner: string, cursor: number): number {
|
|
||||||
process.stdout.write("\x1b[H"); // cursor home
|
process.stdout.write("\x1b[H"); // cursor home
|
||||||
|
|
||||||
let lineCount = 0;
|
let lineCount = 0;
|
||||||
for (const line of banner.split("\n")) {
|
|
||||||
clearLine();
|
|
||||||
process.stdout.write(line + "\n");
|
|
||||||
lineCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const G = GREEN();
|
|
||||||
const C = CYAN();
|
const C = CYAN();
|
||||||
const D = DIM();
|
const D = DIM();
|
||||||
|
const G = GREEN();
|
||||||
const R = RESET();
|
const R = RESET();
|
||||||
const ARROW = "➤";
|
const width = 72;
|
||||||
|
const separator = `${D}${"─".repeat(width)}${R}`;
|
||||||
|
|
||||||
// Calculate padding
|
const write = (line = "") => {
|
||||||
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
|
clearLine();
|
||||||
|
process.stdout.write(`${line}\n`);
|
||||||
|
lineCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
write("");
|
||||||
|
write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`);
|
||||||
|
write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`);
|
||||||
|
write(` ${separator}`);
|
||||||
|
write("");
|
||||||
|
|
||||||
|
const keyWidth = 3;
|
||||||
|
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++) {
|
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
||||||
const item = MENU_ITEMS[i]!;
|
const item = MENU_ITEMS[i]!;
|
||||||
const num = String(i + 1);
|
const num = String(i + 1);
|
||||||
const active = i === cursor;
|
const active = i === cursor;
|
||||||
|
|
||||||
clearLine();
|
if (item.group !== currentGroup) {
|
||||||
if (active) {
|
if (currentGroup !== null) write("");
|
||||||
const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label)));
|
write(` ${D}${item.group.toUpperCase()}${R}`);
|
||||||
process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`);
|
currentGroup = item.group;
|
||||||
} else {
|
}
|
||||||
const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
|
|
||||||
process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`);
|
const pointer = active ? `${C}›${R}` : " ";
|
||||||
|
const key = active ? `${G}${num}${R}` : `${D}${num}${R}`;
|
||||||
|
const label = active ? `${BOLD()}${item.label}${R}` : item.label;
|
||||||
|
const description = active ? item.description : `${D}${item.description}${R}`;
|
||||||
|
const row = [
|
||||||
|
pointer,
|
||||||
|
padRight(key, keyWidth),
|
||||||
|
padRight(label, labelWidth),
|
||||||
|
description,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
write(` ${C}│${R} ${row}`);
|
||||||
|
} else {
|
||||||
|
write(` ${row}`);
|
||||||
}
|
}
|
||||||
lineCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer
|
write("");
|
||||||
clearLine();
|
write(` ${separator}`);
|
||||||
process.stdout.write("\n");
|
write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
|
||||||
lineCount++;
|
write("");
|
||||||
clearLine();
|
|
||||||
process.stdout.write(` ${D}↑↓ | Enter | ${G}H${D} Help | ${G}V${D} Version | ${G}Q${D} Quit${R}\n`);
|
|
||||||
lineCount++;
|
|
||||||
clearLine();
|
|
||||||
process.stdout.write("\n");
|
|
||||||
lineCount++;
|
|
||||||
|
|
||||||
// Clear rest of screen
|
// Clear rest of screen
|
||||||
process.stdout.write("\x1b[J");
|
process.stdout.write("\x1b[J");
|
||||||
@@ -144,15 +150,18 @@ 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)) {
|
||||||
|
return 0; // user explicitly backed out — skip "Press Enter" and return directly
|
||||||
|
}
|
||||||
await waitForEnter();
|
await waitForEnter();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -163,7 +172,6 @@ async function showMenu(): Promise<number> {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const banner = showBanner();
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
const wasRaw = process.stdin.isRaw;
|
const wasRaw = process.stdin.isRaw;
|
||||||
|
|
||||||
@@ -172,7 +180,14 @@ async function showMenu(): Promise<number> {
|
|||||||
hideCursor();
|
hideCursor();
|
||||||
|
|
||||||
// Initial render
|
// Initial render
|
||||||
renderMenu(banner, 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) {
|
||||||
@@ -180,32 +195,29 @@ async function showMenu(): Promise<number> {
|
|||||||
|
|
||||||
// Escape sequences (arrows)
|
// Escape sequences (arrows)
|
||||||
if (raw === "\x1b[A" || raw === "\x1bOA") {
|
if (raw === "\x1b[A" || raw === "\x1bOA") {
|
||||||
if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
|
if (cursor > 0) { cursor--; renderMenu(cursor); }
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (raw === "\x1b[B" || raw === "\x1bOB") {
|
if (raw === "\x1b[B" || raw === "\x1bOB") {
|
||||||
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); }
|
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(cursor); }
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
renderMenu(banner, cursor);
|
renderMenu(cursor);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -213,13 +225,13 @@ async function showMenu(): Promise<number> {
|
|||||||
const idx = parseInt(raw) - 1;
|
const idx = parseInt(raw) - 1;
|
||||||
if (idx < MENU_ITEMS.length) {
|
if (idx < MENU_ITEMS.length) {
|
||||||
cursor = idx;
|
cursor = idx;
|
||||||
renderMenu(banner, 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();
|
||||||
renderMenu(banner, cursor);
|
renderMenu(cursor);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,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);
|
console.log(`gai v${VERSION}`);
|
||||||
process.stdin.pause();
|
return exitMenu(0);
|
||||||
process.stdout.write("\x1b[2J\x1b[H");
|
|
||||||
console.log("gai v0.1.3");
|
|
||||||
return 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 {
|
||||||
@@ -405,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";
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-21
@@ -1,26 +1,28 @@
|
|||||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
const commands: string[][] = [];
|
const commands: string[][] = [];
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
commands.push(["pbcopy"]);
|
commands.push(["pbcopy"]);
|
||||||
} else if (process.platform === "linux") {
|
} else if (process.platform === "linux") {
|
||||||
commands.push(["xclip", "-selection", "clipboard"]);
|
commands.push(["xclip", "-selection", "clipboard"]);
|
||||||
commands.push(["xsel", "--clipboard", "--input"]);
|
commands.push(["xsel", "--clipboard", "--input"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(cmd, {
|
const proc = Bun.spawn(cmd, {
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
});
|
});
|
||||||
proc.stdin.write(text);
|
proc.stdin.write(text);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbacks: StreamCallbacks = tty ? {
|
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||||
onToken: (token) => process.stdout.write(token),
|
onToken: (token) => process.stdout.write(token),
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
|
|||||||
+38
-124
@@ -1,4 +1,3 @@
|
|||||||
import * as readline from "node:readline";
|
|
||||||
import {
|
import {
|
||||||
isGitRepo,
|
isGitRepo,
|
||||||
getRepoRoot,
|
getRepoRoot,
|
||||||
@@ -7,30 +6,22 @@ import {
|
|||||||
getStagedDiff,
|
getStagedDiff,
|
||||||
getRecentCommits,
|
getRecentCommits,
|
||||||
stageFiles,
|
stageFiles,
|
||||||
|
applyFileSelection,
|
||||||
commit,
|
commit,
|
||||||
} from "../git";
|
} from "../git";
|
||||||
import { selectFiles } from "../selector";
|
import { selectFiles } from "../selector";
|
||||||
import { BACK } from "../menu";
|
import { BACK, SKIP_WAIT } from "../menu";
|
||||||
import { collectProjectContext } from "../context";
|
import { collectProjectContext } from "../context";
|
||||||
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
|
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
|
||||||
import { generateCommitMessage } from "../ai";
|
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
|
||||||
@@ -50,6 +41,19 @@ function printCommitResult(result: CommitResult, msg: string) {
|
|||||||
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
|
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printSelectionResult(result: { staged: string[]; unstaged: string[] }) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.staged.length > 0) {
|
||||||
|
parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`);
|
||||||
|
}
|
||||||
|
if (result.unstaged.length > 0) {
|
||||||
|
parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`);
|
||||||
|
}
|
||||||
|
if (parts.length > 0) {
|
||||||
|
console.log(` Updated staging area: ${parts.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
|
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
|
||||||
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
|
||||||
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
console.log(` ${GREEN()}${message}${RESET()}\n`);
|
||||||
@@ -61,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> {
|
||||||
@@ -173,16 +86,22 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
|
|||||||
const stagedFiles = await getStagedFiles();
|
const stagedFiles = await getStagedFiles();
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
const unstagedFiles = await getUnstagedFiles();
|
||||||
|
|
||||||
if (stagedFiles.length === 0 && !amend) {
|
if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
|
||||||
if (autoMode && unstagedFiles.length > 0) {
|
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
|
||||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
return 1;
|
||||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
}
|
||||||
} else if (unstagedFiles.length > 0) {
|
|
||||||
|
if (!amend && autoMode && unstagedFiles.length > 0) {
|
||||||
|
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||||
|
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||||
|
} else if (!amend) {
|
||||||
|
if (isStdinTTY()) {
|
||||||
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
|
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||||
|
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
|
||||||
|
} else if (stagedFiles.length === 0 && unstagedFiles.length > 0) {
|
||||||
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
} else {
|
|
||||||
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,18 +134,13 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unstagedFiles.length > 0) {
|
if (autoMode && unstagedFiles.length > 0) {
|
||||||
if (autoMode) {
|
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
} else {
|
||||||
} else {
|
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
||||||
const selected = await selectFiles(stagedFiles, unstagedFiles);
|
if (selected === BACK) return SKIP_WAIT as unknown as number;
|
||||||
if (selected === BACK) return 0;
|
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
|
||||||
if (selected.length > 0) {
|
|
||||||
await stageFiles(selected);
|
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = await getStagedDiff();
|
const diff = await getStagedDiff();
|
||||||
|
|||||||
+9
-19
@@ -1,6 +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 { editLine } from "../tty-input";
|
||||||
import type { Config } from "../types";
|
import type { Config } from "../types";
|
||||||
import type { ParsedArgs } from "../cli";
|
import type { ParsedArgs } from "../cli";
|
||||||
|
|
||||||
@@ -65,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,
|
||||||
@@ -150,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,7 +313,7 @@ export async function handleConfig(args: ParsedArgs): Promise<number> {
|
|||||||
// gai config (no args) → interactive
|
// gai config (no args) → interactive
|
||||||
if (positional.length === 0) {
|
if (positional.length === 0) {
|
||||||
const result = await interactiveConfig();
|
const result = await interactiveConfig();
|
||||||
return result === "back" ? 0 : 0;
|
return result === "back" ? (SKIP_WAIT as unknown as number) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
|
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
|
||||||
|
|||||||
+52
-107
@@ -1,8 +1,6 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
|
import { collectDiff } from "../diff-source";
|
||||||
import { selectFiles } from "../selector";
|
import { BACK, SKIP_WAIT } from "../menu";
|
||||||
import { BACK } 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";
|
||||||
@@ -11,119 +9,66 @@ import type { StreamCallbacks } from "../types";
|
|||||||
import type { ParsedArgs } from "../cli";
|
import type { ParsedArgs } from "../cli";
|
||||||
|
|
||||||
export async function handleExplain(args: ParsedArgs): Promise<number> {
|
export async function handleExplain(args: ParsedArgs): Promise<number> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
|
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unstaged = args.flags["unstaged"] as boolean;
|
const unstaged = args.flags["unstaged"] as boolean;
|
||||||
const staged = args.flags["staged"] as boolean;
|
const verbose = args.flags["verbose"] as boolean;
|
||||||
const verbose = args.flags["verbose"] as boolean;
|
|
||||||
|
|
||||||
// Determine which diff to explain
|
let diff: string;
|
||||||
let diff: string;
|
let sourceLabel: string;
|
||||||
let sourceLabel: string;
|
let contextPrefix: string;
|
||||||
|
|
||||||
if (unstaged) {
|
try {
|
||||||
if (!(await isGitRepo())) {
|
const result = await collectDiff({ unstaged, includeProjectContext: true });
|
||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
if (result.back) return SKIP_WAIT as unknown as number;
|
||||||
return 1;
|
diff = result.diff;
|
||||||
}
|
sourceLabel = result.sourceLabel;
|
||||||
try {
|
contextPrefix = result.contextPrefix;
|
||||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
} catch (err) {
|
||||||
} catch {
|
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
diff = "";
|
return 1;
|
||||||
}
|
}
|
||||||
sourceLabel = "unstaged changes";
|
|
||||||
} else {
|
|
||||||
// Default: staged changes (or piped)
|
|
||||||
if (isStdinTTY()) {
|
|
||||||
if (!(await isGitRepo())) {
|
|
||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
sourceLabel = "staged changes";
|
|
||||||
|
|
||||||
// If no staged changes, offer to stage unstaged files
|
if (!diff) {
|
||||||
if (!diff) {
|
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
return 0;
|
||||||
if (unstagedFiles.length > 0) {
|
}
|
||||||
const selected = await selectFiles([], unstagedFiles);
|
|
||||||
if (selected === BACK) return 0;
|
|
||||||
if (selected.length > 0) {
|
|
||||||
await stageFiles(selected);
|
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Read from pipe
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
||||||
}
|
|
||||||
diff = Buffer.concat(chunks).toString("utf-8").trim();
|
|
||||||
sourceLabel = "piped input";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!diff) {
|
if (verbose) {
|
||||||
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
|
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
|
||||||
return 0;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (args.flags["verbose"]) {
|
const userPrompt = contextPrefix + buildExplainPrompt(diff);
|
||||||
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_DIFF_SIZE = 15000;
|
if (verbose) {
|
||||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
}
|
||||||
: diff;
|
|
||||||
|
|
||||||
// Collect project context for better explanations
|
const tty = isStdinTTY();
|
||||||
let contextPrefix = "";
|
if (tty) {
|
||||||
try {
|
console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
|
||||||
if (await isGitRepo()) {
|
}
|
||||||
const repoRoot = await getRepoRoot();
|
|
||||||
const ctx = await collectProjectContext(repoRoot);
|
|
||||||
if (ctx.packageDescription) {
|
|
||||||
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
|
try {
|
||||||
|
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||||
|
onToken: (token) => process.stdout.write(token),
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
if (verbose) {
|
const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
|
||||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
if (callbacks) {
|
||||||
}
|
process.stdout.write("\n");
|
||||||
|
} else {
|
||||||
|
process.stdout.write(explanation + "\n");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
const tty = isStdinTTY();
|
return 0;
|
||||||
if (tty) {
|
|
||||||
console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const callbacks: StreamCallbacks = tty ? {
|
|
||||||
onToken: (token) => process.stdout.write(token),
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
|
|
||||||
if (callbacks) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
} else {
|
|
||||||
// Non-TTY: print the result directly
|
|
||||||
process.stdout.write(explanation + "\n");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-14
@@ -1,10 +1,9 @@
|
|||||||
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";
|
||||||
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
|
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
|
||||||
import { generatePRMessage } from "../ai";
|
import { generatePRMessage } from "../ai";
|
||||||
import { BACK, selectOne } from "../menu";
|
import { BACK, SKIP_WAIT, selectOne } from "../menu";
|
||||||
import {
|
import {
|
||||||
getDefaultBranch,
|
getDefaultBranch,
|
||||||
getBranchName,
|
getBranchName,
|
||||||
@@ -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`);
|
||||||
@@ -68,7 +58,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
|
|||||||
if (!platform) {
|
if (!platform) {
|
||||||
const hostname = (await getRemoteHostname()) || "unknown";
|
const hostname = (await getRemoteHostname()) || "unknown";
|
||||||
const chosen = await selectPlatform(hostname);
|
const chosen = await selectPlatform(hostname);
|
||||||
if (chosen === BACK) return 0;
|
if (chosen === BACK) return SKIP_WAIT as unknown as number;
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
console.log(" Aborted.");
|
console.log(" Aborted.");
|
||||||
return 0;
|
return 0;
|
||||||
@@ -98,7 +88,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
|
|||||||
items: [{ label: "Back", value: "back" as const }],
|
items: [{ label: "Back", value: "back" as const }],
|
||||||
});
|
});
|
||||||
if (choice === null) process.exit(0);
|
if (choice === null) process.exit(0);
|
||||||
return 0;
|
return SKIP_WAIT as unknown as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
|
console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`);
|
||||||
|
|||||||
+59
-106
@@ -1,8 +1,6 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
|
import { collectDiff } from "../diff-source";
|
||||||
import { selectFiles } from "../selector";
|
import { BACK, SKIP_WAIT } from "../menu";
|
||||||
import { BACK } 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";
|
||||||
@@ -11,119 +9,74 @@ import type { StreamCallbacks } from "../types";
|
|||||||
import type { ParsedArgs } from "../cli";
|
import type { ParsedArgs } from "../cli";
|
||||||
|
|
||||||
export async function handleReview(args: ParsedArgs): Promise<number> {
|
export async function handleReview(args: ParsedArgs): Promise<number> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
|
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const strictnessFlag = args.flags["strict"] as boolean
|
const strictnessFlag = args.flags["strict"] as boolean
|
||||||
? "strict"
|
? "strict"
|
||||||
: args.flags["lenient"] as boolean
|
: args.flags["lenient"] as boolean
|
||||||
? "lenient"
|
? "lenient"
|
||||||
: "normal";
|
: "normal";
|
||||||
|
|
||||||
const unstaged = args.flags["unstaged"] as boolean;
|
const unstaged = args.flags["unstaged"] as boolean;
|
||||||
const verbose = args.flags["verbose"] as boolean;
|
const verbose = args.flags["verbose"] as boolean;
|
||||||
|
|
||||||
let diff: string;
|
let diff: string;
|
||||||
let sourceLabel: string;
|
let sourceLabel: string;
|
||||||
|
let contextPrefix: string;
|
||||||
|
|
||||||
if (unstaged) {
|
try {
|
||||||
if (!(await isGitRepo())) {
|
const result = await collectDiff({ unstaged, includeProjectContext: true });
|
||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
if (result.back) return SKIP_WAIT as unknown as number;
|
||||||
return 1;
|
diff = result.diff;
|
||||||
}
|
sourceLabel = result.sourceLabel;
|
||||||
try {
|
contextPrefix = result.contextPrefix;
|
||||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
} catch (err) {
|
||||||
} catch {
|
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
diff = "";
|
return 1;
|
||||||
}
|
}
|
||||||
sourceLabel = "unstaged changes";
|
|
||||||
} else if (!isStdinTTY()) {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
||||||
}
|
|
||||||
diff = Buffer.concat(chunks).toString("utf-8").trim();
|
|
||||||
sourceLabel = "piped input";
|
|
||||||
} else {
|
|
||||||
if (!(await isGitRepo())) {
|
|
||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
sourceLabel = "staged changes";
|
|
||||||
|
|
||||||
// If no staged changes, offer to stage unstaged files
|
if (!diff) {
|
||||||
if (!diff) {
|
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
return 0;
|
||||||
if (unstagedFiles.length > 0) {
|
}
|
||||||
const selected = await selectFiles([], unstagedFiles);
|
|
||||||
if (selected === BACK) return 0;
|
|
||||||
if (selected.length > 0) {
|
|
||||||
await stageFiles(selected);
|
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!diff) {
|
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
|
||||||
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_DIFF_SIZE = 15000;
|
const strictnessLabel = strictnessFlag === "strict"
|
||||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
? `${RED()}strict${RESET()}`
|
||||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
: strictnessFlag === "lenient"
|
||||||
: diff;
|
? `${GREEN()}lenient${RESET()}`
|
||||||
|
: `${YELLOW()}normal${RESET()}`;
|
||||||
|
|
||||||
let contextPrefix = "";
|
if (verbose) {
|
||||||
try {
|
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
|
||||||
if (await isGitRepo()) {
|
}
|
||||||
const repoRoot = await getRepoRoot();
|
|
||||||
const ctx = await collectProjectContext(repoRoot);
|
|
||||||
if (ctx.packageDescription) {
|
|
||||||
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag);
|
const tty = isStdinTTY();
|
||||||
|
if (tty) {
|
||||||
|
console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
const strictnessLabel = strictnessFlag === "strict"
|
try {
|
||||||
? `${RED()}strict${RESET()}`
|
const callbacks: StreamCallbacks | undefined = tty ? {
|
||||||
: strictnessFlag === "lenient"
|
onToken: (token) => process.stdout.write(token),
|
||||||
? `${GREEN()}lenient${RESET()}`
|
} : undefined;
|
||||||
: `${YELLOW()}normal${RESET()}`;
|
|
||||||
|
|
||||||
if (verbose) {
|
const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
|
||||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
|
if (callbacks) {
|
||||||
}
|
process.stdout.write("\n");
|
||||||
|
} else {
|
||||||
|
process.stdout.write(result + "\n");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
const tty = isStdinTTY();
|
return 0;
|
||||||
if (tty) {
|
|
||||||
console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const callbacks: StreamCallbacks = tty ? {
|
|
||||||
onToken: (token) => process.stdout.write(token),
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
|
|
||||||
if (callbacks) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
} else {
|
|
||||||
process.stdout.write(result + "\n");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-113
@@ -1,11 +1,10 @@
|
|||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
|
import { collectDiff } from "../diff-source";
|
||||||
import { selectFiles } from "../selector";
|
import { BACK, SKIP_WAIT } from "../menu";
|
||||||
import { BACK } from "../menu";
|
|
||||||
import {
|
import {
|
||||||
SUGGEST_SYSTEM_PROMPT,
|
SUGGEST_SYSTEM_PROMPT,
|
||||||
buildSuggestBranchPrompt,
|
buildSuggestBranchPrompt,
|
||||||
buildSuggestTypePrompt,
|
buildSuggestTypePrompt,
|
||||||
} from "../prompt";
|
} from "../prompt";
|
||||||
import { callAI } from "../ai";
|
import { callAI } from "../ai";
|
||||||
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
|
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
|
||||||
@@ -14,133 +13,101 @@ import type { Config } from "../types";
|
|||||||
import type { ParsedArgs } from "../cli";
|
import type { ParsedArgs } from "../cli";
|
||||||
|
|
||||||
export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
export async function handleSuggest(args: ParsedArgs): Promise<number> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
|
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = args.positional[0] || "branch";
|
const mode = args.positional[0] || "branch";
|
||||||
const verbose = args.flags["verbose"] as boolean;
|
const verbose = args.flags["verbose"] as boolean;
|
||||||
|
|
||||||
if (mode !== "branch" && mode !== "type") {
|
if (mode !== "branch" && mode !== "type") {
|
||||||
console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
|
console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
|
||||||
console.error(` Try: gai suggest branch | gai suggest type\n`);
|
console.error(` Try: gai suggest branch | gai suggest type\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get diff (staged, or unstaged if --unstaged, or piped)
|
const unstaged = args.flags["unstaged"] as boolean;
|
||||||
let diff: string;
|
|
||||||
if (!isStdinTTY()) {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
||||||
}
|
|
||||||
diff = Buffer.concat(chunks).toString("utf-8").trim();
|
|
||||||
} else {
|
|
||||||
if (!(await isGitRepo())) {
|
|
||||||
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.flags["unstaged"] as boolean) {
|
let diff: string;
|
||||||
try {
|
|
||||||
diff = (await Bun.$`git diff`.quiet().text()).trim();
|
|
||||||
} catch {
|
|
||||||
diff = "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
|
|
||||||
// If no staged changes, offer to stage unstaged files
|
try {
|
||||||
if (!diff) {
|
const result = await collectDiff({ unstaged, includeProjectContext: false });
|
||||||
const unstagedFiles = await getUnstagedFiles();
|
if (result.back) return SKIP_WAIT as unknown as number;
|
||||||
if (unstagedFiles.length > 0) {
|
diff = result.diff;
|
||||||
const selected = await selectFiles([], unstagedFiles);
|
} catch (err) {
|
||||||
if (selected === BACK) return 0;
|
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
if (selected.length > 0) {
|
return 1;
|
||||||
await stageFiles(selected);
|
}
|
||||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
|
||||||
diff = await getStagedDiff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!diff) {
|
if (!diff) {
|
||||||
console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
|
console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_DIFF_SIZE = 15000;
|
if (verbose) {
|
||||||
const truncatedDiff = diff.length > MAX_DIFF_SIZE
|
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
||||||
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
|
}
|
||||||
: diff;
|
|
||||||
|
|
||||||
if (verbose) {
|
if (mode === "branch") {
|
||||||
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
|
return handleSuggestBranch(config, diff);
|
||||||
}
|
}
|
||||||
|
return handleSuggestType(config, diff);
|
||||||
if (mode === "branch") {
|
|
||||||
return handleSuggestBranch(config, truncatedDiff);
|
|
||||||
} else {
|
|
||||||
return handleSuggestType(config, truncatedDiff);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
|
async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
|
||||||
const tty = isStdinTTY();
|
const tty = isStdinTTY();
|
||||||
if (tty) {
|
if (tty) {
|
||||||
console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
|
console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
|
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
|
||||||
const suggestions = raw
|
const suggestions = raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
|
.map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
console.log(` ${DIM()}No suggestions generated.${RESET()}`);
|
console.log(` ${DIM()}No suggestions generated.${RESET()}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const s of suggestions) {
|
for (const s of suggestions) {
|
||||||
console.log(` ${GREEN()}${s}${RESET()}`);
|
console.log(` ${GREEN()}${s}${RESET()}`);
|
||||||
}
|
}
|
||||||
console.log("");
|
console.log("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSuggestType(config: Config, diff: string): Promise<number> {
|
async function handleSuggestType(config: Config, diff: string): Promise<number> {
|
||||||
const tty = isStdinTTY();
|
const tty = isStdinTTY();
|
||||||
if (tty) {
|
if (tty) {
|
||||||
console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
|
console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
|
const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
|
||||||
const type = raw.trim().toLowerCase();
|
const type = raw.trim().toLowerCase();
|
||||||
const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
|
const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
|
||||||
|
|
||||||
if (validTypes.includes(type)) {
|
if (validTypes.includes(type)) {
|
||||||
console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
|
console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
|
||||||
} else {
|
} else {
|
||||||
console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
|
console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
|
||||||
console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
|
console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
+38
-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[]> {
|
||||||
@@ -98,6 +103,33 @@ export async function stageFiles(paths: string[]): Promise<void> {
|
|||||||
await Bun.$`git add -- ${paths}`;
|
await Bun.$`git add -- ${paths}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function unstageFiles(paths: string[]): Promise<void> {
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
try {
|
||||||
|
await Bun.$`git restore --staged -- ${paths}`.quiet();
|
||||||
|
} catch {
|
||||||
|
await Bun.$`git rm --cached -r -- ${paths}`.quiet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyFileSelection(
|
||||||
|
stagedFiles: FileEntry[],
|
||||||
|
unstagedFiles: FileEntry[],
|
||||||
|
selectedPaths: string[],
|
||||||
|
): Promise<{ staged: string[]; unstaged: string[] }> {
|
||||||
|
const selected = new Set(selectedPaths);
|
||||||
|
const stagedPaths = new Set(stagedFiles.map((file) => file.path));
|
||||||
|
const unstagedPaths = new Set(unstagedFiles.map((file) => file.path));
|
||||||
|
|
||||||
|
const toUnstage = [...stagedPaths].filter((path) => !selected.has(path));
|
||||||
|
const toStage = [...selected].filter((path) => unstagedPaths.has(path));
|
||||||
|
|
||||||
|
await unstageFiles(toUnstage);
|
||||||
|
await stageFiles(toStage);
|
||||||
|
|
||||||
|
return { staged: toStage, unstaged: toUnstage };
|
||||||
|
}
|
||||||
|
|
||||||
export async function commit(
|
export async function commit(
|
||||||
message: string,
|
message: string,
|
||||||
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
|
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
|
||||||
@@ -113,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] ?? "";
|
||||||
|
|
||||||
|
|||||||
+76
-49
@@ -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";
|
||||||
@@ -15,6 +15,10 @@ const BACKSPACE = "\x7f";
|
|||||||
export const BACK = Symbol("prompt-back");
|
export const BACK = Symbol("prompt-back");
|
||||||
export type PromptBack = typeof BACK;
|
export type PromptBack = typeof BACK;
|
||||||
|
|
||||||
|
// Sent by command handlers to skip the "Press Enter to return" wait in the
|
||||||
|
// interactive menu when the user explicitly backed out of a sub-menu.
|
||||||
|
export const SKIP_WAIT = Symbol("skip-wait");
|
||||||
|
|
||||||
export interface Choice<T> {
|
export interface Choice<T> {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
@@ -39,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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,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: "" };
|
||||||
@@ -96,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: "" };
|
||||||
@@ -164,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 = () => {
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
clearPrompt(renderedLines);
|
||||||
|
showCursor();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
cleanup();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
const finish = (value: T | null | PromptBack) => {
|
const finish = (value: T | null | PromptBack) => {
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeListener("data", onData);
|
process.stdin.removeListener("data", onData);
|
||||||
clearPrompt(renderedLines);
|
cleanup();
|
||||||
showCursor();
|
|
||||||
if (value === null && options.cancelMessage) {
|
if (value === null && options.cancelMessage) {
|
||||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||||
}
|
}
|
||||||
@@ -180,15 +186,20 @@ export async function selectOne<T>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onData = (data: Buffer) => {
|
const onData = (data: Buffer) => {
|
||||||
const result = normalizeKey(data.toString(), escapeBuf);
|
try {
|
||||||
escapeBuf = result.escapeBuf;
|
const result = normalizeKey(data.toString(), escapeBuf);
|
||||||
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
if (result.action === "cancel") return finish(null);
|
if (result.action === "cancel") return finish(null);
|
||||||
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||||
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||||
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
|
else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +232,7 @@ export async function selectMany<T>(
|
|||||||
if (!options.selectAllLabel) return;
|
if (!options.selectAllLabel) return;
|
||||||
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
items[0]!.selected = items.slice(1).every((item) => item.selected);
|
||||||
};
|
};
|
||||||
|
syncSelectAll();
|
||||||
|
|
||||||
const toggle = (index: number) => {
|
const toggle = (index: number) => {
|
||||||
const item = items[index]!;
|
const item = items[index]!;
|
||||||
@@ -238,15 +250,25 @@ export async function selectMany<T>(
|
|||||||
renderedLines,
|
renderedLines,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
render();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const cleanup = () => {
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
clearPrompt(renderedLines);
|
||||||
|
showCursor();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
cleanup();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
const finish = (value: T[] | null | PromptBack) => {
|
const finish = (value: T[] | null | PromptBack) => {
|
||||||
process.stdin.setRawMode(wasRaw === true);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeListener("data", onData);
|
process.stdin.removeListener("data", onData);
|
||||||
clearPrompt(renderedLines);
|
cleanup();
|
||||||
showCursor();
|
|
||||||
if (value === null && options.cancelMessage) {
|
if (value === null && options.cancelMessage) {
|
||||||
process.stdout.write(` ${options.cancelMessage}\n`);
|
process.stdout.write(` ${options.cancelMessage}\n`);
|
||||||
}
|
}
|
||||||
@@ -254,16 +276,21 @@ export async function selectMany<T>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onData = (data: Buffer) => {
|
const onData = (data: Buffer) => {
|
||||||
const result = normalizeKey(data.toString(), escapeBuf);
|
try {
|
||||||
escapeBuf = result.escapeBuf;
|
const result = normalizeKey(data.toString(), escapeBuf);
|
||||||
|
escapeBuf = result.escapeBuf;
|
||||||
|
|
||||||
if (result.action === "cancel") return finish(null);
|
if (result.action === "cancel") return finish(null);
|
||||||
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
if (result.action === "back" && options.allowBack !== false) return finish(BACK);
|
||||||
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
if (result.action === "up" && cursor > 0) { cursor--; render(); }
|
||||||
else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
|
else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
|
||||||
else if (result.action === "space") { toggle(cursor); render(); }
|
else if (result.action === "space") { toggle(cursor); render(); }
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,80 +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",
|
|
||||||
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() || `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",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
@@ -213,9 +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() || `tea pulls create failed (exit code ${exitCode})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
const match = stdout.match(/(https?:\/\/[^\s]+)/);
|
||||||
|
|||||||
+29
-19
@@ -1,44 +1,54 @@
|
|||||||
import type { FileEntry } from "./types";
|
import type { FileEntry } from "./types";
|
||||||
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
|
|
||||||
import { isStdinTTY } from "./tty";
|
import { isStdinTTY } from "./tty";
|
||||||
import { BACK, selectMany } from "./menu";
|
import { BACK, selectMany } from "./menu";
|
||||||
import type { PromptBack } from "./menu";
|
import type { PromptBack } from "./menu";
|
||||||
|
|
||||||
|
function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) {
|
||||||
|
const files = new Map<string, FileEntry & { staged: boolean; unstaged: boolean }>();
|
||||||
|
|
||||||
|
for (const file of stagedFiles) {
|
||||||
|
files.set(file.path, { ...file, staged: true, unstaged: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of unstagedFiles) {
|
||||||
|
const existing = files.get(file.path);
|
||||||
|
if (existing) {
|
||||||
|
existing.unstaged = true;
|
||||||
|
existing.label = existing.label === file.label
|
||||||
|
? existing.label
|
||||||
|
: `${existing.label}, ${file.label}`;
|
||||||
|
} else {
|
||||||
|
files.set(file.path, { ...file, staged: false, unstaged: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...files.values()];
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectFiles(
|
export async function selectFiles(
|
||||||
stagedFiles: FileEntry[],
|
stagedFiles: FileEntry[],
|
||||||
unstagedFiles: FileEntry[],
|
unstagedFiles: FileEntry[],
|
||||||
): Promise<string[] | PromptBack> {
|
): Promise<string[] | PromptBack> {
|
||||||
if (unstagedFiles.length === 0) return [];
|
const files = mergeFiles(stagedFiles, unstagedFiles);
|
||||||
|
if (files.length === 0) return [];
|
||||||
|
|
||||||
if (stagedFiles.length > 0) {
|
if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
|
||||||
process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
|
|
||||||
for (const f of stagedFiles) {
|
|
||||||
process.stdout.write(` ${GREEN()}✓${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStdinTTY()) return [];
|
|
||||||
|
|
||||||
const selected = await selectMany({
|
const selected = await selectMany({
|
||||||
title: "Select files to stage",
|
title: "Select files for this action",
|
||||||
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
|
subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
|
||||||
selectAllLabel: "Select all",
|
selectAllLabel: "Select all",
|
||||||
cancelMessage: "Aborted.",
|
cancelMessage: "Aborted.",
|
||||||
items: unstagedFiles.map((f) => ({
|
items: files.map((f) => ({
|
||||||
label: f.path,
|
label: f.path,
|
||||||
value: f.path,
|
value: f.path,
|
||||||
description: f.label,
|
description: f.label,
|
||||||
|
selected: f.staged,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selected === null) process.exit(1);
|
if (selected === null) process.exit(1);
|
||||||
if (selected === BACK) return BACK;
|
if (selected === BACK) return BACK;
|
||||||
|
|
||||||
if (selected.length > 0) {
|
|
||||||
process.stdout.write(
|
|
||||||
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-11
@@ -1,29 +1,39 @@
|
|||||||
// 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 {
|
||||||
_enabled = enabled;
|
_enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isColorEnabled(): boolean {
|
export function isColorEnabled(): boolean {
|
||||||
if (_enabled !== null) return _enabled;
|
if (_enabled !== null) return _enabled;
|
||||||
|
|
||||||
// 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 !== "") {
|
||||||
return false;
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
_enabled = true;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function s(code: string): string {
|
function s(code: string): string {
|
||||||
return isColorEnabled() ? code : "";
|
return isColorEnabled() ? code : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BOLD = () => s("\x1b[1m");
|
export const BOLD = () => s("\x1b[1m");
|
||||||
@@ -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