From ce050d78b8928c234f4e4c94617a6267c4d99084 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 04:56:03 +0000 Subject: [PATCH 1/2] Add LSP diagnostics support via IDE passthrough - Add Diagnostic struct and DiagnosticSeverity enum to ide module - Add IdeEvent::Diagnostics variant for receiving LSP diagnostics - Create diagnostics_tracking.lua that hooks into DiagnosticChanged - Update NvimHandler to parse codey_diagnostics notifications - Store diagnostics in App keyed by file path Diagnostics are sent from Neovim after LSP updates (debounced 100ms), providing the agent with error/warning information for edited files. --- src/app.rs | 31 +++++++- src/ide/mod.rs | 49 +++++++++++- src/ide/nvim/lua/diagnostics_tracking.lua | 66 ++++++++++++++++ src/ide/nvim/mod.rs | 93 ++++++++++++++++++----- 4 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 src/ide/nvim/lua/diagnostics_tracking.lua diff --git a/src/app.rs b/src/app.rs index f647abd..d49c4cd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -21,7 +21,7 @@ use crate::llm::{Agent, AgentStep, RequestMode}; use crate::tools::{ToolDecision, ToolEvent, ToolExecutor, ToolRegistry}; use crate::tool_filter::ToolFilters; use crate::transcript::{BlockType, Role, Status, TextBlock, Transcript}; -use crate::ide::{Ide, IdeEvent, Nvim}; +use crate::ide::{Diagnostic, Ide, IdeEvent, Nvim}; use crate::ui::{Attachment, ChatView, InputBox}; @@ -230,6 +230,8 @@ pub struct App { tool_filters: ToolFilters, /// IDE connection for editor integration (e.g., Neovim) ide: Option>, + /// LSP diagnostics from the IDE, keyed by file path + diagnostics: std::collections::HashMap>, /// Terminal event stream events: EventStream, /// Current input mode @@ -341,6 +343,7 @@ impl App { alert: None, tool_filters, ide, + diagnostics: std::collections::HashMap::new(), events: EventStream::new(), input_mode: InputMode::Normal, agent: None, @@ -594,7 +597,7 @@ impl App { Ok(()) } - /// Handle an IDE event (selection changes, etc.) + /// Handle an IDE event (selection changes, diagnostics, etc.) fn handle_ide_event(&mut self, event: IdeEvent) { match event { IdeEvent::SelectionChanged(selection) => { @@ -604,12 +607,34 @@ impl App { .and_then(|cwd| std::path::Path::new(&sel.path).strip_prefix(cwd).ok()) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or(sel.path); - + Attachment::ide_selection(path, sel.content, sel.start_line, sel.end_line) }); self.input.set_ide_selection(attachment); let _ = self.draw_throttled(); } + IdeEvent::Diagnostics(diagnostics) => { + // Group diagnostics by path and store them + if let Some(first) = diagnostics.first() { + let path = first.path.clone(); + let rel_path = std::env::current_dir() + .ok() + .and_then(|cwd| std::path::Path::new(&path).strip_prefix(cwd).ok()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or(path); + + if diagnostics.is_empty() { + self.diagnostics.remove(&rel_path); + } else { + tracing::debug!( + "Received {} diagnostics for {}", + diagnostics.len(), + rel_path + ); + self.diagnostics.insert(rel_path, diagnostics); + } + } + } } } diff --git a/src/ide/mod.rs b/src/ide/mod.rs index df0469d..d297946 100644 --- a/src/ide/mod.rs +++ b/src/ide/mod.rs @@ -18,7 +18,6 @@ pub mod nvim; use anyhow::Result; use async_trait::async_trait; -use tokio::sync::mpsc; pub use nvim::Nvim; @@ -65,11 +64,59 @@ pub struct Selection { pub end_line: u32, } +/// Severity level for LSP diagnostics +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiagnosticSeverity { + Error, + Warning, + Info, + Hint, +} + +impl DiagnosticSeverity { + /// Parse from LSP severity number (1=Error, 2=Warning, 3=Info, 4=Hint) + pub fn from_lsp(severity: u32) -> Self { + match severity { + 1 => Self::Error, + 2 => Self::Warning, + 3 => Self::Info, + 4 => Self::Hint, + _ => Self::Info, + } + } +} + +impl std::fmt::Display for DiagnosticSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Error => write!(f, "error"), + Self::Warning => write!(f, "warning"), + Self::Info => write!(f, "info"), + Self::Hint => write!(f, "hint"), + } + } +} + +/// A single LSP diagnostic from the IDE +#[derive(Debug, Clone)] +pub struct Diagnostic { + pub path: String, + pub line: u32, + pub col: u32, + pub end_line: Option, + pub end_col: Option, + pub severity: DiagnosticSeverity, + pub message: String, + pub source: Option, +} + /// Events streamed from the IDE to the app #[derive(Debug, Clone)] pub enum IdeEvent { /// Selection changed (or cleared if None) SelectionChanged(Option), + /// LSP diagnostics updated for a file (after save) + Diagnostics(Vec), } // ============================================================================ diff --git a/src/ide/nvim/lua/diagnostics_tracking.lua b/src/ide/nvim/lua/diagnostics_tracking.lua new file mode 100644 index 0000000..1349f93 --- /dev/null +++ b/src/ide/nvim/lua/diagnostics_tracking.lua @@ -0,0 +1,66 @@ +-- Diagnostics tracking for Codey +-- Sets up autocommands to notify the app when LSP diagnostics change +-- Requires: vim.g.codey_channel_id to be set before loading + +local group = vim.api.nvim_create_augroup('CodeyDiagnostics', { clear = true }) +local channel_id = vim.g.codey_channel_id + +-- Debounce timer to avoid flooding with rapid diagnostic updates +local debounce_timer = nil +local debounce_ms = 100 + +local function send_diagnostics(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + + -- Only send for real files + local path = vim.api.nvim_buf_get_name(bufnr) + if path == '' or vim.bo[bufnr].buftype ~= '' then + return + end + + local diagnostics = vim.diagnostic.get(bufnr) + if #diagnostics == 0 then + -- Send empty list to clear diagnostics for this file + vim.rpcnotify(channel_id, 'codey_diagnostics', { + path = path, + diagnostics = {}, + }) + return + end + + local formatted = {} + for _, d in ipairs(diagnostics) do + table.insert(formatted, { + line = d.lnum + 1, -- Convert 0-indexed to 1-indexed + col = d.col + 1, + end_line = d.end_lnum and (d.end_lnum + 1) or nil, + end_col = d.end_col and (d.end_col + 1) or nil, + severity = d.severity, -- 1=Error, 2=Warning, 3=Info, 4=Hint + message = d.message, + source = d.source, + }) + end + + vim.rpcnotify(channel_id, 'codey_diagnostics', { + path = path, + diagnostics = formatted, + }) +end + +local function send_diagnostics_debounced(bufnr) + if debounce_timer then + vim.fn.timer_stop(debounce_timer) + end + debounce_timer = vim.fn.timer_start(debounce_ms, function() + debounce_timer = nil + send_diagnostics(bufnr) + end) +end + +-- Send diagnostics when they change (LSP updates, linter runs, etc.) +vim.api.nvim_create_autocmd('DiagnosticChanged', { + group = group, + callback = function(args) + send_diagnostics_debounced(args.buf) + end, +}) diff --git a/src/ide/nvim/mod.rs b/src/ide/nvim/mod.rs index 569bccc..f8d61f4 100644 --- a/src/ide/nvim/mod.rs +++ b/src/ide/nvim/mod.rs @@ -10,7 +10,7 @@ //! 2. Auto-discovered from tmux session name: `/tmp/nvim-{session}.sock` //! 3. Set via `$NVIM_LISTEN_ADDRESS` environment variable -use super::{Ide, IdeEvent, Selection, ToolPreview}; +use super::{Diagnostic, DiagnosticSeverity, Ide, IdeEvent, Selection, ToolPreview}; use anyhow::{Context, Result}; use async_trait::async_trait; use nvim_rs::{compat::tokio::Compat, create::tokio as create, Handler, Neovim, Value}; @@ -93,15 +93,18 @@ impl Handler for NvimHandler { args: Vec, _neovim: Neovim, ) { - match name.as_str() { - "codey_selection" => { - let event = parse_selection_event(&args); - if let Err(e) = self.event_tx.send(event).await { - warn!("Failed to send IDE event: {}", e); - } - } + let event = match name.as_str() { + "codey_selection" => Some(parse_selection_event(&args)), + "codey_diagnostics" => parse_diagnostics_event(&args), other => { debug!("Ignoring nvim notification: {}", other); + None + } + }; + + if let Some(event) = event { + if let Err(e) = self.event_tx.send(event).await { + warn!("Failed to send IDE event: {}", e); } } } @@ -138,6 +141,53 @@ fn parse_selection_event(args: &[Value]) -> IdeEvent { IdeEvent::SelectionChanged(selection) } +/// Parse a diagnostics event from nvim notification args +fn parse_diagnostics_event(args: &[Value]) -> Option { + // Expected format: [{ path, diagnostics: [{ line, col, severity, message, source? }] }] + let v = args.first()?; + let map = v.as_map()?; + + let get_str = |m: &[(Value, Value)], key: &str| -> Option { + m.iter() + .find(|(k, _)| k.as_str() == Some(key)) + .and_then(|(_, v)| v.as_str().map(|s| s.to_string())) + }; + + let get_u32 = |m: &[(Value, Value)], key: &str| -> Option { + m.iter() + .find(|(k, _)| k.as_str() == Some(key)) + .and_then(|(_, v)| v.as_u64().map(|n| n as u32)) + }; + + let path = get_str(map, "path")?; + + let diagnostics_val = map + .iter() + .find(|(k, _)| k.as_str() == Some("diagnostics")) + .map(|(_, v)| v)?; + + let diagnostics_arr = diagnostics_val.as_array()?; + + let diagnostics: Vec = diagnostics_arr + .iter() + .filter_map(|d| { + let dm = d.as_map()?; + Some(Diagnostic { + path: path.clone(), + line: get_u32(dm, "line")?, + col: get_u32(dm, "col").unwrap_or(1), + end_line: get_u32(dm, "end_line"), + end_col: get_u32(dm, "end_col"), + severity: DiagnosticSeverity::from_lsp(get_u32(dm, "severity").unwrap_or(3)), + message: get_str(dm, "message").unwrap_or_default(), + source: get_str(dm, "source"), + }) + }) + .collect(); + + Some(IdeEvent::Diagnostics(diagnostics)) +} + /// Connection to a Neovim instance pub struct Nvim { client: Arc>>, @@ -184,8 +234,8 @@ impl Nvim { event_rx, }; - // Set up autocommands for selection tracking - nvim.setup_selection_tracking().await?; + // Set up autocommands for selection and diagnostics tracking + nvim.setup_event_tracking().await?; Ok(nvim) } @@ -248,29 +298,30 @@ impl Nvim { &self.socket_path } - /// Set up autocommands to track visual mode selection changes - async fn setup_selection_tracking(&self) -> Result<()> { + /// Set up autocommands to track visual mode selection changes and LSP diagnostics + async fn setup_event_tracking(&self) -> Result<()> { // First, store our channel ID in a global variable let channel_info = self.exec_lua("return vim.api.nvim_get_chan_info(0)", vec![]).await?; - + let channel_id = channel_info .as_map() .and_then(|m| m.iter().find(|(k, _)| k.as_str() == Some("id"))) .and_then(|(_, v)| v.as_i64()) .unwrap_or(0); - + debug!("Neovim assigned channel ID: {}", channel_id); - - // Set the channel ID then load the selection tracking script + + // Set the channel ID then load tracking scripts let setup_lua = format!( - "vim.g.codey_channel_id = {}\n{}", + "vim.g.codey_channel_id = {}\n{}\n{}", channel_id, - include_str!("lua/selection_tracking.lua") + include_str!("lua/selection_tracking.lua"), + include_str!("lua/diagnostics_tracking.lua") ); - + self.exec_lua(&setup_lua, vec![]).await?; - info!("Set up neovim selection tracking"); - + info!("Set up neovim event tracking (selection + diagnostics)"); + Ok(()) } From 3090586d7c78d67458da82a47d0648b63db0c13c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Dec 2025 05:26:00 +0000 Subject: [PATCH 2/2] Integrate LSP diagnostics into edit_file tool results - Add get_diagnostics.lua that uses vim.wait() for DiagnosticChanged - Add Ide::reload_and_get_diagnostics() for synchronous diagnostic fetch - Modify tool completion handler to append diagnostics to results After edit_file executes, the tool result now includes any LSP errors or warnings, giving the agent immediate feedback on code issues: Successfully applied 1 edit(s) to src/foo.rs LSP Diagnostics: line 12: [rust-analyzer] error: cannot find value `foo` line 15: [rust-analyzer] warning: unused variable `x` Uses event-driven waiting (DiagnosticChanged autocmd + vim.wait) rather than polling, with a 500ms timeout. --- src/app.rs | 42 ++++++++++++++++--- src/ide/mod.rs | 6 +++ src/ide/nvim/lua/get_diagnostics.lua | 61 ++++++++++++++++++++++++++++ src/ide/nvim/mod.rs | 44 ++++++++++++++++++++ 4 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 src/ide/nvim/lua/get_diagnostics.lua diff --git a/src/app.rs b/src/app.rs index d49c4cd..d86421a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -856,18 +856,50 @@ impl App { if is_error { Status::Error } else { Status::Complete } ); - // Execute post-actions from the tool + // Execute post-actions from the tool, collecting diagnostics + let mut final_content = content; for action in ide_post_actions { if let Some(ide) = &self.ide { - if let Err(e) = ide.execute(&action).await { - tracing::warn!("Failed to execute IDE action: {}", e); + match &action { + crate::ide::IdeAction::ReloadBuffer(path) => { + // Reload and get LSP diagnostics + match ide.reload_and_get_diagnostics(path).await { + Ok(diagnostics) => { + // Filter to errors and warnings only + let important: Vec<_> = diagnostics.iter() + .filter(|d| matches!(d.severity, + crate::ide::DiagnosticSeverity::Error | + crate::ide::DiagnosticSeverity::Warning)) + .collect(); + + if !important.is_empty() { + final_content.push_str("\n\nLSP Diagnostics:\n"); + for d in important { + let source = d.source.as_deref().unwrap_or("lsp"); + final_content.push_str(&format!( + " line {}: [{}] {}: {}\n", + d.line, source, d.severity, d.message + )); + } + } + } + Err(e) => { + tracing::debug!("Failed to get diagnostics: {}", e); + } + } + } + _ => { + if let Err(e) = ide.execute(&action).await { + tracing::warn!("Failed to execute IDE action: {}", e); + } + } } } } - // Tell agent about the result + // Tell agent about the result (with diagnostics appended if any) if let Some(agent) = self.agent.as_mut() { - agent.submit_tool_result(&call_id, content); + agent.submit_tool_result(&call_id, final_content); } // Tool is done - go back to streaming mode. diff --git a/src/ide/mod.rs b/src/ide/mod.rs index d297946..cca69f7 100644 --- a/src/ide/mod.rs +++ b/src/ide/mod.rs @@ -164,6 +164,12 @@ pub trait Ide: Send + Sync { /// Check if a file has unsaved changes async fn has_unsaved_changes(&self, path: &str) -> Result; + /// Reload a buffer and wait for LSP diagnostics + /// + /// This reloads the buffer, waits for the LSP to analyze it (via DiagnosticChanged), + /// and returns any diagnostics. Used after file edits to get immediate feedback. + async fn reload_and_get_diagnostics(&self, path: &str) -> Result>; + // === Events: IDE → App (streaming) === /// Poll for the next event from the IDE diff --git a/src/ide/nvim/lua/get_diagnostics.lua b/src/ide/nvim/lua/get_diagnostics.lua new file mode 100644 index 0000000..7ab2a24 --- /dev/null +++ b/src/ide/nvim/lua/get_diagnostics.lua @@ -0,0 +1,61 @@ +-- Get diagnostics for a file, waiting for LSP to process after reload +-- Args: target_path (string), timeout_ms (number) +-- Returns: array of diagnostics + +local target_path, timeout_ms = ... +timeout_ms = timeout_ms or 500 + +-- Find the buffer +local bufnr = nil +for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_get_name(buf) == target_path then + bufnr = buf + break + end +end + +if not bufnr then + return {} +end + +-- Track if we've received new diagnostics +local received = false +local autocmd_id = vim.api.nvim_create_autocmd('DiagnosticChanged', { + buffer = bufnr, + once = true, + callback = function() + received = true + end, +}) + +-- Reload the buffer to trigger LSP analysis +vim.api.nvim_buf_call(bufnr, function() + vim.cmd('checktime') +end) + +-- Wait for DiagnosticChanged or timeout +-- The 10ms interval allows Neovim to process events +vim.wait(timeout_ms, function() + return received +end, 10) + +-- Clean up autocmd if it didn't fire +pcall(vim.api.nvim_del_autocmd, autocmd_id) + +-- Get current diagnostics for the buffer +local diagnostics = vim.diagnostic.get(bufnr) +local result = {} + +for _, d in ipairs(diagnostics) do + table.insert(result, { + line = d.lnum + 1, -- Convert 0-indexed to 1-indexed + col = d.col + 1, + end_line = d.end_lnum and (d.end_lnum + 1) or nil, + end_col = d.end_col and (d.end_col + 1) or nil, + severity = d.severity, -- 1=Error, 2=Warning, 3=Info, 4=Hint + message = d.message, + source = d.source, + }) +end + +return result diff --git a/src/ide/nvim/mod.rs b/src/ide/nvim/mod.rs index f8d61f4..c0f91bd 100644 --- a/src/ide/nvim/mod.rs +++ b/src/ide/nvim/mod.rs @@ -459,6 +459,50 @@ impl Ide for Nvim { Ok(result.as_bool().unwrap_or(false)) } + async fn reload_and_get_diagnostics(&self, path: &str) -> Result> { + let args = vec![ + Value::from(path), + Value::from(500i64), // timeout_ms + ]; + let result = self.exec_lua(include_str!("lua/get_diagnostics.lua"), args).await?; + + // Parse the array of diagnostics + let diagnostics: Vec = result + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|d| { + let map = d.as_map()?; + let get_str = |key: &str| -> Option { + map.iter() + .find(|(k, _)| k.as_str() == Some(key)) + .and_then(|(_, v)| v.as_str().map(|s| s.to_string())) + }; + let get_u32 = |key: &str| -> Option { + map.iter() + .find(|(k, _)| k.as_str() == Some(key)) + .and_then(|(_, v)| v.as_u64().map(|n| n as u32)) + }; + + Some(Diagnostic { + path: path.to_string(), + line: get_u32("line")?, + col: get_u32("col").unwrap_or(1), + end_line: get_u32("end_line"), + end_col: get_u32("end_col"), + severity: DiagnosticSeverity::from_lsp(get_u32("severity").unwrap_or(3)), + message: get_str("message").unwrap_or_default(), + source: get_str("source"), + }) + }) + .collect() + }) + .unwrap_or_default(); + + debug!("Got {} diagnostics for {}", diagnostics.len(), path); + Ok(diagnostics) + } + async fn next(&mut self) -> Option { self.event_rx.recv().await }