4 Commits

Author SHA1 Message Date
Mplan e1354e8651 feat(menu): add back key navigation
Build / bun-build (push) Successful in 21s
2026-06-11 20:10:34 +08:00
Mplan 7e662b25cc refactor(pr): rely on selected CLI for PR creation 2026-06-11 19:43:19 +08:00
Mplan 5bb2dc8e8a chore(config): bump version to 0.1.2 2026-06-11 19:39:50 +08:00
Mplan 1dbfac7985 feat(pr): add GitLab support 2026-06-11 19:38:36 +08:00
7 changed files with 147 additions and 87 deletions
+3 -2
View File
@@ -22,7 +22,7 @@ Generate **Conventional Commits** messages and pull request descriptions using A
- **Conventional Commits** — `feat(scope): description` format by default - **Conventional Commits** — `feat(scope): description` format by default
- **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all" - **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all"
- **Inline editing** — edit AI-generated messages right in the terminal with cursor movement - **Inline editing** — edit AI-generated messages right in the terminal with cursor movement
- **AI-generated PRs** — create GitHub or Gitea pull requests with generated title and body - **AI-generated PRs** — create GitHub, Gitea, or GitLab pull requests with generated title and body
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more - **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more
- **Review before commit** — confirm, edit, or abort the generated message - **Review before commit** — confirm, edit, or abort the generated message
- **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies - **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies
@@ -84,7 +84,7 @@ $ gai commit
Select files to stage: Select files to stage:
2 unstaged files available 2 unstaged files available
↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel ↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel
□ Select all □ Select all
□ src/ai.ts modified □ src/ai.ts modified
@@ -108,6 +108,7 @@ $ gai commit
- GitHub remotes use the `gh` CLI - GitHub remotes use the `gh` CLI
- Gitea remotes use the `tea` CLI - Gitea remotes use the `tea` CLI
- GitLab remotes use the `glab` CLI
- Unknown remotes prompt you to choose a platform - Unknown remotes prompt you to choose a platform
The command compares your current branch against the default branch, pushes the branch if needed, generates a PR title/body from the branch commits and diff, then asks for confirmation before creating the PR. The command compares your current branch against the default branch, pushes the branch if needed, generates a PR title/body from the branch commits and diff, then asks for confirmation before creating the PR.
+38 -28
View File
@@ -13,7 +13,8 @@ import {
commit, commit,
} from "./src/git"; } from "./src/git";
import { selectFiles } from "./src/selector"; import { selectFiles } from "./src/selector";
import { selectOne } from "./src/menu"; import { BACK, selectOne } from "./src/menu";
import type { PromptBack } from "./src/menu";
import { collectProjectContext } from "./src/context"; import { collectProjectContext } from "./src/context";
import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt"; import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt";
import { generateCommitMessage } from "./src/ai"; import { generateCommitMessage } from "./src/ai";
@@ -29,8 +30,6 @@ import {
getBranchDiff, getBranchDiff,
detectPlatform, detectPlatform,
getRemoteHostname, getRemoteHostname,
checkCLI,
checkAuth,
createPR, createPR,
} from "./src/pr"; } from "./src/pr";
import type { Platform } from "./src/pr"; import type { Platform } from "./src/pr";
@@ -306,9 +305,11 @@ async function showMenu(): Promise<void> {
process.exit(1); process.exit(1);
} }
while (true) {
const selected = await selectOne({ const selected = await selectOne({
title: "gai", title: "gai",
subtitle: "Choose a workflow", subtitle: "Choose a workflow",
allowBack: false,
items: MENU_ACTIONS.map((action) => ({ items: MENU_ACTIONS.map((action) => ({
label: action.label, label: action.label,
value: action.key, value: action.key,
@@ -316,12 +317,22 @@ async function showMenu(): Promise<void> {
})), })),
}); });
if (selected === "commit") await handleCommit(false, false); if (selected === null || selected === BACK) return;
if (selected === "pr") await handlePR(false);
if (selected === "config") await handleConfig(); const result =
selected === "commit"
? await handleCommit(false, false)
: selected === "pr"
? await handlePR(false)
: await handleConfig().then(() => "done" as const);
if (result !== "back") return;
}
} }
async function selectPlatform(hostname: string): Promise<Platform | null> { async function selectPlatform(
hostname: string,
): Promise<Platform | null | PromptBack> {
if (!process.stdin.isTTY) { if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`); console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`);
process.exit(1); process.exit(1);
@@ -333,11 +344,15 @@ async function selectPlatform(hostname: string): Promise<Platform | null> {
items: [ items: [
{ label: "GitHub", value: "github" as Platform, description: "Use gh CLI" }, { label: "GitHub", value: "github" as Platform, description: "Use gh CLI" },
{ label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" }, { label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" },
{ label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" },
], ],
}); });
} }
async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> { async function handleCommit(
autoMode: boolean,
dryRun: boolean,
): Promise<"done" | "back"> {
const config = await loadConfig(); const config = await loadConfig();
if (!config.apiKey) { if (!config.apiKey) {
@@ -357,7 +372,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
if (stagedFiles.length === 0 && unstagedFiles.length === 0) { if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.log(" Nothing to commit."); console.log(" Nothing to commit.");
return; return "done";
} }
if (unstagedFiles.length > 0) { if (unstagedFiles.length > 0) {
@@ -368,6 +383,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
); );
} else { } else {
const selected = await selectFiles(stagedFiles, unstagedFiles); const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return "back";
if (selected.length > 0) { if (selected.length > 0) {
await stageFiles(selected); await stageFiles(selected);
console.log( console.log(
@@ -380,7 +396,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
const diff = await getStagedDiff(); const diff = await getStagedDiff();
if (!diff) { if (!diff) {
console.log(" No staged changes to commit."); console.log(" No staged changes to commit.");
return; return "done";
} }
const MAX_DIFF_SIZE = 15000; const MAX_DIFF_SIZE = 15000;
@@ -418,7 +434,7 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
console.log(` ${GREEN}${message}${RESET}`); console.log(` ${GREEN}${message}${RESET}`);
await copyToClipboard(message); await copyToClipboard(message);
console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`); console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`);
return; return "done";
} }
const action = await confirmCommit(message); const action = await confirmCommit(message);
@@ -456,9 +472,11 @@ async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> {
: ` Aborted. Message: ${message}`, : ` Aborted. Message: ${message}`,
); );
} }
return "done";
} }
async function handlePR(draft: boolean): Promise<void> { async function handlePR(draft: boolean): Promise<"done" | "back"> {
const config = await loadConfig(); const config = await loadConfig();
if (!config.apiKey) { if (!config.apiKey) {
@@ -477,6 +495,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (!platform) { if (!platform) {
const hostname = (await getRemoteHostname()) || "unknown"; const hostname = (await getRemoteHostname()) || "unknown";
const chosen = await selectPlatform(hostname); const chosen = await selectPlatform(hostname);
if (chosen === BACK) return "back";
if (!chosen) { if (!chosen) {
console.log(" Aborted."); console.log(" Aborted.");
process.exit(0); process.exit(0);
@@ -484,21 +503,10 @@ async function handlePR(draft: boolean): Promise<void> {
platform = chosen; platform = chosen;
} }
const platformLabel = platform === "github" ? "GitHub" : "Gitea"; const platformLabel =
platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea";
console.log(` Using: ${CYAN}${platformLabel}${RESET}`); console.log(` Using: ${CYAN}${platformLabel}${RESET}`);
const cliError = checkCLI(platform);
if (cliError) {
console.error(` ${RED}Error: ${cliError}${RESET}`);
process.exit(1);
}
const authError = await checkAuth(platform);
if (authError) {
console.error(` ${RED}Error: ${authError}${RESET}`);
process.exit(1);
}
const baseBranch = await getDefaultBranch(); const baseBranch = await getDefaultBranch();
const branchName = await getBranchName(); const branchName = await getBranchName();
@@ -535,7 +543,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (answer.toLowerCase() === "n") { if (answer.toLowerCase() === "n") {
console.log(" Aborted."); console.log(" Aborted.");
return; return "done";
} }
console.log(` Pushing ${CYAN}${branchName}${RESET}...`); console.log(` Pushing ${CYAN}${branchName}${RESET}...`);
@@ -604,7 +612,7 @@ async function handlePR(draft: boolean): Promise<void> {
if (lower === "n") { if (lower === "n") {
console.log(" Aborted."); console.log(" Aborted.");
return; return "done";
} }
if (lower === "e") { if (lower === "e") {
@@ -612,7 +620,7 @@ async function handlePR(draft: boolean): Promise<void> {
const newBody = await ask(" Body (optional): "); const newBody = await ask(" Body (optional): ");
if (!newTitle.trim()) { if (!newTitle.trim()) {
console.log(" Aborted."); console.log(" Aborted.");
return; return "done";
} }
title = newTitle; title = newTitle;
body = newBody; body = newBody;
@@ -630,6 +638,8 @@ async function handlePR(draft: boolean): Promise<void> {
); );
process.exit(1); process.exit(1);
} }
return "done";
} }
async function main() { async function main() {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gai", "name": "gai",
"version": "0.1.0", "version": "0.1.2",
"description": "AI-powered git commit message generator", "description": "AI-powered git commit message generator",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
+29 -8
View File
@@ -2,11 +2,17 @@ import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
const UP = "\x1b[A"; const UP = "\x1b[A";
const DOWN = "\x1b[B"; const DOWN = "\x1b[B";
const LEFT = "\x1b[D";
const ALT_UP = "\x1bOA"; const ALT_UP = "\x1bOA";
const ALT_DOWN = "\x1bOB"; const ALT_DOWN = "\x1bOB";
const ALT_LEFT = "\x1bOD";
const SPACE = " "; const SPACE = " ";
const ENTER = "\r"; const ENTER = "\r";
const CTRL_C = "\x03"; const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-back");
export type PromptBack = typeof BACK;
export interface Choice<T> { export interface Choice<T> {
label: string; label: string;
@@ -19,6 +25,7 @@ interface BasePromptOptions {
title: string; title: string;
subtitle?: string; subtitle?: string;
cancelMessage?: string; cancelMessage?: string;
allowBack?: boolean;
} }
interface SinglePromptOptions<T> extends BasePromptOptions { interface SinglePromptOptions<T> extends BasePromptOptions {
@@ -55,11 +62,13 @@ function padLabel(label: string, width: number) {
return label + " ".repeat(Math.max(1, width - visibleLength(label))); return label + " ".repeat(Math.max(1, width - visibleLength(label)));
} }
function controls(mode: "single" | "multi") { function controls(mode: "single" | "multi", showBackHint = true) {
if (mode === "single") { if (mode === "single") {
return `${DIM}↑/↓ navigate · enter/space select · ctrl+c cancel${RESET}`; const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`;
} }
return `${DIM}↑/↓ navigate · space toggle · enter confirm · ctrl+c cancel${RESET}`; const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`;
} }
function renderPrompt(lines: string[], previousLines: number) { function renderPrompt(lines: string[], previousLines: number) {
@@ -89,6 +98,9 @@ function clearPrompt(lines: number) {
function normalizeKey(key: string, escapeBuf: string) { function normalizeKey(key: string, escapeBuf: string) {
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) {
return { action: "back", escapeBuf: "" };
}
if (key === SPACE) return { action: "space", escapeBuf: "" }; if (key === SPACE) return { action: "space", escapeBuf: "" };
if (key === ENTER) return { action: "enter", escapeBuf: "" }; if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
@@ -101,6 +113,9 @@ function normalizeKey(key: string, escapeBuf: string) {
const next = escapeBuf + key; const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) {
return { action: "back", escapeBuf: "" };
}
return { return {
action: null, action: null,
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
@@ -126,7 +141,7 @@ function createLines<T>(
]; ];
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`); if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`);
lines.push(` ${controls(mode)}`, ""); lines.push(` ${controls(mode, options.allowBack !== false)}`, "");
for (let i = 0; i < options.items.length; i++) { for (let i = 0; i < options.items.length; i++) {
const item = options.items[i]!; const item = options.items[i]!;
@@ -156,7 +171,7 @@ function ensureTTY(title: string) {
export async function selectOne<T>( export async function selectOne<T>(
options: SinglePromptOptions<T>, options: SinglePromptOptions<T>,
): Promise<T | null> { ): Promise<T | null | PromptBack> {
ensureTTY(options.title); ensureTTY(options.title);
let cursor = 0; let cursor = 0;
@@ -178,7 +193,7 @@ export async function selectOne<T>(
render(); render();
return new Promise((resolve) => { return new Promise((resolve) => {
const finish = (value: T | null) => { const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
@@ -195,6 +210,9 @@ export async function selectOne<T>(
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) { if (result.action === "up" && cursor > 0) {
cursor--; cursor--;
render(); render();
@@ -212,7 +230,7 @@ export async function selectOne<T>(
export async function selectMany<T>( export async function selectMany<T>(
options: MultiPromptOptions<T>, options: MultiPromptOptions<T>,
): Promise<T[] | null> { ): Promise<T[] | null | PromptBack> {
ensureTTY(options.title); ensureTTY(options.title);
const items: Choice<T | null>[] = options.selectAllLabel const items: Choice<T | null>[] = options.selectAllLabel
@@ -267,7 +285,7 @@ export async function selectMany<T>(
render(); render();
return new Promise((resolve) => { return new Promise((resolve) => {
const finish = (value: T[] | null) => { const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData); process.stdin.removeListener("data", onData);
@@ -284,6 +302,9 @@ export async function selectMany<T>(
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) {
return finish(BACK);
}
if (result.action === "up" && cursor > 0) { if (result.action === "up" && cursor > 0) {
cursor--; cursor--;
render(); render();
+44 -37
View File
@@ -1,4 +1,4 @@
export type Platform = "github" | "gitea"; export type Platform = "github" | "gitea" | "gitlab";
export async function getDefaultBranch(): Promise<string> { export async function getDefaultBranch(): Promise<string> {
try { try {
@@ -93,16 +93,23 @@ function parseRemoteHostname(url: string): string | null {
return hostname || null; return hostname || null;
} }
export function detectPlatformFromHostname(hostname: string | null): Platform | null {
if (!hostname) return null;
if (hostname === "github.com") return "github";
if (hostname === "gitlab.com" || hostname.includes("gitlab")) {
return "gitlab";
}
if (hostname.includes("gitea")) return "gitea";
return null;
}
export async function detectPlatform(): Promise<Platform | null> { export async function detectPlatform(): Promise<Platform | null> {
try { try {
const url = await Bun.$`git remote get-url origin`.quiet().text(); const url = await Bun.$`git remote get-url origin`.quiet().text();
const hostname = parseRemoteHostname(url); const hostname = parseRemoteHostname(url);
if (!hostname) return null; return detectPlatformFromHostname(hostname);
if (hostname === "github.com") return "github";
if (hostname.includes("gitea")) return "gitea";
return null;
} catch { } catch {
return null; return null;
} }
@@ -117,37 +124,6 @@ export async function getRemoteHostname(): Promise<string | null> {
} }
} }
export function checkCLI(platform: Platform): string | null {
const bin = platform === "github" ? "gh" : "tea";
const path = Bun.which(bin);
if (!path) {
if (platform === "github") {
return "GitHub CLI (gh) not found. Install: brew install gh";
}
return "Gitea CLI (tea) not found. Install from: https://gitea.com/gitea/tea";
}
return null;
}
export async function checkAuth(platform: Platform): Promise<string | null> {
if (platform === "github") {
try {
await Bun.$`gh auth status`.quiet();
return null;
} catch {
return "Not authenticated with GitHub CLI. Run: gh auth login";
}
}
try {
const result = await Bun.$`tea logins list`.quiet().text();
if (result.trim()) return null;
return "Not authenticated with Gitea CLI. Run: tea login add";
} catch {
return "Not authenticated with Gitea CLI. Run: tea login add";
}
}
export async function createPR( export async function createPR(
platform: Platform, platform: Platform,
title: string, title: string,
@@ -186,6 +162,37 @@ export async function createPR(
return match?.[1] ?? stdout.trim(); return match?.[1] ?? stdout.trim();
} }
if (platform === "gitlab") {
const args = [
"mr",
"create",
"--title",
title,
"--description",
body,
"--target-branch",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["glab", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `glab mr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
const args = [ const args = [
"pulls", "pulls",
"create", "create",
+4 -2
View File
@@ -1,11 +1,12 @@
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { selectMany } from "./menu"; import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu";
export async function selectFiles( export async function selectFiles(
stagedFiles: FileEntry[], stagedFiles: FileEntry[],
unstagedFiles: FileEntry[], unstagedFiles: FileEntry[],
): Promise<string[]> { ): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return []; if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) { if (stagedFiles.length > 0) {
@@ -30,6 +31,7 @@ export async function selectFiles(
}); });
if (selected === null) process.exit(1); if (selected === null) process.exit(1);
if (selected === BACK) return BACK;
if (selected.length > 0) { if (selected.length > 0) {
process.stdout.write( process.stdout.write(
+19
View File
@@ -0,0 +1,19 @@
import { test, expect, describe } from "bun:test";
import { detectPlatformFromHostname } from "../src/pr";
describe("pr platform detection", () => {
test("detects supported hosted platforms", () => {
expect(detectPlatformFromHostname("github.com")).toBe("github");
expect(detectPlatformFromHostname("gitlab.com")).toBe("gitlab");
});
test("detects self-hosted platform hostnames", () => {
expect(detectPlatformFromHostname("gitlab.example.com")).toBe("gitlab");
expect(detectPlatformFromHostname("gitea.example.com")).toBe("gitea");
});
test("returns null for unknown hostnames", () => {
expect(detectPlatformFromHostname("git.example.com")).toBeNull();
expect(detectPlatformFromHostname(null)).toBeNull();
});
});