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:
asepharyana
2026-07-20 16:59:27 +07:00
parent 785ae19757
commit fef3c925cd
17 changed files with 1968 additions and 0 deletions
+20
View File
@@ -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
&params.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
View File
@@ -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;
+7
View File
@@ -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
))
}
}
+283
View File
@@ -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")
}
+2
View File
@@ -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);
+107
View File
@@ -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();
+9
View File
@@ -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,
} }
} }
+13
View File
@@ -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);
}
} }
} }
+2
View File
@@ -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);