Compare commits
3 Commits
b9de1267e3
...
ab9a41ab83
| Author | SHA1 | Date | |
|---|---|---|---|
| ab9a41ab83 | |||
| 724d4d3b6b | |||
| 8ff481f630 |
@@ -1,7 +1,7 @@
|
|||||||
#!/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.2.0
|
// 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";
|
||||||
@@ -14,9 +14,148 @@ import { handleSuggest } from "./src/commands/suggest";
|
|||||||
import { setColorEnabled } from "./src/terminal";
|
import { setColorEnabled } from "./src/terminal";
|
||||||
import { BOLD, GREEN, CYAN, DIM, RESET } 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 { BACK, selectOne } from "./src/menu";
|
import { showBanner } from "./src/brand";
|
||||||
|
|
||||||
// ── Interactive Menu (default, no subcommand) ─────────────────────────
|
// ── Interactive Menu (mole-style) ─────────────────────────────────────
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
|
{ key: "commit", label: "Commit", description: "Generate AI commit message" },
|
||||||
|
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title" },
|
||||||
|
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language" },
|
||||||
|
{ key: "review", label: "Review", description: "AI code review of staged changes" },
|
||||||
|
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits" },
|
||||||
|
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" },
|
||||||
|
{ key: "amend", label: "Amend", description: "Amend last commit with AI message" },
|
||||||
|
{ key: "config", label: "Config", description: "Configure API settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function hideCursor() { process.stdout.write("\x1b[?25l"); }
|
||||||
|
function showCursor() { process.stdout.write("\x1b[?25h"); }
|
||||||
|
|
||||||
|
function clearLine() { process.stdout.write("\r\x1b[2K"); }
|
||||||
|
|
||||||
|
async function readKey(): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
process.stdin.removeListener("data", onData);
|
||||||
|
resolve(data.toString());
|
||||||
|
};
|
||||||
|
process.stdin.once("data", onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleLen(s: string): number {
|
||||||
|
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMenu(banner: string, cursor: number): number {
|
||||||
|
process.stdout.write("\x1b[H"); // cursor home
|
||||||
|
|
||||||
|
let lineCount = 0;
|
||||||
|
for (const line of banner.split("\n")) {
|
||||||
|
clearLine();
|
||||||
|
process.stdout.write(line + "\n");
|
||||||
|
lineCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const G = GREEN();
|
||||||
|
const C = CYAN();
|
||||||
|
const D = DIM();
|
||||||
|
const R = RESET();
|
||||||
|
const ARROW = "➤";
|
||||||
|
|
||||||
|
// Calculate padding
|
||||||
|
const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
||||||
|
const item = MENU_ITEMS[i]!;
|
||||||
|
const num = String(i + 1);
|
||||||
|
const active = i === cursor;
|
||||||
|
|
||||||
|
clearLine();
|
||||||
|
if (active) {
|
||||||
|
const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label)));
|
||||||
|
process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`);
|
||||||
|
} else {
|
||||||
|
const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
|
||||||
|
process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`);
|
||||||
|
}
|
||||||
|
lineCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
clearLine();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
lineCount++;
|
||||||
|
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
|
||||||
|
process.stdout.write("\x1b[J");
|
||||||
|
|
||||||
|
return lineCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchMenuAction(key: string): Promise<number> {
|
||||||
|
const fakeArgs: ParsedArgs = {
|
||||||
|
command: key,
|
||||||
|
flags: {},
|
||||||
|
positional: [],
|
||||||
|
raw: [],
|
||||||
|
subcommand: { name: key, description: "", handler: async () => 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (key === "amend") fakeArgs.flags["amend"] = true;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "commit": return handleCommit(fakeArgs);
|
||||||
|
case "pr": return handlePR(fakeArgs);
|
||||||
|
case "config": return handleConfig(fakeArgs);
|
||||||
|
case "explain": return handleExplain(fakeArgs);
|
||||||
|
case "review": return handleReview(fakeArgs);
|
||||||
|
case "changelog": return handleChangelog(fakeArgs);
|
||||||
|
case "suggest": return handleSuggest(fakeArgs);
|
||||||
|
case "amend": return handleCommit(fakeArgs);
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForEnter(): Promise<void> {
|
||||||
|
const D = DIM();
|
||||||
|
const R = RESET();
|
||||||
|
process.stdout.write(`\n ${D}Press Enter to return to menu...${R}`);
|
||||||
|
// Read a line from stdin (works in cooked mode — blocks until Enter)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
process.stdin.removeListener("data", onData);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
process.stdin.on("data", onData);
|
||||||
|
// Resume stdin in case it was paused
|
||||||
|
process.stdin.resume();
|
||||||
|
});
|
||||||
|
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
|
||||||
|
showCursor();
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
|
||||||
|
const result = await dispatchMenuAction(item.key);
|
||||||
|
await waitForEnter();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function showMenu(): Promise<number> {
|
async function showMenu(): Promise<number> {
|
||||||
if (!isStdinTTY()) {
|
if (!isStdinTTY()) {
|
||||||
@@ -24,72 +163,97 @@ async function showMenu(): Promise<number> {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
const banner = showBanner();
|
||||||
const selected = await selectOne({
|
let cursor = 0;
|
||||||
title: "gai",
|
const wasRaw = process.stdin.isRaw;
|
||||||
subtitle: "AI-powered git helper — choose a workflow",
|
|
||||||
allowBack: false,
|
|
||||||
items: [
|
|
||||||
{ label: "commit", value: "commit", description: "Generate AI commit message" },
|
|
||||||
{ label: "pr", value: "pr", description: "Create a PR with AI-generated title" },
|
|
||||||
{ label: "explain", value: "explain", description: "Explain staged changes in plain language" },
|
|
||||||
{ label: "review", value: "review", description: "AI code review of staged changes" },
|
|
||||||
{ label: "changelog", value: "changelog", description: "Generate changelog from commits" },
|
|
||||||
{ label: "suggest", value: "suggest", description: "Suggest branch name or commit type" },
|
|
||||||
{ label: "amend", value: "amend", description: "Amend last commit with AI message" },
|
|
||||||
{ label: "config", value: "config", description: "Configure API settings" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selected === null || selected === BACK) return 0;
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
hideCursor();
|
||||||
|
|
||||||
// Build synthetic args for subcommand handlers
|
// Initial render
|
||||||
const fakeArgs: ParsedArgs = {
|
renderMenu(banner, cursor);
|
||||||
command: selected,
|
|
||||||
flags: {},
|
|
||||||
positional: [],
|
|
||||||
raw: [],
|
|
||||||
subcommand: {
|
|
||||||
name: selected,
|
|
||||||
description: "",
|
|
||||||
handler: async () => 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let result: number;
|
try {
|
||||||
switch (selected) {
|
while (true) {
|
||||||
case "commit":
|
const raw = await readKey();
|
||||||
result = await handleCommit(fakeArgs);
|
|
||||||
break;
|
// Escape sequences (arrows)
|
||||||
case "pr":
|
if (raw === "\x1b[A" || raw === "\x1bOA") {
|
||||||
result = await handlePR(fakeArgs);
|
if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
|
||||||
break;
|
continue;
|
||||||
case "config":
|
}
|
||||||
result = await handleConfig(fakeArgs);
|
if (raw === "\x1b[B" || raw === "\x1bOB") {
|
||||||
break;
|
if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); }
|
||||||
case "explain":
|
continue;
|
||||||
result = await handleExplain(fakeArgs);
|
}
|
||||||
break;
|
|
||||||
case "review":
|
// Enter
|
||||||
result = await handleReview(fakeArgs);
|
if (raw === "\r" || raw === "\n") {
|
||||||
break;
|
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
|
||||||
case "changelog":
|
if (result !== 0) return result;
|
||||||
result = await handleChangelog(fakeArgs);
|
hideCursor();
|
||||||
break;
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
case "suggest":
|
process.stdin.resume();
|
||||||
result = await handleSuggest(fakeArgs);
|
renderMenu(banner, cursor);
|
||||||
break;
|
continue;
|
||||||
case "amend":
|
}
|
||||||
fakeArgs.flags["amend"] = true;
|
|
||||||
result = await handleCommit(fakeArgs);
|
// Ctrl+C
|
||||||
break;
|
if (raw === "\x03") {
|
||||||
default:
|
showCursor();
|
||||||
result = 0;
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number hotkeys (1-8)
|
||||||
|
if (raw >= "1" && raw <= "8") {
|
||||||
|
const idx = parseInt(raw) - 1;
|
||||||
|
if (idx < MENU_ITEMS.length) {
|
||||||
|
cursor = idx;
|
||||||
|
renderMenu(banner, cursor);
|
||||||
|
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
|
||||||
|
if (result !== 0) return result;
|
||||||
|
hideCursor();
|
||||||
|
if (wasRaw !== true) process.stdin.setRawMode(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
renderMenu(banner, cursor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letter hotkeys
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
if (lower === "h") {
|
||||||
|
showCursor();
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdout.write("\x1b[2J\x1b[H");
|
||||||
|
console.log(formatHelp(commands));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (lower === "v") {
|
||||||
|
showCursor();
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdout.write("\x1b[2J\x1b[H");
|
||||||
|
console.log("gai v0.1.3");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (lower === "q") {
|
||||||
|
showCursor();
|
||||||
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
// Return to menu unless they explicitly chose "back"
|
showCursor();
|
||||||
if (result !== 0) return result;
|
process.stdin.setRawMode(wasRaw === true);
|
||||||
// Loop back to menu for another action
|
process.stdin.pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gai",
|
"name": "gai",
|
||||||
"version": "0.2.0",
|
"version": "0.1.3",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Brand banner and ASCII art logo for gai
|
||||||
|
|
||||||
|
import { GREEN, CYAN, RESET } from "./terminal";
|
||||||
|
|
||||||
|
const VERSION = "0.1.3";
|
||||||
|
|
||||||
|
export function showBanner(): string {
|
||||||
|
const G = GREEN();
|
||||||
|
const C = CYAN();
|
||||||
|
const R = RESET();
|
||||||
|
|
||||||
|
return [
|
||||||
|
"",
|
||||||
|
`${G} ██████╗ █████╗ ██╗${R}`,
|
||||||
|
`${G} ██╔════╝ ██╔══██╗██║${R}`,
|
||||||
|
`${G} ██║ ██╗ ███████║██║${R}`,
|
||||||
|
`${G} ██║ ██║ ██╔══██║██║${R}`,
|
||||||
|
`${G} ╚██████╝ ██║ ██║██║${R} ${C}AI-powered git helper${R}`,
|
||||||
|
`${G} ╚═════╝ ╚═╝ ╚═╝╚═╝${R} ${C}v${VERSION}${R}`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
+1
-1
@@ -282,7 +282,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.2.0");
|
console.log("gai v0.1.3");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user