Skip to content

Commit 7534260

Browse files
committed
feat: streaming AI code review, copy to agent, faster prompt
1 parent 6def112 commit 7534260

File tree

3 files changed

+177
-137
lines changed

3 files changed

+177
-137
lines changed

clif-pad-ide/src-tauri/src/commands/ai.rs

Lines changed: 75 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -518,31 +518,35 @@ pub struct CodeReviewResult {
518518

519519
#[tauri::command]
520520
pub async fn ai_review_code(
521+
window: tauri::Window,
521522
diff: String,
522523
staged_files: Vec<String>,
523524
model: String,
524525
api_key: Option<String>,
525526
provider: String,
526-
) -> Result<CodeReviewResult, String> {
527+
) -> Result<(), String> {
528+
let app = window.app_handle().clone();
529+
let label = window.label().to_string();
527530
let url = get_provider_url(&provider);
528531

529532
// Truncate diff if too long for faster processing
530-
let truncated_diff = if diff.len() > 8000 {
531-
format!("{}...\n[Diff truncated for performance]", &diff[..8000])
533+
let truncated_diff = if diff.len() > 6000 {
534+
format!("{}...\n[Truncated]", &diff[..6000])
532535
} else {
533536
diff.clone()
534537
};
535538

536539
let files_list = staged_files.join(", ");
540+
// Shorter, faster prompt
537541
let prompt = format!(
538-
r#"Review this code diff briefly. Files: {}
542+
r#"Quick review of: {}
539543
540544
{}
541545
542-
Return JSON only:
543-
{{"suggestions":[{{"file":"path","line":N,"severity":"warning|info","title":"issue","description":"why"}}],"summary":"one sentence"}}
546+
List issues in this format:
547+
**file:line** (severity): description
544548
545-
Only report real issues. Max 3 suggestions."#,
549+
Only real issues. Be brief."#,
546550
files_list, truncated_diff
547551
);
548552

@@ -554,8 +558,8 @@ Only report real issues. Max 3 suggestions."#,
554558
let mut request_body = json!({
555559
"model": model,
556560
"messages": api_messages,
557-
"stream": false,
558-
"max_tokens": 800,
561+
"stream": true,
562+
"max_tokens": 500,
559563
});
560564

561565
if provider == "openrouter" {
@@ -583,57 +587,71 @@ Only report real issues. Max 3 suggestions."#,
583587
}
584588
}
585589

586-
let response = req_builder
587-
.json(&request_body)
588-
.send()
589-
.await
590-
.map_err(|e| format!("Failed to send request: {}", e))?;
590+
// Spawn streaming task
591+
tokio::spawn(async move {
592+
let response = match req_builder.json(&request_body).send().await {
593+
Ok(resp) => resp,
594+
Err(e) => {
595+
let _ = app.emit_to(&label, "code_review_error", format!("Request failed: {}", e));
596+
return;
597+
}
598+
};
591599

592-
if !response.status().is_success() {
593-
let status = response.status();
594-
let body = response.text().await.unwrap_or_default();
595-
return Err(format!("API error {}: {}", status, body));
596-
}
600+
if !response.status().is_success() {
601+
let status = response.status();
602+
let body = response.text().await.unwrap_or_default();
603+
let _ = app.emit_to(&label, "code_review_error", format!("API error {}: {}", status, body));
604+
return;
605+
}
597606

598-
let json: serde_json::Value = response
599-
.json()
600-
.await
601-
.map_err(|e| format!("Failed to parse response: {}", e))?;
607+
// Emit start event with files being scanned
608+
let _ = app.emit_to(&label, "code_review_start", staged_files.clone());
602609

603-
let content = json["choices"][0]["message"]["content"]
604-
.as_str()
605-
.unwrap_or("{}");
606-
607-
// Parse the LLM response
608-
let parsed: serde_json::Value = serde_json::from_str(content)
609-
.unwrap_or_else(|_| json!({"suggestions": [], "summary": "Could not parse review response"}));
610-
611-
let suggestions: Vec<CodeReviewSuggestion> = parsed["suggestions"]
612-
.as_array()
613-
.map(|arr| {
614-
arr.iter()
615-
.filter_map(|s| {
616-
Some(CodeReviewSuggestion {
617-
file: s["file"].as_str()?.to_string(),
618-
line: s["line"].as_u64().map(|l| l as usize),
619-
severity: s["severity"].as_str().unwrap_or("info").to_string(),
620-
title: s["title"].as_str().unwrap_or("Issue").to_string(),
621-
description: s["description"].as_str().unwrap_or("").to_string(),
622-
suggestion: s["suggestion"].as_str().map(|s| s.to_string()),
623-
})
624-
})
625-
.collect()
626-
})
627-
.unwrap_or_default();
610+
// Stream the response
611+
let mut stream = response.bytes_stream();
612+
use futures::StreamExt;
628613

629-
let summary = parsed["summary"]
630-
.as_str()
631-
.unwrap_or("Review complete")
632-
.to_string();
614+
let mut buffer = String::new();
615+
let mut full_content = String::new();
616+
617+
while let Some(chunk_result) = stream.next().await {
618+
match chunk_result {
619+
Ok(chunk) => {
620+
let chunk_str = String::from_utf8_lossy(&chunk);
621+
buffer.push_str(&chunk_str);
633622

634-
Ok(CodeReviewResult {
635-
files_scanned: staged_files,
636-
suggestions,
637-
summary,
638-
})
623+
while let Some(newline_pos) = buffer.find('\n') {
624+
let line = buffer[..newline_pos].trim().to_string();
625+
buffer = buffer[newline_pos + 1..].to_string();
626+
627+
if line.is_empty() || !line.starts_with("data: ") {
628+
continue;
629+
}
630+
631+
let data = &line[6..];
632+
633+
if data == "[DONE]" {
634+
let _ = app.emit_to(&label, "code_review_done", &full_content);
635+
return;
636+
}
637+
638+
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
639+
if let Some(content) = parsed["choices"][0]["delta"]["content"].as_str() {
640+
full_content.push_str(content);
641+
let _ = app.emit_to(&label, "code_review_stream", content);
642+
}
643+
}
644+
}
645+
}
646+
Err(e) => {
647+
let _ = app.emit_to(&label, "code_review_error", format!("Stream error: {}", e));
648+
return;
649+
}
650+
}
651+
}
652+
653+
let _ = app.emit_to(&label, "code_review_done", &full_content);
654+
});
655+
656+
Ok(())
639657
}

