diff --git a/index.ts b/index.ts index 598dcaa..07ecbc6 100644 --- a/index.ts +++ b/index.ts @@ -37,8 +37,8 @@ ${BOLD}Usage:${RESET} ${BOLD}Configuration:${RESET} Set via ${CYAN}gai config${RESET} or environment variables: GAI_API_KEY OpenAI-compatible API key - GAI_API_BASE API base URL (default: https://api.openai.com/v1) - GAI_MODEL Model name (default: gpt-4o) + GAI_API_BASE API base URL (default: https://api.deepseek.com/v1) + GAI_MODEL Model name (default: deepseek-chat) GAI_MAX_TOKENS Max tokens (default: 500) GAI_TEMPERATURE Temperature (default: 0.7) `); diff --git a/src/ai.ts b/src/ai.ts index b9a246a..4fe4243 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -8,15 +8,42 @@ interface ChatMessage { interface ChatCompletionResponse { choices?: Array<{ message?: { - content?: string; + content?: string | null; }; + finish_reason?: string; }>; + error?: { + message?: string; + type?: string; + code?: string; + }; +} + +const MAX_RETRIES = 3; +const RETRY_DELAY = 1000; + +function cleanMessage(raw: string): string { + let msg = raw.trim(); + if (msg.startsWith("```") && msg.endsWith("```")) { + const lines = msg.split("\n"); + if (lines.length > 2) { + lines.shift(); + lines.pop(); + msg = lines.join("\n").trim(); + } + } + return msg; +} + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); } export async function generateCommitMessage( config: Config, systemPrompt: string, userPrompt: string, + retries = MAX_RETRIES, ): Promise { const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`; @@ -25,31 +52,72 @@ export async function generateCommitMessage( { role: "user", content: userPrompt }, ]; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.apiKey}`, - }, - body: JSON.stringify({ - model: config.model, - max_tokens: config.maxTokens, - temperature: config.temperature, - messages, - }), - }); + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + max_tokens: config.maxTokens, + temperature: config.temperature, + messages, + }), + }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`API request failed (${response.status}): ${text}`); + if (!response.ok) { + const text = await response.text(); + if (response.status === 429 && attempt < retries) { + await sleep(RETRY_DELAY * attempt); + continue; + } + throw new Error(`API request failed (${response.status}): ${text}`); + } + + const data = (await response.json()) as ChatCompletionResponse; + + if (data.error) { + throw new Error( + `API error: ${data.error.message ?? JSON.stringify(data.error)}`, + ); + } + + const raw = data.choices?.[0]?.message?.content; + const finishReason = data.choices?.[0]?.finish_reason; + + if (raw && raw.trim()) { + return cleanMessage(raw); + } + + if (finishReason === "length") { + throw new Error( + "Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.", + ); + } + + if (finishReason === "content_filter") { + throw new Error("Response blocked by content filter."); + } + + if (attempt < retries) { + await sleep(RETRY_DELAY * attempt); + continue; + } + + throw new Error( + `Empty response from AI after ${retries} attempts. finish_reason: ${finishReason ?? "unknown"}`, + ); + } catch (err) { + if (attempt >= retries) throw err; + if (err instanceof Error && err.message.startsWith("API error")) throw err; + if (err instanceof Error && err.message.includes("max_tokens")) throw err; + if (err instanceof Error && err.message.includes("content filter")) throw err; + await sleep(RETRY_DELAY * attempt); + } } - const data = (await response.json()) as ChatCompletionResponse; - const message = data.choices?.[0]?.message?.content?.trim(); - - if (!message) { - throw new Error("Empty response from AI"); - } - - return message; + throw new Error("Failed to generate commit message"); } diff --git a/src/config.ts b/src/config.ts index 69f3f1d..387ffff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,8 +5,8 @@ import type { Config } from "./types"; const DEFAULT_CONFIG: Config = { apiKey: "", - apiBase: "https://api.openai.com/v1", - model: "gpt-4o", + apiBase: "https://api.deepseek.com/v1", + model: "deepseek-chat", maxTokens: 500, temperature: 0.7, }; diff --git a/src/selector.ts b/src/selector.ts index 0f6c6ff..026c80e 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,6 +1,37 @@ -import * as readline from "node:readline"; import type { FileEntry } from "./types"; -import { BOLD, GREEN, YELLOW, CYAN, RESET } from "./terminal"; +import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal"; + +const UP = "\x1b[A"; +const DOWN = "\x1b[B"; +const SPACE = " "; +const ENTER = "\r"; +const CTRL_C = "\x03"; + +function hideCursor() { + process.stdout.write("\x1b[?25l"); +} + +function showCursor() { + process.stdout.write("\x1b[?25h"); +} + +function moveUp(n: number) { + process.stdout.write(`\x1b[${n}A`); +} + +function moveDown(n: number) { + process.stdout.write(`\x1b[${n}B`); +} + +function clearLine() { + process.stdout.write("\x1b[2K\r"); +} + +interface SelectItem { + label: string; + path: string; + selected: boolean; +} export async function selectFiles( stagedFiles: FileEntry[], @@ -9,54 +40,129 @@ export async function selectFiles( if (unstagedFiles.length === 0) return []; if (stagedFiles.length > 0) { - console.log(`\n ${BOLD}Staged files (will be included):${RESET}`); + process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`); for (const f of stagedFiles) { - console.log(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`); + process.stdout.write(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`); } } - console.log(`\n ${BOLD}Unstaged files:${RESET}`); - for (let i = 0; i < unstagedFiles.length; i++) { - const f = unstagedFiles[i]!; - console.log( - ` ${CYAN}${i + 1}.${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`, - ); + const items: SelectItem[] = [ + { label: "Select all", path: "__all__", selected: false }, + ...unstagedFiles.map((f) => ({ + label: `${f.path} (${f.label})`, + path: f.path, + selected: false, + })), + ]; + + let cursor = 0; + + process.stdout.write(`\n ${BOLD}Select files to stage:${RESET}\n`); + process.stdout.write(` ${DIM}↑/↓ navigate, space select, enter confirm${RESET}\n\n`); + + const itemStartRow = 4 + (stagedFiles.length > 0 ? stagedFiles.length + 2 : 0); + + function render() { + for (let i = 0; i < items.length; i++) { + process.stdout.write("\x1b[2K\r"); + const item = items[i]!; + const isAll = i === 0; + const cursor_ = i === cursor ? `${CYAN}❯${RESET} ` : " "; + const checkbox = item.selected ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`; + + if (isAll) { + process.stdout.write(`${cursor_} ${checkbox} ${BOLD}${item.label}${RESET}\n`); + } else { + process.stdout.write(`${cursor_} ${checkbox} ${item.path.includes("(") ? item.label : `${item.label}`}\n`); + } + } + moveUp(items.length); } - console.log(""); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); + function toggleItem(index: number) { + const item = items[index]!; + item.selected = !item.selected; + + if (index === 0) { + for (let i = 1; i < items.length; i++) { + items[i]!.selected = item.selected; + } + } else { + const allSelected = items.slice(1).every((it) => it.selected); + items[0]!.selected = allSelected; + } + } return new Promise((resolve) => { - rl.question( - " Enter files to stage (e.g. 1,3) or 'a' for all: ", - (answer) => { - rl.close(); - const trimmed = answer.trim().toLowerCase(); + if (process.stdin.isTTY !== true) { + resolve([]); + return; + } - if (trimmed === "a" || trimmed === "all") { - resolve(unstagedFiles.map((f) => f.path)); - return; + const wasRaw = process.stdin.isRaw; + if (wasRaw !== true) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + hideCursor(); + + render(); + + const onData = (data: Buffer) => { + const key = data.toString(); + + if (key === CTRL_C) { + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + showCursor(); + for (let i = 0; i < items.length; i++) { + process.stdout.write("\x1b[2K\n"); + } + moveUp(items.length); + process.stdout.write(`\n Aborted.\n`); + process.exit(1); + } + + if (key === UP) { + if (cursor > 0) { + cursor--; + render(); + } + } else if (key === DOWN) { + if (cursor < items.length - 1) { + cursor++; + render(); + } + } else if (key === SPACE) { + toggleItem(cursor); + render(); + } else if (key === ENTER) { + process.stdin.setRawMode(wasRaw === true); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + + for (let i = 0; i < items.length; i++) { + process.stdout.write("\x1b[2K\n"); + } + moveUp(items.length); + + const selected = items + .slice(1) + .filter((it) => it.selected) + .map((it) => it.path); + + if (selected.length > 0) { + process.stdout.write( + ` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`, + ); } - if (trimmed === "") { - resolve([]); - return; - } + showCursor(); + resolve(selected); + } + }; - const indices = trimmed - .split(/[,\s]+/) - .map((s) => parseInt(s.trim())) - .filter( - (n) => !isNaN(n) && n >= 1 && n <= unstagedFiles.length, - ) - .map((n) => n - 1); - - const uniqueIndices = [...new Set(indices)]; - resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path)); - }, - ); + process.stdin.on("data", onData); }); }