Skip to content
Merged
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
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ readme = "README.md"
keywords = ["ai", "cli", "dev"]

[features]
default = ['loop', 'git-worktree', 'mcp']
default = ['loop', 'git-worktree', 'mcp', 'lsp']
loop = []
git-worktree = []
mcp = [
Expand All @@ -26,6 +26,7 @@ semantic-ts = ["semantic", "dep:tree-sitter-typescript"]
semantic-python = ["semantic", "dep:tree-sitter-python"]
semantic-bash = ["semantic", "dep:tree-sitter-bash"]
plugin = ["dep:janetrs"]
lsp = ["dep:lsp-types"]

[dependencies]
rig = { version = "0.37", features = ["rmcp"] }
Expand Down Expand Up @@ -66,7 +67,7 @@ streaming-iterator = { version = "0.1", optional = true }
janetrs = { version = "0.8", optional = true }
html2text = "0.17"
indexmap = "2"
lsp-types = "0.97"
lsp-types = { version = "0.97", optional = true }

[dev-dependencies]
tokio = { version = "1", features = ["test-util"] }
Expand Down
6 changes: 5 additions & 1 deletion src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
question_tx: Option<QuestionSender>,
plan_tx: Option<PlanSwitchSender>,
bg_store: Option<BackgroundStore>,
lsp_manager: Option<std::sync::Arc<crate::lsp::manager::LspManager>>,
#[cfg(feature = "lsp")] lsp_manager: Option<std::sync::Arc<crate::lsp::manager::LspManager>>,
sandbox: Sandbox,
parent_model: Option<AnyModel>,
#[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>,
Expand Down Expand Up @@ -141,20 +141,23 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
permission.clone(),
ask_tx.clone(),
cache.clone(),
#[cfg(feature = "lsp")]
lsp_manager.clone(),
)),
Box::new(tools::WriteTool::with_cache(
permission.clone(),
ask_tx.clone(),
plan_file.clone(),
cache.clone(),
#[cfg(feature = "lsp")]
lsp_manager.clone(),
)),
Box::new(tools::EditTool::with_cache(
permission.clone(),
ask_tx.clone(),
plan_file.clone(),
cache.clone(),
#[cfg(feature = "lsp")]
lsp_manager.clone(),
)),
Box::new(tools::BashTool::with_cache(
Expand Down Expand Up @@ -262,6 +265,7 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
builder = builder.tools(vec![task_tool, status_tool]);
}

#[cfg(feature = "lsp")]
if let Some(manager) = &lsp_manager {
let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into());
let lsp_tool = Box::new(tools::LspTool::new(
Expand Down
29 changes: 19 additions & 10 deletions src/agent/tools/edit.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use std::path::PathBuf;
#[cfg(feature = "lsp")]
use std::sync::Arc;

use rig::completion::ToolDefinition;
use rig::tool::Tool;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, EditArgs, PermCheck, ToolError, check_perm_path};
#[cfg(feature = "lsp")]
use crate::lsp::manager::LspManager;

pub struct EditTool {
Expand All @@ -16,6 +18,7 @@ pub struct EditTool {
/// When set, the tool touches the edited file on the LSP server and
/// appends any diagnostic block to its output. `None` reproduces the
/// pre-LSP behaviour.
#[cfg(feature = "lsp")]
lsp_manager: Option<Arc<LspManager>>,
}

Expand All @@ -31,6 +34,7 @@ impl EditTool {
ask_tx,
plan_file,
cache: None,
#[cfg(feature = "lsp")]
lsp_manager: None,
}
}
Expand All @@ -40,13 +44,14 @@ impl EditTool {
ask_tx: Option<AskSender>,
plan_file: Option<PathBuf>,
cache: ToolCache,
lsp_manager: Option<Arc<LspManager>>,
#[cfg(feature = "lsp")] lsp_manager: Option<Arc<LspManager>>,
) -> Self {
EditTool {
permission,
ask_tx,
plan_file,
cache: Some(cache),
#[cfg(feature = "lsp")]
lsp_manager,
}
}
Expand Down Expand Up @@ -212,6 +217,7 @@ impl Tool for EditTool {
new_content
};

#[cfg(feature = "lsp")]
let write_at = std::time::Instant::now();
tokio::fs::write(&args.path, &output).await?;
// File mutated → invalidate cached reads/greps/listings for this turn.
Expand All @@ -236,15 +242,18 @@ impl Tool for EditTool {
));
}

