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 {