chore: release v0.1.4 with npm packaging and shared helpers (#7)
Build / bun-build (push) Failing after 34s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-06-23 23:11:59 +08:00
parent 586487d897
commit 79ac4715e6
22 changed files with 774 additions and 699 deletions
+3
View File
@@ -25,6 +25,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.env.production.local .env.production.local
.env.local .env.local
# npm config (may contain auth tokens for private registries)
.npmrc
# caches # caches
.eslintcache .eslintcache
.cache .cache
+43 -1
View File
@@ -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.
<details>
<summary><strong>From source (development)</strong></summary>
```bash
git clone https://git.catpl.top/Mplan/gai.git
cd gai
bun install
bun link # makes `gai` point to your local checkout
```
</details>
---
## Features ## Features
- **🤖 AI Commit Messages** — generate Conventional Commits from staged diffs with project context - **🤖 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: 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 AI-powered git helper for commits, PRs, reviews, and changelogs
──────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────
+21 -43
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
// gai — AI-powered git commit and PR helper // gai — AI-powered git commit and PR helper
// v0.1.3
import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli"; import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
import { handleCommit } from "./src/commands/commit"; import { handleCommit } from "./src/commands/commit";
@@ -11,8 +10,7 @@ import { handleExplain } from "./src/commands/explain";
import { handleReview } from "./src/commands/review"; import { handleReview } from "./src/commands/review";
import { handleChangelog } from "./src/commands/changelog"; import { handleChangelog } from "./src/commands/changelog";
import { handleSuggest } from "./src/commands/suggest"; import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled } from "./src/terminal"; import { setColorEnabled, BOLD, GREEN, CYAN, DIM, RESET, hideCursor, showCursor, clearLine, clearScreen, visibleLength, padRight } from "./src/terminal";
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty"; import { isStdinTTY, initTTY } from "./src/tty";
import { VERSION } from "./src/brand"; import { VERSION } from "./src/brand";
import { SKIP_WAIT } from "./src/menu"; import { SKIP_WAIT } from "./src/menu";
@@ -37,11 +35,6 @@ const MENU_ITEMS: MenuItem[] = [
{ key: "config", label: "Config", description: "Configure API settings", group: "Project" }, { 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<string> { async function readKey(): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
@@ -52,14 +45,6 @@ async function readKey(): Promise<string> {
}); });
} }
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 { function renderMenu(cursor: number): number {
process.stdout.write("\x1b[H"); // cursor home process.stdout.write("\x1b[H"); // cursor home
@@ -84,7 +69,7 @@ function renderMenu(cursor: number): number {
write(""); write("");
const keyWidth = 3; 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; let currentGroup: MenuItem["group"] | null = null;
for (let i = 0; i < MENU_ITEMS.length; i++) { for (let i = 0; i < MENU_ITEMS.length; i++) {
@@ -165,14 +150,14 @@ async function waitForEnter(): Promise<void> {
// Resume stdin in case it was paused // Resume stdin in case it was paused
process.stdin.resume(); process.stdin.resume();
}); });
process.stdout.write("\x1b[2J\x1b[H"); // clear screen clearScreen();
} }
async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> { async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
showCursor(); showCursor();
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H"); // clear screen clearScreen();
const result = await dispatchMenuAction(item.key); const result = await dispatchMenuAction(item.key);
if (result === (SKIP_WAIT as unknown as number)) { if (result === (SKIP_WAIT as unknown as number)) {
return 0; // user explicitly backed out — skip "Press Enter" and return directly return 0; // user explicitly backed out — skip "Press Enter" and return directly
@@ -197,6 +182,13 @@ async function showMenu(): Promise<number> {
// Initial render // Initial render
renderMenu(cursor); renderMenu(cursor);
const exitMenu = (exitCode: number): number => {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
return exitCode;
};
try { try {
while (true) { while (true) {
const raw = await readKey(); const raw = await readKey();
@@ -214,7 +206,7 @@ async function showMenu(): Promise<number> {
// Enter // Enter
if (raw === "\r" || raw === "\n") { if (raw === "\r" || raw === "\n") {
const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw); const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
if (result !== 0) return result; if (result !== 0) return exitMenu(result);
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
@@ -224,11 +216,8 @@ async function showMenu(): Promise<number> {
// Ctrl+C // Ctrl+C
if (raw === "\x03") { if (raw === "\x03") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\n"); process.stdout.write("\n");
return 0; return exitMenu(0);
} }
// Number hotkeys (1-8) // Number hotkeys (1-8)
@@ -238,7 +227,7 @@ async function showMenu(): Promise<number> {
cursor = idx; cursor = idx;
renderMenu(cursor); renderMenu(cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw); const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
if (result !== 0) return result; if (result !== 0) return exitMenu(result);
hideCursor(); hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
@@ -250,27 +239,18 @@ async function showMenu(): Promise<number> {
// Letter hotkeys // Letter hotkeys
const lower = raw.toLowerCase(); const lower = raw.toLowerCase();
if (lower === "h") { if (lower === "h") {
showCursor(); clearScreen();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log(formatHelp(commands)); console.log(formatHelp(commands));
return 0; return exitMenu(0);
} }
if (lower === "v") { if (lower === "v") {
showCursor(); clearScreen();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log(`gai v${VERSION}`); console.log(`gai v${VERSION}`);
return 0; return exitMenu(0);
} }
if (lower === "q") { if (lower === "q") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\n"); process.stdout.write("\n");
return 0; return exitMenu(0);
} }
} }
} finally { } finally {
@@ -428,10 +408,8 @@ const commands = registerCommands(
}, },
); );
// Keep the defs accessible for help command // Keep canonical command defs accessible for help command (deduplicate by reference)
const allCommandDefs = [...commands.values()].filter( const allCommandDefs = [...new Set(commands.values())];
(c, i, arr) => arr.findIndex((x) => x.name === c.name) === i,
);
// ── Main ─────────────────────────────────────────────────────────────── // ── Main ───────────────────────────────────────────────────────────────
+37 -7
View File
@@ -1,21 +1,51 @@
{ {
"name": "gai", "name": "@mplan/gai",
"version": "0.1.3", "version": "0.1.4",
"description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more", "description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
"module": "index.ts", "license": "MIT",
"type": "module", "type": "module",
"bin": { "bin": {
"gai": "./index.ts" "gai": "./index.ts"
}, },
"files": [
"index.ts",
"src",
"README.md",
"LICENSE"
],
"engines": {
"bun": ">=1"
},
"scripts": { "scripts": {
"gai": "bun run index.ts", "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, "private": false,
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
} }
} }
+7 -2
View File
@@ -123,7 +123,6 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); const lines = buffer.split("\n");
// Keep the last potentially incomplete line
buffer = lines.pop() ?? ""; buffer = lines.pop() ?? "";
for (const line of lines) { for (const line of lines) {
@@ -136,7 +135,12 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
try { try {
const parsed = JSON.parse(data) as { const parsed = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>; 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; const token = parsed.choices?.[0]?.delta?.content;
if (token) { if (token) {
fullText += token; fullText += token;
@@ -152,7 +156,8 @@ async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCal
} }
} }
} finally { } finally {
reader.releaseLock(); try { await reader.cancel(); } catch {}
// releaseLock is not needed after cancel
} }
callbacks.onDone?.(fullText); callbacks.onDone?.(fullText);
+1 -1
View File
@@ -2,7 +2,7 @@
import { GREEN, CYAN, RESET } from "./terminal"; import { GREEN, CYAN, RESET } from "./terminal";
export const VERSION = "0.1.3"; export const VERSION = "0.1.4";
export function showBanner(): string { export function showBanner(): string {
const G = GREEN(); const G = GREEN();
+3 -14
View File
@@ -1,6 +1,8 @@
// Lightweight CLI argument parser aligned with mainstream CLI conventions. // Lightweight CLI argument parser aligned with mainstream CLI conventions.
// Supports: subcommands, short/long flags, flag values, positional args, --help, --version. // Supports: subcommands, short/long flags, flag values, positional args, --help, --version.
import { VERSION } from "./brand";
export interface FlagDef { export interface FlagDef {
long: string; // e.g. "dry-run" long: string; // e.g. "dry-run"
short?: string; // e.g. "d" short?: string; // e.g. "d"
@@ -44,19 +46,6 @@ function buildFlagIndex(flags: FlagDef[]): Map<string, FlagDef> {
return index; 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( function parseArgs(
rawArgs: string[], rawArgs: string[],
commands: Map<string, CommandDef>, commands: Map<string, CommandDef>,
@@ -282,7 +271,7 @@ export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef
// Handle --version globally // Handle --version globally
if (result.flags["version"]) { if (result.flags["version"]) {
console.log("gai v0.1.3"); console.log(`gai v${VERSION}`);
return 0; return 0;
} }
+3 -1
View File
@@ -19,7 +19,9 @@ export async function copyToClipboard(text: string): Promise<boolean> {
proc.stdin.end(); proc.stdin.end();
const exitCode = await proc.exited; const exitCode = await proc.exited;
if (exitCode === 0) return true; if (exitCode === 0) return true;
} catch {} } catch {
// Try next command
}
} }
return false; return false;
+2 -103
View File
@@ -1,4 +1,3 @@
import * as readline from "node:readline";
import { import {
isGitRepo, isGitRepo,
getRepoRoot, getRepoRoot,
@@ -18,20 +17,11 @@ import { generateCommitMessage } from "../ai";
import { copyToClipboard } from "../clipboard"; import { copyToClipboard } from "../clipboard";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty"; import { isStdinTTY } from "../tty";
import { ask, editLine } from "../tty-input";
import type { Config, CommitResult, StreamCallbacks } from "../types"; import type { Config, CommitResult, StreamCallbacks } from "../types";
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
function ask(question: string): Promise<string> {
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) { function printCommitResult(result: CommitResult, msg: string) {
console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`); console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`);
const id = result.branch && result.hash 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<string | null> { async function editMessage(current: string): Promise<string | null> {
if (!isStdinTTY()) return null;
process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`); process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`);
return editLine(current);
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(); }
}
}
});
} }
export async function handleCommit(args: ParsedArgs): Promise<number> { export async function handleCommit(args: ParsedArgs): Promise<number> {
+7 -18
View File
@@ -1,7 +1,8 @@
import { loadConfig, saveConfig } from "../config"; 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 { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu"; import { SKIP_WAIT } from "../menu";
import { editLine } from "../tty-input";
import type { Config } from "../types"; import type { Config } from "../types";
import type { ParsedArgs } from "../cli"; 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( function renderConfigPage(
config: Config, config: Config,
cursor: number, cursor: number,
@@ -151,29 +140,29 @@ async function interactiveConfig(): Promise<"done" | "back"> {
if (wasRaw !== true) process.stdin.setRawMode(true); if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume(); process.stdin.resume();
process.stdout.write("\x1b[?25l"); hideCursor();
const render = () => { const render = () => {
moveUp(renderedCursorRow); moveUp(renderedCursorRow);
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState); renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
renderedCursorRow = editState ? 4 + cursor : 0; renderedCursorRow = editState ? 4 + cursor : 0;
process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l"); editState ? showCursor() : hideCursor();
}; };
render(); render();
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const finish = (value: "done" | "back") => { const finish = (value: "done" | "back") => {
process.stdin.removeListener("data", onData);
process.stdin.setRawMode(wasRaw === true); process.stdin.setRawMode(wasRaw === true);
process.stdin.pause(); process.stdin.pause();
process.stdin.removeListener("data", onData);
moveUp(renderedCursorRow); moveUp(renderedCursorRow);
for (let i = 0; i < renderedLines; i++) { for (let i = 0; i < renderedLines; i++) {
clearLine(); clearLine();
process.stdout.write("\n"); process.stdout.write("\n");
} }
moveUp(renderedLines); moveUp(renderedLines);
process.stdout.write("\x1b[?25h"); showCursor();
resolve(value); resolve(value);
}; };
+11 -66
View File
@@ -1,15 +1,6 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { collectDiff } from "../diff-source";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt"; import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai"; import { callAI } from "../ai";
import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal"; import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal";
@@ -28,76 +19,31 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
const unstaged = args.flags["unstaged"] as boolean; const unstaged = args.flags["unstaged"] as boolean;
const verbose = args.flags["verbose"] as boolean; const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain
let diff: string; let diff: string;
let sourceLabel: 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 { try {
diff = (await Bun.$`git diff`.quiet().text()).trim(); const result = await collectDiff({ unstaged, includeProjectContext: true });
} catch { if (result.back) return SKIP_WAIT as unknown as number;
diff = ""; diff = result.diff;
} sourceLabel = result.sourceLabel;
sourceLabel = "unstaged changes"; contextPrefix = result.contextPrefix;
} else { } catch (err) {
// Default: staged changes (or piped) console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
if (isStdinTTY()) {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1; return 1;
} }
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 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) { if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`); console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
return 0; return 0;
} }
if (args.flags["verbose"]) { if (verbose) {
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`); console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
} }
const MAX_DIFF_SIZE = 15000; const userPrompt = contextPrefix + buildExplainPrompt(diff);
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
// 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 userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
if (verbose) { if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
@@ -117,7 +63,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
if (callbacks) { if (callbacks) {
process.stdout.write("\n"); process.stdout.write("\n");
} else { } else {
// Non-TTY: print the result directly
process.stdout.write(explanation + "\n"); process.stdout.write(explanation + "\n");
} }
} catch (err) { } catch (err) {
+1 -11
View File
@@ -1,4 +1,3 @@
import * as readline from "node:readline";
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { isGitRepo, getRepoRoot } from "../git"; import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context"; import { collectProjectContext } from "../context";
@@ -19,19 +18,10 @@ import {
import type { Platform } from "../pr"; import type { Platform } from "../pr";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty"; import { isStdinTTY } from "../tty";
import { ask } from "../tty-input";
import { copyToClipboard } from "../clipboard"; import { copyToClipboard } from "../clipboard";
import type { ParsedArgs } from "../cli"; import type { ParsedArgs } from "../cli";
function ask(question: string): Promise<string> {
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<Platform | null | typeof BACK> { async function selectPlatform(hostname: string): Promise<Platform | null | typeof BACK> {
if (!isStdinTTY()) { if (!isStdinTTY()) {
console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`); console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`);
+10 -58
View File
@@ -1,15 +1,6 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { collectDiff } from "../diff-source";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt"; import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai"; import { callAI } from "../ai";
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal"; import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
@@ -36,64 +27,25 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
let diff: string; let diff: string;
let sourceLabel: 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 { try {
diff = (await Bun.$`git diff`.quiet().text()).trim(); const result = await collectDiff({ unstaged, includeProjectContext: true });
} catch { if (result.back) return SKIP_WAIT as unknown as number;
diff = ""; diff = result.diff;
} sourceLabel = result.sourceLabel;
sourceLabel = "unstaged changes"; contextPrefix = result.contextPrefix;
} else if (!isStdinTTY()) { } catch (err) {
const chunks: Buffer[] = []; console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
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; return 1;
} }
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 SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
}
if (!diff) { if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`); console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
return 0; return 0;
} }
const MAX_DIFF_SIZE = 15000; const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
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 userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag);
const strictnessLabel = strictnessFlag === "strict" const strictnessLabel = strictnessFlag === "strict"
? `${RED()}strict${RESET()}` ? `${RED()}strict${RESET()}`
+11 -44
View File
@@ -1,12 +1,5 @@
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { import { collectDiff } from "../diff-source";
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { BACK, SKIP_WAIT } from "../menu"; import { BACK, SKIP_WAIT } from "../menu";
import { import {
SUGGEST_SYSTEM_PROMPT, SUGGEST_SYSTEM_PROMPT,
@@ -36,37 +29,17 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
return 1; return 1;
} }
// Get diff (staged, or unstaged if --unstaged, or piped) const unstaged = args.flags["unstaged"] as boolean;
let diff: string; 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;
}
if (args.flags["unstaged"] as boolean) {
try { try {
diff = (await Bun.$`git diff`.quiet().text()).trim(); const result = await collectDiff({ unstaged, includeProjectContext: false });
} catch { if (result.back) return SKIP_WAIT as unknown as number;
diff = ""; diff = result.diff;
} } catch (err) {
} else { console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
const stagedFiles = await getStagedFiles(); return 1;
const unstagedFiles = await getUnstagedFiles();
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) { if (!diff) {
@@ -74,20 +47,14 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
return 0; 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) { if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
} }
if (mode === "branch") { if (mode === "branch") {
return handleSuggestBranch(config, truncatedDiff); return handleSuggestBranch(config, diff);
} else {
return handleSuggestType(config, truncatedDiff);
} }
return handleSuggestType(config, diff);
} }
async function handleSuggestBranch(config: Config, diff: string): Promise<number> { async function handleSuggestBranch(config: Config, diff: string): Promise<number> {
+98
View File
@@ -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<DiffSourceResult> {
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 };
}
+11 -6
View File
@@ -37,12 +37,17 @@ function parseNameStatus(output: string): FileEntry[] {
return output return output
.trim() .trim()
.split("\n") .split("\n")
.filter(Boolean) .filter((line) => line.trim())
.map((line) => { .map((line) => {
const [status, ...pathParts] = line.split("\t"); const tabIdx = line.indexOf("\t");
const path = pathParts[pathParts.length - 1] ?? ""; if (tabIdx === -1) return null;
return { path, status: status!, label: statusToLabel(status!) }; 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<FileEntry[]> { export async function getStagedFiles(): Promise<FileEntry[]> {
@@ -140,7 +145,7 @@ export async function commit(
throw new Error(stderr.trim() || `git commit failed (exit code ${exitCode})`); 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 branch = branchHashMatch?.[1] ?? "";
const hash = branchHashMatch?.[2] ?? ""; const hash = branchHashMatch?.[2] ?? "";
+50 -28
View File
@@ -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"; import { isStdinTTY } from "./tty";
const UP = "\x1b[A"; const UP = "\x1b[A";
@@ -43,22 +43,7 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
doneLabel?: string; doneLabel?: string;
} }
function hideCursor() { process.stdout.write("\x1b[?25l"); } function padLabel(label: string, width: number): string {
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) {
return label + " ".repeat(Math.max(1, width - visibleLength(label))); return label + " ".repeat(Math.max(1, width - visibleLength(label)));
} }
@@ -92,7 +77,8 @@ function clearPrompt(lines: number) {
moveUp(lines); 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 === 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 === 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 === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", 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) { if (escapeBuf) {
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: "" }; 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: "" }; return { action: null, escapeBuf: "" };
@@ -168,15 +160,25 @@ export async function selectOne<T>(
const render = () => { const render = () => {
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines); renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
}; };
render();
return new Promise((resolve) => { const cleanup = () => {
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);
clearPrompt(renderedLines); clearPrompt(renderedLines);
showCursor(); showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T | null | PromptBack) => {
process.stdin.removeListener("data", onData);
cleanup();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -184,6 +186,7 @@ export async function selectOne<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
try {
const result = normalizeKey(data.toString(), escapeBuf); const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
@@ -194,6 +197,10 @@ export async function selectOne<T>(
else if (result.action === "space" || result.action === "enter") { else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value); finish(options.items[cursor]!.value);
} }
} catch (err) {
cleanup();
reject(err);
}
}; };
process.stdin.on("data", onData); process.stdin.on("data", onData);
@@ -243,15 +250,25 @@ export async function selectMany<T>(
renderedLines, renderedLines,
); );
}; };
render();
return new Promise((resolve) => { const cleanup = () => {
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);
clearPrompt(renderedLines); clearPrompt(renderedLines);
showCursor(); showCursor();
};
try {
render();
} catch (err) {
cleanup();
throw err;
}
return new Promise((resolve, reject) => {
const finish = (value: T[] | null | PromptBack) => {
process.stdin.removeListener("data", onData);
cleanup();
if (value === null && options.cancelMessage) { if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`); process.stdout.write(` ${options.cancelMessage}\n`);
} }
@@ -259,6 +276,7 @@ export async function selectMany<T>(
}; };
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
try {
const result = normalizeKey(data.toString(), escapeBuf); const result = normalizeKey(data.toString(), escapeBuf);
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
@@ -270,6 +288,10 @@ export async function selectMany<T>(
else if (result.action === "enter") { else if (result.action === "enter") {
finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T)); finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
} }
} catch (err) {
cleanup();
reject(err);
}
}; };
process.stdin.on("data", onData); process.stdin.on("data", onData);
+33 -77
View File
@@ -124,6 +124,36 @@ export async function getRemoteHostname(): Promise<string | null> {
} }
} }
const PLATFORM_CLI: Record<Platform, {
bin: string;
args: (title: string, body: string, base: string, draft: boolean) => 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( export async function createPR(
platform: Platform, platform: Platform,
title: string, title: string,
@@ -131,20 +161,8 @@ export async function createPR(
base: string, base: string,
draft: boolean, draft: boolean,
): Promise<string> { ): Promise<string> {
if (platform === "github") { const cli = PLATFORM_CLI[platform];
const args = [ const proc = Bun.spawn([cli.bin, ...cli.args(title, body, base, draft)], {
"pr",
"create",
"--title",
title,
"--body",
body,
"--base",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["gh", ...args], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}); });
@@ -153,69 +171,7 @@ export async function createPR(
const stderr = await new Response(proc.stderr).text(); const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error( throw new Error(stderr.trim() || `${cli.label} failed (exit code ${exitCode})`);
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], {
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() || `tea pulls create failed (exit code ${exitCode})`,
);
} }
const match = stdout.match(/(https?:\/\/[^\s]+)/); const match = stdout.match(/(https?:\/\/[^\s]+)/);
+45 -3
View File
@@ -1,8 +1,10 @@
// Terminal styling utilities. // Terminal styling and rendering utilities.
// Respects NO_COLOR convention, --no-color flag, and TTY detection. // Respects NO_COLOR convention, --no-color flag, and TTY detection.
import { isStdoutTTY } from "./tty"; import { isStdoutTTY } from "./tty";
// ── Color support ─────────────────────────────────────────────────────
let _enabled: boolean | null = null; let _enabled: boolean | null = null;
export function setColorEnabled(enabled: boolean): void { export function setColorEnabled(enabled: boolean): void {
@@ -14,11 +16,19 @@ export function isColorEnabled(): boolean {
// Respect NO_COLOR: https://no-color.org/ // Respect NO_COLOR: https://no-color.org/
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") { if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
_enabled = false;
return false; return false;
} }
if (!isStdoutTTY()) return false; if (!isStdoutTTY()) {
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true; _enabled = false;
return false;
}
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") {
_enabled = true;
return true;
}
_enabled = true;
return true; return true;
} }
@@ -33,3 +43,35 @@ export const YELLOW = () => s("\x1b[33m");
export const CYAN = () => s("\x1b[36m"); export const CYAN = () => s("\x1b[36m");
export const RED = () => s("\x1b[31m"); export const RED = () => s("\x1b[31m");
export const RESET = () => s("\x1b[0m"); 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)));
}
+163
View File
@@ -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<string> {
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<string | null> {
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();
}
});
});
}
+3 -4
View File
@@ -23,14 +23,13 @@ export function isStdinTTY(): boolean {
} }
export function isStdoutTTY(): boolean { export function isStdoutTTY(): boolean {
// Use a heuristic for stdout — check if we're in a terminal // Primary check: fstat on fd 1 (stdout) — most reliable
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
if (process.env.NO_COLOR) return false;
// Try fstat on fd 1 (stdout)
try { try {
const stat = fstatSync(1); const stat = fstatSync(1);
return stat.isCharacterDevice(); return stat.isCharacterDevice();
} catch { } catch {
// Fall back to TERM heuristic only when fstat fails
if (process.env.TERM || process.env.TERM_PROGRAM) return true;
return false; return false;
} }
} }
+6 -7
View File
@@ -12,22 +12,21 @@ export interface FileEntry {
label: string; label: string;
} }
export interface ProjectContext { export interface BaseContext {
readme: string | null; readme: string | null;
packageDescription: string | null; packageDescription: string | null;
structure: string | null; structure: string | null;
recentCommits: string[];
diff: string; diff: string;
} }
export interface PRContext { export interface ProjectContext extends BaseContext {
readme: string | null; recentCommits: string[];
packageDescription: string | null; }
structure: string | null;
export interface PRContext extends BaseContext {
branchName: string; branchName: string;
baseBranch: string; baseBranch: string;
branchCommits: string[]; branchCommits: string[];
diff: string;
} }
export interface CommitResult { export interface CommitResult {