import { loadConfig } from "../config"; import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git"; import { selectFiles } from "../selector"; import { BACK } 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"; import { isStdinTTY } from "../tty"; import type { StreamCallbacks } from "../types"; import type { ParsedArgs } from "../cli"; export async function handleExplain(args: ParsedArgs): Promise { 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; } const unstaged = args.flags["unstaged"] as boolean; const staged = args.flags["staged"] as boolean; const verbose = args.flags["verbose"] as boolean; // Determine which diff to explain let diff: string; let sourceLabel: 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; } diff = await getStagedDiff(); sourceLabel = "staged changes"; // If no staged changes, offer to stage unstaged files if (!diff) { const unstagedFiles = await getUnstagedFiles(); if (unstagedFiles.length > 0) { const selected = await selectFiles([], unstagedFiles); if (selected === BACK) return 0; if (selected.length > 0) { await stageFiles(selected); console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`); 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 (args.flags["verbose"]) { console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`); } const MAX_DIFF_SIZE = 15000; 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) { console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); } const tty = isStdinTTY(); if (tty) { console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`); } try { const callbacks: StreamCallbacks = 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; }