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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user