diff --git a/index.ts b/index.ts index 27a681f..a89eab2 100644 --- a/index.ts +++ b/index.ts @@ -14,9 +14,121 @@ import { handleSuggest } from "./src/commands/suggest"; import { setColorEnabled } from "./src/terminal"; import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal"; 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 { + 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 { + 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 showMenu(): Promise { if (!isStdinTTY()) { @@ -24,72 +136,108 @@ async function showMenu(): Promise { return 1; } - while (true) { - const selected = await selectOne({ - title: "gai", - 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" }, - ], - }); + const banner = showBanner(); + let cursor = 0; + const wasRaw = process.stdin.isRaw; - if (selected === null || selected === BACK) return 0; + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + hideCursor(); - // Build synthetic args for subcommand handlers - const fakeArgs: ParsedArgs = { - command: selected, - flags: {}, - positional: [], - raw: [], - subcommand: { - name: selected, - description: "", - handler: async () => 0, - }, - }; + // Initial render + renderMenu(banner, cursor); - let result: number; - switch (selected) { - case "commit": - result = await handleCommit(fakeArgs); - break; - case "pr": - result = await handlePR(fakeArgs); - break; - case "config": - result = await handleConfig(fakeArgs); - break; - case "explain": - result = await handleExplain(fakeArgs); - break; - case "review": - result = await handleReview(fakeArgs); - break; - case "changelog": - result = await handleChangelog(fakeArgs); - break; - case "suggest": - result = await handleSuggest(fakeArgs); - break; - case "amend": - fakeArgs.flags["amend"] = true; - result = await handleCommit(fakeArgs); - break; - default: - result = 0; + try { + while (true) { + const raw = await readKey(); + + // Escape sequences (arrows) + if (raw === "\x1b[A" || raw === "\x1bOA") { + if (cursor > 0) { cursor--; renderMenu(banner, cursor); } + continue; + } + if (raw === "\x1b[B" || raw === "\x1bOB") { + if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); } + continue; + } + + // Enter + if (raw === "\r" || raw === "\n") { + const item = MENU_ITEMS[cursor]!; + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdout.write("\x1b[2J\x1b[H"); // clear screen + const result = await dispatchMenuAction(item.key); + if (result !== 0) return result; + // Return to menu + hideCursor(); + if (wasRaw !== true) process.stdin.setRawMode(true); + process.stdin.resume(); + renderMenu(banner, cursor); + continue; + } + + // Ctrl+C + if (raw === "\x03") { + showCursor(); + 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 item = MENU_ITEMS[idx]!; + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdout.write("\x1b[2J\x1b[H"); // clear screen + const result = await dispatchMenuAction(item.key); + 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.2.0"); + return 0; + } + if (lower === "q") { + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdout.write("\n"); + return 0; + } } - - // Return to menu unless they explicitly chose "back" - if (result !== 0) return result; - // Loop back to menu for another action + } finally { + showCursor(); + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); } } diff --git a/src/brand.ts b/src/brand.ts new file mode 100644 index 0000000..56fb7b6 --- /dev/null +++ b/src/brand.ts @@ -0,0 +1,20 @@ +// Brand banner and ASCII art logo for gai + +import { GREEN, CYAN, RESET } from "./terminal"; + +const VERSION = "0.2.0"; + +export function showBanner(): string { + const G = GREEN(); + const C = CYAN(); + const R = RESET(); + + return [ + "", + `${G} ___ ___ _${R}`, + `${G} / _ \\ / _ | (_)${R}`, + `${G} | (_) | (_| | | |${R} ${C}AI-powered git helper${R}`, + `${G} \\___/ \\__,_|_|_|${R} ${C}v${VERSION}${R}`, + "", + ].join("\n"); +}