clif-pad-ide/src/components/layout/RightSidebar.tsx

Lines changed: 76 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Component, Show, For, createSignal, createMemo, lazy, Suspense } from "solid-js";
1+
import { Component, Show, For, createSignal, createMemo, lazy, Suspense, onCleanup } from "solid-js";
22
import { projectRoot, openFile, openDiff, refreshFileTree } from "../../stores/fileStore";
3-
import { revealPath, renameEntry, deleteEntry, scanFilesSecurity, gitDiff, generateCommitMessage, getApiKey, aiReviewCode, type CodeReviewResult } from "../../lib/tauri";
3+
import { revealPath, renameEntry, deleteEntry, scanFilesSecurity, gitDiff, generateCommitMessage, getApiKey, aiReviewCode, onCodeReviewStart, onCodeReviewStream, onCodeReviewDone, onCodeReviewError } from "../../lib/tauri";
44
import { securityEnabled, setSecurityResults, setSecurityShowModal, securityShowModal } from "../../stores/securityStore";
55
import SecurityModal from "../security/SecurityModal";
66
import ContextMenu, { type ContextMenuItem } from "../explorer/ContextMenu";
@@ -16,6 +16,7 @@ import type { GitLogEntry } from "../../types/git";
1616
import { FileRow, GitGraphRow, PlusIcon } from "../git";
1717
import { ResizeHandle, GitSyncButton, SidebarToolbarButton } from "../ui";
1818
import { settings } from "../../stores/settingsStore";
19+
import type { UnlistenFn } from "@tauri-apps/api/event";
1920

2021
const FileTree = lazy(() => import("../explorer/FileTree"));
2122

