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.
This commit is contained in:
@@ -39,5 +39,20 @@ Workflow:
|
|||||||
multi-step tasks needing parallel analysis or verification. Pass inline
|
multi-step tasks needing parallel analysis or verification. Pass inline
|
||||||
scripts with agent(), parallel(), and pipeline() primitives.
|
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
|
Each write/edit call MUST include a non-empty reason argument explaining
|
||||||
why the change is being made. This is enforced deterministically.
|
why the change is being made. This is enforced deterministically.
|
||||||
|
|||||||
@@ -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<std::process::ChildStdout>,
|
||||||
|
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<Self> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
let mut content_length: Option<usize> = 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::<usize>()
|
||||||
|
.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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
self.call("textDocument/documentSymbol", json!({
|
||||||
|
"textDocument": { "uri": uri }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect_diagnostics(
|
||||||
|
&mut self,
|
||||||
|
uri: &str,
|
||||||
|
language_id: &str,
|
||||||
|
text: &str,
|
||||||
|
) -> anyhow::Result<Value> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub language_id: String,
|
||||||
|
pub client: Arc<Mutex<LspClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LspManager {
|
||||||
|
pub servers: Vec<LspServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Arc<Mutex<LspClient>>> {
|
||||||
|
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<String> {
|
||||||
|
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LspManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
//! Top-level application module: harness, modes, runtime loop, state,
|
//! 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 harness;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
@@ -9,3 +10,4 @@ pub mod subagent;
|
|||||||
pub mod review;
|
pub mod review;
|
||||||
pub mod bgbash;
|
pub mod bgbash;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod lsp;
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// 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<u32>) -> (f32, Option<u32>) {
|
||||||
let idx = level.min(EFFORT_LEVELS.len() - 1);
|
let idx = level.min(EFFORT_LEVELS.len() - 1);
|
||||||
let temperature = TEMPERATURE_OVERRIDE[idx];
|
let temperature = TEMPERATURE_OVERRIDE[idx];
|
||||||
let max_tokens = ((base_max_tokens as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32;
|
let max_tokens = base_max_tokens.map(|t| ((t as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32);
|
||||||
(temperature, max_tokens.max(256))
|
(temperature, max_tokens.map(|t| t.max(256)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
|
/// Return the current effort level index, clamped to a valid `EFFORT_LEVELS` slot.
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ pub enum Action {
|
|||||||
},
|
},
|
||||||
ModelList,
|
ModelList,
|
||||||
AbortTurn,
|
AbortTurn,
|
||||||
|
Compact,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an `Action` to the application state.
|
/// 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 {
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
rt.usage.tokens_in += tokens_in;
|
rt.usage.tokens_in += tokens_in;
|
||||||
rt.usage.tokens_out += tokens_out;
|
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;
|
rt.usage.api_calls += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,6 +466,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.misc.thinking = false;
|
state.misc.thinking = false;
|
||||||
turn_finished = true;
|
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 {
|
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.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
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 } => {
|
Action::LessonAccept { name } => {
|
||||||
if let Some(ref rt) = state.session_runtime {
|
if let Some(ref rt) = state.session_runtime {
|
||||||
let _ = crate::app::review::resolve_pending_lesson(
|
let _ = crate::app::review::resolve_pending_lesson(
|
||||||
@@ -533,6 +560,10 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
let model = state.settings.model.clone();
|
let model = state.settings.model.clone();
|
||||||
let base_url = state.app_config.providers.get(&state.settings.provider)
|
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||||
.map(|p| p.api_base.clone());
|
.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 api_key.is_empty() {
|
||||||
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
||||||
api_key = provider_cfg.api_key_env.as_ref()
|
api_key = provider_cfg.api_key_env.as_ref()
|
||||||
@@ -570,10 +601,11 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
|
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
|
||||||
let tc = TurnCtx {
|
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,
|
tdefs: tool_defs,
|
||||||
tools,
|
tools,
|
||||||
ctx,
|
ctx,
|
||||||
|
context_window,
|
||||||
|
|
||||||
workspace_roots,
|
workspace_roots,
|
||||||
edit_log_session_dir: edit_session_dir,
|
edit_log_session_dir: edit_session_dir,
|
||||||
@@ -601,13 +633,14 @@ struct TurnCtx {
|
|||||||
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
tdefs: Vec<crate::dto::provider::request::ToolDef>,
|
||||||
tools: Vec<Box<dyn crate::tool::Tool>>,
|
tools: Vec<Box<dyn crate::tool::Tool>>,
|
||||||
ctx: crate::tool::ToolCtx,
|
ctx: crate::tool::ToolCtx,
|
||||||
|
context_window: usize,
|
||||||
|
|
||||||
workspace_roots: Vec<std::path::PathBuf>,
|
workspace_roots: Vec<std::path::PathBuf>,
|
||||||
edit_log_session_dir: std::path::PathBuf,
|
edit_log_session_dir: std::path::PathBuf,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
max_tokens: u32,
|
max_tokens: Option<u32>,
|
||||||
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
abort_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,14 +819,26 @@ fn run_agent_turn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let wire_msgs = if crate::app::runtime::shortsend::should_shape(msgs.len(), prev_shaped) {
|
let total_chars: usize = msgs.iter()
|
||||||
let total_chars: usize = msgs.iter()
|
.filter_map(|m| m.content.as_deref())
|
||||||
.filter_map(|m| m.content.as_deref())
|
.map(|c| c.len())
|
||||||
.map(|c| c.len())
|
.sum();
|
||||||
.sum();
|
let token_estimate = total_chars / 4;
|
||||||
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;
|
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 {
|
} else {
|
||||||
prev_shaped = false;
|
prev_shaped = false;
|
||||||
msgs.clone()
|
msgs.clone()
|
||||||
@@ -805,9 +850,9 @@ fn run_agent_turn(
|
|||||||
let mut usage = None;
|
let mut usage = None;
|
||||||
let result = tc.client.chat_with_tools_streaming(
|
let result = tc.client.chat_with_tools_streaming(
|
||||||
&wire_msgs,
|
&wire_msgs,
|
||||||
Some(tc.tdefs.clone()),
|
if tc.tdefs.is_empty() { None } else { Some(tc.tdefs.clone()) },
|
||||||
Some(tc.temperature),
|
Some(tc.temperature),
|
||||||
Some(tc.max_tokens),
|
tc.max_tokens,
|
||||||
|event| -> bool {
|
|event| -> bool {
|
||||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
Command::ModelList => {
|
Command::ModelList => {
|
||||||
vec![Action::ModelList]
|
vec![Action::ModelList]
|
||||||
}
|
}
|
||||||
|
Command::Compact => {
|
||||||
|
vec![Action::Compact]
|
||||||
|
}
|
||||||
Command::Unknown(cmd) => {
|
Command::Unknown(cmd) => {
|
||||||
vec![Action::SystemNote {
|
vec![Action::SystemNote {
|
||||||
kind: "error".to_string(),
|
kind: "error".to_string(),
|
||||||
|
|||||||
@@ -3,34 +3,27 @@
|
|||||||
//! LLM API.
|
//! LLM API.
|
||||||
use crate::dto::chat::message::ChatMessage;
|
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
|
/// Decide whether the message list should be shaped (compacted) before
|
||||||
/// sending to the LLM.
|
/// sending to the LLM.
|
||||||
///
|
///
|
||||||
/// Flow: skip shaping if fewer than `MIN_MESSAGES_BEFORE_SHAPE` messages
|
/// Flow: trigger based on token estimate. If `token_estimate` exceeds
|
||||||
/// → once past that threshold, use hysteresis (require 5 more messages
|
/// `MAX_WIRE_TOKENS * 0.8`, we shape. We also apply hysteresis so it doesn't
|
||||||
/// before re-engaging if shaping is currently active) to avoid oscillation.
|
/// flutter.
|
||||||
///
|
///
|
||||||
/// Return: `true` if shaping should be applied.
|
/// Return: `true` if shaping should be applied.
|
||||||
pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
|
pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool {
|
||||||
if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let threshold = if prev_shaped {
|
let threshold = if prev_shaped {
|
||||||
MIN_MESSAGES_BEFORE_SHAPE + ENGAGE_HYSTERESIS
|
(max_wire_tokens as f32 * 0.85) as usize
|
||||||
} else {
|
} 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
|
/// Compact a long message list by dropping middle messages and inserting
|
||||||
/// a summary placeholder.
|
/// 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
|
/// unchanged → otherwise keep the system message and the most recent
|
||||||
/// messages (up to `MAX_WIRE_TOKENS / 200` of them) with a `[prior
|
/// messages (up to `MAX_WIRE_TOKENS / 200` of them) with a `[prior
|
||||||
/// conversation compacted]` system message in between.
|
/// 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
|
/// Why: keeps context-size overhead roughly constant regardless of
|
||||||
/// session length.
|
/// session length.
|
||||||
///
|
///
|
||||||
/// Return: a new Vec<ChatMessage> that preserves the first message and
|
pub fn shape_messages(
|
||||||
/// the tail.
|
messages: &[ChatMessage],
|
||||||
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
|
token_count: usize,
|
||||||
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
|
max_wire_tokens: usize,
|
||||||
|
force: bool,
|
||||||
|
client: Option<&crate::service::provider::LlmClient>,
|
||||||
|
) -> Vec<ChatMessage> {
|
||||||
|
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
|
||||||
return messages.to_vec();
|
return messages.to_vec();
|
||||||
}
|
}
|
||||||
let keep_recent = messages
|
|
||||||
.iter()
|
let target_tokens = (max_wire_tokens as f32 * 0.70) as usize;
|
||||||
.rev()
|
let mut current_tokens = 0;
|
||||||
.take(MAX_WIRE_TOKENS / 200)
|
let mut keep_recent = Vec::new();
|
||||||
.cloned()
|
let mut dropped_msgs = Vec::new();
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut result = Vec::new();
|
// Always keep the very first message (System Prompt) which we don't count here
|
||||||
if let Some(first) = messages.first() {
|
// as we just blindly preserve it later.
|
||||||
result.push(first.clone());
|
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::<Vec<_>>()
|
||||||
|
.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.extend(keep_recent.into_iter().rev());
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ pub struct MiscState {
|
|||||||
pub selected_index: usize,
|
pub selected_index: usize,
|
||||||
pub editor: Option<super::super::mode::editor::EditorState>,
|
pub editor: Option<super::super::mode::editor::EditorState>,
|
||||||
pub api_connected: bool,
|
pub api_connected: bool,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub api_context_length: Option<u32>,
|
||||||
pub tick_count: u64,
|
pub tick_count: u64,
|
||||||
pub todo_content: String,
|
pub todo_content: String,
|
||||||
}
|
}
|
||||||
@@ -294,6 +296,7 @@ impl MiscState {
|
|||||||
selected_index: 0,
|
selected_index: 0,
|
||||||
editor: None,
|
editor: None,
|
||||||
api_connected: false,
|
api_connected: false,
|
||||||
|
api_context_length: None,
|
||||||
tick_count: 0,
|
tick_count: 0,
|
||||||
todo_content: String::new(),
|
todo_content: String::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use tokio::sync::RwLock;
|
|||||||
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
||||||
use super::runtime::{SessionRuntime, TurnEvent};
|
use super::runtime::{SessionRuntime, TurnEvent};
|
||||||
use super::types::{Origin, Toast, TranscriptCache};
|
use super::types::{Origin, Toast, TranscriptCache};
|
||||||
|
use crate::app::lsp::LspManager;
|
||||||
use crate::app::mcp::manager::McpManager;
|
use crate::app::mcp::manager::McpManager;
|
||||||
use crate::app::workflow::engine::WorkflowEngine;
|
use crate::app::workflow::engine::WorkflowEngine;
|
||||||
use crate::model::app_config::AppConfig;
|
use crate::model::app_config::AppConfig;
|
||||||
@@ -66,6 +67,7 @@ pub struct AppStateRest {
|
|||||||
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
|
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
|
||||||
pub workflow_engine: WorkflowEngine,
|
pub workflow_engine: WorkflowEngine,
|
||||||
pub mcp_manager: McpManager,
|
pub mcp_manager: McpManager,
|
||||||
|
pub lsp_manager: Arc<Mutex<LspManager>>,
|
||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
}
|
}
|
||||||
@@ -117,6 +119,7 @@ impl AppStateRest {
|
|||||||
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
||||||
workflow_engine: WorkflowEngine::new(),
|
workflow_engine: WorkflowEngine::new(),
|
||||||
mcp_manager: McpManager::new(),
|
mcp_manager: McpManager::new(),
|
||||||
|
lsp_manager: Arc::new(Mutex::new(LspManager::new())),
|
||||||
sessions: Vec::new(),
|
sessions: Vec::new(),
|
||||||
transcript_cache: TranscriptCache::new(200),
|
transcript_cache: TranscriptCache::new(200),
|
||||||
scroll: ScrollState::new(),
|
scroll: ScrollState::new(),
|
||||||
@@ -196,6 +199,7 @@ impl AppStateRest {
|
|||||||
dir_cache: self.dir_cache.clone(),
|
dir_cache: self.dir_cache.clone(),
|
||||||
origin,
|
origin,
|
||||||
graduated_checks: Vec::new(),
|
graduated_checks: Vec::new(),
|
||||||
|
lsp_manager: self.lsp_manager.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct UsageStats {
|
pub struct UsageStats {
|
||||||
pub tokens_in: u64,
|
pub tokens_in: u64,
|
||||||
pub tokens_out: u64,
|
pub tokens_out: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_tokens_in: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_tokens_out: u64,
|
||||||
pub api_calls: u64,
|
pub api_calls: u64,
|
||||||
pub review_tokens: u64,
|
pub review_tokens: u64,
|
||||||
pub total_ms: u64,
|
pub total_ms: u64,
|
||||||
@@ -96,6 +100,7 @@ pub enum TurnEvent {
|
|||||||
tokens_in: u64,
|
tokens_in: u64,
|
||||||
tokens_out: u64,
|
tokens_out: u64,
|
||||||
},
|
},
|
||||||
|
Compacted(Vec<crate::dto::chat::message::ChatMessage>),
|
||||||
Error(String),
|
Error(String),
|
||||||
Done,
|
Done,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub enum Command {
|
|||||||
command: String,
|
command: String,
|
||||||
},
|
},
|
||||||
ModelList,
|
ModelList,
|
||||||
|
Compact,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"/model" => Command::ModelList,
|
"/model" => Command::ModelList,
|
||||||
|
"/compact" => Command::Compact,
|
||||||
_ => Command::Unknown(cmd.to_string()),
|
_ => Command::Unknown(cmd.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct AppConfig {
|
|||||||
pub model_roles: HashMap<String, ModelRole>,
|
pub model_roles: HashMap<String, ModelRole>,
|
||||||
pub default_provider: String,
|
pub default_provider: String,
|
||||||
pub default_model: String,
|
pub default_model: String,
|
||||||
|
pub default_context_window: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connection details for a single LLM provider (base URL, API key source).
|
/// Connection details for a single LLM provider (base URL, API key source).
|
||||||
@@ -30,6 +31,7 @@ pub struct ModelRole {
|
|||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub max_tokens: Option<u32>,
|
pub max_tokens: Option<u32>,
|
||||||
|
pub context_window: Option<u32>,
|
||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ impl Default for AppConfig {
|
|||||||
model_roles.insert("default".to_string(), ModelRole {
|
model_roles.insert("default".to_string(), ModelRole {
|
||||||
provider: "zen".to_string(),
|
provider: "zen".to_string(),
|
||||||
model: "deepseek-v4-flash-free".to_string(),
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
max_tokens: Some(8192),
|
max_tokens: None,
|
||||||
|
context_window: None,
|
||||||
temperature: Some(0.7),
|
temperature: Some(0.7),
|
||||||
});
|
});
|
||||||
AppConfig {
|
AppConfig {
|
||||||
@@ -60,6 +63,7 @@ impl Default for AppConfig {
|
|||||||
model_roles,
|
model_roles,
|
||||||
default_provider: "zen".to_string(),
|
default_provider: "zen".to_string(),
|
||||||
default_model: "deepseek-v4-flash-free".to_string(),
|
default_model: "deepseek-v4-flash-free".to_string(),
|
||||||
|
default_context_window: 256_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ pub struct Conversation {
|
|||||||
pub system_prompt: String,
|
pub system_prompt: String,
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub max_tokens: u32,
|
pub max_tokens: Option<u32>,
|
||||||
pub temperature: f32,
|
pub temperature: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Conversation {
|
impl Conversation {
|
||||||
@@ -23,8 +23,8 @@ impl Conversation {
|
|||||||
system_prompt,
|
system_prompt,
|
||||||
session_id,
|
session_id,
|
||||||
model: "anthropic/claude-opus-4-8".to_string(),
|
model: "anthropic/claude-opus-4-8".to_string(),
|
||||||
max_tokens: 8192,
|
max_tokens: None,
|
||||||
temperature: 0.7,
|
temperature: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ pub struct Settings {
|
|||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub api_keys: std::collections::HashMap<String, String>,
|
pub api_keys: std::collections::HashMap<String, String>,
|
||||||
pub max_tokens: u32,
|
pub max_tokens: Option<u32>,
|
||||||
pub temperature: f32,
|
pub temperature: Option<f32>,
|
||||||
pub review_enabled: bool,
|
pub review_enabled: bool,
|
||||||
pub review_max_lessons_per_run: usize,
|
pub review_max_lessons_per_run: usize,
|
||||||
pub adaptive_review_max_skip: u32,
|
pub adaptive_review_max_skip: u32,
|
||||||
@@ -49,8 +49,8 @@ impl Default for Settings {
|
|||||||
provider: "zen".to_string(),
|
provider: "zen".to_string(),
|
||||||
model: "deepseek-v4-flash-free".to_string(),
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
api_keys: std::collections::HashMap::new(),
|
api_keys: std::collections::HashMap::new(),
|
||||||
max_tokens: 8192,
|
max_tokens: None,
|
||||||
temperature: 0.7,
|
temperature: None,
|
||||||
review_enabled: true,
|
review_enabled: true,
|
||||||
review_max_lessons_per_run: 5,
|
review_max_lessons_per_run: 5,
|
||||||
adaptive_review_max_skip: 3,
|
adaptive_review_max_skip: 3,
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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<String> = 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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -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::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ pub mod fs;
|
|||||||
pub mod git_cred;
|
pub mod git_cred;
|
||||||
pub mod git_operator;
|
pub mod git_operator;
|
||||||
pub mod git_worktree;
|
pub mod git_worktree;
|
||||||
|
pub mod lsp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
@@ -46,6 +48,7 @@ pub struct ToolCtx {
|
|||||||
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||||
pub origin: crate::app::state::types::Origin,
|
pub origin: crate::app::state::types::Origin,
|
||||||
pub graduated_checks: Vec<GraduatedCheck>,
|
pub graduated_checks: Vec<GraduatedCheck>,
|
||||||
|
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find which graduated checks apply to a given file path/content pair.
|
/// 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<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
|
||||||
pub origin: crate::app::state::types::Origin,
|
pub origin: crate::app::state::types::Origin,
|
||||||
pub graduated_checks: Vec<GraduatedCheck>,
|
pub graduated_checks: Vec<GraduatedCheck>,
|
||||||
|
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolCtxBuilder {
|
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())),
|
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
|
||||||
origin: crate::app::state::types::Origin::Main,
|
origin: crate::app::state::types::Origin::Main,
|
||||||
graduated_checks: Vec::new(),
|
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 }
|
pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self }
|
||||||
/// Set the origin (main process vs. daemon-attached).
|
/// Set the origin (main process vs. daemon-attached).
|
||||||
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
|
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<Mutex<crate::app::lsp::LspManager>>) -> Self { self.lsp_manager = v; self }
|
||||||
/// Consume the builder and produce the final `ToolCtx`.
|
/// Consume the builder and produce the final `ToolCtx`.
|
||||||
pub fn build(self) -> ToolCtx {
|
pub fn build(self) -> ToolCtx {
|
||||||
ToolCtx {
|
ToolCtx {
|
||||||
@@ -114,6 +122,7 @@ impl ToolCtxBuilder {
|
|||||||
dir_cache: self.dir_cache,
|
dir_cache: self.dir_cache,
|
||||||
origin: self.origin,
|
origin: self.origin,
|
||||||
graduated_checks: self.graduated_checks,
|
graduated_checks: self.graduated_checks,
|
||||||
|
lsp_manager: self.lsp_manager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +158,13 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
|||||||
Box::new(super::tool::utility::dir_cache_update::DirCacheUpdate),
|
Box::new(super::tool::utility::dir_cache_update::DirCacheUpdate),
|
||||||
Box::new(super::tool::utility::pong::Pong),
|
Box::new(super::tool::utility::pong::Pong),
|
||||||
Box::new(super::tool::utility::todowrite::Todowrite),
|
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),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -142,11 +142,11 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
|
|||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
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),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
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),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
|
|||||||
+31
-2
@@ -50,9 +50,38 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
|||||||
status,
|
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(
|
spans.push(Span::styled(
|
||||||
format!(" {} · {} ", state.settings.provider, state.settings.model),
|
right_str,
|
||||||
Style::default().fg(Theme::DIM),
|
Style::default().fg(Theme::DIM),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user