let path = std::path::Path::new(&args.path);
result.push_str(
&crate::agent::tools::write::append_lsp_block(
self.lsp_manager.as_ref(),
path,
write_at,
)
.await,
);
#[cfg(feature = "lsp")]
{
let path = std::path::Path::new(&args.path);
result.push_str(
&crate::agent::tools::write::append_lsp_block(
self.lsp_manager.as_ref(),
path,
write_at,
)
.await,
);
}
Ok(result)
}
}
2 changes: 2 additions & 0 deletions src/agent/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod find_files;
mod glob;
mod grep;
mod list_dir;
#[cfg(feature = "lsp")]
mod lsp;
mod memory;
pub(crate) mod plan;
Expand All @@ -30,6 +31,7 @@ pub use find_files::FindFilesTool;
pub use glob::GlobTool;
pub use grep::GrepTool;
pub use list_dir::ListDirTool;
#[cfg(feature = "lsp")]
pub use lsp::LspTool;
pub use memory::MemoryTool;
pub use plan::{PlanEnterTool, PlanExitTool};
Expand Down
8 changes: 7 additions & 1 deletion src/agent/tools/read.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
#[cfg(feature = "lsp")]
use std::sync::Arc;

use rig::completion::ToolDefinition;
use rig::tool::Tool;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, PermCheck, ReadArgs, ToolError, check_perm_path};
#[cfg(feature = "lsp")]
use crate::lsp::manager::{LspManager, TouchMode};