@@ -78,10 +79,11 @@ const RightSidebar: Component<{ onOpenFolder?: () => void; onOpenRecent?: (path:
7879
const [isDraggingGitSplitter, setIsDraggingGitSplitter] = createSignal(false);
7980
const [searchQuery, setSearchQuery] = createSignal("");
8081
const [isAiReviewing, setIsAiReviewing] = createSignal(false);
81-
const [aiReviewResult, setAiReviewResult] = createSignal<CodeReviewResult | null>(null);
82+
const [aiReviewContent, setAiReviewContent] = createSignal("");
8283
const [aiReviewExpanded, setAiReviewExpanded] = createSignal(true);
83-
const [aiReviewAbortController, setAiReviewAbortController] = createSignal<AbortController | null>(null);
84+
const [aiReviewFiles, setAiReviewFiles] = createSignal<string[]>([]);
8485
let gitSplitContainerRef: HTMLDivElement | undefined;
86+
let aiReviewUnlisten: UnlistenFn | undefined;
8587

8688
async function handleGenerateCommitMessage() {
8789
if (isGeneratingMsg() || stagedFiles().length === 0) return;
@@ -110,56 +112,75 @@ const RightSidebar: Component<{ onOpenFolder?: () => void; onOpenRecent?: (path:
110112
async function handleAiReview() {
111113
if (isAiReviewing() || stagedFiles().length === 0) return;
112114
setIsAiReviewing(true);
113-
setAiReviewResult(null);
115+
setAiReviewContent("");
114116

115-
const controller = new AbortController();
116-
setAiReviewAbortController(controller);
117-
118-
try {
119-
const s = settings();
120-
const model = s.aiModel || "anthropic/claude-sonnet-4";
121-
const provider = s.aiProvider || "openrouter";
122-
const apiKey = await getApiKey(provider);
117+
const s = settings();
118+
const model = s.aiModel || "anthropic/claude-sonnet-4";
119+
const provider = s.aiProvider || "openrouter";
120+
const apiKey = await getApiKey(provider);
123121

124-
const diff = await gitDiff(projectRoot() || "", undefined);
125-
const stagedPaths = stagedFiles().map(f => f.path);
122+
const diff = await gitDiff(projectRoot() || "", undefined);
123+
const stagedPaths = stagedFiles().map(f => f.path);
126124

127-
const result = await aiReviewCode(diff, stagedPaths, model, apiKey, provider);
128-
setAiReviewResult(result);
129-
setAiReviewExpanded(true);
125+
// Set up event listeners for streaming
126+
const unlistenStart = await onCodeReviewStart((files) => {
127+
setAiReviewFiles(files);
128+
});
129+
130+
const unlistenStream = await onCodeReviewStream((chunk) => {
131+
setAiReviewContent(prev => prev + chunk);
132+
});
133+
134+
const unlistenDone = await onCodeReviewDone(() => {
135+
setIsAiReviewing(false);
136+
});
137+
138+
const unlistenError = await onCodeReviewError((error) => {
139+
console.error("AI review error:", error);
140+
setAiReviewContent(prev => prev + `\n\n**Error:** ${error}`);
141+
setIsAiReviewing(false);
142+
});
143+
144+
// Store unlisteners for cleanup
145+
aiReviewUnlisten = () => {
146+
unlistenStart();
147+
unlistenStream();
148+
unlistenDone();
149+
unlistenError();
150+
};
151+
152+
setAiReviewExpanded(true);
153+
154+
try {
155+
await aiReviewCode(diff, stagedPaths, model, apiKey, provider);
130156
} catch (e) {
131157
console.error("Failed to run AI code review:", e);
132-
} finally {
133158
setIsAiReviewing(false);
134-
setAiReviewAbortController(null);
135159
}
136160
}
137161

138162
function cancelAiReview() {
139-
const controller = aiReviewAbortController();
140-
if (controller) {
141-
controller.abort();
142-
setIsAiReviewing(false);
143-
setAiReviewAbortController(null);
163+
// Currently no backend cancel for this - just reset state
164+
if (aiReviewUnlisten) {
165+
aiReviewUnlisten();
144166
}
167+
setIsAiReviewing(false);
168+
setAiReviewContent(prev => prev + "\n\n*[Cancelled]*");
145169
}
146170

147171
function copyAiReviewToAgent() {
148-
if (!aiReviewResult()) return;
149-
const result = aiReviewResult()!;
150-
let text = `## AI Code Review\n\n**Files scanned:** ${result.files_scanned.join(", ")}\n\n`;
151-
text += `**Summary:** ${result.summary}\n\n`;
152-
if (result.suggestions.length > 0) {
153-
text += `### Suggestions\n\n`;
154-
for (const s of result.suggestions) {
155-
text += `**${s.severity.toUpperCase()}**: ${s.file}${s.line ? `:${s.line}` : ""} - ${s.title}\n`;
156-
text += `${s.description}\n`;
157-
if (s.suggestion) text += `Suggestion: ${s.suggestion}\n`;
158-
text += "\n";
159-
}
160-
}
172+
const content = aiReviewContent();
173+
if (!content) return;
174+
175+
const text = `## AI Code Review\n\n**Files:** ${aiReviewFiles().join(", ")}\n\n${content}`;
161176
navigator.clipboard.writeText(text);
162177
}
178+
179+
onCleanup(() => {
180+
if (aiReviewUnlisten) {
181+
aiReviewUnlisten();
182+
}
183+
});
163184

164185
function handleGitSplitterMouseDown(e: MouseEvent) {
165186
e.preventDefault();
@@ -941,33 +962,30 @@ const RightSidebar: Component<{ onOpenFolder?: () => void; onOpenRecent?: (path:
941962

942963
{/* Expandable content */}
943964
<Show when={aiReviewExpanded()}>
944-
<div class="px-2 py-1.5" style={{ "max-height": "150px", "overflow-y": "auto" }}>
945-
<Show when={isAiReviewing()}>
965+
<div class="px-2 py-1.5" style={{ "max-height": "200px", "overflow-y": "auto" }}>
966+
<Show when={isAiReviewing() && !aiReviewContent()}>
946967
<div class="flex items-center gap-2 text-xs" style={{ color: "var(--text-muted)" }}>
947968
<SpinnerIcon />
948969
<span>Analyzing staged changes...</span>
949970
</div>
950971
</Show>
951-
<Show when={aiReviewResult()}>
952-
<div class="text-xs" style={{ color: "var(--text-muted)", "margin-bottom": "8px" }}>
953-
{aiReviewResult()!.summary}
972+
<Show when={aiReviewContent()}>
973+
<div
974+
class="text-xs"
975+
style={{
976+
color: "var(--text-primary)",
977+
"white-space": "pre-wrap",
978+
"word-break": "break-word",
979+
"line-height": "1.5",
980+
}}
981+
>
982+
{aiReviewContent()}
954983
</div>
955-
<Show when={aiReviewResult()!.suggestions.length > 0}>
956-
<For each={aiReviewResult()!.suggestions}>
957-
{(s) => (
958-
<div class="mb-2 p-1.5 rounded" style={{ background: "var(--bg-hover)", "font-size": "0.75em" }}>
959-
<div class="flex items-center gap-1" style={{ color: s.severity === "warning" ? "var(--accent-yellow)" : "var(--text-secondary)" }}>
960-
<span style={{ "font-weight": 600 }}>{s.severity.toUpperCase()}</span>
961-
<span style={{ color: "var(--text-muted)" }}>{s.file}{s.line ? `:${s.line}` : ""}</span>
962-
</div>
963-
<div style={{ color: "var(--text-primary)", "margin-top": "2px" }}>{s.title}</div>
964-
</div>
965-
)}
966-
</For>
984+
<Show when={!isAiReviewing()}>
967985
<button
968986
type="button"
969987
onClick={copyAiReviewToAgent}
970-
class="w-full py-1 rounded text-xs transition-colors"
988+
class="w-full mt-2 py-1 rounded text-xs transition-colors"
971989
style={{
972990
background: "var(--accent-primary)",
973991
color: "var(--accent-text, #fff)",
@@ -978,15 +996,10 @@ const RightSidebar: Component<{ onOpenFolder?: () => void; onOpenRecent?: (path:
978996
Copy to Agent
979997
</button>
980998
</Show>
981-
<Show when={aiReviewResult()!.suggestions.length === 0}>
982-
<div class="text-xs" style={{ color: "var(--accent-green)" }}>
983-
No issues found in staged changes.
984-
</div>
985-
</Show>
986999
</Show>
987-
<Show when={!isAiReviewing() && !aiReviewResult()}>
1000+
<Show when={!isAiReviewing() && !aiReviewContent()}>
9881001
<div class="text-xs" style={{ color: "var(--text-muted)" }}>
989-
Stage files and click AI scan to review
1002+
Click the AI button to analyze staged changes.
9901003
</div>
9911004
</Show>
9921005
</div>

0 commit comments

Comments
 (0)