From abc7a58e31474a632e781e848cd5c04f78b9beff Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 12 Jul 2026 13:40:58 +0700 Subject: [PATCH] feat(lsp): implement LSP client and server management - Added LspClient for handling communication with LSP servers, including methods for initialization, notifications, and requests. - Introduced LspManager to manage multiple LSP server connections, allowing for connection, disconnection, and retrieval of server capabilities. - Created tools for connecting to LSP servers, retrieving diagnostics, hover information, code completion, definitions, and references. - Enhanced UI rendering to display token usage and settings in the overlay. - Updated status bar to show current token usage and selected provider/model. --- src-misc/system-tools.txt | 15 + src/app/lsp/client.rs | 329 ++++++++++++++++ src/app/lsp/mod.rs | 80 ++++ src/app/mod.rs | 4 +- src/app/mode/effort.rs | 6 +- src/app/runtime/actions/mod.rs | 67 +++- src/app/runtime/commands.rs | 3 + src/app/runtime/shortsend.rs | 105 +++-- src/app/state/misc.rs | 3 + src/app/state/rest.rs | 4 + src/app/state/runtime.rs | 5 + src/controller/command.rs | 2 + src/model/app_config.rs | 6 +- src/model/conversation.rs | 8 +- src/model/settings.rs | 8 +- src/tool/lsp/mod.rs | 677 +++++++++++++++++++++++++++++++++ src/tool/mod.rs | 18 +- src/view/mod.rs | 4 +- src/view/status.rs | 33 +- 19 files changed, 1317 insertions(+), 60 deletions(-) create mode 100644 src/app/lsp/client.rs create mode 100644 src/app/lsp/mod.rs create mode 100644 src/tool/lsp/mod.rs diff --git a/src-misc/system-tools.txt b/src-misc/system-tools.txt index 1680c18..cd1dc9f 100644 --- a/src-misc/system-tools.txt +++ b/src-misc/system-tools.txt @@ -39,5 +39,20 @@ Workflow: multi-step tasks needing parallel analysis or verification. Pass inline scripts with agent(), parallel(), and pipeline() primitives. +Language Server Protocol (LSP) tools: +- lsp_connect(name, command, args?, language_id) — Start an LSP server for a + programming language (e.g. 'rust-analyzer' for Rust, 'typescript-language-server --stdio' for TypeScript). +- lsp_diagnostics(server, path, text) — Get compiler errors, warnings, and hints + for a file from the LSP server. +- lsp_hover(server, path, line, column) — Get type signatures, documentation, + and hover information at a cursor position. +- lsp_completion(server, path, line, column) — Get code completion suggestions + at a cursor position. +- lsp_definition(server, path, line, column) — Find the definition location of + a symbol at the cursor. +- lsp_references(server, path, line, column) — Find all references to a symbol + across the project. +- lsp_disconnect(name) — Disconnect from a running LSP server. + Each write/edit call MUST include a non-empty reason argument explaining why the change is being made. This is enforced deterministically. diff --git a/src/app/lsp/client.rs b/src/app/lsp/client.rs new file mode 100644 index 0000000..5fad0c5 --- /dev/null +++ b/src/app/lsp/client.rs @@ -0,0 +1,329 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +const LSP_INIT_TIMEOUT_MS: u64 = 60_000; +const LSP_CALL_TIMEOUT_MS: u64 = 30_000; +const LSP_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000; + +pub struct LspClient { + stdin: std::process::ChildStdin, + stdout: BufReader, + next_id: u64, + server_capabilities: Value, +} + +fn file_path_to_uri(path: &str) -> String { + let abs_path = std::path::Path::new(path); + let abs_path = if abs_path.is_relative() { + match std::env::current_dir() { + Ok(cwd) => cwd.join(path), + Err(_) => abs_path.to_path_buf(), + } + } else { + abs_path.to_path_buf() + }; + let canonical = abs_path.canonicalize().unwrap_or(abs_path); + let path_str = canonical.to_string_lossy(); + if cfg!(windows) { + let path_str = path_str.replace('\\', "/"); + if path_str.starts_with('/') { + format!("file://{}", path_str) + } else { + format!("file:///{}", path_str) + } + } else { + format!("file://{}", path_str) + } +} + +impl LspClient { + pub fn spawn(command: &str, args: &[String]) -> anyhow::Result { + let mut cmd = Command::new(command); + cmd.args(args); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn() + .map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{}': {}", command, e))?; + + let stdin = child.stdin.take() + .ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?; + let stdout = BufReader::new(child.stdout.take() + .ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?); + + let mut client = LspClient { + stdin, + stdout, + next_id: 0, + server_capabilities: Value::Null, + }; + + let init_params = json!({ + "processId": std::process::id(), + "clientInfo": { + "name": "zesdex", + "version": "0.1.0" + }, + "capabilities": { + "textDocument": { + "synchronization": { + "dynamicRegistration": true, + "willSave": false, + "willSaveWaitUntil": false, + "didSave": false + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": ["plaintext", "markdown"] + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": false + } + }, + "definition": { + "dynamicRegistration": true + }, + "references": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "hierarchicalDocumentSymbolSupport": true + } + }, + "workspace": { + "workspaceFolders": true + }, + "general": { + "positionEncodings": ["utf-16"] + } + } + }); + + let result = client.call_with_timeout("initialize", init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?; + client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default(); + + client.notify("initialized", json!({}))?; + + Ok(client) + } + + pub fn server_capabilities(&self) -> &Value { + &self.server_capabilities + } + + pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result { + self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS)) + } + + fn call_with_timeout(&mut self, method: &str, params: Value, timeout: Duration) -> anyhow::Result { + self.next_id += 1; + let id = self.next_id; + let req = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }); + self.send_frame(&req)?; + self.read_response(id, timeout) + } + + pub fn notify(&mut self, method: &str, params: Value) -> anyhow::Result<()> { + let req = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + }); + self.send_frame(&req) + } + + fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> { + let body = serde_json::to_string(msg) + .map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {}", e))?; + let header = format!("Content-Length: {}\r\n\r\n", body.len()); + self.stdin.write_all(header.as_bytes()) + .map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {}", e))?; + self.stdin.write_all(body.as_bytes()) + .map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {}", e))?; + self.stdin.flush() + .map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {}", e))?; + Ok(()) + } + + fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + if Instant::now() > deadline { + anyhow::bail!("LSP call timed out after {}ms", timeout.as_millis()); + } + let frame = self.read_frame()?; + if frame.get("id") == Some(&json!(expected_id)) { + if let Some(err) = frame.get("error") { + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0); + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); + anyhow::bail!("LSP error {}: {}", code, msg); + } + return Ok(frame.get("result").cloned().unwrap_or(Value::Null)); + } + } + } + + pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + if Instant::now() > deadline { + anyhow::bail!("timed out waiting for LSP notification '{}'", method); + } + let frame = self.read_frame()?; + if frame.get("method") == Some(&json!(method)) { + return Ok(frame.get("params").cloned().unwrap_or(Value::Null)); + } + } + } + + fn read_frame(&mut self) -> anyhow::Result { + let mut content_length: Option = None; + loop { + let mut line = String::new(); + match self.stdout.read_line(&mut line) { + Ok(0) => anyhow::bail!("LSP server closed the connection"), + Ok(_) => {} + Err(e) => anyhow::bail!("LSP read error: {}", e), + } + let trimmed = line.trim(); + if trimmed.is_empty() { + break; + } + if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") { + content_length = Some( + len_str.trim().parse::() + .map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?, + ); + } + } + + let length = content_length + .ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?; + + let mut body = vec![0u8; length]; + self.stdout.read_exact(&mut body) + .map_err(|e| anyhow::anyhow!("failed to read LSP body ({} bytes): {}", length, e))?; + + let json_str = String::from_utf8(body) + .map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {}", e))?; + + serde_json::from_str(&json_str) + .map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {}", e)) + } + + pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> { + self.notify("textDocument/didOpen", json!({ + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": version, + "text": text + } + })) + } + + #[allow(dead_code)] + pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> { + self.notify("textDocument/didChange", json!({ + "textDocument": { + "uri": uri, + "version": version + }, + "contentChanges": [{ + "text": text + }] + })) + } + + pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> { + self.notify("textDocument/didClose", json!({ + "textDocument": { + "uri": uri + } + })) + } + + pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { + self.call("textDocument/hover", json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + })) + } + + pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { + self.call("textDocument/completion", json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + })) + } + + pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { + self.call("textDocument/definition", json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character } + })) + } + + pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { + self.call("textDocument/references", json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + "context": { + "includeDeclaration": true + } + })) + } + + #[allow(dead_code)] + pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result { + self.call("textDocument/documentSymbol", json!({ + "textDocument": { "uri": uri } + })) + } + + pub fn collect_diagnostics( + &mut self, + uri: &str, + language_id: &str, + text: &str, + ) -> anyhow::Result { + self.did_open(uri, language_id, 1, text)?; + let result = self.read_notification( + "textDocument/publishDiagnostics", + Duration::from_millis(LSP_DIAGNOSTICS_TIMEOUT_MS), + ); + self.did_close(uri)?; + match result { + Ok(params) => Ok(params.get("diagnostics").cloned().unwrap_or_else(|| json!([]))), + Err(e) => Err(e), + } + } + + pub fn shutdown(&mut self) -> anyhow::Result<()> { + let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5)); + let _ = self.notify("exit", json!({})); + Ok(()) + } +} + +impl Drop for LspClient { + fn drop(&mut self) { + let _ = self.notify("exit", json!({})); + } +} + +pub fn path_to_lsp_uri(path: &str) -> String { + file_path_to_uri(path) +} diff --git a/src/app/lsp/mod.rs b/src/app/lsp/mod.rs new file mode 100644 index 0000000..bd37a5c --- /dev/null +++ b/src/app/lsp/mod.rs @@ -0,0 +1,80 @@ +use std::sync::{Arc, Mutex}; + +mod client; +pub use client::{path_to_lsp_uri, LspClient}; + +#[derive(Clone)] +pub struct LspServer { + #[allow(dead_code)] + pub name: String, + #[allow(dead_code)] + pub command: String, + #[allow(dead_code)] + pub args: Vec, + pub language_id: String, + pub client: Arc>, +} + +#[derive(Clone)] +pub struct LspManager { + pub servers: Vec, +} + +impl LspManager { + pub fn new() -> Self { + LspManager { + servers: Vec::new(), + } + } + + pub fn connect( + &mut self, + name: &str, + command: &str, + args: &[String], + language_id: &str, + ) -> anyhow::Result<()> { + if self.servers.iter().any(|s| s.name == name) { + anyhow::bail!("LSP server '{}' is already connected", name); + } + let client = LspClient::spawn(command, args)?; + self.servers.push(LspServer { + name: name.to_string(), + command: command.to_string(), + args: args.to_vec(), + language_id: language_id.to_string(), + client: Arc::new(Mutex::new(client)), + }); + Ok(()) + } + + #[allow(dead_code)] + pub fn find_server(&self, name: &str) -> Option<&LspServer> { + self.servers.iter().find(|s| s.name == name) + } + + pub fn get_client(&self, name: &str) -> Option>> { + self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone()) + } + + pub fn disconnect(&mut self, name: &str) -> bool { + if let Some(server) = self.servers.iter().find(|s| s.name == name) { + if let Ok(mut client) = server.client.lock() { + let _ = client.shutdown(); + } + } + let len = self.servers.len(); + self.servers.retain(|s| s.name != name); + self.servers.len() < len + } + + pub fn get_language_id(&self, name: &str) -> Option { + self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone()) + } +} + +impl Default for LspManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index cf8cd3d..74ea31f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ //! Top-level application module: harness, modes, runtime loop, state, -//! workflows, subagents, review, background bash, and MCP integration. +//! workflows, subagents, review, background bash, MCP integration, and +//! native LSP client. pub mod harness; pub mod mode; pub mod runtime; @@ -9,3 +10,4 @@ pub mod subagent; pub mod review; pub mod bgbash; pub mod mcp; +pub mod lsp; diff --git a/src/app/mode/effort.rs b/src/app/mode/effort.rs index a92c5a6..14222f8 100644 --- a/src/app/mode/effort.rs +++ b/src/app/mode/effort.rs @@ -13,11 +13,11 @@ const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1]; /// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent /// to the LLM, scaling the user's configured `max_tokens` by the level's multiplier. -pub fn generation_params(level: usize, base_max_tokens: u32) -> (f32, u32) { +pub fn generation_params(level: usize, base_max_tokens: Option) -> (f32, Option) { let idx = level.min(EFFORT_LEVELS.len() - 1); let temperature = TEMPERATURE_OVERRIDE[idx]; - let max_tokens = ((base_max_tokens as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32; - (temperature, max_tokens.max(256)) + let max_tokens = base_max_tokens.map(|t| ((t as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32); + (temperature, max_tokens.map(|t| t.max(256))) } /// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot. diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 294879e..b274672 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -81,6 +81,7 @@ pub enum Action { }, ModelList, AbortTurn, + Compact, } /// Apply an `Action` to the application state. @@ -441,6 +442,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { if let Some(ref mut rt) = state.session_runtime { rt.usage.tokens_in += tokens_in; rt.usage.tokens_out += tokens_out; + rt.usage.last_tokens_in = tokens_in; + rt.usage.last_tokens_out = tokens_out; rt.usage.api_calls += 1; } } @@ -463,6 +466,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.misc.thinking = false; turn_finished = true; } + TurnEvent::Compacted(new_msgs) => { + if let Some(ref mut rt) = state.session_runtime { + rt.messages = new_msgs; + state.push_toast(Toast::new(ToastKind::Info, "History auto-compacted by AI.".to_string())); + state.dirty = true; + } + } } } if turn_finished { @@ -476,6 +486,23 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst); state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string())); } + Action::Compact => { + let max_wire_tokens = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window) + .unwrap_or(state.app_config.default_context_window) as usize; + + if let Some(ref mut rt) = state.session_runtime { + let total_chars: usize = rt.messages.iter() + .filter_map(|m| m.content.as_deref()) + .map(|c| c.len()) + .sum(); + let token_estimate = total_chars / 4; + rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None); + state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string())); + state.dirty = true; + } + } Action::LessonAccept { name } => { if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( @@ -533,6 +560,10 @@ fn spawn_turn(state: &AppStateRest) { let model = state.settings.model.clone(); let base_url = state.app_config.providers.get(&state.settings.provider) .map(|p| p.api_base.clone()); + let context_window = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window) + .unwrap_or(state.app_config.default_context_window) as usize; if api_key.is_empty() { if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { api_key = provider_cfg.api_key_env.as_ref() @@ -570,10 +601,11 @@ fn spawn_turn(state: &AppStateRest) { .ok() .map(|c| std::sync::Arc::new(std::sync::Mutex::new(c))); let tc = TurnCtx { - client: crate::service::provider::LlmClient::new(api_key, model, base_url), + client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url), tdefs: tool_defs, tools, ctx, + context_window, workspace_roots, edit_log_session_dir: edit_session_dir, @@ -601,13 +633,14 @@ struct TurnCtx { tdefs: Vec, tools: Vec>, ctx: crate::tool::ToolCtx, + context_window: usize, workspace_roots: Vec, edit_log_session_dir: std::path::PathBuf, session_id: String, db: Option>>, temperature: f32, - max_tokens: u32, + max_tokens: Option, abort_flag: std::sync::Arc, } @@ -786,14 +819,26 @@ fn run_agent_turn( } loop { - let wire_msgs = if crate::app::runtime::shortsend::should_shape(msgs.len(), prev_shaped) { - let total_chars: usize = msgs.iter() - .filter_map(|m| m.content.as_deref()) - .map(|c| c.len()) - .sum(); - let token_estimate = total_chars / 4; + let total_chars: usize = msgs.iter() + .filter_map(|m| m.content.as_deref()) + .map(|c| c.len()) + .sum(); + let token_estimate = total_chars / 4; + let max_wire_tokens = tc.context_window; + + let wire_msgs = if crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped) { prev_shaped = true; - crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate) + let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client)); + + // Dispatch the compacted messages to the main thread so the local session history + // is permanently compacted and doesn't trigger shaping again immediately on next turn. + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::Compacted(compacted.clone())); + } + + // Also update our local `msgs` variable so the rest of the loop operates on the compacted version + msgs = compacted.clone(); + compacted } else { prev_shaped = false; msgs.clone() @@ -805,9 +850,9 @@ fn run_agent_turn( let mut usage = None; let result = tc.client.chat_with_tools_streaming( &wire_msgs, - Some(tc.tdefs.clone()), + if tc.tdefs.is_empty() { None } else { Some(tc.tdefs.clone()) }, Some(tc.temperature), - Some(tc.max_tokens), + tc.max_tokens, |event| -> bool { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { return false; diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs index ec6f6f1..d57c7c2 100644 --- a/src/app/runtime/commands.rs +++ b/src/app/runtime/commands.rs @@ -78,6 +78,9 @@ pub fn apply_command(command: Command) -> Vec { Command::ModelList => { vec![Action::ModelList] } + Command::Compact => { + vec![Action::Compact] + } Command::Unknown(cmd) => { vec![Action::SystemNote { kind: "error".to_string(), diff --git a/src/app/runtime/shortsend.rs b/src/app/runtime/shortsend.rs index e7f2dcf..2b38de1 100644 --- a/src/app/runtime/shortsend.rs +++ b/src/app/runtime/shortsend.rs @@ -3,34 +3,27 @@ //! LLM API. use crate::dto::chat::message::ChatMessage; -const MAX_WIRE_TOKENS: usize = 2_000_000; -const MIN_MESSAGES_BEFORE_SHAPE: usize = 20; -const ENGAGE_HYSTERESIS: usize = 5; - /// Decide whether the message list should be shaped (compacted) before /// sending to the LLM. /// -/// Flow: skip shaping if fewer than `MIN_MESSAGES_BEFORE_SHAPE` messages -/// → once past that threshold, use hysteresis (require 5 more messages -/// before re-engaging if shaping is currently active) to avoid oscillation. +/// Flow: trigger based on token estimate. If `token_estimate` exceeds +/// `MAX_WIRE_TOKENS * 0.8`, we shape. We also apply hysteresis so it doesn't +/// flutter. /// /// Return: `true` if shaping should be applied. -pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool { - if total_messages < MIN_MESSAGES_BEFORE_SHAPE { - return false; - } +pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool { let threshold = if prev_shaped { - MIN_MESSAGES_BEFORE_SHAPE + ENGAGE_HYSTERESIS + (max_wire_tokens as f32 * 0.85) as usize } else { - MIN_MESSAGES_BEFORE_SHAPE + (max_wire_tokens as f32 * 0.90) as usize }; - total_messages >= threshold + token_estimate >= threshold } /// Compact a long message list by dropping middle messages and inserting /// a summary placeholder. /// -/// Flow: if the estimated token count is within budget, return messages +/// Flow: if the estimated token count is within budget and not forced, return messages /// unchanged → otherwise keep the system message and the most recent /// messages (up to `MAX_WIRE_TOKENS / 200` of them) with a `[prior /// conversation compacted]` system message in between. @@ -38,25 +31,75 @@ pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool { /// Why: keeps context-size overhead roughly constant regardless of /// session length. /// -/// Return: a new Vec that preserves the first message and -/// the tail. -pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec { - if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 { +pub fn shape_messages( + messages: &[ChatMessage], + token_count: usize, + max_wire_tokens: usize, + force: bool, + client: Option<&crate::service::provider::LlmClient>, +) -> Vec { + if !force && (token_count <= max_wire_tokens || messages.len() < 5) { return messages.to_vec(); } - let keep_recent = messages - .iter() - .rev() - .take(MAX_WIRE_TOKENS / 200) - .cloned() - .collect::>(); - let mut result = Vec::new(); - if let Some(first) = messages.first() { - result.push(first.clone()); + + let target_tokens = (max_wire_tokens as f32 * 0.70) as usize; + let mut current_tokens = 0; + let mut keep_recent = Vec::new(); + let mut dropped_msgs = Vec::new(); + + // Always keep the very first message (System Prompt) which we don't count here + // as we just blindly preserve it later. + let mut msgs_to_eval = messages.to_vec(); + let first = if !msgs_to_eval.is_empty() { + Some(msgs_to_eval.remove(0)) + } else { + None + }; + + // Iterate backwards from the most recent to oldest + for m in msgs_to_eval.into_iter().rev() { + let text = m.content.as_deref().unwrap_or(""); + let msg_tokens = text.len() / 4; + + if current_tokens + msg_tokens <= target_tokens { + current_tokens += msg_tokens; + keep_recent.push(m); + } else { + dropped_msgs.push(m); // These will end up in reverse chronological order + } } - result.push(ChatMessage::system( - "[prior conversation compacted]".to_string(), - )); + + // Reverse dropped_msgs so they are back in chronological order + dropped_msgs.reverse(); + + let mut result = Vec::new(); + if let Some(f) = first { + result.push(f); + } + + if !dropped_msgs.is_empty() { + let mut summary_text = "[prior conversation compacted]".to_string(); + + if let Some(llm) = client { + let prompt = format!( + "Summarize the following dropped conversation history briefly. Focus on main goals, decisions made, and files modified, so the context is preserved for future turns. Keep it concise.\n\nHistory:\n{}", + dropped_msgs.iter() + .map(|m| format!("[{}]: {}", if m.role == crate::dto::chat::message::Role::User { "User" } else { "Assistant" }, m.content.as_deref().unwrap_or(""))) + .collect::>() + .join("\n\n") + ); + + let req_msgs = vec![ChatMessage::user(prompt)]; + if let Ok(resp) = llm.chat_with_tools_non_streaming(&req_msgs, None) { + if let Some(content) = resp.0.content { + summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content); + } + } + } + + result.push(ChatMessage::system(summary_text)); + } + result.extend(keep_recent.into_iter().rev()); result } diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 0fc4edc..be3fff5 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -277,6 +277,8 @@ pub struct MiscState { pub selected_index: usize, pub editor: Option, pub api_connected: bool, + #[allow(dead_code)] + pub api_context_length: Option, pub tick_count: u64, pub todo_content: String, } @@ -294,6 +296,7 @@ impl MiscState { selected_index: 0, editor: None, api_connected: false, + api_context_length: None, tick_count: 0, todo_content: String::new(), } diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index b120132..b4effe0 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -12,6 +12,7 @@ use tokio::sync::RwLock; use super::misc::{DirCache, InputState, MiscState, ScrollState}; use super::runtime::{SessionRuntime, TurnEvent}; use super::types::{Origin, Toast, TranscriptCache}; +use crate::app::lsp::LspManager; use crate::app::mcp::manager::McpManager; use crate::app::workflow::engine::WorkflowEngine; use crate::model::app_config::AppConfig; @@ -66,6 +67,7 @@ pub struct AppStateRest { pub abort_flag: Arc, pub workflow_engine: WorkflowEngine, pub mcp_manager: McpManager, + pub lsp_manager: Arc>, pub dirty: bool, pub quit: bool, } @@ -117,6 +119,7 @@ impl AppStateRest { session_runtime: Some(SessionRuntime::new(session_dir.clone())), workflow_engine: WorkflowEngine::new(), mcp_manager: McpManager::new(), + lsp_manager: Arc::new(Mutex::new(LspManager::new())), sessions: Vec::new(), transcript_cache: TranscriptCache::new(200), scroll: ScrollState::new(), @@ -196,6 +199,7 @@ impl AppStateRest { dir_cache: self.dir_cache.clone(), origin, graduated_checks: Vec::new(), + lsp_manager: self.lsp_manager.clone(), } } } diff --git a/src/app/state/runtime.rs b/src/app/state/runtime.rs index 9424cec..0f8afd8 100644 --- a/src/app/state/runtime.rs +++ b/src/app/state/runtime.rs @@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize}; pub struct UsageStats { pub tokens_in: u64, pub tokens_out: u64, + #[serde(default)] + pub last_tokens_in: u64, + #[serde(default)] + pub last_tokens_out: u64, pub api_calls: u64, pub review_tokens: u64, pub total_ms: u64, @@ -96,6 +100,7 @@ pub enum TurnEvent { tokens_in: u64, tokens_out: u64, }, + Compacted(Vec), Error(String), Done, } diff --git a/src/controller/command.rs b/src/controller/command.rs index a017b50..a1309cc 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -24,6 +24,7 @@ pub enum Command { command: String, }, ModelList, + Compact, Unknown(String), } @@ -86,6 +87,7 @@ pub fn parse_command(text: &str) -> Command { } } "/model" => Command::ModelList, + "/compact" => Command::Compact, _ => Command::Unknown(cmd.to_string()), } } diff --git a/src/model/app_config.rs b/src/model/app_config.rs index ef3793c..4be8dbd 100644 --- a/src/model/app_config.rs +++ b/src/model/app_config.rs @@ -12,6 +12,7 @@ pub struct AppConfig { pub model_roles: HashMap, pub default_provider: String, pub default_model: String, + pub default_context_window: u32, } /// Connection details for a single LLM provider (base URL, API key source). @@ -30,6 +31,7 @@ pub struct ModelRole { pub provider: String, pub model: String, pub max_tokens: Option, + pub context_window: Option, pub temperature: Option, } @@ -52,7 +54,8 @@ impl Default for AppConfig { model_roles.insert("default".to_string(), ModelRole { provider: "zen".to_string(), model: "deepseek-v4-flash-free".to_string(), - max_tokens: Some(8192), + max_tokens: None, + context_window: None, temperature: Some(0.7), }); AppConfig { @@ -60,6 +63,7 @@ impl Default for AppConfig { model_roles, default_provider: "zen".to_string(), default_model: "deepseek-v4-flash-free".to_string(), + default_context_window: 256_000, } } } diff --git a/src/model/conversation.rs b/src/model/conversation.rs index 409da47..de32de8 100644 --- a/src/model/conversation.rs +++ b/src/model/conversation.rs @@ -10,8 +10,8 @@ pub struct Conversation { pub system_prompt: String, pub session_id: String, pub model: String, - pub max_tokens: u32, - pub temperature: f32, + pub max_tokens: Option, + pub temperature: Option, } impl Conversation { @@ -23,8 +23,8 @@ impl Conversation { system_prompt, session_id, model: "anthropic/claude-opus-4-8".to_string(), - max_tokens: 8192, - temperature: 0.7, + max_tokens: None, + temperature: None, } } diff --git a/src/model/settings.rs b/src/model/settings.rs index 61cb545..a0a6d0a 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -31,8 +31,8 @@ pub struct Settings { pub provider: String, pub model: String, pub api_keys: std::collections::HashMap, - pub max_tokens: u32, - pub temperature: f32, + pub max_tokens: Option, + pub temperature: Option, pub review_enabled: bool, pub review_max_lessons_per_run: usize, pub adaptive_review_max_skip: u32, @@ -49,8 +49,8 @@ impl Default for Settings { provider: "zen".to_string(), model: "deepseek-v4-flash-free".to_string(), api_keys: std::collections::HashMap::new(), - max_tokens: 8192, - temperature: 0.7, + max_tokens: None, + temperature: None, review_enabled: true, review_max_lessons_per_run: 5, adaptive_review_max_skip: 3, diff --git a/src/tool/lsp/mod.rs b/src/tool/lsp/mod.rs new file mode 100644 index 0000000..5c078a2 --- /dev/null +++ b/src/tool/lsp/mod.rs @@ -0,0 +1,677 @@ +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; + +use crate::tool::{Tool, ToolCtx}; +use crate::app::lsp::path_to_lsp_uri; + +pub struct LspConnect; + +impl Tool for LspConnect { + fn name(&self) -> &'static str { + "lsp_connect" + } + + fn description(&self) -> &'static str { + "Connect to a Language Server Protocol (LSP) server for a programming language" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short name for this LSP connection (e.g. 'rust', 'typescript')" + }, + "command": { + "type": "string", + "description": "The LSP server binary to spawn (e.g. 'rust-analyzer', 'typescript-language-server')" + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Command-line arguments for the LSP server" + }, + "language_id": { + "type": "string", + "description": "Language identifier (e.g. 'rust', 'typescript', 'python')" + } + }, + "required": ["name", "command", "language_id"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let name = args.get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: name"))?; + let command = args.get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: command"))?; + let language_id = args.get("language_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: language_id"))?; + let extra_args: Vec = args.get("args") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + let mut manager = ctx.lsp_manager.lock() + .map_err(|e| anyhow!("LSP manager lock error: {}", e))?; + manager.connect(name, command, &extra_args, language_id)?; + + let client_arc = manager.get_client(name); + let caps = client_arc.map(|c| { + c.lock().ok().map(|guard| guard.server_capabilities().clone()) + }).flatten().unwrap_or_default(); + + let caps_summary = serde_json::to_string_pretty(&caps) + .unwrap_or_else(|_| "{}".to_string()); + + Ok(format!( + "Connected to LSP server '{}' (language: {})\nServer capabilities:\n{}", + name, language_id, caps_summary + )) + } +} + +pub struct LspDiagnostics; + +impl Tool for LspDiagnostics { + fn name(&self) -> &'static str { + "lsp_diagnostics" + } + + fn description(&self) -> &'static str { + "Get diagnostics (errors, warnings, hints) for a file from an LSP server" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Name of the connected LSP server" + }, + "path": { + "type": "string", + "description": "Path to the file to analyze (relative to workspace root)" + }, + "text": { + "type": "string", + "description": "The full text content of the file" + } + }, + "required": ["server", "path", "text"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let server_name = args.get("server") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: server"))?; + let rel_path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))?; + let text = args.get("text") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: text"))?; + + let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; + let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); + + let manager = ctx.lsp_manager.lock() + .map_err(|e| anyhow!("LSP manager lock error: {}", e))?; + let language_id = manager.get_language_id(server_name) + .ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; + let client_arc = manager.get_client(server_name) + .ok_or_else(|| anyhow!("LSP server '{}' not found", server_name))?; + drop(manager); + + let mut client = client_arc.lock() + .map_err(|e| anyhow!("LSP client lock error: {}", e))?; + + match client.collect_diagnostics(&uri, &language_id, text) { + Ok(diags) => { + let diags_array = diags.as_array().cloned().unwrap_or_default(); + if diags_array.is_empty() { + return Ok("No diagnostics found for this file.".to_string()); + } + let mut output = String::from("Diagnostics:\n"); + for d in &diags_array { + let range = d.get("range").and_then(|r| r.get("start")); + let severity = match d.get("severity").and_then(|s| s.as_i64()).unwrap_or(0) { + 1 => "ERROR", + 2 => "WARNING", + 3 => "INFO", + 4 => "HINT", + _ => "NOTE", + }; + let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?"); + let line = range.and_then(|r| r.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); + let col = range.and_then(|r| r.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); + let code = d.get("code") + .and_then(|c| c.as_str().or_else(|| c.as_i64().map(|n| Box::leak(Box::new(n.to_string()))).map(|s| s.as_str()))) + .unwrap_or(""); + let code_str = if code.is_empty() { String::new() } else { format!(" [{}]", code) }; + output.push_str(&format!(" {}:{}:{} - {}{}: {}\n", rel_path, line + 1, col, severity, code_str, message)); + } + Ok(output) + } + Err(e) => { + if e.to_string().contains("timed out") { + Ok("Diagnostics request timed out. The server may still be initializing. Try again in a moment.".to_string()) + } else { + Err(e) + } + } + } + } +} + +pub struct LspHover; + +impl Tool for LspHover { + fn name(&self) -> &'static str { + "lsp_hover" + } + + fn description(&self) -> &'static str { + "Get hover information (type signature, documentation) at a cursor position in a file" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Name of the connected LSP server" + }, + "path": { + "type": "string", + "description": "Path to the file (relative to workspace root)" + }, + "line": { + "type": "integer", + "description": "Line number (0-based)" + }, + "column": { + "type": "integer", + "description": "Column number (0-based)" + }, + "language_id": { + "type": "string", + "description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect." + } + }, + "required": ["server", "path", "line", "column"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let server_name = args.get("server") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: server"))?; + let rel_path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))?; + let line = args.get("line") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; + let column = args.get("column") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + + let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; + let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); + + let file_content = std::fs::read_to_string(&abs_path) + .map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; + + let manager = ctx.lsp_manager.lock() + .map_err(|e| anyhow!("LSP manager lock error: {}", e))?; + let language_id = manager.get_language_id(server_name) + .unwrap_or_else(|| { + args.get("language_id").and_then(|v| v.as_str()).unwrap_or("plaintext").to_string() + }); + let client_arc = manager.get_client(server_name) + .ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; + drop(manager); + + let mut client = client_arc.lock() + .map_err(|e| anyhow!("LSP client lock error: {}", e))?; + + client.did_open(&uri, &language_id, 1, &file_content)?; + let result = client.hover(&uri, line, column); + let _ = client.did_close(&uri); + + match result { + Ok(hover_result) => { + if hover_result == Value::Null { + return Ok("No hover information available at this position.".to_string()); + } + let contents = hover_result.get("contents"); + let range = hover_result.get("range"); + let mut output = String::new(); + if let Some(range_val) = range { + if let Some(start) = range_val.get("start") { + let rl = start.get("line").and_then(|l| l.as_i64()).unwrap_or(0); + let rc = start.get("character").and_then(|c| c.as_i64()).unwrap_or(0); + output.push_str(&format!("Range: {}:{}\n", rl + 1, rc + 1)); + } + } + if let Some(contents_val) = contents { + output.push_str(&format_hover_contents(contents_val)); + } else { + output.push_str(&serde_json::to_string_pretty(&hover_result).unwrap_or_default()); + } + Ok(output) + } + Err(e) => Err(e), + } + } +} + +fn format_hover_contents(contents: &Value) -> String { + let mut out = String::new(); + match contents { + Value::String(s) => { + out.push_str(s); + } + Value::Object(map) => { + if let Some(kind) = map.get("kind").and_then(|k| k.as_str()) { + out.push_str(&format!("[{kind}] ")); + } + if let Some(value) = map.get("value").and_then(|v| v.as_str()) { + out.push_str(value); + } + } + Value::Array(arr) => { + for (i, item) in arr.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&format_hover_contents(item)); + } + } + _ => { + out.push_str(&serde_json::to_string_pretty(contents).unwrap_or_default()); + } + } + out +} + +pub struct LspCompletion; + +impl Tool for LspCompletion { + fn name(&self) -> &'static str { + "lsp_completion" + } + + fn description(&self) -> &'static str { + "Get code completion suggestions at a cursor position from an LSP server" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Name of the connected LSP server" + }, + "path": { + "type": "string", + "description": "Path to the file (relative to workspace root)" + }, + "line": { + "type": "integer", + "description": "Line number (0-based)" + }, + "column": { + "type": "integer", + "description": "Column number (0-based)" + } + }, + "required": ["server", "path", "line", "column"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let server_name = args.get("server") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: server"))?; + let rel_path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))?; + let line = args.get("line") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; + let column = args.get("column") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + + let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; + let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); + + let file_content = std::fs::read_to_string(&abs_path) + .map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; + + let manager = ctx.lsp_manager.lock() + .map_err(|e| anyhow!("LSP manager lock error: {}", e))?; + let language_id = manager.get_language_id(server_name) + .unwrap_or_else(|| "plaintext".to_string()); + let client_arc = manager.get_client(server_name) + .ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; + drop(manager); + + let mut client = client_arc.lock() + .map_err(|e| anyhow!("LSP client lock error: {}", e))?; + + client.did_open(&uri, &language_id, 1, &file_content)?; + let result = client.completion(&uri, line, column); + let _ = client.did_close(&uri); + + match result { + Ok(completion_result) => { + let items = if let Some(items) = completion_result.as_array() { + items.clone() + } else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array()) { + arr.clone() + } else { + Vec::new() + }; + + if items.is_empty() { + return Ok("No completions available at this position.".to_string()); + } + + let mut output = format!("{} completion suggestions at {}:{}:\n", items.len(), line + 1, column + 1); + for (i, item) in items.iter().enumerate().take(50) { + let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?"); + let kind = match item.get("kind").and_then(|k| k.as_i64()).unwrap_or(0) { + 1 => "Text", + 2 => "Method", + 3 => "Function", + 4 => "Constructor", + 5 => "Field", + 6 => "Variable", + 7 => "Class", + 8 => "Interface", + 9 => "Module", + 10 => "Property", + 11 => "Unit", + 12 => "Value", + 13 => "Enum", + 14 => "Keyword", + 15 => "Snippet", + 16 => "Color", + 17 => "File", + 18 => "Reference", + 19 => "Folder", + 20 => "EnumMember", + 21 => "Constant", + 22 => "Struct", + 23 => "Event", + 24 => "Operator", + 25 => "TypeParameter", + _ => "Other", + }; + let detail = item.get("detail").and_then(|d| d.as_str()).unwrap_or(""); + let detail_str = if detail.is_empty() { String::new() } else { format!(" - {}", detail) }; + output.push_str(&format!(" {}. [{}] {}{}\n", i + 1, kind, label, detail_str)); + } + if items.len() > 50 { + output.push_str(&format!(" ... and {} more\n", items.len() - 50)); + } + Ok(output) + } + Err(e) => Err(e), + } + } +} + +pub struct LspDefinition; + +impl Tool for LspDefinition { + fn name(&self) -> &'static str { + "lsp_definition" + } + + fn description(&self) -> &'static str { + "Go to definition: find the location where a symbol is defined" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Name of the connected LSP server" + }, + "path": { + "type": "string", + "description": "Path to the file (relative to workspace root)" + }, + "line": { + "type": "integer", + "description": "Line number (0-based)" + }, + "column": { + "type": "integer", + "description": "Column number (0-based)" + } + }, + "required": ["server", "path", "line", "column"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let server_name = args.get("server") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: server"))?; + let rel_path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))?; + let line = args.get("line") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; + let column = args.get("column") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + + let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; + let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); + + let file_content = std::fs::read_to_string(&abs_path) + .map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; + + let manager = ctx.lsp_manager.lock() + .map_err(|e| anyhow!("LSP manager lock error: {}", e))?; + let language_id = manager.get_language_id(server_name) + .unwrap_or_else(|| "plaintext".to_string()); + let client_arc = manager.get_client(server_name) + .ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; + drop(manager); + + let mut client = client_arc.lock() + .map_err(|e| anyhow!("LSP client lock error: {}", e))?; + + client.did_open(&uri, &language_id, 1, &file_content)?; + let result = client.goto_definition(&uri, line, column); + let _ = client.did_close(&uri); + + match result { + Ok(def_result) => { + if def_result == Value::Null { + return Ok("No definition found at this position.".to_string()); + } + let locations = if let Some(loc) = def_result.as_array() { + loc.clone() + } else { + vec![def_result.clone()] + }; + + if locations.is_empty() { + return Ok("No definition found.".to_string()); + } + + let mut output = String::from("Definition(s):\n"); + for (i, loc) in locations.iter().enumerate().take(10) { + let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?"); + let target_range = loc.get("range").or_else(|| loc.get("targetRange")); + let target_start = target_range.and_then(|r| r.get("start")); + let tl = target_start.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); + let tc = target_start.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); + let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); + output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, tl + 1, tc + 1)); + } + if locations.len() > 10 { + output.push_str(&format!(" ... and {} more locations\n", locations.len() - 10)); + } + Ok(output) + } + Err(e) => Err(e), + } + } +} + +pub struct LspReferences; + +impl Tool for LspReferences { + fn name(&self) -> &'static str { + "lsp_references" + } + + fn description(&self) -> &'static str { + "Find all references to a symbol at a cursor position" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Name of the connected LSP server" + }, + "path": { + "type": "string", + "description": "Path to the file (relative to workspace root)" + }, + "line": { + "type": "integer", + "description": "Line number (0-based)" + }, + "column": { + "type": "integer", + "description": "Column number (0-based)" + } + }, + "required": ["server", "path", "line", "column"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let server_name = args.get("server") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: server"))?; + let rel_path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))?; + let line = args.get("line") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; + let column = args.get("column") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + + let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; + let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); + + let file_content = std::fs::read_to_string(&abs_path) + .map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?; + + let manager = ctx.lsp_manager.lock() + .map_err(|e| anyhow!("LSP manager lock error: {}", e))?; + let language_id = manager.get_language_id(server_name) + .unwrap_or_else(|| "plaintext".to_string()); + let client_arc = manager.get_client(server_name) + .ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?; + drop(manager); + + let mut client = client_arc.lock() + .map_err(|e| anyhow!("LSP client lock error: {}", e))?; + + client.did_open(&uri, &language_id, 1, &file_content)?; + let result = client.references(&uri, line, column); + let _ = client.did_close(&uri); + + match result { + Ok(ref_result) => { + let locations = ref_result.as_array().cloned().unwrap_or_default(); + if locations.is_empty() { + return Ok("No references found for this symbol.".to_string()); + } + + let mut output = format!("{} reference(s) found:\n", locations.len()); + for (i, loc) in locations.iter().enumerate().take(50) { + let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?"); + let range = loc.get("range").and_then(|r| r.get("start")); + let rl = range.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0); + let rc = range.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0); + let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri); + output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, rl + 1, rc + 1)); + } + if locations.len() > 50 { + output.push_str(&format!(" ... and {} more references\n", locations.len() - 50)); + } + Ok(output) + } + Err(e) => Err(e), + } + } +} + +pub struct LspDisconnect; + +impl Tool for LspDisconnect { + fn name(&self) -> &'static str { + "lsp_disconnect" + } + + fn description(&self) -> &'static str { + "Disconnect from a running LSP server and release its resources" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the LSP server to disconnect" + } + }, + "required": ["name"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let name = args.get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: name"))?; + + let mut manager = ctx.lsp_manager.lock() + .map_err(|e| anyhow!("LSP manager lock error: {}", e))?; + + if manager.disconnect(name) { + Ok(format!("Disconnected from LSP server '{}'", name)) + } else { + Err(anyhow!("LSP server '{}' not found", name)) + } + } +} diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 3b0b953..7c3cfa0 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -1,6 +1,7 @@ -//! Tool trait, execution context, and the registry of all 28 built-in tools. +//! Tool trait, execution context, and the registry of all built-in tools. use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use serde_json::Value; use anyhow::Result; @@ -9,6 +10,7 @@ pub mod fs; pub mod git_cred; pub mod git_operator; pub mod git_worktree; +pub mod lsp; pub mod memory; pub mod plan; pub mod search; @@ -46,6 +48,7 @@ pub struct ToolCtx { pub dir_cache: std::sync::Arc>, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, + pub lsp_manager: Arc>, } /// Find which graduated checks apply to a given file path/content pair. @@ -81,6 +84,7 @@ pub struct ToolCtxBuilder { pub dir_cache: std::sync::Arc>, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, + pub lsp_manager: Arc>, } impl Default for ToolCtxBuilder { @@ -94,6 +98,7 @@ impl Default for ToolCtxBuilder { dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())), origin: crate::app::state::types::Origin::Main, graduated_checks: Vec::new(), + lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())), } } } @@ -103,6 +108,9 @@ impl ToolCtxBuilder { pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self } /// Set the origin (main process vs. daemon-attached). pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self } + /// Set the lsp_manager. + #[allow(dead_code)] + pub fn lsp_manager(mut self, v: Arc>) -> Self { self.lsp_manager = v; self } /// Consume the builder and produce the final `ToolCtx`. pub fn build(self) -> ToolCtx { ToolCtx { @@ -114,6 +122,7 @@ impl ToolCtxBuilder { dir_cache: self.dir_cache, origin: self.origin, graduated_checks: self.graduated_checks, + lsp_manager: self.lsp_manager, } } } @@ -149,6 +158,13 @@ pub fn all_tools() -> Vec> { Box::new(super::tool::utility::dir_cache_update::DirCacheUpdate), Box::new(super::tool::utility::pong::Pong), Box::new(super::tool::utility::todowrite::Todowrite), + Box::new(super::tool::lsp::LspConnect), + Box::new(super::tool::lsp::LspDiagnostics), + Box::new(super::tool::lsp::LspHover), + Box::new(super::tool::lsp::LspCompletion), + Box::new(super::tool::lsp::LspDefinition), + Box::new(super::tool::lsp::LspReferences), + Box::new(super::tool::lsp::LspDisconnect), ] } diff --git a/src/view/mod.rs b/src/view/mod.rs index 125c7b1..7fb8e49 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -142,11 +142,11 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Max tokens: {}", state.settings.max_tokens), + format!("Max tokens: {}", state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Temperature: {:.1}", state.settings.temperature), + format!("Temperature: {}", state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( diff --git a/src/view/status.rs b/src/view/status.rs index 2898ffd..facafb3 100644 --- a/src/view/status.rs +++ b/src/view/status.rs @@ -50,9 +50,38 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state: status, ]; - // Right chunk: provider · model + // Right chunk: token usage, provider, model + let right_str = if let Some(ref rt) = state.session_runtime { + let max_tokens = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window); + + let total_chars: usize = rt.messages.iter() + .filter_map(|m| m.content.as_deref()) + .map(|c| c.len()) + .sum(); + let current_tokens = total_chars / 4; + + let mut parts = Vec::new(); + if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 { + parts.push(format!("↑{} ↓{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out)); + } + let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string()); + parts.push(format!("{}/{}", current_tokens, max_str)); + parts.push(state.settings.provider.clone()); + parts.push(state.settings.model.clone()); + + format!(" {} ", parts.join(" · ")) + } else { + let max_tokens = state.app_config.model_roles.values() + .find(|role| role.provider == state.settings.provider && role.model == state.settings.model) + .and_then(|role| role.context_window); + let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string()); + format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model) + }; + spans.push(Span::styled( - format!(" {} · {} ", state.settings.provider, state.settings.model), + right_str, Style::default().fg(Theme::DIM), ));