pub struct ReadTool {
Expand All @@ -14,6 +16,7 @@ pub struct ReadTool {
/// When set, the tool fires off a `touch_file` to warm the LSP server
/// so subsequent edits surface diagnostics quickly. Fire-and-forget:
/// the read tool does not wait or surface diagnostics in its output.
#[cfg(feature = "lsp")]
pub lsp_manager: Option<Arc<LspManager>>,
}

Expand All @@ -24,6 +27,7 @@ impl ReadTool {
permission,
ask_tx,
cache: None,
#[cfg(feature = "lsp")]
lsp_manager: None,
}
}
Expand All @@ -32,12 +36,13 @@ impl ReadTool {
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
cache: ToolCache,
lsp_manager: Option<Arc<LspManager>>,
#[cfg(feature = "lsp")] lsp_manager: Option<Arc<LspManager>>,
) -> Self {
ReadTool {
permission,
ask_tx,
cache: Some(cache),
#[cfg(feature = "lsp")]
lsp_manager,
}
}
Expand Down Expand Up @@ -122,6 +127,7 @@ impl Tool for ReadTool {
// Fire-and-forget LSP warmup so the server already has the file
// open by the time the agent edits it (and we can wait_for_push
// quickly). No diagnostic surfacing on read.
#[cfg(feature = "lsp")]
if let Some(manager) = self.lsp_manager.clone() {
let path = std::path::PathBuf::from(&args.path);
tokio::spawn(async move {
Expand Down
16 changes: 14 additions & 2 deletions src/agent/tools/write.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
use std::path::{Path, PathBuf};
#[cfg(feature = "lsp")]
use std::sync::Arc;
#[cfg(feature = "lsp")]
use std::time::{Duration, Instant};

use rig::completion::ToolDefinition;
use rig::tool::Tool;

use crate::agent::tools::cache::ToolCache;
use crate::agent::tools::{AskSender, PermCheck, ToolError, WriteArgs, check_perm_path};
#[cfg(feature = "lsp")]
use crate::lsp::diagnostic;
#[cfg(feature = "lsp")]
use crate::lsp::manager::{LspManager, TouchMode};

/// How long to wait for the LSP server to publish fresh diagnostics after
/// a write. Matches opencode's `DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS`. Bounded
/// so a stuck server doesn't hold up the agent's turn.
#[cfg(feature = "lsp")]
const DIAGNOSTIC_WAIT: Duration = Duration::from_secs(10);

pub struct WriteTool {
Expand All @@ -23,6 +28,7 @@ pub struct WriteTool {
/// When set, the tool touches the file on the LSP server after writing
/// and appends any resulting diagnostic block to its output. `None`
/// reproduces the pre-LSP behaviour exactly.
#[cfg(feature = "lsp")]
lsp_manager: Option<Arc<LspManager>>,
}

Expand All @@ -38,6 +44,7 @@ impl WriteTool {
ask_tx,
plan_file,
cache: None,
#[cfg(feature = "lsp")]
lsp_manager: None,
}
}
Expand All @@ -47,13 +54,14 @@ impl WriteTool {
ask_tx: Option<AskSender>,
plan_file: Option<PathBuf>,
cache: ToolCache,
lsp_manager: Option<Arc<LspManager>>,
#[cfg(feature = "lsp")] lsp_manager: Option<Arc<LspManager>>,
) -> Self {
WriteTool {
permission,
ask_tx,
plan_file,
cache: Some(cache),
#[cfg(feature = "lsp")]
lsp_manager,
}
}
Expand Down Expand Up @@ -106,14 +114,17 @@ impl Tool for WriteTool {
tokio::fs::create_dir_all(parent).await?;
}
let bytes = args.content.len();
#[cfg(feature = "lsp")]
let write_at = Instant::now();
tokio::fs::write(path, &args.content).await?;
// File mutated → invalidate cached reads/greps/listings for this turn.
if let Some(ref cache) = self.cache {
cache.clear();
}

#[allow(unused_mut)]
let mut output = format!("Written {} bytes to {}", bytes, args.path);
#[cfg(feature = "lsp")]
output.push_str(&append_lsp_block(self.lsp_manager.as_ref(), path, write_at).await);
Ok(output)
}
Expand All @@ -124,6 +135,7 @@ impl Tool for WriteTool {
/// Errors during touch/wait are intentionally swallowed — diagnostic
/// surfacing is a side-effect; the write tool's primary contract is
/// "wrote the file".
#[cfg(feature = "lsp")]
pub(crate) async fn append_lsp_block(
manager: Option<&Arc<LspManager>>,
path: &Path,
Expand All @@ -145,7 +157,7 @@ pub(crate) async fn append_lsp_block(
diagnostic::build_report_block(path, &diagnostics)
}

#[cfg(test)]
#[cfg(all(test, feature = "lsp"))]
mod tests {
use super::*;
use crate::agent::tools::cache::ToolCache;
Expand Down
2 changes: 2 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub struct Cli {
#[arg(long = "no-tools", help = "Disable all tools")]
pub no_tools: bool,

#[cfg(feature = "lsp")]
#[arg(
long = "no-lsp",
help = "Disable LSP integration (no diagnostics on edit/write, no `lsp` agent tool)"
Expand Down Expand Up @@ -167,6 +168,7 @@ impl Cli {
self.no_tools || cfg.no_tools.unwrap_or(false)
}

#[cfg(feature = "lsp")]
pub fn resolve_lsp_enabled(&self, cfg: &config::Config) -> bool {
if self.no_lsp || self.no_tools {
return false;
Expand Down
6 changes: 5 additions & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub struct ToolsConfig {
/// - `{ "disabled": true }` to turn off a built-in server entirely.
/// - any subset of `{ command, extensions, env, initialization, disabled }`
/// to override pieces of the default.
#[cfg(feature = "lsp")]
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct LspServerConfig {
Expand All @@ -46,13 +47,15 @@ pub struct LspServerConfig {
/// `lsp = false` → disable LSP entirely.
/// `lsp = { server-id = { … } }` → enable defaults, overriding the named
/// servers with the provided config.
#[cfg(feature = "lsp")]
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum LspConfig {
Enabled(bool),
Servers(HashMap<String, LspServerConfig>),
}

#[cfg(feature = "lsp")]
impl LspConfig {
/// `true` when LSP should be on. Defaults to enabled.
pub fn is_enabled(&self) -> bool {
Expand Down Expand Up @@ -102,6 +105,7 @@ pub struct Config {
pub tool_result_max_chars: Option<usize>,
pub default_prompt: Option<String>,
pub tools: Option<ToolsConfig>,
#[cfg(feature = "lsp")]
pub lsp: Option<LspConfig>,
#[cfg(feature = "mcp")]
pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
Expand Down Expand Up @@ -194,7 +198,7 @@ pub fn load() -> Config {
cfg
}

#[cfg(test)]
#[cfg(all(test, feature = "lsp"))]
mod tests {
use super::*;

Expand Down
1 change: 1 addition & 0 deletions src/extras/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ async fn run_prompt(
None,
None,
None,
#[cfg(feature = "lsp")]
None,
sandbox,
#[cfg(feature = "mcp")]
Expand Down
Loading
Loading