Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};


Expand Down Expand Up @@ -230,6 +230,8 @@ pub struct App {
tool_filters: ToolFilters,
/// IDE connection for editor integration (e.g., Neovim)
ide: Option<Box<dyn Ide>>,
/// LSP diagnostics from the IDE, keyed by file path
diagnostics: std::collections::HashMap<String, Vec<Diagnostic>>,
/// Terminal event stream
events: EventStream,
/// Current input mode
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
}
}
}
}
}

Expand Down Expand Up @@ -831,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.
Expand Down
55 changes: 54 additions & 1 deletion src/ide/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ pub mod nvim;

use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::mpsc;

pub use nvim::Nvim;

Expand Down Expand Up @@ -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<u32>,
pub end_col: Option<u32>,
pub severity: DiagnosticSeverity,
pub message: String,
pub source: Option<String>,
}

/// Events streamed from the IDE to the app
#[derive(Debug, Clone)]
pub enum IdeEvent {
/// Selection changed (or cleared if None)
SelectionChanged(Option<Selection>),
/// LSP diagnostics updated for a file (after save)
Diagnostics(Vec<Diagnostic>),
}

// ============================================================================
Expand Down Expand Up @@ -117,6 +164,12 @@ pub trait Ide: Send + Sync {
/// Check if a file has unsaved changes
async fn has_unsaved_changes(&self, path: &str) -> Result<bool>;

/// 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<Vec<Diagnostic>>;

// === Events: IDE → App (streaming) ===

/// Poll for the next event from the IDE
Expand Down
66 changes: 66 additions & 0 deletions src/ide/nvim/lua/diagnostics_tracking.lua
Original file line number Diff line number Diff line change
@@ -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,
})
61 changes: 61 additions & 0 deletions src/ide/nvim/lua/get_diagnostics.lua
Original file line number Diff line number Diff line change
@@ -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
Loading