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
+2
View File
@@ -330,6 +330,8 @@ fn handle_submit_input(state: &mut AppStateRest, text: String) {
api_key,
model: state.settings.model.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);
+107
View File
@@ -12,6 +12,7 @@
//! *how* state is updated — only *what* action to produce.
use crate::state::Overlay;
use std::process::Command;
use tracing::debug;
/// A single well-typed event in the TUI that mutates `AppStateRest`.
@@ -85,6 +86,10 @@ pub enum Action {
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
/// Show the git diff preview overlay.
ShowDiff,
/// Scroll the diff overlay.
DiffScroll(i32),
}
/// Apply an `Action` to `AppStateRest`.
@@ -309,5 +314,107 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
Action::Compact => {
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,
/// `/usage` — open the usage-stats overlay.
UsageOpen,
/// `/diff` — show git diff preview.
Diff,
/// Catch-all: unrecognised or non-slash input.
Unknown(String),
}
@@ -89,6 +91,7 @@ pub fn parse_command(text: &str) -> Command {
"/todo" => Command::TodoOpen,
"/plan" => Command::PlanOpen,
"/usage" => Command::UsageOpen,
"/diff" => Command::Diff,
_ => Command::Unknown(cmd.to_string()),
};
@@ -142,6 +145,9 @@ pub fn apply_command(cmd: Command) -> Vec<crate::action::Action> {
Command::UsageOpen => {
vec![crate::action::Action::OpenOverlay(crate::state::Overlay::Usage)]
}
Command::Diff => {
vec![crate::action::Action::ShowDiff]
}
Command::Unknown(text) => {
if text.starts_with('/') {
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 ──────────────────────────────────────────────────
if state.misc.overlay == Overlay::Learning {
match key.code {
@@ -164,6 +189,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
}
Vec::new()
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
vec![crate::action::Action::ShowDiff]
}
KeyCode::Enter => {
if state.input.autocomplete_visible {
state.input.select_autocomplete();
+9
View File
@@ -439,6 +439,8 @@ pub enum Overlay {
ModelSelector,
/// "Clear conversation?" confirmation.
ClearConfirm,
/// Git diff preview overlay.
Diff,
}
impl Overlay {
@@ -462,6 +464,7 @@ impl Overlay {
Overlay::Loading => "loading",
Overlay::ModelSelector => "model_selector",
Overlay::ClearConfirm => "clear_confirm",
Overlay::Diff => "diff",
}
}
@@ -510,6 +513,10 @@ pub struct MiscState {
pub lesson_running: bool,
/// Text waiting to be written to the system clipboard.
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 {
@@ -529,6 +536,8 @@ impl MiscState {
plan_content: String::new(),
lesson_running: false,
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);
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 {
messages,
session_dir,
@@ -71,6 +82,8 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
api_key,
model,
api_base,
edit_count,
consecutive_empty_reviews,
};
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 clear_confirm;
pub mod diff;
pub mod editor;
pub mod effort;
pub mod help;
@@ -123,5 +124,8 @@ pub fn render_overlay(
Overlay::ClearConfirm => {
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,
model,
api_base: None,
edit_count: 0,
consecutive_empty_reviews: 0,
};
zesdex_infrastructure::agent::spawn_agent_turn(params);