From 662609a78e6b51c69a1f58dd0ff7fa8b8d1488ad Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 16 Jun 2026 23:46:19 +0800 Subject: [PATCH] refactor(ui): group menu by category and unify file selection --- index.ts | 109 ++++++++++++++++++++++---------------- src/brand.ts | 2 +- src/commands/changelog.ts | 2 +- src/commands/commit.ts | 55 ++++++++++++------- src/commands/explain.ts | 34 ++++++------ src/commands/review.ts | 33 ++++++------ src/commands/suggest.ts | 28 +++++----- src/git.ts | 27 ++++++++++ src/menu.ts | 1 + src/selector.ts | 48 ++++++++++------- 10 files changed, 206 insertions(+), 133 deletions(-) diff --git a/index.ts b/index.ts index 484cba3..b8169f0 100644 --- a/index.ts +++ b/index.ts @@ -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`); + if (item.group !== currentGroup) { + if (currentGroup !== null) write(""); + write(` ${D}${item.group.toUpperCase()}${R}`); + currentGroup = item.group; + } + + 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 - 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++; + 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 { return 1; } - const banner = showBanner(); let cursor = 0; const wasRaw = process.stdin.isRaw; @@ -176,7 +195,7 @@ async function showMenu(): Promise { hideCursor(); // Initial render - renderMenu(banner, cursor); + renderMenu(cursor); try { while (true) { @@ -184,11 +203,11 @@ async function showMenu(): Promise { // 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 { 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 { 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 { 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") { diff --git a/src/brand.ts b/src/brand.ts index 508fbbb..1aa4454 100644 --- a/src/brand.ts +++ b/src/brand.ts @@ -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(); diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index 4d3b723..1ec76ca 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -62,7 +62,7 @@ export async function handleChangelog(args: ParsedArgs): Promise { } try { - const callbacks: StreamCallbacks = tty ? { + const callbacks: StreamCallbacks | undefined = tty ? { onToken: (token) => process.stdout.write(token), } : undefined; diff --git a/src/commands/commit.ts b/src/commands/commit.ts index 544711a..d8ff3e6 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -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 { const stagedFiles = await getStagedFiles(); const unstagedFiles = await getUnstagedFiles(); - if (stagedFiles.length === 0 && !amend) { - if (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) { + 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 (!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 { return 0; } - if (unstagedFiles.length > 0) { - if (autoMode) { - 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()}`); - } - } + 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; + printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); } const diff = await getStagedDiff(); diff --git a/src/commands/explain.ts b/src/commands/explain.ts index e775306..15eb9ac 100644 --- a/src/commands/explain.ts +++ b/src/commands/explain.ts @@ -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 { } 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 { console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); return 1; } - diff = await getStagedDiff(); - sourceLabel = "staged changes"; + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); + sourceLabel = "selected changes"; - // If no staged changes, offer to stage unstaged files - if (!diff) { - const unstagedFiles = await getUnstagedFiles(); - if (unstagedFiles.length > 0) { - const selected = await selectFiles([], 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()}`); - diff = await getStagedDiff(); - } - } + if (stagedFiles.length > 0 || unstagedFiles.length > 0) { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + await applyFileSelection(stagedFiles, unstagedFiles, selected); } + diff = await getStagedDiff(); } else { // Read from pipe const chunks: Buffer[] = []; @@ -109,7 +109,7 @@ export async function handleExplain(args: ParsedArgs): Promise { } try { - const callbacks: StreamCallbacks = tty ? { + const callbacks: StreamCallbacks | undefined = tty ? { onToken: (token) => process.stdout.write(token), } : undefined; diff --git a/src/commands/review.ts b/src/commands/review.ts index b18a301..754459e 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -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,22 +60,16 @@ export async function handleReview(args: ParsedArgs): Promise { console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); return 1; } - diff = await getStagedDiff(); - sourceLabel = "staged changes"; + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); + sourceLabel = "selected changes"; - // If no staged changes, offer to stage unstaged files - if (!diff) { - const unstagedFiles = await getUnstagedFiles(); - if (unstagedFiles.length > 0) { - const selected = await selectFiles([], 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()}`); - diff = await getStagedDiff(); - } - } + if (stagedFiles.length > 0 || unstagedFiles.length > 0) { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + await applyFileSelection(stagedFiles, unstagedFiles, selected); } + diff = await getStagedDiff(); } if (!diff) { @@ -110,7 +111,7 @@ export async function handleReview(args: ParsedArgs): Promise { } try { - const callbacks: StreamCallbacks = tty ? { + const callbacks: StreamCallbacks | undefined = tty ? { onToken: (token) => process.stdout.write(token), } : undefined; diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts index 4d64141..676051c 100644 --- a/src/commands/suggest.ts +++ b/src/commands/suggest.ts @@ -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,21 +57,15 @@ export async function handleSuggest(args: ParsedArgs): Promise { diff = ""; } } else { - diff = await getStagedDiff(); + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); - // If no staged changes, offer to stage unstaged files - if (!diff) { - const unstagedFiles = await getUnstagedFiles(); - if (unstagedFiles.length > 0) { - const selected = await selectFiles([], 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()}`); - diff = await getStagedDiff(); - } - } + if (stagedFiles.length > 0 || unstagedFiles.length > 0) { + const selected = await selectFiles(stagedFiles, unstagedFiles); + if (selected === BACK) return SKIP_WAIT as unknown as number; + await applyFileSelection(stagedFiles, unstagedFiles, selected); } + diff = await getStagedDiff(); } } diff --git a/src/git.ts b/src/git.ts index 6e09840..58cf11f 100644 --- a/src/git.ts +++ b/src/git.ts @@ -98,6 +98,33 @@ export async function stageFiles(paths: string[]): Promise { await Bun.$`git add -- ${paths}`; } +export async function unstageFiles(paths: string[]): Promise { + 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 }> { diff --git a/src/menu.ts b/src/menu.ts index 8e679cb..92158fe 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -225,6 +225,7 @@ export async function selectMany( if (!options.selectAllLabel) return; items[0]!.selected = items.slice(1).every((item) => item.selected); }; + syncSelectAll(); const toggle = (index: number) => { const item = items[index]!; diff --git a/src/selector.ts b/src/selector.ts index 2672130..c4e428b 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -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(); + + 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 { - 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; }