diff --git a/.gitignore b/.gitignore
index 14a18f6..a87fe11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.env.production.local
.env.local
+# npm config (may contain auth tokens for private registries)
+.npmrc
+
# caches
.eslintcache
.cache
diff --git a/README.md b/README.md
index 61b1205..a107b77 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,48 @@ Generate **Conventional Commits** messages, pull request descriptions, code revi
---
+## Installation
+
+`gai` runs on the [Bun](https://bun.sh) runtime. Install Bun first if you don't have it:
+
+```bash
+curl -fsSL https://bun.sh/install | bash
+```
+
+Then install `gai` globally from npm:
+
+```bash
+# npm
+npm install -g @mplan/gai
+
+# Bun
+bun add -g @mplan/gai
+```
+
+Or run it once without installing:
+
+```bash
+bunx @mplan/gai
+# or
+npx @mplan/gai
+```
+
+After installing, the `gai` command is available everywhere. Run `gai config` to set your API key, then `gai` to open the interactive menu.
+
+
+From source (development)
+
+```bash
+git clone https://git.catpl.top/Mplan/gai.git
+cd gai
+bun install
+bun link # makes `gai` point to your local checkout
+```
+
+
+
+---
+
## Features
- **🤖 AI Commit Messages** — generate Conventional Commits from staged diffs with project context
@@ -121,7 +163,7 @@ git diff | gai suggest branch
Run `gai` without arguments to open the mole-style interactive menu:
```
- gai v0.1.3
+ gai v0.1.4
AI-powered git helper for commits, PRs, reviews, and changelogs
────────────────────────────────────────────────────────────────────────
diff --git a/index.ts b/index.ts
index b8169f0..5929b79 100644
--- a/index.ts
+++ b/index.ts
@@ -1,7 +1,6 @@
#!/usr/bin/env bun
// gai — AI-powered git commit and PR helper
-// v0.1.3
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
import { handleCommit } from "./src/commands/commit";
@@ -11,8 +10,7 @@ import { handleExplain } from "./src/commands/explain";
import { handleReview } from "./src/commands/review";
import { handleChangelog } from "./src/commands/changelog";
import { handleSuggest } from "./src/commands/suggest";
-import { setColorEnabled } from "./src/terminal";
-import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
+import { setColorEnabled, BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, clearScreen, visibleLength, padRight } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty";
import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu";
@@ -37,11 +35,6 @@ const MENU_ITEMS: MenuItem[] = [
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" },
];
-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) => {
@@ -52,14 +45,6 @@ async function readKey(): Promise {
});
}
-function visibleLen(s: string): number {
- return s.replace(/\x1b\[[0-9;]*m/g, "").length;
-}
-
-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
@@ -84,7 +69,7 @@ function renderMenu(cursor: number): number {
write("");
const keyWidth = 3;
- const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
+ const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLength(m.label))) + 2;
let currentGroup: MenuItem["group"] | null = null;
for (let i = 0; i < MENU_ITEMS.length; i++) {
@@ -165,14 +150,14 @@ async function waitForEnter(): Promise {
// Resume stdin in case it was paused
process.stdin.resume();
});
- process.stdout.write("\x1b[2J\x1b[H"); // clear screen
+ clearScreen();
}
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
- process.stdout.write("\x1b[2J\x1b[H"); // clear screen
+ clearScreen();
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
@@ -197,6 +182,13 @@ async function showMenu(): Promise {
// Initial render
renderMenu(cursor);
+ const exitMenu = (exitCode: number): number => {
+ showCursor();
+ process.stdin.setRawMode(wasRaw === true);
+ process.stdin.pause();
+ return exitCode;
+ };
+
try {
while (true) {
const raw = await readKey();
@@ -214,7 +206,7 @@ async function showMenu(): Promise {
// Enter
if (raw === "\r" || raw === "\n") {
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
- if (result !== 0) return result;
+ if (result !== 0) return exitMenu(result);
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
@@ -224,11 +216,8 @@ async function showMenu(): Promise {
// Ctrl+C
if (raw === "\x03") {
- showCursor();
- process.stdin.setRawMode(wasRaw === true);
- process.stdin.pause();
process.stdout.write("\n");
- return 0;
+ return exitMenu(0);
}
// Number hotkeys (1-8)
@@ -238,7 +227,7 @@ async function showMenu(): Promise {
cursor = idx;
renderMenu(cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
- if (result !== 0) return result;
+ if (result !== 0) return exitMenu(result);
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
@@ -250,27 +239,18 @@ async function showMenu(): Promise {
// 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");
+ clearScreen();
console.log(formatHelp(commands));
- return 0;
+ return exitMenu(0);
}
if (lower === "v") {
- showCursor();
- process.stdin.setRawMode(wasRaw === true);
- process.stdin.pause();
- process.stdout.write("\x1b[2J\x1b[H");
+ clearScreen();
console.log(`gai v${VERSION}`);
- return 0;
+ return exitMenu(0);
}
if (lower === "q") {
- showCursor();
- process.stdin.setRawMode(wasRaw === true);
- process.stdin.pause();
process.stdout.write("\n");
- return 0;
+ return exitMenu(0);
}
}
} finally {
@@ -428,10 +408,8 @@ const commands = registerCommands(
},
);
-// Keep the defs accessible for help command
-const allCommandDefs = [...commands.values()].filter(
- (c, i, arr) => arr.findIndex((x) => x.name === c.name) === i,
-);
+// Keep canonical command defs accessible for help command (deduplicate by reference)
+const allCommandDefs = [...new Set(commands.values())];
// ── Main ───────────────────────────────────────────────────────────────
diff --git a/package.json b/package.json
index 849414d..8e968a2 100644
--- a/package.json
+++ b/package.json
@@ -1,21 +1,51 @@
{
- "name": "gai",
- "version": "0.1.3",
+ "name": "@mplan/gai",
+ "version": "0.1.4",
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
- "module": "index.ts",
+ "license": "MIT",
"type": "module",
"bin": {
"gai": "./index.ts"
},
+ "files": [
+ "index.ts",
+ "src",
+ "README.md",
+ "LICENSE"
+ ],
+ "engines": {
+ "bun": ">=1"
+ },
"scripts": {
"gai": "bun run index.ts",
- "build": "bun build --compile index.ts --outfile gai"
+ "build": "bun build --compile index.ts --outfile gai",
+ "prepublishOnly": "bun test"
+ },
+ "keywords": [
+ "git",
+ "ai",
+ "cli",
+ "commit",
+ "pull-request",
+ "code-review",
+ "changelog",
+ "bun",
+ "deepseek"
+ ],
+ "author": "Mplan",
+ "repository": {
+ "type": "git",
+ "url": "https://git.catpl.top/Mplan/gai.git"
+ },
+ "homepage": "https://git.catpl.top/Mplan/gai",
+ "bugs": {
+ "url": "https://git.catpl.top/Mplan/gai/issues"
+ },
+ "publishConfig": {
+ "registry": "https://git.catpl.top/api/packages/mplan/npm/"
},
"private": false,
"devDependencies": {
"@types/bun": "latest"
- },
- "peerDependencies": {
- "typescript": "^5"
}
}
diff --git a/src/ai.ts b/src/ai.ts
index 744da83..ea11ca9 100644
--- a/src/ai.ts
+++ b/src/ai.ts
@@ -123,7 +123,6 @@ async function readStream(body: ReadableStream, callbacks: StreamCal
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
- // Keep the last potentially incomplete line
buffer = lines.pop() ?? "";
for (const line of lines) {
@@ -136,7 +135,12 @@ async function readStream(body: ReadableStream, callbacks: StreamCal
try {
const parsed = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>;
+ error?: { message?: string };
};
+ if (parsed.error) {
+ callbacks.onError?.(new Error(`Stream error: ${parsed.error.message ?? "unknown"}`));
+ continue;
+ }
const token = parsed.choices?.[0]?.delta?.content;
if (token) {
fullText += token;
@@ -152,7 +156,8 @@ async function readStream(body: ReadableStream, callbacks: StreamCal
}
}
} finally {
- reader.releaseLock();
+ try { await reader.cancel(); } catch {}
+ // releaseLock is not needed after cancel
}
callbacks.onDone?.(fullText);
diff --git a/src/brand.ts b/src/brand.ts
index 1aa4454..9c76c99 100644
--- a/src/brand.ts
+++ b/src/brand.ts
@@ -2,7 +2,7 @@
import { GREEN, CYAN, RESET } from "./terminal";
-export const VERSION = "0.1.3";
+export const VERSION = "0.1.4";
export function showBanner(): string {
const G = GREEN();
diff --git a/src/cli.ts b/src/cli.ts
index 1c10a3a..8aff37b 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,6 +1,8 @@
// Lightweight CLI argument parser aligned with mainstream CLI conventions.
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
+import { VERSION } from "./brand";
+
export interface FlagDef {
long: string; // e.g. "dry-run"
short?: string; // e.g. "d"
@@ -44,19 +46,6 @@ function buildFlagIndex(flags: FlagDef[]): Map {
return index;
}
-function resolveFlagName(raw: string): { flag: FlagDef; value?: string } | null {
- // "--key=value"
- const eqIndex = raw.indexOf("=");
- if (eqIndex !== -1) {
- const name = raw.slice(0, eqIndex);
- const value = raw.slice(eqIndex + 1);
- const allFlags = buildFlagIndex([...GLOBAL_FLAGS]); // we'll rebuild in context
- // We'll handle = syntax in the main parse loop with proper index
- return null; // handled inline
- }
- return null; // handled inline
-}
-
function parseArgs(
rawArgs: string[],
commands: Map,
@@ -282,7 +271,7 @@ export async function runCLI(rawArgs: string[], commands: Map {
- const commands: string[][] = [];
+ const commands: string[][] = [];
- if (process.platform === "darwin") {
- commands.push(["pbcopy"]);
- } else if (process.platform === "linux") {
- commands.push(["xclip", "-selection", "clipboard"]);
- commands.push(["xsel", "--clipboard", "--input"]);
- }
+ if (process.platform === "darwin") {
+ commands.push(["pbcopy"]);
+ } else if (process.platform === "linux") {
+ commands.push(["xclip", "-selection", "clipboard"]);
+ commands.push(["xsel", "--clipboard", "--input"]);
+ }
- for (const cmd of commands) {
- try {
- const proc = Bun.spawn(cmd, {
- stdin: "pipe",
- stdout: "ignore",
- stderr: "ignore",
- });
- proc.stdin.write(text);
- proc.stdin.end();
- const exitCode = await proc.exited;
- if (exitCode === 0) return true;
- } catch {}
- }
+ for (const cmd of commands) {
+ try {
+ const proc = Bun.spawn(cmd, {
+ stdin: "pipe",
+ stdout: "ignore",
+ stderr: "ignore",
+ });
+ proc.stdin.write(text);
+ proc.stdin.end();
+ const exitCode = await proc.exited;
+ if (exitCode === 0) return true;
+ } catch {
+ // Try next command
+ }
+ }
- return false;
+ return false;
}
diff --git a/src/commands/commit.ts b/src/commands/commit.ts
index d8ff3e6..9cc0575 100644
--- a/src/commands/commit.ts
+++ b/src/commands/commit.ts
@@ -1,4 +1,3 @@
-import * as readline from "node:readline";
import {
isGitRepo,
getRepoRoot,
@@ -18,20 +17,11 @@ import { generateCommitMessage } from "../ai";
import { copyToClipboard } from "../clipboard";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
+import { ask, editLine } from "../tty-input";
import type { Config, CommitResult, StreamCallbacks } from "../types";
import { loadConfig } from "../config";
import type { ParsedArgs } from "../cli";
-function ask(question: string): Promise {
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
- return new Promise((resolve) => {
- rl.question(question, (answer) => {
- rl.close();
- resolve(answer.trim());
- });
- });
-}
-
function printCommitResult(result: CommitResult, msg: string) {
console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`);
const id = result.branch && result.hash
@@ -75,99 +65,8 @@ async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
}
async function editMessage(current: string): Promise {
- if (!isStdinTTY()) return null;
process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`);
-
- const savedRaw = process.stdin.isRaw;
- process.stdin.setRawMode(true);
- process.stdin.resume();
-
- let buffer = current;
- let cursor = current.length;
-
- function render() {
- process.stdout.write("\x1b[2K\r > " + buffer);
- if (cursor < buffer.length) {
- process.stdout.write(`\x1b[${buffer.length - cursor}D`);
- }
- }
-
- process.stdout.write(" > ");
- process.stdout.write(buffer);
-
- return new Promise((resolve) => {
- let escapeBuf = "";
-
- process.stdin.on("data", (data: Buffer) => {
- const key = data.toString();
-
- if (key === "\x03") {
- process.stdin.setRawMode(savedRaw === true);
- process.stdin.pause();
- process.stdin.removeAllListeners("data");
- process.stdout.write("\n");
- resolve(null);
- return;
- }
-
- if (key === "\x1b" || key.startsWith("\x1b[")) {
- escapeBuf = key;
- if (key.length >= 3) { handleSeq(key); escapeBuf = ""; }
- return;
- }
-
- if (escapeBuf) {
- escapeBuf += key;
- if (/^[A-Za-z~]$/.test(key)) { handleSeq(escapeBuf); escapeBuf = ""; }
- else if (escapeBuf.length > 8) escapeBuf = "";
- return;
- }
-
- if (key === "\r") {
- process.stdin.setRawMode(savedRaw === true);
- process.stdin.pause();
- process.stdin.removeAllListeners("data");
- process.stdout.write("\n");
- const result = buffer.trim();
- resolve(result || null);
- return;
- }
-
- if (key === "\x7f") {
- if (cursor > 0) {
- buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
- cursor--;
- render();
- }
- return;
- }
-
- if (key === "\x01") { if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } return; }
- if (key === "\x05") { if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } return; }
- if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; }
- if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; }
-
- if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
- buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
- cursor++;
- render();
- }
- });
-
- function handleSeq(seq: string) {
- if (seq === "\x1b[D" || seq === "\x1bOD") {
- if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); }
- } else if (seq === "\x1b[C" || seq === "\x1bOC") {
- if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); }
- } else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") {
- if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
- } else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") {
- if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
- } else if (seq === "\x1b[3~") {
- if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); }
- }
- }
- });
+ return editLine(current);
}
export async function handleCommit(args: ParsedArgs): Promise {
diff --git a/src/commands/config.ts b/src/commands/config.ts
index b692fab..91d24ff 100644
--- a/src/commands/config.ts
+++ b/src/commands/config.ts
@@ -1,7 +1,8 @@
import { loadConfig, saveConfig } from "../config";
-import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
+import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength } from "../terminal";
import { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu";
+import { editLine } from "../tty-input";
import type { Config } from "../types";
import type { ParsedArgs } from "../cli";
@@ -66,18 +67,6 @@ const CONFIG_FIELDS: ConfigField[] = [
},
];
-function visibleLength(value: string) {
- return value.replace(/\x1b\[[0-9;]*m/g, "").length;
-}
-
-function clearLine() {
- process.stdout.write("\r\x1b[2K");
-}
-
-function moveUp(lines: number) {
- if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
-}
-
function renderConfigPage(
config: Config,
cursor: number,
@@ -151,29 +140,29 @@ async function interactiveConfig(): Promise<"done" | "back"> {
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
- process.stdout.write("\x1b[?25l");
+ hideCursor();
const render = () => {
moveUp(renderedCursorRow);
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
renderedCursorRow = editState ? 4 + cursor : 0;
- process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l");
+ editState ? showCursor() : hideCursor();
};
render();
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
const finish = (value: "done" | "back") => {
+ process.stdin.removeListener("data", onData);
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
- process.stdin.removeListener("data", onData);
moveUp(renderedCursorRow);
for (let i = 0; i < renderedLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(renderedLines);
- process.stdout.write("\x1b[?25h");
+ showCursor();
resolve(value);
};
diff --git a/src/commands/explain.ts b/src/commands/explain.ts
index 15eb9ac..88ac103 100644
--- a/src/commands/explain.ts
+++ b/src/commands/explain.ts
@@ -1,15 +1,6 @@
import { loadConfig } from "../config";
-import {
- isGitRepo,
- getStagedFiles,
- getStagedDiff,
- getUnstagedFiles,
- getRepoRoot,
- applyFileSelection,
-} from "../git";
-import { selectFiles } from "../selector";
+import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
-import { collectProjectContext } from "../context";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal";
@@ -18,112 +9,66 @@ import type { StreamCallbacks } from "../types";
import type { ParsedArgs } from "../cli";
export async function handleExplain(args: ParsedArgs): Promise {
- const config = await loadConfig();
+ const config = await loadConfig();
- if (!config.apiKey) {
- console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
- return 1;
- }
+ if (!config.apiKey) {
+ console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
+ return 1;
+ }
- const unstaged = args.flags["unstaged"] as boolean;
- const verbose = args.flags["verbose"] as boolean;
+ const unstaged = args.flags["unstaged"] as boolean;
+ const verbose = args.flags["verbose"] as boolean;
- // Determine which diff to explain
- let diff: string;
- let sourceLabel: string;
+ let diff: string;
+ let sourceLabel: string;
+ let contextPrefix: string;
- if (unstaged) {
- if (!(await isGitRepo())) {
- console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
- return 1;
- }
- try {
- diff = (await Bun.$`git diff`.quiet().text()).trim();
- } catch {
- diff = "";
- }
- sourceLabel = "unstaged changes";
- } else {
- // Default: staged changes (or piped)
- if (isStdinTTY()) {
- if (!(await isGitRepo())) {
- console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
- return 1;
- }
- const stagedFiles = await getStagedFiles();
- const unstagedFiles = await getUnstagedFiles();
- sourceLabel = "selected changes";
+ try {
+ const result = await collectDiff({ unstaged, includeProjectContext: true });
+ if (result.back) return SKIP_WAIT as unknown as number;
+ diff = result.diff;
+ sourceLabel = result.sourceLabel;
+ contextPrefix = result.contextPrefix;
+ } catch (err) {
+ console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
+ return 1;
+ }
- 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[] = [];
- for await (const chunk of process.stdin) {
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
- }
- diff = Buffer.concat(chunks).toString("utf-8").trim();
- sourceLabel = "piped input";
- }
- }
+ if (!diff) {
+ console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
+ return 0;
+ }
- if (!diff) {
- console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
- return 0;
- }
+ if (verbose) {
+ console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
+ }
- if (args.flags["verbose"]) {
- console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
- }
+ const userPrompt = contextPrefix + buildExplainPrompt(diff);
- const MAX_DIFF_SIZE = 15000;
- const truncatedDiff = diff.length > MAX_DIFF_SIZE
- ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
- : diff;
+ if (verbose) {
+ console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
+ }
- // Collect project context for better explanations
- let contextPrefix = "";
- try {
- if (await isGitRepo()) {
- const repoRoot = await getRepoRoot();
- const ctx = await collectProjectContext(repoRoot);
- if (ctx.packageDescription) {
- contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
- }
- }
- } catch {}
+ const tty = isStdinTTY();
+ if (tty) {
+ console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
+ }
- const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
+ try {
+ const callbacks: StreamCallbacks | undefined = tty ? {
+ onToken: (token) => process.stdout.write(token),
+ } : undefined;
- if (verbose) {
- console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
- }
+ const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
+ if (callbacks) {
+ process.stdout.write("\n");
+ } else {
+ process.stdout.write(explanation + "\n");
+ }
+ } catch (err) {
+ console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
+ return 1;
+ }
- const tty = isStdinTTY();
- if (tty) {
- console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`);
- }
-
- try {
- const callbacks: StreamCallbacks | undefined = tty ? {
- onToken: (token) => process.stdout.write(token),
- } : undefined;
-
- const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks);
- if (callbacks) {
- process.stdout.write("\n");
- } else {
- // Non-TTY: print the result directly
- process.stdout.write(explanation + "\n");
- }
- } catch (err) {
- console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
- return 1;
- }
-
- return 0;
+ return 0;
}
diff --git a/src/commands/pr.ts b/src/commands/pr.ts
index 304ac83..af17075 100644
--- a/src/commands/pr.ts
+++ b/src/commands/pr.ts
@@ -1,4 +1,3 @@
-import * as readline from "node:readline";
import { loadConfig } from "../config";
import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context";
@@ -19,19 +18,10 @@ import {
import type { Platform } from "../pr";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
+import { ask } from "../tty-input";
import { copyToClipboard } from "../clipboard";
import type { ParsedArgs } from "../cli";
-function ask(question: string): Promise {
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
- return new Promise((resolve) => {
- rl.question(question, (answer) => {
- rl.close();
- resolve(answer.trim());
- });
- });
-}
-
async function selectPlatform(hostname: string): Promise {
if (!isStdinTTY()) {
console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`);
diff --git a/src/commands/review.ts b/src/commands/review.ts
index 754459e..aa8a02a 100644
--- a/src/commands/review.ts
+++ b/src/commands/review.ts
@@ -1,15 +1,6 @@
import { loadConfig } from "../config";
-import {
- isGitRepo,
- getStagedFiles,
- getStagedDiff,
- getUnstagedFiles,
- getRepoRoot,
- applyFileSelection,
-} from "../git";
-import { selectFiles } from "../selector";
+import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
-import { collectProjectContext } from "../context";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
@@ -18,113 +9,74 @@ import type { StreamCallbacks } from "../types";
import type { ParsedArgs } from "../cli";
export async function handleReview(args: ParsedArgs): Promise {
- const config = await loadConfig();
+ const config = await loadConfig();
- if (!config.apiKey) {
- console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
- return 1;
- }
+ if (!config.apiKey) {
+ console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
+ return 1;
+ }
- const strictnessFlag = args.flags["strict"] as boolean
- ? "strict"
- : args.flags["lenient"] as boolean
- ? "lenient"
- : "normal";
+ const strictnessFlag = args.flags["strict"] as boolean
+ ? "strict"
+ : args.flags["lenient"] as boolean
+ ? "lenient"
+ : "normal";
- const unstaged = args.flags["unstaged"] as boolean;
- const verbose = args.flags["verbose"] as boolean;
+ const unstaged = args.flags["unstaged"] as boolean;
+ const verbose = args.flags["verbose"] as boolean;
- let diff: string;
- let sourceLabel: string;
+ let diff: string;
+ let sourceLabel: string;
+ let contextPrefix: string;
- if (unstaged) {
- if (!(await isGitRepo())) {
- console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
- return 1;
- }
- try {
- diff = (await Bun.$`git diff`.quiet().text()).trim();
- } catch {
- diff = "";
- }
- sourceLabel = "unstaged changes";
- } else if (!isStdinTTY()) {
- const chunks: Buffer[] = [];
- for await (const chunk of process.stdin) {
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
- }
- diff = Buffer.concat(chunks).toString("utf-8").trim();
- sourceLabel = "piped input";
- } else {
- if (!(await isGitRepo())) {
- console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
- return 1;
- }
- const stagedFiles = await getStagedFiles();
- const unstagedFiles = await getUnstagedFiles();
- sourceLabel = "selected changes";
+ try {
+ const result = await collectDiff({ unstaged, includeProjectContext: true });
+ if (result.back) return SKIP_WAIT as unknown as number;
+ diff = result.diff;
+ sourceLabel = result.sourceLabel;
+ contextPrefix = result.contextPrefix;
+ } catch (err) {
+ console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
+ return 1;
+ }
- 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()}`);
+ return 0;
+ }
- if (!diff) {
- console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
- return 0;
- }
+ const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
- const MAX_DIFF_SIZE = 15000;
- const truncatedDiff = diff.length > MAX_DIFF_SIZE
- ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
- : diff;
+ const strictnessLabel = strictnessFlag === "strict"
+ ? `${RED()}strict${RESET()}`
+ : strictnessFlag === "lenient"
+ ? `${GREEN()}lenient${RESET()}`
+ : `${YELLOW()}normal${RESET()}`;
- let contextPrefix = "";
- try {
- if (await isGitRepo()) {
- const repoRoot = await getRepoRoot();
- const ctx = await collectProjectContext(repoRoot);
- if (ctx.packageDescription) {
- contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
- }
- }
- } catch {}
+ if (verbose) {
+ console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
+ }
- const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag);
+ const tty = isStdinTTY();
+ if (tty) {
+ console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
+ }
- const strictnessLabel = strictnessFlag === "strict"
- ? `${RED()}strict${RESET()}`
- : strictnessFlag === "lenient"
- ? `${GREEN()}lenient${RESET()}`
- : `${YELLOW()}normal${RESET()}`;
+ try {
+ const callbacks: StreamCallbacks | undefined = tty ? {
+ onToken: (token) => process.stdout.write(token),
+ } : undefined;
- if (verbose) {
- console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`);
- }
+ const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
+ if (callbacks) {
+ process.stdout.write("\n");
+ } else {
+ process.stdout.write(result + "\n");
+ }
+ } catch (err) {
+ console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
+ return 1;
+ }
- const tty = isStdinTTY();
- if (tty) {
- console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`);
- }
-
- try {
- const callbacks: StreamCallbacks | undefined = tty ? {
- onToken: (token) => process.stdout.write(token),
- } : undefined;
-
- const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks);
- if (callbacks) {
- process.stdout.write("\n");
- } else {
- process.stdout.write(result + "\n");
- }
- } catch (err) {
- console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
- return 1;
- }
-
- return 0;
+ return 0;
}
diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts
index 676051c..35059bc 100644
--- a/src/commands/suggest.ts
+++ b/src/commands/suggest.ts
@@ -1,17 +1,10 @@
import { loadConfig } from "../config";
-import {
- isGitRepo,
- getStagedFiles,
- getStagedDiff,
- getUnstagedFiles,
- applyFileSelection,
-} from "../git";
-import { selectFiles } from "../selector";
+import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
import {
- SUGGEST_SYSTEM_PROMPT,
- buildSuggestBranchPrompt,
- buildSuggestTypePrompt,
+ SUGGEST_SYSTEM_PROMPT,
+ buildSuggestBranchPrompt,
+ buildSuggestTypePrompt,
} from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
@@ -20,127 +13,101 @@ import type { Config } from "../types";
import type { ParsedArgs } from "../cli";
export async function handleSuggest(args: ParsedArgs): Promise {
- const config = await loadConfig();
+ const config = await loadConfig();
- if (!config.apiKey) {
- console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
- return 1;
- }
+ if (!config.apiKey) {
+ console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`);
+ return 1;
+ }
- const mode = args.positional[0] || "branch";
- const verbose = args.flags["verbose"] as boolean;
+ const mode = args.positional[0] || "branch";
+ const verbose = args.flags["verbose"] as boolean;
- if (mode !== "branch" && mode !== "type") {
- console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
- console.error(` Try: gai suggest branch | gai suggest type\n`);
- return 1;
- }
+ if (mode !== "branch" && mode !== "type") {
+ console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`);
+ console.error(` Try: gai suggest branch | gai suggest type\n`);
+ return 1;
+ }
- // Get diff (staged, or unstaged if --unstaged, or piped)
- let diff: string;
- if (!isStdinTTY()) {
- const chunks: Buffer[] = [];
- for await (const chunk of process.stdin) {
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
- }
- diff = Buffer.concat(chunks).toString("utf-8").trim();
- } else {
- if (!(await isGitRepo())) {
- console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
- return 1;
- }
+ const unstaged = args.flags["unstaged"] as boolean;
- if (args.flags["unstaged"] as boolean) {
- try {
- diff = (await Bun.$`git diff`.quiet().text()).trim();
- } catch {
- diff = "";
- }
- } else {
- const stagedFiles = await getStagedFiles();
- const unstagedFiles = await getUnstagedFiles();
+ let diff: string;
- 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();
- }
- }
+ try {
+ const result = await collectDiff({ unstaged, includeProjectContext: false });
+ if (result.back) return SKIP_WAIT as unknown as number;
+ diff = result.diff;
+ } catch (err) {
+ console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
+ return 1;
+ }
- if (!diff) {
- console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
- return 0;
- }
+ if (!diff) {
+ console.log(` ${DIM()}No changes to suggest from.${RESET()}`);
+ return 0;
+ }
- const MAX_DIFF_SIZE = 15000;
- const truncatedDiff = diff.length > MAX_DIFF_SIZE
- ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
- : diff;
+ if (verbose) {
+ console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
+ }
- if (verbose) {
- console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
- }
-
- if (mode === "branch") {
- return handleSuggestBranch(config, truncatedDiff);
- } else {
- return handleSuggestType(config, truncatedDiff);
- }
+ if (mode === "branch") {
+ return handleSuggestBranch(config, diff);
+ }
+ return handleSuggestType(config, diff);
}
async function handleSuggestBranch(config: Config, diff: string): Promise {
- const tty = isStdinTTY();
- if (tty) {
- console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
- }
+ const tty = isStdinTTY();
+ if (tty) {
+ console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`);
+ }
- try {
- const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
- const suggestions = raw
- .split("\n")
- .map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
- .filter(Boolean);
+ try {
+ const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff));
+ const suggestions = raw
+ .split("\n")
+ .map((line) => line.replace(/^[\d.\s\-*]+/, "").trim())
+ .filter(Boolean);
- if (suggestions.length === 0) {
- console.log(` ${DIM()}No suggestions generated.${RESET()}`);
- return 0;
- }
+ if (suggestions.length === 0) {
+ console.log(` ${DIM()}No suggestions generated.${RESET()}`);
+ return 0;
+ }
- for (const s of suggestions) {
- console.log(` ${GREEN()}${s}${RESET()}`);
- }
- console.log("");
- } catch (err) {
- console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
- return 1;
- }
+ for (const s of suggestions) {
+ console.log(` ${GREEN()}${s}${RESET()}`);
+ }
+ console.log("");
+ } catch (err) {
+ console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
+ return 1;
+ }
- return 0;
+ return 0;
}
async function handleSuggestType(config: Config, diff: string): Promise {
- const tty = isStdinTTY();
- if (tty) {
- console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
- }
+ const tty = isStdinTTY();
+ if (tty) {
+ console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`);
+ }
- try {
- const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
- const type = raw.trim().toLowerCase();
- const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
+ try {
+ const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff));
+ const type = raw.trim().toLowerCase();
+ const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"];
- if (validTypes.includes(type)) {
- console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
- } else {
- console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
- console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
- }
- } catch (err) {
- console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
- return 1;
- }
+ if (validTypes.includes(type)) {
+ console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`);
+ } else {
+ console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`);
+ console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`);
+ }
+ } catch (err) {
+ console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`);
+ return 1;
+ }
- return 0;
+ return 0;
}
diff --git a/src/diff-source.ts b/src/diff-source.ts
new file mode 100644
index 0000000..b64dfd8
--- /dev/null
+++ b/src/diff-source.ts
@@ -0,0 +1,98 @@
+// Shared diff-source helper used by explain, review, suggest, and commit commands.
+// Handles the common pattern of: detecting where to get a diff from
+// (staged, unstaged, or piped stdin), optionally presenting an interactive
+// file selector, applying truncation, and collecting project context.
+
+import { isGitRepo, getStagedFiles, getStagedDiff, getUnstagedFiles, getRepoRoot, applyFileSelection } from "./git";
+import { selectFiles } from "./selector";
+import { BACK } from "./menu";
+import { collectProjectContext } from "./context";
+import { isStdinTTY } from "./tty";
+
+export interface DiffSourceResult {
+ diff: string;
+ sourceLabel: string;
+ contextPrefix: string;
+ back: boolean;
+}
+
+const MAX_DIFF_SIZE = 15000;
+
+/**
+ * Collect a diff from the appropriate source based on flags and TTY state.
+ *
+ * - If `unstaged` is true: uses `git diff` (unstaged changes)
+ * - If TTY (interactive): shows file selector for staged/unstaged, then uses staged diff
+ * - If piped (non-TTY): reads diff from stdin
+ *
+ * Returns the diff, a human-readable source label, project context prefix,
+ * and whether the user pressed back.
+ */
+export async function collectDiff(opts: {
+ unstaged?: boolean;
+ includeProjectContext?: boolean;
+} = {}): Promise {
+ const { unstaged = false, includeProjectContext = true } = opts;
+
+ let diff: string;
+ let sourceLabel: string;
+ let contextPrefix = "";
+
+ if (unstaged) {
+ if (!(await isGitRepo())) {
+ throw new Error("Not a git repository.");
+ }
+ try {
+ diff = (await Bun.$`git diff`.quiet().text()).trim();
+ } catch {
+ diff = "";
+ }
+ sourceLabel = "unstaged changes";
+ } else if (isStdinTTY()) {
+ if (!(await isGitRepo())) {
+ throw new Error("Not a git repository.");
+ }
+ const stagedFiles = await getStagedFiles();
+ const unstagedFiles = await getUnstagedFiles();
+ sourceLabel = "selected changes";
+
+ if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
+ const selected = await selectFiles(stagedFiles, unstagedFiles);
+ if (selected === BACK) {
+ return { diff: "", sourceLabel, contextPrefix, back: true };
+ }
+ await applyFileSelection(stagedFiles, unstagedFiles, selected);
+ }
+ diff = await getStagedDiff();
+ } else {
+ // Piped input (non-TTY)
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) {
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
+ }
+ diff = Buffer.concat(chunks).toString("utf-8").trim();
+ sourceLabel = "piped input";
+ }
+
+ // Truncate large diffs
+ if (diff.length > MAX_DIFF_SIZE) {
+ diff = diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)";
+ }
+
+ // Collect project context for better AI responses
+ if (includeProjectContext && diff) {
+ try {
+ if (await isGitRepo()) {
+ const repoRoot = await getRepoRoot();
+ const ctx = await collectProjectContext(repoRoot);
+ if (ctx.packageDescription) {
+ contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
+ }
+ }
+ } catch {
+ // Context collection is best-effort
+ }
+ }
+
+ return { diff, sourceLabel, contextPrefix, back: false };
+}
diff --git a/src/git.ts b/src/git.ts
index 58cf11f..81a5433 100644
--- a/src/git.ts
+++ b/src/git.ts
@@ -37,12 +37,17 @@ function parseNameStatus(output: string): FileEntry[] {
return output
.trim()
.split("\n")
- .filter(Boolean)
+ .filter((line) => line.trim())
.map((line) => {
- const [status, ...pathParts] = line.split("\t");
- const path = pathParts[pathParts.length - 1] ?? "";
- return { path, status: status!, label: statusToLabel(status!) };
- });
+ const tabIdx = line.indexOf("\t");
+ if (tabIdx === -1) return null;
+ const status = line.slice(0, tabIdx);
+ // Join path parts back (paths may contain escaped chars but not tabs)
+ const path = line.slice(tabIdx + 1);
+ if (!status || !path) return null;
+ return { path, status, label: statusToLabel(status) };
+ })
+ .filter((entry): entry is FileEntry => entry !== null);
}
export async function getStagedFiles(): Promise {
@@ -140,7 +145,7 @@ export async function commit(
throw new Error(stderr.trim() || `git commit failed (exit code ${exitCode})`);
}
- const branchHashMatch = stdout.match(/\[(\S+)\s+([0-9a-f]{7,})/);
+ const branchHashMatch = stdout.match(/\[(\S+)\s+(?:\(root-commit\)\s+)?([0-9a-f]{7,})/);
const branch = branchHashMatch?.[1] ?? "";
const hash = branchHashMatch?.[2] ?? "";
diff --git a/src/menu.ts b/src/menu.ts
index 92158fe..96413b2 100644
--- a/src/menu.ts
+++ b/src/menu.ts
@@ -1,4 +1,4 @@
-import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
+import { BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength, padRight } from "./terminal";
import { isStdinTTY } from "./tty";
const UP = "\x1b[A";
@@ -43,22 +43,7 @@ interface MultiPromptOptions extends BasePromptOptions {
doneLabel?: string;
}
-function hideCursor() { process.stdout.write("\x1b[?25l"); }
-function showCursor() { process.stdout.write("\x1b[?25h"); }
-
-function moveUp(lines: number) {
- if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
-}
-
-function clearLine() {
- process.stdout.write("\r\x1b[2K");
-}
-
-function visibleLength(value: string) {
- return value.replace(/\x1b\[[0-9;]*m/g, "").length;
-}
-
-function padLabel(label: string, width: number) {
+function padLabel(label: string, width: number): string {
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
}
@@ -92,7 +77,8 @@ function clearPrompt(lines: number) {
moveUp(lines);
}
-function normalizeKey(key: string, escapeBuf: string) {
+function normalizeKey(key: string, escapeBuf: string): { action: string | null; escapeBuf: string } {
+ // Single-chunk actions
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" };
@@ -100,14 +86,20 @@ function normalizeKey(key: string, escapeBuf: string) {
if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
- if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key };
+ // Start of an escape sequence — buffer it
+ if (key === "\x1b" || key.startsWith("\x1b[") || key.startsWith("\x1bO")) {
+ return { action: null, escapeBuf: key };
+ }
+ // Continue buffering an escape sequence
if (escapeBuf) {
const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" };
- return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next };
+ // If key is a terminal character (letter/digit/~) or buffer got too long, flush
+ if (/^[A-Za-z~0-9]$/.test(key) || next.length > 10) return { action: null, escapeBuf: "" };
+ return { action: null, escapeBuf: next };
}
return { action: null, escapeBuf: "" };
@@ -168,15 +160,25 @@ export async function selectOne(
const render = () => {
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
};
- render();
- return new Promise((resolve) => {
+ const cleanup = () => {
+ process.stdin.setRawMode(wasRaw === true);
+ process.stdin.pause();
+ clearPrompt(renderedLines);
+ showCursor();
+ };
+
+ try {
+ render();
+ } catch (err) {
+ cleanup();
+ throw err;
+ }
+
+ return new Promise((resolve, reject) => {
const finish = (value: T | null | PromptBack) => {
- process.stdin.setRawMode(wasRaw === true);
- process.stdin.pause();
process.stdin.removeListener("data", onData);
- clearPrompt(renderedLines);
- showCursor();
+ cleanup();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
@@ -184,15 +186,20 @@ export async function selectOne(
};
const onData = (data: Buffer) => {
- const result = normalizeKey(data.toString(), escapeBuf);
- escapeBuf = result.escapeBuf;
+ try {
+ const result = normalizeKey(data.toString(), escapeBuf);
+ escapeBuf = result.escapeBuf;
- if (result.action === "cancel") return finish(null);
- if (result.action === "back" && options.allowBack !== false) return finish(BACK);
- if (result.action === "up" && cursor > 0) { cursor--; render(); }
- else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
- else if (result.action === "space" || result.action === "enter") {
- finish(options.items[cursor]!.value);
+ if (result.action === "cancel") return finish(null);
+ if (result.action === "back" && options.allowBack !== false) return finish(BACK);
+ if (result.action === "up" && cursor > 0) { cursor--; render(); }
+ else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
+ else if (result.action === "space" || result.action === "enter") {
+ finish(options.items[cursor]!.value);
+ }
+ } catch (err) {
+ cleanup();
+ reject(err);
}
};
@@ -243,15 +250,25 @@ export async function selectMany(
renderedLines,
);
};
- render();
- return new Promise((resolve) => {
+ const cleanup = () => {
+ process.stdin.setRawMode(wasRaw === true);
+ process.stdin.pause();
+ clearPrompt(renderedLines);
+ showCursor();
+ };
+
+ try {
+ render();
+ } catch (err) {
+ cleanup();
+ throw err;
+ }
+
+ return new Promise((resolve, reject) => {
const finish = (value: T[] | null | PromptBack) => {
- process.stdin.setRawMode(wasRaw === true);
- process.stdin.pause();
process.stdin.removeListener("data", onData);
- clearPrompt(renderedLines);
- showCursor();
+ cleanup();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
@@ -259,16 +276,21 @@ export async function selectMany(
};
const onData = (data: Buffer) => {
- const result = normalizeKey(data.toString(), escapeBuf);
- escapeBuf = result.escapeBuf;
+ try {
+ const result = normalizeKey(data.toString(), escapeBuf);
+ escapeBuf = result.escapeBuf;
- if (result.action === "cancel") return finish(null);
- if (result.action === "back" && options.allowBack !== false) return finish(BACK);
- if (result.action === "up" && cursor > 0) { cursor--; render(); }
- else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
- else if (result.action === "space") { toggle(cursor); render(); }
- else if (result.action === "enter") {
- finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
+ if (result.action === "cancel") return finish(null);
+ if (result.action === "back" && options.allowBack !== false) return finish(BACK);
+ if (result.action === "up" && cursor > 0) { cursor--; render(); }
+ else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
+ else if (result.action === "space") { toggle(cursor); render(); }
+ else if (result.action === "enter") {
+ finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
+ }
+ } catch (err) {
+ cleanup();
+ reject(err);
}
};
diff --git a/src/pr.ts b/src/pr.ts
index eafd7df..44ee4ce 100644
--- a/src/pr.ts
+++ b/src/pr.ts
@@ -124,6 +124,36 @@ export async function getRemoteHostname(): Promise {
}
}
+const PLATFORM_CLI: Record string[];
+ label: string;
+}> = {
+ github: {
+ bin: "gh",
+ args: (title, body, base, draft) => {
+ const a = ["pr", "create", "--title", title, "--body", body, "--base", base];
+ if (draft) a.push("--draft");
+ return a;
+ },
+ label: "gh pr create",
+ },
+ gitlab: {
+ bin: "glab",
+ args: (title, body, base, draft) => {
+ const a = ["mr", "create", "--title", title, "--description", body, "--target-branch", base];
+ if (draft) a.push("--draft");
+ return a;
+ },
+ label: "glab mr create",
+ },
+ gitea: {
+ bin: "tea",
+ args: (title, body, base, _draft) => ["pulls", "create", "--title", title, "--description", body, "--base", base],
+ label: "tea pulls create",
+ },
+};
+
export async function createPR(
platform: Platform,
title: string,
@@ -131,80 +161,8 @@ export async function createPR(
base: string,
draft: boolean,
): Promise {
- if (platform === "github") {
- const args = [
- "pr",
- "create",
- "--title",
- title,
- "--body",
- body,
- "--base",
- base,
- ];
- if (draft) args.push("--draft");
-
- const proc = Bun.spawn(["gh", ...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() || `gh pr create failed (exit code ${exitCode})`,
- );
- }
-
- const match = stdout.match(/(https?:\/\/[^\s]+)/);
- 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 = [
- "pulls",
- "create",
- "--title",
- title,
- "--description",
- body,
- "--base",
- base,
- ];
-
- const proc = Bun.spawn(["tea", ...args], {
+ const cli = PLATFORM_CLI[platform];
+ const proc = Bun.spawn([cli.bin, ...cli.args(title, body, base, draft)], {
stdout: "pipe",
stderr: "pipe",
});
@@ -213,9 +171,7 @@ export async function createPR(
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
- throw new Error(
- stderr.trim() || `tea pulls create failed (exit code ${exitCode})`,
- );
+ throw new Error(stderr.trim() || `${cli.label} failed (exit code ${exitCode})`);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
diff --git a/src/terminal.ts b/src/terminal.ts
index df203b3..81a4717 100644
--- a/src/terminal.ts
+++ b/src/terminal.ts
@@ -1,29 +1,39 @@
-// Terminal styling utilities.
+// Terminal styling and rendering utilities.
// Respects NO_COLOR convention, --no-color flag, and TTY detection.
import { isStdoutTTY } from "./tty";
+// ── Color support ─────────────────────────────────────────────────────
+
let _enabled: boolean | null = null;
export function setColorEnabled(enabled: boolean): void {
- _enabled = enabled;
+ _enabled = enabled;
}
export function isColorEnabled(): boolean {
- if (_enabled !== null) return _enabled;
+ if (_enabled !== null) return _enabled;
- // Respect NO_COLOR: https://no-color.org/
- if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
- return false;
- }
- if (!isStdoutTTY()) return false;
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
+ // Respect NO_COLOR: https://no-color.org/
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
+ _enabled = false;
+ return false;
+ }
+ if (!isStdoutTTY()) {
+ _enabled = false;
+ return false;
+ }
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
+ _enabled = true;
+ return true;
+ }
- return true;
+ _enabled = true;
+ return true;
}
function s(code: string): string {
- return isColorEnabled() ? code : "";
+ return isColorEnabled() ? code : "";
}
export const BOLD = () => s("\x1b[1m");
@@ -33,3 +43,35 @@ export const YELLOW = () => s("\x1b[33m");
export const CYAN = () => s("\x1b[36m");
export const RED = () => s("\x1b[31m");
export const RESET = () => s("\x1b[0m");
+
+// ── Terminal rendering helpers ───────────────────────────────────────
+
+export function hideCursor(): void {
+ process.stdout.write("\x1b[?25l");
+}
+
+export function showCursor(): void {
+ process.stdout.write("\x1b[?25h");
+}
+
+export function clearLine(): void {
+ process.stdout.write("\r\x1b[2K");
+}
+
+export function moveUp(lines: number): void {
+ if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
+}
+
+export function clearScreen(): void {
+ process.stdout.write("\x1b[2J\x1b[H");
+}
+
+/** Calculate visible length of a string, stripping ANSI escape codes. */
+export function visibleLength(value: string): number {
+ return value.replace(/\x1b\[[0-9;]*m/g, "").length;
+}
+
+/** Pad a string to the given visible width (accounting for ANSI codes). */
+export function padRight(value: string, width: number): string {
+ return value + " ".repeat(Math.max(0, width - visibleLength(value)));
+}
diff --git a/src/tty-input.ts b/src/tty-input.ts
new file mode 100644
index 0000000..349380b
--- /dev/null
+++ b/src/tty-input.ts
@@ -0,0 +1,163 @@
+// Shared TTY input utilities used by command handlers.
+// Provides a simple line-input "ask" helper and a reusable inline
+// raw-mode text editor (used by both commit message editing and
+// interactive config editing).
+
+import * as readline from "node:readline";
+import { isStdinTTY } from "./tty";
+
+// ── Simple line input (cooked mode) ──────────────────────────────────
+
+export function ask(question: string): Promise {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+ return new Promise((resolve) => {
+ rl.question(question, (answer) => {
+ rl.close();
+ resolve(answer.trim());
+ });
+ });
+}
+
+// ── Inline raw-mode editor ────────────────────────────────────────────
+//
+// Provides a simple line editor that runs in raw mode and supports:
+// - Left/Right arrows, Home/End for cursor movement
+// - Backspace / Delete for character removal
+// - Ctrl+A (beginning), Ctrl+E (end), Ctrl+K (kill to end), Ctrl+U (kill to start)
+// - Enter to confirm, Ctrl+C / Esc to cancel
+//
+// Returns the edited string, or null if the user cancelled.
+
+export interface EditResult {
+ value: string | null;
+}
+
+export async function editLine(initial: string): Promise {
+ if (!isStdinTTY()) return null;
+
+ const savedRaw = process.stdin.isRaw;
+ process.stdin.setRawMode(true);
+ process.stdin.resume();
+
+ let buffer = initial;
+ let cursor = initial.length;
+
+ function render() {
+ // Clear line, move to start, show prompt + buffer, then reposition cursor
+ process.stdout.write("\r\x1b[2K > " + buffer);
+ if (cursor < buffer.length) {
+ process.stdout.write(`\x1b[${buffer.length - cursor}D`);
+ }
+ }
+
+ process.stdout.write(" > " + buffer);
+
+ return new Promise((resolve) => {
+ let escapeBuf = "";
+
+ function finish(value: string | null) {
+ process.stdin.setRawMode(savedRaw === true);
+ process.stdin.pause();
+ process.stdin.removeAllListeners("data");
+ process.stdout.write("\n");
+ resolve(value);
+ }
+
+ function handleEscapeSeq(seq: string) {
+ switch (seq) {
+ case "\x1b[D": case "\x1bOD": // Left
+ if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); }
+ break;
+ case "\x1b[C": case "\x1bOC": // Right
+ if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); }
+ break;
+ case "\x1b[H": case "\x1b[1~": case "\x1bOH": // Home
+ if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
+ break;
+ case "\x1b[F": case "\x1b[4~": case "\x1bOF": // End
+ if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
+ break;
+ case "\x1b[3~": // Delete
+ if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); }
+ break;
+ }
+ }
+
+ process.stdin.on("data", (data: Buffer) => {
+ const key = data.toString();
+
+ // Ctrl+C
+ if (key === "\x03") { finish(null); return; }
+ // Esc
+ if (key === "\x1b") {
+ if (escapeBuf) {
+ // Already buffering — check if this completes a sequence
+ const next = escapeBuf + key;
+ if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(next)) {
+ handleEscapeSeq(next);
+ escapeBuf = "";
+ } else {
+ // Treat lone Esc as cancel
+ finish(null);
+ }
+ return;
+ }
+ escapeBuf = "\x1b";
+ return;
+ }
+
+ // Buffering an escape sequence
+ if (escapeBuf) {
+ escapeBuf += key;
+ // Check if this completes a valid sequence
+ if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(escapeBuf)) {
+ handleEscapeSeq(escapeBuf);
+ escapeBuf = "";
+ } else if (escapeBuf.length > 10 || /^[A-Za-z~]$/.test(key)) {
+ // Timeout or terminator that didn't match — discard
+ escapeBuf = "";
+ }
+ return;
+ }
+
+ // Enter
+ if (key === "\r" || key === "\n") {
+ const result = buffer.trim();
+ finish(result || null);
+ return;
+ }
+
+ // Backspace
+ if (key === "\x7f") {
+ if (cursor > 0) {
+ buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
+ cursor--;
+ render();
+ }
+ return;
+ }
+
+ // Ctrl+A → beginning of line
+ if (key === "\x01") {
+ if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; }
+ return;
+ }
+ // Ctrl+E → end of line
+ if (key === "\x05") {
+ if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; }
+ return;
+ }
+ // Ctrl+K → kill to end
+ if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; }
+ // Ctrl+U → kill to start
+ if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; }
+
+ // Printable characters
+ if (key >= " " && key !== "\x7f") {
+ buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
+ cursor += key.length;
+ render();
+ }
+ });
+ });
+}
diff --git a/src/tty.ts b/src/tty.ts
index 704c2a0..7237c43 100644
--- a/src/tty.ts
+++ b/src/tty.ts
@@ -23,14 +23,13 @@ export function isStdinTTY(): boolean {
}
export function isStdoutTTY(): boolean {
- // Use a heuristic for stdout — check if we're in a terminal
- if (process.env.TERM || process.env.TERM_PROGRAM) return true;
- if (process.env.NO_COLOR) return false;
- // Try fstat on fd 1 (stdout)
+ // Primary check: fstat on fd 1 (stdout) — most reliable
try {
const stat = fstatSync(1);
return stat.isCharacterDevice();
} catch {
+ // Fall back to TERM heuristic only when fstat fails
+ if (process.env.TERM || process.env.TERM_PROGRAM) return true;
return false;
}
}
diff --git a/src/types.ts b/src/types.ts
index 5a5c41f..c548fbe 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -12,22 +12,21 @@ export interface FileEntry {
label: string;
}
-export interface ProjectContext {
+export interface BaseContext {
readme: string | null;
packageDescription: string | null;
structure: string | null;
- recentCommits: string[];
diff: string;
}
-export interface PRContext {
- readme: string | null;
- packageDescription: string | null;
- structure: string | null;
+export interface ProjectContext extends BaseContext {
+ recentCommits: string[];
+}
+
+export interface PRContext extends BaseContext {
branchName: string;
baseBranch: string;
branchCommits: string[];
- diff: string;
}
export interface CommitResult {