4 Commits

Author SHA1 Message Date
Mplan 586487d897 feat: overhaul CLI with new AI commands and mole-style menu (#6)
Build / bun-build (push) Successful in 1m20s
Major CLI redesign introducing a mole-style interactive menu with grouped categories. Added new AI-powered subcommands: explain, review, changelog, and suggest. Includes streaming output, pipe support, and updated brand logo. Refactored codebase into command modules for maintainability.

Reviewed-on: #6
2026-06-17 00:17:31 +08:00
Mplan 0d9c31ae3b Revert "feat(ui): redesign menu and add explain, review, changelog, suggest commands"
Build / bun-build (push) Has been cancelled
This reverts commit 1e370be8af.
2026-06-17 00:14:35 +08:00
Mplan 1e370be8af feat(ui): redesign menu and add explain, review, changelog, suggest commands
Build / bun-build (push) Has been cancelled
2026-06-17 00:11:58 +08:00
Mplan 55db09c973 feat(config): add interactive config editor and GitLab PR support (#4)
Build / bun-build (push) Successful in 32s
Revamp the configuration UI with an interactive editor that supports inline text editing, navigation, and field validation, replacing the previous sequential prompts. Add GitLab pull request creation support via the `glab` CLI, and extend back navigation to all interactive menus for a consistent user experience.

Reviewed-on: #4
2026-06-12 09:00:28 +08:00
11 changed files with 224 additions and 148 deletions
+18 -15
View File
@@ -121,23 +121,26 @@ git diff | gai suggest branch
Run `gai` without arguments to open the mole-style interactive menu:
```
██████╗ █████╗ ██╗
██╔════╝ ██╔══██╗██║
██║ ██╗ ███████║██║
██║ ██║ ██╔══██║██║
╚██████╝ ██║ ██║██║ AI-powered git helper
╚═════╝ ╚═╝ ╚═╝╚═╝ v0.1.3
gai v0.1.3
AI-powered git helper for commits, PRs, reviews, and changelogs
────────────────────────────────────────────────────────────────────────
➤ 1. Commit Generate AI commit message
2. PR Create a PR with AI-generated title
3. Explain Explain staged changes in plain language
4. Review AI code review of staged changes
5. Changelog Generate changelog from commits
6. Suggest Suggest branch name or commit type
7. Amend Amend last commit with AI message
8. Config Configure API settings
CREATE
1 Commit Generate AI commit message
2 PR Create a PR with AI-generated title
3 Amend Amend last commit with AI message
↑↓ | Enter | H Help | V Version | Q Quit
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.
+65 -46
View File
@@ -14,7 +14,7 @@ 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 { showBanner } from "./src/brand";
import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu";
// ── Interactive Menu (mole-style) ─────────────────────────────────────
@@ -23,17 +23,18 @@ interface MenuItem {
key: string;
label: string;
description: string;
group: "Create" | "Inspect" | "Project";
}
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" },
{ key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" },
{ key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" },
{ key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" },
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
];
function hideCursor() { process.stdout.write("\x1b[?25l"); }
@@ -55,51 +56,70 @@ function visibleLen(s: string): number {
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function renderMenu(banner: string, cursor: number): number {
function padRight(value: string, width: number): string {
return value + " ".repeat(Math.max(0, width - visibleLen(value)));
}
function renderMenu(cursor: number): number {
process.stdout.write("\x1b[H"); // cursor home
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 G = GREEN();
const R = RESET();
const ARROW = "➤";
const width = 72;
const separator = `${D}${"─".repeat(width)}${R}`;
// Calculate padding
const write = (line = "") => {
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) => visibleLen(m.label))) + 2;
let currentGroup: MenuItem["group"] | null = null;
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++;
if (item.group !== currentGroup) {
if (currentGroup !== null) write("");
write(` ${D}${item.group.toUpperCase()}${R}`);
currentGroup = item.group;
}
// 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++;
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}`);
}
}
write("");
write(` ${separator}`);
write(` ${D}↑/↓ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`);
write("");
// Clear rest of screen
process.stdout.write("\x1b[J");
@@ -167,7 +187,6 @@ async function showMenu(): Promise<number> {
return 1;
}
const banner = showBanner();
let cursor = 0;
const wasRaw = process.stdin.isRaw;
@@ -176,7 +195,7 @@ async function showMenu(): Promise<number> {
hideCursor();
// Initial render
renderMenu(banner, cursor);
renderMenu(cursor);
try {
while (true) {
@@ -184,11 +203,11 @@ async function showMenu(): Promise<number> {
// Escape sequences (arrows)
if (raw === "\x1b[A" || raw === "\x1bOA") {
if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
if (cursor > 0) { cursor--; renderMenu(cursor); }
continue;
}
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;
}
@@ -199,7 +218,7 @@ async function showMenu(): Promise<number> {
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(banner, cursor);
renderMenu(cursor);
continue;
}
@@ -217,13 +236,13 @@ async function showMenu(): Promise<number> {
const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) {
cursor = idx;
renderMenu(banner, cursor);
renderMenu(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);
renderMenu(cursor);
continue;
}
}
@@ -243,7 +262,7 @@ async function showMenu(): Promise<number> {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log("gai v0.1.3");
console.log(`gai v${VERSION}`);
return 0;
}
if (lower === "q") {
+1 -1
View File
@@ -2,7 +2,7 @@
import { GREEN, CYAN, RESET } from "./terminal";
const VERSION = "0.1.3";
export const VERSION = "0.1.3";
export function showBanner(): string {
const G = GREEN();
+1 -1
View File
@@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise<number> {
}
try {
const callbacks: StreamCallbacks = tty ? {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
+28 -13
View File
@@ -7,6 +7,7 @@ import {
getStagedDiff,
getRecentCommits,
stageFiles,
applyFileSelection,
commit,
} from "../git";
import { selectFiles } from "../selector";
@@ -50,6 +51,19 @@ function printCommitResult(result: CommitResult, msg: string) {
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"> {
console.log(`\n ${BOLD()}Generated commit message:${RESET()}`);
console.log(` ${GREEN()}${message}${RESET()}\n`);
@@ -173,16 +187,22 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && !amend) {
if (autoMode && unstagedFiles.length > 0) {
if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
return 1;
}
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 (unstagedFiles.length > 0) {
} 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`);
return 1;
} else {
console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`);
return 1;
}
}
@@ -215,18 +235,13 @@ export async function handleCommit(args: ParsedArgs): Promise<number> {
return 0;
}
if (unstagedFiles.length > 0) {
if (autoMode) {
if (autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
}
}
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
}
const diff = await getStagedDiff();
+16 -16
View File
@@ -1,5 +1,12 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
@@ -19,7 +26,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
}
const unstaged = args.flags["unstaged"] as boolean;
const staged = args.flags["staged"] as boolean;
const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain
@@ -44,22 +50,16 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
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) {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
}
}
}
} else {
// Read from pipe
const chunks: Buffer[] = [];
@@ -109,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
}
try {
const callbacks: StreamCallbacks = tty ? {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
+16 -15
View File
@@ -1,5 +1,12 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
@@ -53,23 +60,17 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
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) {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
}
}
}
}
if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
@@ -110,7 +111,7 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
}
try {
const callbacks: StreamCallbacks = tty ? {
const callbacks: StreamCallbacks | undefined = tty ? {
onToken: (token) => process.stdout.write(token),
} : undefined;
+13 -13
View File
@@ -1,5 +1,11 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu";
import {
@@ -51,23 +57,17 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
diff = "";
}
} else {
diff = await getStagedDiff();
// If no staged changes, offer to stage unstaged files
if (!diff) {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
}
}
}
}
}
if (!diff) {
console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
+27
View File
@@ -98,6 +98,33 @@ export async function stageFiles(paths: string[]): Promise<void> {
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(
message: string,
): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> {
+1
View File
@@ -225,6 +225,7 @@ export async function selectMany<T>(
if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected);
};
syncSelectAll();
const toggle = (index: number) => {
const item = items[index]!;
+29 -19
View File
@@ -1,44 +1,54 @@
import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty";
import { BACK, selectMany } 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(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return [];
const files = mergeFiles(stagedFiles, unstagedFiles);
if (files.length === 0) return [];
if (stagedFiles.length > 0) {
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 [];
if (!isStdinTTY()) return stagedFiles.map((file) => file.path);
const selected = await selectMany({
title: "Select files to stage",
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
title: "Select files for this action",
subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`,
selectAllLabel: "Select all",
cancelMessage: "Aborted.",
items: unstagedFiles.map((f) => ({
items: files.map((f) => ({
label: f.path,
value: f.path,
description: f.label,
selected: f.staged,
})),
});
if (selected === null) process.exit(1);
if (selected === BACK) return BACK;
if (selected.length > 0) {
process.stdout.write(
` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
);
}
return selected;
}