feat(ai): Add comprehensive pre-commit AI code review

Introduces a robust system for running automated, staged diff reviews against various large language models. This feature allows users to submit their changes to external AI services and receive structured feedback on potential bugs, security issues, and maintainability risks before committing.

The implementation covers the entire stack:
*   Backend logic was added to handle API communication with OpenAI, Anthropic, and custom endpoints.
*   A dedicated parser ensures that complex JSON outputs from LLMs are reliably converted into structured findings (severity, title, description).
*   New components and UI elements provide a clear visualization of the AI's assessment and actionable suggestions.

- Supports multiple major LLM providers (OpenAI, Anthropic)
- Parses structured JSON output for consistent review results
- Adds dedicated UI dialog to display AI findings and risk level
This commit is contained in:
Christoph Brandau
2026-07-13 00:03:11 +02:00
parent bad1263dcf
commit 82c47c8d03
10 changed files with 614 additions and 12 deletions
+63
View File
@@ -10,6 +10,7 @@
import RepoToolbar from "./lib/RepoToolbar.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
@@ -46,6 +47,7 @@
cloneRepository,
commit,
commitAiGenerate,
commitAiReview,
commitAiLoad,
commitAiLocalModels,
commitAiStatus,
@@ -107,6 +109,7 @@
} from "./lib/git";
import type {
AiReviewResult,
AiSettings,
AppLanguage,
AppTheme,
@@ -250,6 +253,9 @@
let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
let commitAiReviewing = false;
let aiReviewResult: AiReviewResult | null = null;
let aiReviewOpen = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
let aiSettings: AiSettings = defaultAiSettings();
let aiSettingsOpen = false;
@@ -931,6 +937,51 @@
}
}
async function reviewStagedWithAi() {
if (!activeRepoPath || commitAiReviewing || stagedCount === 0) return;
if (aiSettings.provider === "local") {
errorMessage = "Pre-commit review currently requires OpenAI, Anthropic, or a custom endpoint.";
return;
}
commitAiReviewing = true;
errorMessage = "";
try {
if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai");
aiReviewResult = await commitAiReview(activeRepoPath, {
provider: "openai",
model: aiSettings.openaiModel,
apiKey: cred?.password,
});
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
aiReviewResult = await commitAiReview(activeRepoPath, {
provider: "anthropic",
model: aiSettings.anthropicModel,
apiKey: cred?.password,
});
} else {
const cred = await credLoad("ai:custom");
aiReviewResult = await commitAiReview(activeRepoPath, {
provider: "custom",
model: aiSettings.customModel,
baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password,
});
}
aiReviewOpen = true;
trackEvent("ai_precommit_review", {
provider: aiSettings.provider,
findings: aiReviewResult.findings.length,
risk: aiReviewResult.risk,
});
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
commitAiReviewing = false;
}
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) void autoRefreshTick();
@@ -4012,11 +4063,13 @@
commitAiProvider={aiSettings.provider}
{commitAiPhase}
{commitAiGenerating}
{commitAiReviewing}
{canAmend}
{amendMode}
onCommit={commitChanges}
onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi}
onReviewStaged={reviewStagedWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
onToggleAmend={toggleAmendMode}
onUndoLastCommit={undoLastCommitChange}
@@ -4147,6 +4200,16 @@
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} />
{/if}
{#if aiReviewOpen && aiReviewResult}
<AiReviewDialog
result={aiReviewResult}
provider={aiSettings.provider}
isReviewing={commitAiReviewing}
onRerun={reviewStagedWithAi}
onClose={() => { aiReviewOpen = false; }}
/>
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
file={linePatchFile}
+102
View File
@@ -5926,3 +5926,105 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
color: #0755c8;
font-weight: 800;
}
/* AI pre-commit review --------------------------------------------------- */
.commit-review-button {
border-color: color-mix(in srgb, var(--color-primary) 26%, var(--color-border));
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.commit-review-button:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
}
.ai-review-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
width: min(920px, calc(100vw - 40px));
height: min(780px, calc(100vh - 40px));
overflow: hidden;
}
.ai-review-header h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 16px; }
.ai-review-provider {
padding: 3px 7px;
border-radius: 5px;
color: var(--color-ink-dim);
background: var(--color-surface-hover);
font-size: 10.5px;
font-weight: 750;
}
.ai-review-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.ai-review-summary-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 9px;
color: #d9891b;
background: color-mix(in srgb, #d9891b 12%, transparent);
}
.ai-review-summary-icon.clean { color: #2da44e; background: color-mix(in srgb, #2da44e 12%, transparent); }
.ai-review-summary-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ai-review-summary-line > strong { color: var(--color-ink); font-size: 14px; }
.ai-review-summary p { margin: 5px 0 0; color: var(--color-ink-muted); font-size: 12.5px; line-height: 1.45; }
.ai-review-risk {
padding: 2px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 850;
letter-spacing: .06em;
text-transform: uppercase;
}
.ai-review-risk.low { color: #2da44e; background: color-mix(in srgb, #2da44e 12%, transparent); }
.ai-review-risk.medium { color: #b96500; background: color-mix(in srgb, #d9891b 13%, transparent); }
.ai-review-risk.high { color: #d1242f; background: color-mix(in srgb, #d1242f 12%, transparent); }
.ai-review-findings { min-height: 0; padding: 10px; overflow: auto; background: var(--app-dialog-bg); }
.ai-review-finding {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 8px;
padding: 12px;
border: 1px solid var(--color-border-subtle);
border-left: 3px solid var(--color-border);
border-radius: 7px;
background: var(--color-surface-solid);
}
.ai-review-finding + .ai-review-finding { margin-top: 8px; }
.ai-review-finding.critical { border-left-color: #d1242f; }
.ai-review-finding.warning { border-left-color: #d9891b; }
.ai-review-finding.info { border-left-color: var(--color-primary); }
.ai-review-finding-icon { display: grid; place-items: start center; padding-top: 1px; color: var(--color-primary); }
.ai-review-finding.critical .ai-review-finding-icon { color: #d1242f; }
.ai-review-finding.warning .ai-review-finding-icon { color: #d9891b; }
.ai-review-finding-title { display: flex; align-items: center; gap: 7px; }
.ai-review-finding-title span { color: var(--color-ink-faint); font-size: 9px; font-weight: 850; letter-spacing: .07em; text-transform: uppercase; }
.ai-review-finding-title strong { color: var(--color-ink); font-size: 12.5px; }
.ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); }
.ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); }
.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); }
.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; }
.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; }
.ai-review-clean-state { display: grid; place-items: center; align-content: center; min-height: 280px; gap: 8px; color: #2da44e; text-align: center; }
.ai-review-clean-state strong { color: var(--color-ink); font-size: 14px; }
.ai-review-clean-state span { color: var(--color-ink-faint); font-size: 12px; }
.ai-review-footer p { margin: 0; color: var(--color-ink-faint); font-size: 10.5px; }
.ai-review-footer > div { display: flex; gap: 7px; }
@media (max-width: 720px) {
.commit-head-actions .pill-count { display: none; }
.commit-review-button,
.commit-generate-button { padding-inline: 8px; }
.ai-review-dialog { width: calc(100vw - 16px); height: calc(100vh - 16px); }
.ai-review-footer { align-items: stretch; flex-direction: column; }
.ai-review-footer > div { justify-content: flex-end; }
}
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts">
import { AlertTriangle, CircleAlert, FileCode, Info, LoaderCircle, RotateCw, ShieldCheck, X } from "@lucide/svelte";
import type { AiReviewFinding, AiReviewResult, CommitAiProvider } from "../types";
interface Props {
result: AiReviewResult;
provider: CommitAiProvider;
isReviewing: boolean;
onRerun: () => void;
onClose: () => void;
}
let { result, provider, isReviewing = false, onRerun, onClose }: Props = $props();
function providerLabel(value: CommitAiProvider): string {
if (value === "openai") return "OpenAI";
if (value === "anthropic") return "Anthropic";
if (value === "custom") return "Custom endpoint";
return "Local AI";
}
function locationLabel(finding: AiReviewFinding): string {
if (!finding.file) return "Repository-wide";
return finding.line ? `${finding.file}:${finding.line}` : finding.file;
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isReviewing) onClose(); }} />
<div class="dialog-backdrop" role="presentation">
<div class="dialog ai-review-dialog" role="dialog" aria-modal="true" aria-label="AI pre-commit review" tabindex="-1">
<header class="dialog-header ai-review-header">
<div>
<span class="eyebrow">Staged changes</span>
<h2>AI pre-commit review</h2>
</div>
<div class="dialog-header-actions">
<span class="ai-review-provider">{providerLabel(provider)}</span>
<button class="dialog-close" type="button" onclick={onClose} disabled={isReviewing} aria-label="Close review"><X size={18} aria-hidden="true" /></button>
</div>
</header>
<div class="ai-review-summary">
<div class="ai-review-summary-icon" class:clean={result.findings.length === 0}>
{#if result.findings.length === 0}<ShieldCheck size={22} aria-hidden="true" />{:else}<AlertTriangle size={22} aria-hidden="true" />{/if}
</div>
<div>
<div class="ai-review-summary-line">
<strong>{result.findings.length === 0 ? "No actionable issues found" : `${result.findings.length} review ${result.findings.length === 1 ? "finding" : "findings"}`}</strong>
<span class="ai-review-risk {result.risk}">{result.risk} risk</span>
</div>
<p>{result.summary}</p>
</div>
</div>
<div class="ai-review-findings">
{#if result.findings.length === 0}
<div class="ai-review-clean-state">
<ShieldCheck size={30} aria-hidden="true" />
<strong>The staged diff looks ready for human verification.</strong>
<span>AI reviews can miss issues. Run the relevant tests before committing.</span>
</div>
{:else}
{#each result.findings as finding, index (`${finding.file ?? "repo"}:${finding.line ?? 0}:${finding.title}:${index}`)}
<article class="ai-review-finding {finding.severity}">
<div class="ai-review-finding-icon">
{#if finding.severity === "critical"}<CircleAlert size={17} aria-hidden="true" />{:else if finding.severity === "warning"}<AlertTriangle size={17} aria-hidden="true" />{:else}<Info size={17} aria-hidden="true" />{/if}
</div>
<div class="ai-review-finding-body">
<div class="ai-review-finding-title">
<span>{finding.severity}</span>
<strong>{finding.title}</strong>
</div>
<p>{finding.description}</p>
<div class="ai-review-location"><FileCode size={13} aria-hidden="true" /><code>{locationLabel(finding)}</code></div>
{#if finding.suggestion}<div class="ai-review-suggestion"><strong>Suggested next step</strong><span>{finding.suggestion}</span></div>{/if}
</div>
</article>
{/each}
{/if}
</div>
<footer class="dialog-footer ai-review-footer">
<p>Review suggestions are advisory and never modify files automatically.</p>
<div>
<button class="btn-secondary" type="button" onclick={onRerun} disabled={isReviewing}>
{#if isReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<RotateCw size={14} aria-hidden="true" />{/if}
Review again
</button>
<button class="btn-primary" type="button" onclick={onClose} disabled={isReviewing}>Done</button>
</div>
</footer>
</div>
</div>
+17 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -13,11 +13,13 @@
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
commitAiReviewing: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onReviewStaged: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
@@ -34,11 +36,13 @@
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
commitAiReviewing = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onReviewStaged = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
@@ -61,9 +65,11 @@
hasRepository &&
!isBusy &&
!commitAiGenerating &&
!commitAiReviewing &&
stagedCount > 0 &&
(commitAiProvider !== "local" || commitAiPhase === "ready"),
);
let canReview = $derived(canGenerate && commitAiProvider !== "local");
</script>
<section class="panel commit-panel" aria-label="Commit">
@@ -74,6 +80,16 @@
</div>
<div class="commit-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<button
class="commit-review-button"
type="button"
onclick={onReviewStaged}
disabled={!canReview}
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
>
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
Review
</button>
<button
class="commit-generate-button"
type="button"
+11
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
AiReviewResult,
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
@@ -244,6 +245,16 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
});
}
export function commitAiReview(path: string, options: CommitAiGenerateOptions): Promise<AiReviewResult> {
return invoke<AiReviewResult>("commit_ai_review", {
path,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
}
+18
View File
@@ -19,6 +19,24 @@ export interface CommitAiStatus {
error: string | null;
}
export type AiReviewRisk = "low" | "medium" | "high";
export type AiReviewSeverity = "critical" | "warning" | "info";
export interface AiReviewFinding {
severity: AiReviewSeverity;
title: string;
description: string;
file: string | null;
line: number | null;
suggestion: string;
}
export interface AiReviewResult {
summary: string;
risk: AiReviewRisk;
findings: AiReviewFinding[];
}
export interface LocalModelOption {
id: string;
label: string;