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
13 changed files with 247 additions and 155 deletions
+24 -14
View File
@@ -118,22 +118,32 @@ git diff | gai suggest branch
### Interactive Menu
```
$ gai
Run `gai` without arguments to open the mole-style interactive menu:
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
+69 -46
View File
@@ -14,7 +14,8 @@ 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) ─────────────────────────────────────
@@ -22,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"); }
@@ -54,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");
@@ -153,6 +174,9 @@ async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number>
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
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();
return result;
}
@@ -163,7 +187,6 @@ async function showMenu(): Promise<number> {
return 1;
}
const banner = showBanner();
let cursor = 0;
const wasRaw = process.stdin.isRaw;
@@ -172,7 +195,7 @@ async function showMenu(): Promise<number> {
hideCursor();
// Initial render
renderMenu(banner, cursor);
renderMenu(cursor);
try {
while (true) {
@@ -180,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;
}
@@ -195,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;
}
@@ -213,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;
}
}
@@ -239,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;
+30 -15
View File
@@ -7,10 +7,11 @@ import {
getStagedDiff,
getRecentCommits,
stageFiles,
applyFileSelection,
commit,
} from "../git";
import { selectFiles } from "../selector";
import { BACK } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { buildPrompt, SYSTEM_PROMPT } from "../prompt";
import { generateCommitMessage } from "../ai";
@@ -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 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
}
}
if (selected === BACK) return SKIP_WAIT as unknown as number;
printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected));
}
const diff = await getStagedDiff();
+2 -1
View File
@@ -1,6 +1,7 @@
import { loadConfig, saveConfig } from "../config";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu";
import type { Config } from "../types";
import type { ParsedArgs } from "../cli";
@@ -323,7 +324,7 @@ export async function handleConfig(args: ParsedArgs): Promise<number> {
// gai config (no args) → interactive
if (positional.length === 0) {
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()}`);
+18 -18
View File
@@ -1,7 +1,14 @@
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 } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai";
@@ -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);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
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;
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;
+3 -3
View File
@@ -4,7 +4,7 @@ import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context";
import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt";
import { generatePRMessage } from "../ai";
import { BACK, selectOne } from "../menu";
import { BACK, SKIP_WAIT, selectOne } from "../menu";
import {
getDefaultBranch,
getBranchName,
@@ -68,7 +68,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname);
if (chosen === BACK) return 0;
if (chosen === BACK) return SKIP_WAIT as unknown as number;
if (!chosen) {
console.log(" Aborted.");
return 0;
@@ -98,7 +98,7 @@ export async function handlePR(args: ParsedArgs): Promise<number> {
items: [{ label: "Back", value: "back" as const }],
});
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`);
+18 -17
View File
@@ -1,7 +1,14 @@
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 } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai";
@@ -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);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
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;
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;
+15 -15
View File
@@ -1,7 +1,13 @@
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 } from "../menu";
import { BACK, SKIP_WAIT } from "../menu";
import {
SUGGEST_SYSTEM_PROMPT,
buildSuggestBranchPrompt,
@@ -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 (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
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) {
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 }> {
+5
View File
@@ -15,6 +15,10 @@ const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-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> {
label: string;
value: T;
@@ -221,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;
}