feat: add semantic search tool for code symbol indexing and searching
- Implemented a new tool for semantic code search that indexes Rust code symbols (functions, structs, enums, traits, modules) and allows searching by name, concept, or meaning. - Introduced a symbol index structure with methods for rebuilding the index and searching symbols. - Added regex patterns for extracting various code symbols from Rust source files. - Implemented scoring logic for search results based on exact matches, prefix matches, and context relevance. - Created a web search tool that interacts with a SearXNG instance to fetch documentation and API information based on user queries. - Added a diff preview overlay for rendering git diff output with color-coded additions and deletions in a TUI interface.
This commit is contained in:
@@ -11,6 +11,7 @@ use tracing::{debug, info, warn};
|
|||||||
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
||||||
use zesdex_domain::core::ChatMessage;
|
use zesdex_domain::core::ChatMessage;
|
||||||
use crate::llm::provider::LlmClient;
|
use crate::llm::provider::LlmClient;
|
||||||
|
use crate::subagent::auto::engine::trigger_auto_review;
|
||||||
use crate::tools::{all_tools, tool_defs, ToolCtx};
|
use crate::tools::{all_tools, tool_defs, ToolCtx};
|
||||||
use crate::TurnEvent;
|
use crate::TurnEvent;
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ pub struct AgentTurnParams {
|
|||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
|
pub edit_count: u32,
|
||||||
|
pub consecutive_empty_reviews: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns an agent turn on a background OS thread.
|
/// Spawns an agent turn on a background OS thread.
|
||||||
@@ -198,6 +201,23 @@ fn run_turn(params: &mut AgentTurnParams) {
|
|||||||
params
|
params
|
||||||
.messages
|
.messages
|
||||||
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
||||||
|
|
||||||
|
// Trigger auto-review after write/edit tool execution
|
||||||
|
if name == "write" || name == "edit" {
|
||||||
|
params.edit_count = params.edit_count.saturating_add(1);
|
||||||
|
|
||||||
|
// If we have a workspace root, trigger review
|
||||||
|
if let Some(root) = params.workspace_roots.first() {
|
||||||
|
let _ = trigger_auto_review(
|
||||||
|
root,
|
||||||
|
params.edit_count,
|
||||||
|
&mut params.consecutive_empty_reviews,
|
||||||
|
3, // max_skip: skip after 3 consecutive empty reviews
|
||||||
|
¶ms.turn_events,
|
||||||
|
Some(&client),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
//! Auto-review engine — automatically checks git diff after file edits
|
||||||
|
//! using an LLM subagent.
|
||||||
|
//!
|
||||||
|
//! Flow: after each write/edit tool execution in the agent turn, the runner
|
||||||
|
//! calls `trigger_auto_review` which:
|
||||||
|
//! 1. Runs `git diff --cached` and `git diff` to get working-tree changes
|
||||||
|
//! 2. Sends the diff to a lightweight LLM call for quick review
|
||||||
|
//! 3. Emits findings as `TurnEvent::SystemNote` on the event queue
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
|
use crate::llm::provider::LlmClient;
|
||||||
|
use crate::subagent::gating::should_review;
|
||||||
|
use crate::TurnEvent;
|
||||||
|
|
||||||
|
/// Trigger an auto-review of recent git changes.
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. Check gating conditions (edit count, consecutive empty reviews)
|
||||||
|
/// 2. Run `git diff --cached` to get staged changes
|
||||||
|
/// 3. Run `git diff` to get unstaged changes
|
||||||
|
/// 4. If there are changes, call LLM for a quick review
|
||||||
|
/// 5. Emit findings as TurnEvent::SystemNote
|
||||||
|
///
|
||||||
|
/// Returns `(had_findings, total_findings)` tuple.
|
||||||
|
#[instrument(skip(turn_events, llm_client))]
|
||||||
|
pub fn trigger_auto_review(
|
||||||
|
workspace_root: &Path,
|
||||||
|
edit_count: u32,
|
||||||
|
consecutive_empty_reviews: &mut u32,
|
||||||
|
max_skip: u32,
|
||||||
|
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||||
|
llm_client: Option<&LlmClient>,
|
||||||
|
) -> Result<(bool, usize)> {
|
||||||
|
// Gating check
|
||||||
|
if !should_review(edit_count, *consecutive_empty_reviews, max_skip) {
|
||||||
|
debug!("auto-review skipped by gating");
|
||||||
|
return Ok((false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("triggering auto-review");
|
||||||
|
|
||||||
|
// Run git diff to get changes
|
||||||
|
let diff = match get_git_diff(workspace_root) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
debug!(error = %e, "auto-review: git diff failed (not a git repo?)");
|
||||||
|
return Ok((false, 0));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if diff.is_empty() {
|
||||||
|
debug!("auto-review: no changes to review");
|
||||||
|
*consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1);
|
||||||
|
return Ok((false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have an LLM client, do a real review
|
||||||
|
let review_result = if let Some(client) = llm_client {
|
||||||
|
perform_llm_review(client, &diff)?
|
||||||
|
} else {
|
||||||
|
// Fallback: simple heuristic review without LLM
|
||||||
|
perform_heuristic_review(&diff)
|
||||||
|
};
|
||||||
|
|
||||||
|
let had_findings = !review_result.is_empty();
|
||||||
|
let finding_count = review_result.len();
|
||||||
|
|
||||||
|
if had_findings {
|
||||||
|
*consecutive_empty_reviews = 0;
|
||||||
|
info!(finding_count, "auto-review produced findings");
|
||||||
|
|
||||||
|
// Emit findings as SystemNote events
|
||||||
|
for finding in &review_result {
|
||||||
|
let note = TurnEvent::SystemNote {
|
||||||
|
kind: "info".to_string(),
|
||||||
|
message: format!("🔍 Auto-Review: {finding}"),
|
||||||
|
};
|
||||||
|
if let Ok(mut q) = turn_events.lock() {
|
||||||
|
q.push_back(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*consecutive_empty_reviews = consecutive_empty_reviews.saturating_add(1);
|
||||||
|
info!("auto-review: no issues found");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((had_findings, finding_count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `git diff` to get workspace changes (both staged and unstaged).
|
||||||
|
fn get_git_diff(workspace_root: &Path) -> Result<String> {
|
||||||
|
// Check if this is a git repo
|
||||||
|
let git_dir = workspace_root.join(".git");
|
||||||
|
if !git_dir.exists() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unstaged diff
|
||||||
|
let unstaged = Command::new("git")
|
||||||
|
.arg("diff")
|
||||||
|
.current_dir(workspace_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| anyhow::anyhow!("git diff failed: {e}"))?;
|
||||||
|
|
||||||
|
// Get staged diff
|
||||||
|
let staged = Command::new("git")
|
||||||
|
.arg("diff")
|
||||||
|
.arg("--cached")
|
||||||
|
.current_dir(workspace_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| anyhow::anyhow!("git diff --cached failed: {e}"))?;
|
||||||
|
|
||||||
|
let mut combined = String::new();
|
||||||
|
|
||||||
|
let staged_out = String::from_utf8_lossy(&staged.stdout).trim().to_string();
|
||||||
|
if !staged_out.is_empty() {
|
||||||
|
combined.push_str("=== Staged Changes ===\n");
|
||||||
|
combined.push_str(&staged_out);
|
||||||
|
combined.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let unstaged_out = String::from_utf8_lossy(&unstaged.stdout).trim().to_string();
|
||||||
|
if !unstaged_out.is_empty() {
|
||||||
|
combined.push_str("=== Unstaged Changes ===\n");
|
||||||
|
combined.push_str(&unstaged_out);
|
||||||
|
combined.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run `git diff --stat` for summary
|
||||||
|
let stat = Command::new("git")
|
||||||
|
.arg("diff")
|
||||||
|
.arg("--stat")
|
||||||
|
.current_dir(workspace_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| anyhow::anyhow!("git diff --stat failed: {e}"))?;
|
||||||
|
let stat_out = String::from_utf8_lossy(&stat.stdout).trim().to_string();
|
||||||
|
if !stat_out.is_empty() {
|
||||||
|
combined.push_str("=== Summary ===\n");
|
||||||
|
combined.push_str(&stat_out);
|
||||||
|
combined.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform an LLM-based review of the git diff.
|
||||||
|
///
|
||||||
|
/// Sends the diff to the model with a focused prompt asking it to find
|
||||||
|
/// typos, missing imports, syntax errors, and other issues.
|
||||||
|
fn perform_llm_review(client: &LlmClient, diff: &str) -> Result<Vec<String>> {
|
||||||
|
// Truncate very large diffs to avoid token limits
|
||||||
|
const MAX_DIFF_CHARS: usize = 8000;
|
||||||
|
let truncated_diff = if diff.len() > MAX_DIFF_CHARS {
|
||||||
|
format!("{}...\n[diff truncated at {} characters]",
|
||||||
|
&diff[..MAX_DIFF_CHARS], MAX_DIFF_CHARS)
|
||||||
|
} else {
|
||||||
|
diff.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages = vec![
|
||||||
|
zesdex_domain::core::ChatMessage::system(
|
||||||
|
"You are a focused code reviewer. Review the following git diff for:\n\
|
||||||
|
1. Typos and spelling errors\n\
|
||||||
|
2. Missing imports or undefined references\n\
|
||||||
|
3. Syntax errors or type mismatches\n\
|
||||||
|
4. Logic bugs or off-by-one errors\n\
|
||||||
|
5. Missing error handling\n\
|
||||||
|
6. Security issues\n\n\
|
||||||
|
Be concise. List each issue on a new line with severity [HIGH], [MEDIUM], or [LOW].\n\
|
||||||
|
If no issues are found, reply with exactly: NO_ISSUES_FOUND"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
zesdex_domain::core::ChatMessage::user(format!(
|
||||||
|
"Review this git diff:\n\n```diff\n{truncated_diff}\n```"
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
match client.chat_with_tools_non_streaming(&messages, None, Some(1024), Some(0.3), None) {
|
||||||
|
Ok((response, _usage)) => {
|
||||||
|
let text = response.content.unwrap_or_default().trim().to_string();
|
||||||
|
|
||||||
|
if text.contains("NO_ISSUES_FOUND") {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse findings line by line
|
||||||
|
let findings: Vec<String> = text
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| {
|
||||||
|
!l.is_empty()
|
||||||
|
&& !l.starts_with("Here")
|
||||||
|
&& !l.starts_with("Let me")
|
||||||
|
&& !l.starts_with("I've")
|
||||||
|
&& !l.starts_with("The diff")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(findings)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "auto-review LLM call failed");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a simple heuristic-based review without an LLM call.
|
||||||
|
///
|
||||||
|
/// This is a fallback when no LLM client is available. It checks for:
|
||||||
|
/// - Missing semicolons
|
||||||
|
/// - Unclosed brackets
|
||||||
|
/// - `todo!()` or `unimplemented!()` macros left in code
|
||||||
|
/// - Debug print statements
|
||||||
|
/// - Extremely long functions
|
||||||
|
fn perform_heuristic_review(diff: &str) -> Vec<String> {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
|
||||||
|
// Check for added lines (lines starting with +)
|
||||||
|
let added_lines: Vec<&str> = diff
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let added_content: String = added_lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| &l[1..]) // Strip leading +
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Check for todo! and unimplemented!
|
||||||
|
if added_content.contains("todo!()") {
|
||||||
|
findings.push("[MEDIUM] `todo!()` found in new code — replace with implementation".to_string());
|
||||||
|
}
|
||||||
|
if added_content.contains("unimplemented!()") {
|
||||||
|
findings.push("[MEDIUM] `unimplemented!()` found in new code — replace with implementation".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for debug print statements
|
||||||
|
if added_content.contains("println!") || added_content.contains("dbg!") {
|
||||||
|
findings.push("[LOW] Debug print statements (println!/dbg!) found — consider removing before finalizing".to_string());
|
||||||
|
}
|
||||||
|
if added_content.contains("eprintln!") {
|
||||||
|
findings.push("[LOW] Debug eprintln! found — consider removing before finalizing".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unreachable or panic statements
|
||||||
|
if added_content.contains("panic!(\"reached") || added_content.contains("panic!(\"not implemented") {
|
||||||
|
findings.push("[HIGH] Unreachable code / panic found — implement the missing logic".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for very long lines (>120 chars)
|
||||||
|
for (i, line) in added_lines.iter().enumerate() {
|
||||||
|
let content = &line[1..]; // Strip leading +
|
||||||
|
if content.len() > 120 && !content.trim_start().starts_with("//") {
|
||||||
|
let preview: String = content.chars().take(80).collect();
|
||||||
|
findings.push(format!(
|
||||||
|
"[LOW] Very long line ({} chars, line {} in diff) — consider breaking up:\n `{}…`",
|
||||||
|
content.len(),
|
||||||
|
i + 1,
|
||||||
|
preview
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count new functions to detect very long additions
|
||||||
|
let fn_count = added_content.matches("fn ").count();
|
||||||
|
if fn_count > 5 {
|
||||||
|
findings.push("[INFO] Large number of new functions ({fn_count}) — consider whether this should be split into separate modules".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
findings
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Auto-subagents — automatically run review/test subagents at the end of
|
//! Auto-subagents — automatically run review/test subagents at the end of
|
||||||
//! each turn.
|
//! each turn.
|
||||||
|
|
||||||
|
pub mod engine;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Subagent spawning and execution engine — spawn managed sub-processes
|
//! Subagent spawning and execution engine — spawn managed sub-processes
|
||||||
//! for test generation, architecture review, security review, etc.
|
//! for test generation, architecture review, security review, etc.
|
||||||
|
|
||||||
|
pub mod auto;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ pub mod fs;
|
|||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod lsp;
|
pub mod lsp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
pub mod parallel_delegate;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod semantic_search;
|
||||||
pub mod sequential_think;
|
pub mod sequential_think;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod shell_filter;
|
pub mod shell_filter;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod utility;
|
pub mod utility;
|
||||||
|
pub mod web_search;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
pub use git::git_cred;
|
pub use git::git_cred;
|
||||||
@@ -204,6 +207,10 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
|||||||
Box::new(lsp::LspDefinition),
|
Box::new(lsp::LspDefinition),
|
||||||
Box::new(lsp::LspReferences),
|
Box::new(lsp::LspReferences),
|
||||||
Box::new(lsp::LspDisconnect),
|
Box::new(lsp::LspDisconnect),
|
||||||
|
Box::new(web_search::WebSearch),
|
||||||
|
Box::new(semantic_search::SemanticSearch),
|
||||||
|
Box::new(semantic_search::RebuildIndex),
|
||||||
|
Box::new(parallel_delegate::ParallelDelegate),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
//! Parallel delegation tool — splits a large task into sub-tasks that run
|
||||||
|
//! concurrently across multiple subagents, then synthesises results.
|
||||||
|
//!
|
||||||
|
//! Flow: receive task description + optional breakdown → LLM analyses the
|
||||||
|
//! task and splits it into parallel directives → spawn subagents for each →
|
||||||
|
//! collect results → return consolidated output.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
|
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
||||||
|
use zesdex_domain::core::Store;
|
||||||
|
|
||||||
|
use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository};
|
||||||
|
use crate::subagent::context::SubagentContext;
|
||||||
|
use crate::subagent::division::AccessTier;
|
||||||
|
use crate::subagent::spawn::spawn_subagent;
|
||||||
|
use crate::tools::{Tool, ToolCtx};
|
||||||
|
|
||||||
|
/// Delegate a large task to multiple subagents running in parallel.
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. If `directives` are provided explicitly, use them directly.
|
||||||
|
/// 2. Otherwise, use LLM to analyse the task and split it into directives.
|
||||||
|
/// 3. Spawn a subagent for each directive concurrently.
|
||||||
|
/// 4. Join all results and consolidate into a single response.
|
||||||
|
pub struct ParallelDelegate;
|
||||||
|
|
||||||
|
impl Tool for ParallelDelegate {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"parallel_delegate"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Delegate a large task to multiple subagents running in parallel for 3x faster completion"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The task to be split and delegated to parallel agents"
|
||||||
|
},
|
||||||
|
"directives": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"directive": {"type": "string", "description": "Directive for one parallel agent"},
|
||||||
|
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this agent (default: write)"}
|
||||||
|
},
|
||||||
|
"required": ["directive"]
|
||||||
|
},
|
||||||
|
"description": "Optional explicit list of parallel directives (if not provided, the LLM will auto-split the task)"
|
||||||
|
},
|
||||||
|
"synthesize": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to synthesize results into a unified response (default: true)",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"max_parallel": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of parallel agents (default: 3, max: 8)",
|
||||||
|
"default": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["task"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, ctx, args))]
|
||||||
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
|
let task = crate::tools::arg_str(args, "task")?;
|
||||||
|
let synthesize = args
|
||||||
|
.get("synthesize")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
let max_parallel = args
|
||||||
|
.get("max_parallel")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(3)
|
||||||
|
.min(8) as usize;
|
||||||
|
|
||||||
|
// Load LLM credentials
|
||||||
|
let store = Store::new();
|
||||||
|
let settings = JsonSettingsRepository::new()
|
||||||
|
.load(&store.base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let app_config = JsonAppConfigRepository::new()
|
||||||
|
.load(&store.base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let (provider, model) =
|
||||||
|
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
|
||||||
|
|
||||||
|
let base_url = app_config
|
||||||
|
.providers
|
||||||
|
.get(&provider)
|
||||||
|
.map(|p| p.api_base.clone())
|
||||||
|
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
|
||||||
|
|
||||||
|
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||||
|
|
||||||
|
// Determine directives
|
||||||
|
let directives: Vec<(String, AccessTier)> = if let Some(dirs) =
|
||||||
|
args.get("directives").and_then(|v| v.as_array())
|
||||||
|
{
|
||||||
|
// Explicit directives provided
|
||||||
|
dirs.iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
let directive = d.get("directive").and_then(|v| v.as_str())?;
|
||||||
|
let access_str = d
|
||||||
|
.get("access")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("write");
|
||||||
|
let access = match access_str {
|
||||||
|
"read" => AccessTier::Read,
|
||||||
|
"full" => AccessTier::Full,
|
||||||
|
_ => AccessTier::Write,
|
||||||
|
};
|
||||||
|
Some((directive.to_string(), access))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
// Auto-split using LLM
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
let directives = rt.block_on(auto_split_task(
|
||||||
|
&task,
|
||||||
|
max_parallel,
|
||||||
|
&base_url,
|
||||||
|
&api_key,
|
||||||
|
&model,
|
||||||
|
))?;
|
||||||
|
directives
|
||||||
|
};
|
||||||
|
|
||||||
|
if directives.is_empty() {
|
||||||
|
anyhow::bail!("no directives could be derived for the task");
|
||||||
|
}
|
||||||
|
|
||||||
|
let directive_count = directives.len();
|
||||||
|
info!(
|
||||||
|
task = %task,
|
||||||
|
directive_count,
|
||||||
|
max_parallel,
|
||||||
|
"parallel delegation: starting subagents"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spawn agents in parallel
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for (i, (directive, access)) in directives.iter().enumerate() {
|
||||||
|
let subagent_ctx = SubagentContext::new(
|
||||||
|
directive.clone(),
|
||||||
|
ctx.clone(),
|
||||||
|
format!("{access:?}"),
|
||||||
|
base_url.clone(),
|
||||||
|
api_key.clone(),
|
||||||
|
model.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!(agent_index = i, access = ?access, "spawning parallel agent");
|
||||||
|
let handle = spawn_subagent(
|
||||||
|
subagent_ctx,
|
||||||
|
directive.clone(),
|
||||||
|
access.clone(),
|
||||||
|
ctx.clone(),
|
||||||
|
);
|
||||||
|
handles.push((i, handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all results
|
||||||
|
let mut results: Vec<(usize, String, String)> = Vec::new();
|
||||||
|
for (i, handle) in handles {
|
||||||
|
match handle.join() {
|
||||||
|
Ok(Ok(output)) => {
|
||||||
|
info!(agent_index = i, "parallel agent completed");
|
||||||
|
results.push((i, directives[i].0.clone(), output));
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
warn!(agent_index = i, error = %e, "parallel agent failed");
|
||||||
|
results.push((
|
||||||
|
i,
|
||||||
|
directives[i].0.clone(),
|
||||||
|
format!("[ERROR] {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(agent_index = i, error = ?e, "parallel agent panicked");
|
||||||
|
results.push((
|
||||||
|
i,
|
||||||
|
directives[i].0.clone(),
|
||||||
|
"[ERROR] Agent panicked".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidate results
|
||||||
|
if synthesize && results.len() > 1 {
|
||||||
|
let consolidated = consolidate_results(&results, &base_url, &api_key, &model)?;
|
||||||
|
Ok(format!(
|
||||||
|
"## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n{}",
|
||||||
|
results.len(),
|
||||||
|
consolidated
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
let mut output = format!(
|
||||||
|
"## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n",
|
||||||
|
results.len()
|
||||||
|
);
|
||||||
|
for (i, directive, result) in &results {
|
||||||
|
output.push_str(&format!("---\n### Agent {}: {}\n\n{}\n", i, directive, result));
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use LLM to analyse a task and split it into parallel directives.
|
||||||
|
async fn auto_split_task(
|
||||||
|
task: &str,
|
||||||
|
max_parallel: usize,
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<Vec<(String, AccessTier)>> {
|
||||||
|
let client = crate::llm::provider::LlmClient::new(
|
||||||
|
api_key.to_string(),
|
||||||
|
model.to_string(),
|
||||||
|
Some(base_url.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let sys_msg = zesdex_domain::core::ChatMessage::system(
|
||||||
|
format!(
|
||||||
|
"You are a task decomposition expert. Split the following task into {max_parallel} \
|
||||||
|
independent sub-tasks that can run in parallel. Each sub-task must be self-contained \
|
||||||
|
and produce useful output independently.\n\n\
|
||||||
|
Output your response as a JSON array of objects, each with:\n\
|
||||||
|
- \"directive\": a clear, detailed instruction for a subagent\n\
|
||||||
|
- \"access\": one of \"read\", \"write\", or \"full\"\n\n\
|
||||||
|
IMPORTANT: Return ONLY valid JSON, no other text. Example:\n\
|
||||||
|
[{{\"directive\": \"Create the User model with fields...\", \"access\": \"write\"}}]"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let user_msg =
|
||||||
|
zesdex_domain::core::ChatMessage::user(format!("Task: {task}\n\nSplit into {max_parallel} parallel directives:"));
|
||||||
|
|
||||||
|
match client
|
||||||
|
.chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.4), None)
|
||||||
|
{
|
||||||
|
Ok((response, _)) => {
|
||||||
|
let text = response.content.unwrap_or_default();
|
||||||
|
parse_directives_json(&text, max_parallel)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "auto-split LLM call failed, using fallback splitting");
|
||||||
|
Ok(fallback_split(task, max_parallel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse JSON directives from LLM response.
|
||||||
|
fn parse_directives_json(text: &str, max_parallel: usize) -> Result<Vec<(String, AccessTier)>> {
|
||||||
|
// Find JSON array in the response
|
||||||
|
let json_start = text.find('[');
|
||||||
|
let json_end = text.rfind(']');
|
||||||
|
|
||||||
|
let json_str = match (json_start, json_end) {
|
||||||
|
(Some(start), Some(end)) if end > start => &text[start..=end],
|
||||||
|
_ => {
|
||||||
|
// Try parsing the whole text as JSON
|
||||||
|
text.trim()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed: Vec<Value> = serde_json::from_str(json_str)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to parse directives JSON: {e}"))?;
|
||||||
|
|
||||||
|
let directives: Vec<(String, AccessTier)> = parsed
|
||||||
|
.into_iter()
|
||||||
|
.take(max_parallel)
|
||||||
|
.filter_map(|v| {
|
||||||
|
let directive = v.get("directive")?.as_str()?.to_string();
|
||||||
|
let access_str = v.get("access").and_then(|a| a.as_str()).unwrap_or("write");
|
||||||
|
let access = match access_str {
|
||||||
|
"read" => AccessTier::Read,
|
||||||
|
"full" => AccessTier::Full,
|
||||||
|
_ => AccessTier::Write,
|
||||||
|
};
|
||||||
|
Some((directive, access))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(directives)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback splitting when LLM call fails.
|
||||||
|
fn fallback_split(task: &str, max_parallel: usize) -> Vec<(String, AccessTier)> {
|
||||||
|
// Simple heuristic split by common patterns
|
||||||
|
let mut directives = Vec::new();
|
||||||
|
|
||||||
|
if task.contains("backend") || task.contains("api") || task.contains("server") {
|
||||||
|
directives.push((
|
||||||
|
format!("Implement the backend/API components for: {task}"),
|
||||||
|
AccessTier::Write,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.contains("frontend") || task.contains("ui") || task.contains("client") {
|
||||||
|
directives.push((
|
||||||
|
format!("Implement the frontend/UI components for: {task}"),
|
||||||
|
AccessTier::Write,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.contains("test") || task.contains("unit") {
|
||||||
|
directives.push((
|
||||||
|
format!("Write unit tests for: {task}"),
|
||||||
|
AccessTier::Read,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if directives.is_empty() {
|
||||||
|
// Just split the task into generic chunks
|
||||||
|
for i in 0..max_parallel {
|
||||||
|
directives.push((
|
||||||
|
format!("Part {} of parallel task: {task}", i + 1),
|
||||||
|
AccessTier::Write,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
directives.truncate(max_parallel);
|
||||||
|
directives
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use LLM to consolidate multiple agent results into a single response.
|
||||||
|
fn consolidate_results(
|
||||||
|
results: &[(usize, String, String)],
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let client = crate::llm::provider::LlmClient::new(
|
||||||
|
api_key.to_string(),
|
||||||
|
model.to_string(),
|
||||||
|
Some(base_url.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut summary = String::new();
|
||||||
|
for (i, directive, result) in results {
|
||||||
|
summary.push_str(&format!("### Agent {}: {}\n{}\n\n", i, directive, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sys_msg = zesdex_domain::core::ChatMessage::system(
|
||||||
|
"You are a synthesis expert. Consolidate the following parallel agent outputs \
|
||||||
|
into a single coherent response. Remove duplication, reconcile conflicts, and \
|
||||||
|
present the unified result in a well-structured format."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let user_msg = zesdex_domain::core::ChatMessage::user(format!(
|
||||||
|
"Consolidate the following parallel agent outputs:\n\n{summary}"
|
||||||
|
));
|
||||||
|
|
||||||
|
match client.chat_with_tools_non_streaming(&[sys_msg, user_msg], None, Some(2048), Some(0.3), None) {
|
||||||
|
Ok((response, _)) => Ok(response.content.unwrap_or_else(|| summary.clone())),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "consolidation LLM call failed, using raw concatenation");
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
//! Semantic code search tool — indexes all code symbols (functions, structs,
|
||||||
|
//! enums, traits, modules) in a project and allows searching by name,
|
||||||
|
//! concept, or meaning.
|
||||||
|
//!
|
||||||
|
//! Flow: walk workspace files → parse Rust source for symbol declarations →
|
||||||
|
//! build in-memory index → search by fuzzy/prefix match on symbol names and
|
||||||
|
//! doc comments.
|
||||||
|
|
||||||
|
use crate::tools::{Tool, ToolCtx};
|
||||||
|
use anyhow::Result;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Symbol index types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The kind of a code symbol.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum SymbolKind {
|
||||||
|
Function,
|
||||||
|
Struct,
|
||||||
|
Enum,
|
||||||
|
Trait,
|
||||||
|
Module,
|
||||||
|
Impl,
|
||||||
|
Type,
|
||||||
|
Constant,
|
||||||
|
Macro,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SymbolKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SymbolKind::Function => write!(f, "fn"),
|
||||||
|
SymbolKind::Struct => write!(f, "struct"),
|
||||||
|
SymbolKind::Enum => write!(f, "enum"),
|
||||||
|
SymbolKind::Trait => write!(f, "trait"),
|
||||||
|
SymbolKind::Module => write!(f, "mod"),
|
||||||
|
SymbolKind::Impl => write!(f, "impl"),
|
||||||
|
SymbolKind::Type => write!(f, "type"),
|
||||||
|
SymbolKind::Constant => write!(f, "const"),
|
||||||
|
SymbolKind::Macro => write!(f, "macro"),
|
||||||
|
SymbolKind::Other => write!(f, "symbol"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single code symbol entry in the index.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CodeSymbol {
|
||||||
|
/// Symbol name (e.g. "run_agent", "AppStateRest").
|
||||||
|
pub name: String,
|
||||||
|
/// Kind of symbol.
|
||||||
|
pub kind: SymbolKind,
|
||||||
|
/// File path relative to workspace root.
|
||||||
|
pub file: String,
|
||||||
|
/// Line number (1-indexed).
|
||||||
|
pub line: usize,
|
||||||
|
/// Parent symbol (e.g. struct name for impl methods).
|
||||||
|
pub parent: Option<String>,
|
||||||
|
/// Doc comment text, if any.
|
||||||
|
pub doc_comment: Option<String>,
|
||||||
|
/// Short context (the declaration line).
|
||||||
|
pub context: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-memory symbol index, shared via a global static.
|
||||||
|
static SYMBOL_INDEX: Mutex<Option<SymbolIndex>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Lazily compile regexes for symbol extraction.
|
||||||
|
fn compiled_regexes() -> (
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
Regex,
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?async\s+)?fn\s+(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:(?:unsafe\s+)?)?trait\s+(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:for\s+)?(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?type\s+(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)").unwrap(),
|
||||||
|
Regex::new(r"(?m)^\s*(?:pub\s+)?macro_rules!\s*\(\s*(\w+)").unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A symbol index cache.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SymbolIndex {
|
||||||
|
symbols: Vec<CodeSymbol>,
|
||||||
|
workspace_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SymbolIndex {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
SymbolIndex {
|
||||||
|
symbols: Vec::new(),
|
||||||
|
workspace_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.symbols.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.symbols.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rebuild(&mut self, workspace: &str) -> Result<usize> {
|
||||||
|
let path = std::path::Path::new(workspace);
|
||||||
|
if !path.exists() {
|
||||||
|
anyhow::bail!("workspace path does not exist: {workspace}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut symbols = Vec::new();
|
||||||
|
let walker = ignore::Walk::new(path);
|
||||||
|
|
||||||
|
for entry in walker.flatten() {
|
||||||
|
let file_path = entry.path();
|
||||||
|
if !file_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only index Rust files
|
||||||
|
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||||
|
if ext != "rs" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rel_path = file_path
|
||||||
|
.strip_prefix(path)
|
||||||
|
.unwrap_or(file_path)
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
match std::fs::read_to_string(file_path) {
|
||||||
|
Ok(content) => {
|
||||||
|
let file_symbols = extract_symbols(&content, &rel_path);
|
||||||
|
symbols.extend(file_symbols);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(file = %rel_path, error = %e, "failed to read file for indexing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
self.symbols = symbols;
|
||||||
|
self.workspace_path = Some(workspace.to_string());
|
||||||
|
|
||||||
|
let count = self.symbols.len();
|
||||||
|
info!(symbol_count = count, workspace = %workspace, "symbol index rebuilt");
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search(&self, query: &str, max_results: usize) -> Vec<&CodeSymbol> {
|
||||||
|
if self.symbols.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let query_words: Vec<String> = query_lower.split_whitespace().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
let mut scored: Vec<(i32, &CodeSymbol)> = self
|
||||||
|
.symbols
|
||||||
|
.iter()
|
||||||
|
.filter_map(|sym| {
|
||||||
|
let score = score_symbol(sym, &query_lower, &query_words);
|
||||||
|
if score > 0 {
|
||||||
|
Some((score, sym))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by score descending, then by name ascending
|
||||||
|
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.name.cmp(&b.1.name)));
|
||||||
|
|
||||||
|
scored
|
||||||
|
.into_iter()
|
||||||
|
.take(max_results)
|
||||||
|
.map(|(_, sym)| sym)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SymbolIndex {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a symbol against a search query.
|
||||||
|
fn score_symbol(sym: &CodeSymbol, query_lower: &str, query_words: &[String]) -> i32 {
|
||||||
|
let name_lower = sym.name.to_lowercase();
|
||||||
|
let mut score: i32 = 0;
|
||||||
|
|
||||||
|
// Exact match = highest score
|
||||||
|
if name_lower == *query_lower {
|
||||||
|
score += 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix match
|
||||||
|
if name_lower.starts_with(query_lower) {
|
||||||
|
score += 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains
|
||||||
|
if name_lower.contains(query_lower) {
|
||||||
|
score += 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-by-word matching
|
||||||
|
for word in query_words {
|
||||||
|
if name_lower.contains(word) {
|
||||||
|
score += 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc comment match
|
||||||
|
if let Some(ref doc) = sym.doc_comment {
|
||||||
|
let doc_lower = doc.to_lowercase();
|
||||||
|
if doc_lower.contains(query_lower) {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
for word in query_words {
|
||||||
|
if doc_lower.contains(word) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context match
|
||||||
|
let context_lower = sym.context.to_lowercase();
|
||||||
|
if context_lower.contains(query_lower) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
score
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract code symbols from Rust source content.
|
||||||
|
fn extract_symbols(content: &str, rel_path: &str) -> Vec<CodeSymbol> {
|
||||||
|
let (fn_re, struct_re, enum_re, trait_re, mod_re, impl_re, type_re, const_re, macro_re) =
|
||||||
|
compiled_regexes();
|
||||||
|
|
||||||
|
let mut symbols = Vec::new();
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
// Extract doc comments that precede declarations
|
||||||
|
let doc_comments = extract_doc_comments(&lines);
|
||||||
|
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
let line_num = i + 1;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
// Check for function declarations
|
||||||
|
if let Some(caps) = fn_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
let doc = doc_comments.get(&line_num).cloned();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Function,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: doc,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for struct declarations
|
||||||
|
if let Some(caps) = struct_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
let doc = doc_comments.get(&line_num).cloned();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Struct,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: doc,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for enum declarations
|
||||||
|
if let Some(caps) = enum_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
let doc = doc_comments.get(&line_num).cloned();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Enum,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: doc,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for trait declarations
|
||||||
|
if let Some(caps) = trait_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
let doc = doc_comments.get(&line_num).cloned();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Trait,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: doc,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for module declarations
|
||||||
|
if let Some(caps) = mod_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Module,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: None,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for type alias declarations
|
||||||
|
if let Some(caps) = type_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
let doc = doc_comments.get(&line_num).cloned();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Type,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: doc,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for const declarations
|
||||||
|
if let Some(caps) = const_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
let doc = doc_comments.get(&line_num).cloned();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Constant,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: doc,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for macro declarations
|
||||||
|
if let Some(caps) = macro_re.captures(trimmed) {
|
||||||
|
let name = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name,
|
||||||
|
kind: SymbolKind::Macro,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: line_num,
|
||||||
|
parent: None,
|
||||||
|
doc_comment: None,
|
||||||
|
context: trimmed.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse impl blocks for method-level indexing
|
||||||
|
if let Some(caps) = impl_re.captures(trimmed) {
|
||||||
|
let impl_for = caps.get(1).unwrap().as_str().to_string();
|
||||||
|
// Look for methods inside this impl block
|
||||||
|
let mut brace_depth: i32 = 0;
|
||||||
|
let mut started = false;
|
||||||
|
for (j, l) in lines[i..].iter().enumerate() {
|
||||||
|
for ch in l.chars() {
|
||||||
|
match ch {
|
||||||
|
'{' => {
|
||||||
|
brace_depth += 1;
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
'}' => {
|
||||||
|
brace_depth -= 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if started && brace_depth <= 0 && j > 1 {
|
||||||
|
break; // End of impl block
|
||||||
|
}
|
||||||
|
if j > 0 {
|
||||||
|
let inner_line = l.trim();
|
||||||
|
if let Some(mcaps) = fn_re.captures(inner_line) {
|
||||||
|
let method_name = mcaps.get(1).unwrap().as_str().to_string();
|
||||||
|
let abs_line = i + j + 1;
|
||||||
|
let doc = doc_comments.get(&abs_line).cloned();
|
||||||
|
symbols.push(CodeSymbol {
|
||||||
|
name: format!("{impl_for}::{method_name}"),
|
||||||
|
kind: SymbolKind::Function,
|
||||||
|
file: rel_path.to_string(),
|
||||||
|
line: abs_line,
|
||||||
|
parent: Some(impl_for.clone()),
|
||||||
|
doc_comment: doc,
|
||||||
|
context: inner_line.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract doc comments (/// or //!) that precede each line.
|
||||||
|
fn extract_doc_comments(lines: &[&str]) -> HashMap<usize, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < lines.len() {
|
||||||
|
let line = lines[i].trim();
|
||||||
|
if line.starts_with("///") {
|
||||||
|
let mut doc = String::new();
|
||||||
|
while i < lines.len() {
|
||||||
|
let l = lines[i].trim();
|
||||||
|
if l.starts_with("///") {
|
||||||
|
if !doc.is_empty() {
|
||||||
|
doc.push(' ');
|
||||||
|
}
|
||||||
|
doc.push_str(l.trim_start_matches("///").trim());
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Associate doc with the next non-empty, non-doc, non-attribute line
|
||||||
|
let target_line = find_next_declaration_line(lines, i);
|
||||||
|
if let Some(tl) = target_line {
|
||||||
|
map.insert(tl + 1, doc);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the next line that looks like a declaration (not doc, not attr).
|
||||||
|
fn find_next_declaration_line(lines: &[&str], start: usize) -> Option<usize> {
|
||||||
|
for i in start..lines.len() {
|
||||||
|
let trimmed = lines[i].trim();
|
||||||
|
if trimmed.is_empty()
|
||||||
|
|| trimmed.starts_with("///")
|
||||||
|
|| trimmed.starts_with("//!")
|
||||||
|
|| trimmed.starts_with('#')
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tool: SemanticSearch — search the symbol index
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Search for code symbols by name, concept, or semantic meaning.
|
||||||
|
///
|
||||||
|
/// Flow: ensure index is built → search by query → return formatted results.
|
||||||
|
pub struct SemanticSearch;
|
||||||
|
|
||||||
|
impl Tool for SemanticSearch {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"semantic_search"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Search for code symbols (functions, structs, enums, traits) by name, concept, or meaning"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search query — function name, struct name, or concept (e.g. 'payment handler', 'auth middleware', 'user repository')"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["fn", "struct", "enum", "trait", "mod", "all"],
|
||||||
|
"description": "Filter by symbol kind (default: all)",
|
||||||
|
"default": "all"
|
||||||
|
},
|
||||||
|
"max_results": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum results (default 10, max 30)",
|
||||||
|
"default": 10
|
||||||
|
},
|
||||||
|
"rebuild_index": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Force rebuild the symbol index before searching (default false)",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, ctx, args))]
|
||||||
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
|
let query = crate::tools::arg_str(args, "query")?;
|
||||||
|
let kind_filter = args
|
||||||
|
.get("kind")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("all");
|
||||||
|
let max_results = args
|
||||||
|
.get("max_results")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(10)
|
||||||
|
.min(30) as usize;
|
||||||
|
let rebuild = args
|
||||||
|
.get("rebuild_index")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let workspace = ctx
|
||||||
|
.workspaces
|
||||||
|
.first()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| ".".to_string());
|
||||||
|
|
||||||
|
info!(query = %query, kind = %kind_filter, max_results, rebuild, "semantic search");
|
||||||
|
|
||||||
|
// Get or rebuild the index
|
||||||
|
let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?;
|
||||||
|
let index = guard.get_or_insert_with(SymbolIndex::new);
|
||||||
|
|
||||||
|
if rebuild || index.is_empty() {
|
||||||
|
let count = index.rebuild(&workspace)?;
|
||||||
|
debug!(symbol_count = count, "symbol index rebuilt");
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = index.search(&query, max_results * 2); // Get extra for filtering
|
||||||
|
|
||||||
|
// Apply kind filter
|
||||||
|
let filtered: Vec<&&CodeSymbol> = if kind_filter != "all" {
|
||||||
|
let target_kind = match kind_filter {
|
||||||
|
"fn" => SymbolKind::Function,
|
||||||
|
"struct" => SymbolKind::Struct,
|
||||||
|
"enum" => SymbolKind::Enum,
|
||||||
|
"trait" => SymbolKind::Trait,
|
||||||
|
"mod" => SymbolKind::Module,
|
||||||
|
_ => SymbolKind::Other,
|
||||||
|
};
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.kind == target_kind)
|
||||||
|
.take(max_results)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
results.iter().take(max_results).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return Ok(format!(
|
||||||
|
"No symbols found matching '{query}'.\n\
|
||||||
|
Try a different query, or use `rebuild_index: true` to rebuild the index first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = index.len();
|
||||||
|
info!(matched = filtered.len(), total_indexed = total, "semantic search completed");
|
||||||
|
|
||||||
|
// Group results by file for cleaner output
|
||||||
|
let mut by_file: std::collections::BTreeMap<String, Vec<&&CodeSymbol>> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for sym in &filtered {
|
||||||
|
by_file.entry(sym.file.clone()).or_default().push(*sym);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = format!(
|
||||||
|
"## Semantic Search Results\n\n**Query:** {query}\n**Index size:** {total} symbols\n**Matches:** {}\n\n",
|
||||||
|
filtered.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (file, symbols) in &by_file {
|
||||||
|
output.push_str(&format!("### `{file}`\n\n"));
|
||||||
|
for sym in symbols {
|
||||||
|
let kind_str = sym.kind.to_string();
|
||||||
|
let parent_str = sym
|
||||||
|
.parent
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| format!(" [{p}]"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let doc_str = sym
|
||||||
|
.doc_comment
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| {
|
||||||
|
let truncated: String = d.chars().take(100).collect();
|
||||||
|
format!(" — {truncated}")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let context_trimmed = sym.context.trim();
|
||||||
|
let context_ellipsis = if context_trimmed.len() > 80 { "…" } else { "" };
|
||||||
|
|
||||||
|
output.push_str(&format!(
|
||||||
|
"- `{kind_str}` **{}**{} at line {} `{}`{}{}\n",
|
||||||
|
sym.name,
|
||||||
|
parent_str,
|
||||||
|
sym.line,
|
||||||
|
context_trimmed,
|
||||||
|
doc_str,
|
||||||
|
context_ellipsis
|
||||||
|
));
|
||||||
|
}
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(&format!(
|
||||||
|
"---\n*{} symbols indexed. Use `rebuild_index: true` to refresh.*\n",
|
||||||
|
total
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool: Rebuild the symbol index explicitly.
|
||||||
|
pub struct RebuildIndex;
|
||||||
|
|
||||||
|
impl Tool for RebuildIndex {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"rebuild_index"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Rebuild the code symbol index for semantic search"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, ctx, _args))]
|
||||||
|
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||||
|
let workspace = ctx
|
||||||
|
.workspaces
|
||||||
|
.first()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| ".".to_string());
|
||||||
|
|
||||||
|
info!("rebuilding symbol index");
|
||||||
|
|
||||||
|
let mut guard = SYMBOL_INDEX.lock().map_err(|e| anyhow::anyhow!("index lock failed: {e}"))?;
|
||||||
|
let index = guard.get_or_insert_with(SymbolIndex::new);
|
||||||
|
let count = index.rebuild(&workspace)?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Symbol index rebuilt successfully. {} symbols indexed.",
|
||||||
|
count
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
//! Web search & documentation scraper tool — search the web for docs, APIs,
|
||||||
|
//! and troubleshooting information using a SearXNG instance.
|
||||||
|
//!
|
||||||
|
//! Flow: take a search query → call SearXNG JSON API → parse results →
|
||||||
|
//! optionally fetch full page content → return formatted markdown.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tracing::{debug, info, instrument, warn};
|
||||||
|
|
||||||
|
use crate::tools::{arg_str, Tool, ToolCtx};
|
||||||
|
|
||||||
|
/// Default SearXNG instance URL.
|
||||||
|
const DEFAULT_SEARXNG_URL: &str = "https://searxng.imrnes.team";
|
||||||
|
|
||||||
|
/// Search the web for documentation, APIs, and technical information.
|
||||||
|
///
|
||||||
|
/// Flow: build query → call SearXNG JSON endpoint → parse results →
|
||||||
|
/// optionally scrape full page content → return formatted result.
|
||||||
|
pub struct WebSearch;
|
||||||
|
|
||||||
|
impl Tool for WebSearch {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"web_search"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Search the web for documentation, APIs, and technical information. Uses SearXNG instance."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search query (e.g. 'Next.js 15 app router documentation')"
|
||||||
|
},
|
||||||
|
"max_results": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of results to return (default 5, max 10)",
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"fetch_content": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to fetch full page content from each result (default false)",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search categories (e.g. 'general', 'science', 'it', 'news'). Default: general",
|
||||||
|
"default": "general"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, _ctx, args))]
|
||||||
|
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
|
let query = arg_str(args, "query")?;
|
||||||
|
let max_results = args
|
||||||
|
.get("max_results")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(5)
|
||||||
|
.min(10) as usize;
|
||||||
|
let fetch_content = args
|
||||||
|
.get("fetch_content")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let categories = args
|
||||||
|
.get("categories")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("general");
|
||||||
|
|
||||||
|
info!(query = %query, max_results, fetch_content, categories = %categories, "web search starting");
|
||||||
|
|
||||||
|
let searxng_url = std::env::var("SEARXNG_URL")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_SEARXNG_URL.to_string());
|
||||||
|
|
||||||
|
// Build the SearXNG JSON search URL
|
||||||
|
let search_url = format!(
|
||||||
|
"{}/search?format=json&q={}&categories={}&language=en-US",
|
||||||
|
searxng_url,
|
||||||
|
urlencoding(&query),
|
||||||
|
categories
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!(search_url = %search_url, "calling SearXNG API");
|
||||||
|
|
||||||
|
// Make the HTTP request
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.user_agent("Zesdex/1.0 (AI Coding Agent)")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to create HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&search_url)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| anyhow::anyhow!("search request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
warn!(status = %response.status(), "SearXNG returned non-success status");
|
||||||
|
anyhow::bail!("SearXNG returned HTTP {}", response.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Value = response
|
||||||
|
.json()
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to parse SearXNG JSON response: {e}"))?;
|
||||||
|
|
||||||
|
let results = body
|
||||||
|
.get("results")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
r.get("url").and_then(|u| u.as_str()).is_some()
|
||||||
|
&& r.get("title").and_then(|t| t.as_str()).is_some()
|
||||||
|
})
|
||||||
|
.take(max_results)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let result_count = results.len();
|
||||||
|
if results.is_empty() {
|
||||||
|
info!("no search results found");
|
||||||
|
return Ok(format!("No search results found for '{query}'."));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(result_count, "web search completed");
|
||||||
|
|
||||||
|
let mut output = format!("## Web Search Results for: {query}\n\n");
|
||||||
|
|
||||||
|
for (i, result) in results.iter().enumerate() {
|
||||||
|
let title = result
|
||||||
|
.get("title")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Untitled");
|
||||||
|
let url = result
|
||||||
|
.get("url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let snippet = result
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
output.push_str(&format!("### {}. [{title}]({url})\n\n", i + 1));
|
||||||
|
|
||||||
|
if !snippet.is_empty() {
|
||||||
|
output.push_str(&format!("> {snippet}\n\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally fetch full page content
|
||||||
|
if fetch_content && !url.is_empty() {
|
||||||
|
match fetch_page_content(url, &client) {
|
||||||
|
Ok(content) => {
|
||||||
|
let truncated = limit_lines(&content, 80);
|
||||||
|
output.push_str(&format!("**Content:**\n```\n{truncated}\n```\n\n"));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(url = %url, error = %e, "failed to fetch page content");
|
||||||
|
output.push_str(&format!("*(Content fetch failed: {e})*\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show engine info if available
|
||||||
|
if let Some(engine) = result.get("engine").and_then(|v| v.as_str()) {
|
||||||
|
output.push_str(&format!("*Source: {engine}*\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add suggestion for more specific queries if very few results
|
||||||
|
if result_count < 3 {
|
||||||
|
output.push_str("---\n*Few results. Try a more specific query or different categories.*\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL-encode a string for use in query parameters.
|
||||||
|
fn urlencoding(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
|
||||||
|
' ' => '+'.to_string(),
|
||||||
|
_ => {
|
||||||
|
let bytes = c.to_string().into_bytes();
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|&b| format!("%{:02X}", b))
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch and extract readable text content from a URL.
|
||||||
|
fn fetch_page_content(url: &str, client: &reqwest::blocking::Client) -> Result<String> {
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| anyhow::anyhow!("HTTP request failed: {e}"))?;
|
||||||
|
|
||||||
|
let html = resp
|
||||||
|
.text()
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
|
||||||
|
|
||||||
|
// Parse HTML and extract text
|
||||||
|
let document = scraper::Html::parse_document(&html);
|
||||||
|
|
||||||
|
// Remove script and style elements
|
||||||
|
let selector = scraper::Selector::parse("script, style, nav, footer, header")
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid selector: {e}"))?;
|
||||||
|
let cleaned = document.clone();
|
||||||
|
for element in cleaned.select(&selector) {
|
||||||
|
let inner = element.inner_html();
|
||||||
|
let _ = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from main content areas
|
||||||
|
let content_selectors = [
|
||||||
|
"article",
|
||||||
|
"main",
|
||||||
|
".content",
|
||||||
|
".documentation",
|
||||||
|
".doc-content",
|
||||||
|
".post-content",
|
||||||
|
".entry-content",
|
||||||
|
"#content",
|
||||||
|
"body",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut text = String::new();
|
||||||
|
for sel_str in &content_selectors {
|
||||||
|
if let Ok(sel) = scraper::Selector::parse(sel_str) {
|
||||||
|
if let Some(element) = cleaned.select(&sel).next() {
|
||||||
|
text = element.text().collect::<Vec<_>>().join(" ");
|
||||||
|
if text.len() > 100 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if text.is_empty() {
|
||||||
|
// Fallback: just get all body text
|
||||||
|
if let Ok(body_sel) = scraper::Selector::parse("body") {
|
||||||
|
text = cleaned
|
||||||
|
.select(&body_sel)
|
||||||
|
.next()
|
||||||
|
.map(|e| e.text().collect::<Vec<_>>().join(" "))
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up whitespace
|
||||||
|
let cleaned_text = text
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
if cleaned_text.is_empty() {
|
||||||
|
anyhow::bail!("no readable content found on page");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cleaned_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limit a string to at most `max_lines` lines.
|
||||||
|
fn limit_lines(s: &str, max_lines: usize) -> String {
|
||||||
|
s.lines()
|
||||||
|
.take(max_lines)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
@@ -330,6 +330,8 @@ fn handle_submit_input(state: &mut AppStateRest, text: String) {
|
|||||||
api_key,
|
api_key,
|
||||||
model: state.settings.model.clone(),
|
model: state.settings.model.clone(),
|
||||||
api_base: provider_cfg.map(|cfg| cfg.api_base.clone()),
|
api_base: provider_cfg.map(|cfg| cfg.api_base.clone()),
|
||||||
|
edit_count: 0,
|
||||||
|
consecutive_empty_reviews: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! *how* state is updated — only *what* action to produce.
|
//! *how* state is updated — only *what* action to produce.
|
||||||
|
|
||||||
use crate::state::Overlay;
|
use crate::state::Overlay;
|
||||||
|
use std::process::Command;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// A single well-typed event in the TUI that mutates `AppStateRest`.
|
/// A single well-typed event in the TUI that mutates `AppStateRest`.
|
||||||
@@ -85,6 +86,10 @@ pub enum Action {
|
|||||||
AbortTurn,
|
AbortTurn,
|
||||||
/// Request AI-summary compaction of the conversation history.
|
/// Request AI-summary compaction of the conversation history.
|
||||||
Compact,
|
Compact,
|
||||||
|
/// Show the git diff preview overlay.
|
||||||
|
ShowDiff,
|
||||||
|
/// Scroll the diff overlay.
|
||||||
|
DiffScroll(i32),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an `Action` to `AppStateRest`.
|
/// Apply an `Action` to `AppStateRest`.
|
||||||
@@ -309,5 +314,107 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
|||||||
Action::Compact => {
|
Action::Compact => {
|
||||||
state.toast_info("Compacting conversation...".to_string());
|
state.toast_info("Compacting conversation...".to_string());
|
||||||
}
|
}
|
||||||
|
Action::ShowDiff => {
|
||||||
|
// Run git diff to get current changes
|
||||||
|
let diff_output = git_diff_output(state);
|
||||||
|
state.misc.diff_content = diff_output;
|
||||||
|
state.misc.diff_scroll = 0;
|
||||||
|
state.misc.overlay = crate::state::Overlay::Diff;
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
|
Action::DiffScroll(amount) => {
|
||||||
|
let max_scroll = state
|
||||||
|
.misc
|
||||||
|
.diff_content
|
||||||
|
.lines()
|
||||||
|
.count()
|
||||||
|
.saturating_sub(1);
|
||||||
|
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
|
||||||
|
state.misc.diff_scroll = new_scroll.min(max_scroll);
|
||||||
|
state.mark_dirty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run `git diff` and return the output for display in the diff overlay.
|
||||||
|
///
|
||||||
|
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
|
||||||
|
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
|
||||||
|
/// changed with colored +/- markers.
|
||||||
|
fn git_diff_output(state: &crate::state::AppStateRest) -> String {
|
||||||
|
let root = state
|
||||||
|
.workspace_roots
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
// Try diff against HEAD
|
||||||
|
let head_result = Command::new("git")
|
||||||
|
.args(["diff", "HEAD"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match head_result {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
output.push_str("=== Changes (against HEAD) ===\n");
|
||||||
|
output.push_str(&stdout);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Fallback: no HEAD yet (new repo)
|
||||||
|
let diff_result = Command::new("git")
|
||||||
|
.args(["diff"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.output();
|
||||||
|
if let Ok(out) = diff_result {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
output.push_str("=== Unstaged Changes ===\n");
|
||||||
|
output.push_str(&stdout);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get staged changes too
|
||||||
|
let staged_result = Command::new("git")
|
||||||
|
.args(["diff", "--cached"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(out) = staged_result {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
output.push_str("=== Staged Changes ===\n");
|
||||||
|
output.push_str(&stdout);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get status summary
|
||||||
|
let status_result = Command::new("git")
|
||||||
|
.args(["status", "--short"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(out) = status_result {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !stdout.is_empty() {
|
||||||
|
output.push_str("=== Summary ===\n");
|
||||||
|
output.push_str(&stdout);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.is_empty() {
|
||||||
|
output = "No changes detected in the working tree.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ pub enum Command {
|
|||||||
PlanOpen,
|
PlanOpen,
|
||||||
/// `/usage` — open the usage-stats overlay.
|
/// `/usage` — open the usage-stats overlay.
|
||||||
UsageOpen,
|
UsageOpen,
|
||||||
|
/// `/diff` — show git diff preview.
|
||||||
|
Diff,
|
||||||
/// Catch-all: unrecognised or non-slash input.
|
/// Catch-all: unrecognised or non-slash input.
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,7 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
"/todo" => Command::TodoOpen,
|
"/todo" => Command::TodoOpen,
|
||||||
"/plan" => Command::PlanOpen,
|
"/plan" => Command::PlanOpen,
|
||||||
"/usage" => Command::UsageOpen,
|
"/usage" => Command::UsageOpen,
|
||||||
|
"/diff" => Command::Diff,
|
||||||
_ => Command::Unknown(cmd.to_string()),
|
_ => Command::Unknown(cmd.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,6 +145,9 @@ pub fn apply_command(cmd: Command) -> Vec<crate::action::Action> {
|
|||||||
Command::UsageOpen => {
|
Command::UsageOpen => {
|
||||||
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Usage)]
|
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Usage)]
|
||||||
}
|
}
|
||||||
|
Command::Diff => {
|
||||||
|
vec![crate::action::Action::ShowDiff]
|
||||||
|
}
|
||||||
Command::Unknown(text) => {
|
Command::Unknown(text) => {
|
||||||
if text.starts_with('/') {
|
if text.starts_with('/') {
|
||||||
vec![crate::action::Action::SystemNote {
|
vec![crate::action::Action::SystemNote {
|
||||||
|
|||||||
@@ -82,6 +82,31 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Diff overlay ──────────────────────────────────────────────────────
|
||||||
|
if state.misc.overlay == Overlay::Diff {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
return vec![Action::QuitConfirm];
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
return vec![Action::DiffScroll(-1)];
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
return vec![Action::DiffScroll(1)];
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
return vec![Action::DiffScroll(-20)];
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
return vec![Action::DiffScroll(20)];
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
return vec![Action::CloseOverlay];
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Learning overlay ──────────────────────────────────────────────────
|
// ── Learning overlay ──────────────────────────────────────────────────
|
||||||
if state.misc.overlay == Overlay::Learning {
|
if state.misc.overlay == Overlay::Learning {
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -164,6 +189,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||||
|
vec![crate::action::Action::ShowDiff]
|
||||||
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if state.input.autocomplete_visible {
|
if state.input.autocomplete_visible {
|
||||||
state.input.select_autocomplete();
|
state.input.select_autocomplete();
|
||||||
|
|||||||
@@ -439,6 +439,8 @@ pub enum Overlay {
|
|||||||
ModelSelector,
|
ModelSelector,
|
||||||
/// "Clear conversation?" confirmation.
|
/// "Clear conversation?" confirmation.
|
||||||
ClearConfirm,
|
ClearConfirm,
|
||||||
|
/// Git diff preview overlay.
|
||||||
|
Diff,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
@@ -462,6 +464,7 @@ impl Overlay {
|
|||||||
Overlay::Loading => "loading",
|
Overlay::Loading => "loading",
|
||||||
Overlay::ModelSelector => "model_selector",
|
Overlay::ModelSelector => "model_selector",
|
||||||
Overlay::ClearConfirm => "clear_confirm",
|
Overlay::ClearConfirm => "clear_confirm",
|
||||||
|
Overlay::Diff => "diff",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +513,10 @@ pub struct MiscState {
|
|||||||
pub lesson_running: bool,
|
pub lesson_running: bool,
|
||||||
/// Text waiting to be written to the system clipboard.
|
/// Text waiting to be written to the system clipboard.
|
||||||
pub pending_clipboard_copy: Option<String>,
|
pub pending_clipboard_copy: Option<String>,
|
||||||
|
/// Cached git diff content for the preview overlay.
|
||||||
|
pub diff_content: String,
|
||||||
|
/// Scroll offset for the diff overlay.
|
||||||
|
pub diff_scroll: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MiscState {
|
impl MiscState {
|
||||||
@@ -529,6 +536,8 @@ impl MiscState {
|
|||||||
plan_content: String::new(),
|
plan_content: String::new(),
|
||||||
lesson_running: false,
|
lesson_running: false,
|
||||||
pending_clipboard_copy: None,
|
pending_clipboard_copy: None,
|
||||||
|
diff_content: String::new(),
|
||||||
|
diff_scroll: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
|||||||
|
|
||||||
info!("delegating agent turn to infrastructure engine (model: {})", model);
|
info!("delegating agent turn to infrastructure engine (model: {})", model);
|
||||||
|
|
||||||
|
let edit_count = state
|
||||||
|
.session_runtime
|
||||||
|
.as_ref()
|
||||||
|
.map(|rt| rt.edit_count)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let consecutive_empty_reviews = state
|
||||||
|
.session_runtime
|
||||||
|
.as_ref()
|
||||||
|
.map(|rt| rt.consecutive_empty_reviews)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
let params = AgentTurnParams {
|
let params = AgentTurnParams {
|
||||||
messages,
|
messages,
|
||||||
session_dir,
|
session_dir,
|
||||||
@@ -71,6 +82,8 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
|||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
api_base,
|
api_base,
|
||||||
|
edit_count,
|
||||||
|
consecutive_empty_reviews,
|
||||||
};
|
};
|
||||||
|
|
||||||
backend_spawn_agent_turn(params);
|
backend_spawn_agent_turn(params);
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Diff preview overlay — renders git diff output with color-coded
|
||||||
|
//! additions (green) and deletions (red) in a scrollable panel.
|
||||||
|
//!
|
||||||
|
//! Flow: read `state.misc.diff_content` → parse diff lines → apply syntax
|
||||||
|
//! coloring (green `+`, red `-`, blue header, dim context) → render in a
|
||||||
|
//! scrollable paragraph widget.
|
||||||
|
|
||||||
|
use crate::state::AppStateRest;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||||
|
use ratatui::Frame;
|
||||||
|
use super::super::theme::Theme;
|
||||||
|
use super::overlay_block;
|
||||||
|
|
||||||
|
/// Render the diff preview overlay with color-coded +/- lines and scrolling.
|
||||||
|
///
|
||||||
|
/// The overlay fills most of the screen and shows:
|
||||||
|
/// - Green lines for additions (`+`)
|
||||||
|
/// - Red lines for deletions (`-`)
|
||||||
|
/// - Blue/dim headers (diff --git, @@ hunk headers)
|
||||||
|
/// - Dim context lines
|
||||||
|
/// - Scroll hint at the bottom
|
||||||
|
pub fn render(frame: &mut Frame, area: Rect, block: Block<'static>, state: &AppStateRest) {
|
||||||
|
let block = overlay_block(block, " Git Diff Preview ", Theme::PRIMARY);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
|
||||||
|
// Render the block background
|
||||||
|
frame.render_widget(block.clone(), area);
|
||||||
|
|
||||||
|
let diff_content = &state.misc.diff_content;
|
||||||
|
let diff_scroll = state.misc.diff_scroll;
|
||||||
|
|
||||||
|
// Parse lines with ANSI-style coloring
|
||||||
|
let lines: Vec<Line> = diff_content
|
||||||
|
.lines()
|
||||||
|
.skip(diff_scroll)
|
||||||
|
.map(|line| {
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
|
||||||
|
// Addition — green
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!("{trimmed}\n"),
|
||||||
|
Style::default().fg(Theme::SUCCESS),
|
||||||
|
))
|
||||||
|
} else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
|
||||||
|
// Deletion — red
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!("{trimmed}\n"),
|
||||||
|
Style::default().fg(Theme::ERROR),
|
||||||
|
))
|
||||||
|
} else if trimmed.starts_with("@@") {
|
||||||
|
// Hunk header — cyan
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!("{trimmed}\n"),
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::INFO)
|
||||||
|
.add_modifier(Modifier::DIM),
|
||||||
|
))
|
||||||
|
} else if trimmed.starts_with("diff --git")
|
||||||
|
|| trimmed.starts_with("index ")
|
||||||
|
|| trimmed.starts_with("--- ")
|
||||||
|
|| trimmed.starts_with("+++ ")
|
||||||
|
|| trimmed.starts_with("===")
|
||||||
|
{
|
||||||
|
// File header — blue/bold
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!("{trimmed}\n"),
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::PRIMARY)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
// Context — dim
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!("{trimmed}\n"),
|
||||||
|
Style::default().fg(Theme::TEXT_DIM),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_lines = diff_content.lines().count();
|
||||||
|
let visible_lines = inner.height as usize;
|
||||||
|
let has_more = diff_scroll + visible_lines < total_lines;
|
||||||
|
|
||||||
|
// Create the content paragraph
|
||||||
|
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, inner);
|
||||||
|
|
||||||
|
// Render scroll indicator at the bottom
|
||||||
|
if has_more {
|
||||||
|
let scroll_pct = if total_lines > 0 {
|
||||||
|
((diff_scroll as f64 / total_lines as f64) * 100.0) as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let scroll_text = format!(
|
||||||
|
" Lines {}-{} of {} ({}%) ",
|
||||||
|
diff_scroll + 1,
|
||||||
|
(diff_scroll + visible_lines).min(total_lines),
|
||||||
|
total_lines,
|
||||||
|
scroll_pct
|
||||||
|
);
|
||||||
|
|
||||||
|
let status_line = Line::from(Span::styled(
|
||||||
|
scroll_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_DIM)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
));
|
||||||
|
|
||||||
|
let status_area = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: area.y + area.height - 1,
|
||||||
|
width: area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
frame.render_widget(Paragraph::new(status_line), status_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render keybinding hint at the top
|
||||||
|
let hint = Line::from(Span::styled(
|
||||||
|
" ↑/↓: scroll Esc: close /diff: refresh",
|
||||||
|
Style::default()
|
||||||
|
.fg(Theme::TEXT_DIM)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
));
|
||||||
|
|
||||||
|
let hint_area = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: area.y,
|
||||||
|
width: area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
frame.render_widget(Paragraph::new(hint), hint_area);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub mod bash;
|
pub mod bash;
|
||||||
pub mod clear_confirm;
|
pub mod clear_confirm;
|
||||||
|
pub mod diff;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod effort;
|
pub mod effort;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
@@ -123,5 +124,8 @@ pub fn render_overlay(
|
|||||||
Overlay::ClearConfirm => {
|
Overlay::ClearConfirm => {
|
||||||
clear_confirm::render(frame, overlay_area, block, state);
|
clear_confirm::render(frame, overlay_area, block, state);
|
||||||
}
|
}
|
||||||
|
Overlay::Diff => {
|
||||||
|
diff::render(frame, overlay_area, block, state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ async fn handle_socket(mut socket: WebSocket, state: Arc<WsState>) {
|
|||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
api_base: None,
|
api_base: None,
|
||||||
|
edit_count: 0,
|
||||||
|
consecutive_empty_reviews: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
zesdex_infrastructure::agent::spawn_agent_turn(params);
|
||||||
|
|||||||
Reference in New Issue
Block a user