Add file badges, menu icons, LSP diagnostics, robust compaction
- Files in chat: preview with type/size badges and file name; attachment chips - Menu and settings tabs with icons - Heatmap: distinct colour for empty hours, hourly labels - Context chip shows used/total tokens (e.g. 221k/240k) plus percent - LSP client: diagnostics appended to edit/write results and a diagnostics tool (typescript-language-server, pyright, rust-analyzer, gopls, clangd; sandboxed) - Compaction summarizes a plain transcript and retries once on empty output - files/stat endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b569b6835
commit
ec1b9b4925
22 files changed
+1036
-48
No files matched your search
@@ -92,6 +92,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/files/list", get(crate::files::list))
|
||||
.route("/files/read", get(crate::files::read))
|
||||
.route("/files/raw", get(crate::files::raw))
|
||||
.route("/files/stat", get(crate::files::stat))
|
||||
.route("/files/write", put(crate::files::write))
|
||||
.route("/files/create", post(crate::files::create))
|
||||
.route("/files/move", post(crate::files::move_or_copy))
|
||||
|
||||
@@ -69,6 +69,8 @@ pub struct App {
|
||||
pub tool_install: crate::reverse::ToolInstall,
|
||||
/// Background shells of all sessions.
|
||||
pub shells: crate::shells::Shells,
|
||||
/// Language servers (diagnostics after edits).
|
||||
pub lsp: crate::lsp::Lsp,
|
||||
}
|
||||
|
||||
/// What a user may do with a session.
|
||||
@@ -105,6 +107,7 @@ impl App {
|
||||
login_failures: Mutex::default(),
|
||||
tool_install: Mutex::default(),
|
||||
shells: Mutex::default(),
|
||||
lsp: Default::default(),
|
||||
});
|
||||
crate::auth::bootstrap(&app)?;
|
||||
app.clone().start_mcp();
|
||||
|
||||
@@ -23,8 +23,43 @@ pub struct Config {
|
||||
pub images: ImagesConfig,
|
||||
pub sandbox: SandboxConfig,
|
||||
pub search: SearchConfig,
|
||||
pub lsp: LspConfig,
|
||||
}
|
||||
|
||||
/// Language servers for diagnostics after edits.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct LspConfig {
|
||||
pub enabled: bool,
|
||||
/// Extra or overridden servers (built-in: rust, typescript, python, go, c).
|
||||
pub servers: std::collections::BTreeMap<String, LspServer>,
|
||||
}
|
||||
|
||||
impl Default for LspConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, servers: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct LspServer {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// File extensions without dot.
|
||||
#[serde(default)]
|
||||
pub extensions: Vec<String>,
|
||||
/// Files that mark the project root (e.g. Cargo.toml).
|
||||
#[serde(default)]
|
||||
pub root_markers: Vec<String>,
|
||||
/// initializationOptions sent to the server.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||||
pub init_options: serde_json::Value,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
/// Web search for the web_search tool.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
|
||||
@@ -259,6 +259,19 @@ pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>,
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StatQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Size, type and modification time of a file (captions in the chat).
|
||||
pub async fn stat(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatQuery>) -> ApiResult {
|
||||
let p = checked_path(&app, &user, &q.path, true)?;
|
||||
let m = tokio::fs::metadata(&p).await?;
|
||||
let mtime = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Ok(Json(json!({"size": m.len(), "dir": m.is_dir(), "mime": mime_for(&p), "mtime": mtime})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WriteBody {
|
||||
path: String,
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Minimal Language Server Protocol client for diagnostics after edits.
|
||||
//!
|
||||
//! Language servers are started on demand per (user, server, project root),
|
||||
//! run in the user's sandbox like shell commands, and are stopped after a
|
||||
//! while without use. After the agent changes a file, the file is synced to
|
||||
//! the server and the errors it reports are appended to the tool result.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::LspServer;
|
||||
use crate::sandbox::SandboxSpec;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin};
|
||||
use tokio::sync::{Notify, oneshot};
|
||||
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
const MAX_SHOWN: usize = 20;
|
||||
|
||||
/// Built-in servers (used when the config defines none).
|
||||
pub fn default_servers() -> Vec<(String, LspServer)> {
|
||||
let s = |cmd: &str, args: &[&str], ext: &[&str], roots: &[&str]| LspServer {
|
||||
command: cmd.into(),
|
||||
args: args.iter().map(|a| a.to_string()).collect(),
|
||||
extensions: ext.iter().map(|a| a.to_string()).collect(),
|
||||
root_markers: roots.iter().map(|a| a.to_string()).collect(),
|
||||
init_options: Value::Null,
|
||||
enabled: true,
|
||||
};
|
||||
// Global TypeScript as fallback when the project has none in node_modules.
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let ts_lib = ["/usr/local/lib/node_modules/typescript/lib".to_string(), format!("{home}/.npm-global/lib/node_modules/typescript/lib")]
|
||||
.into_iter()
|
||||
.find(|p| Path::new(p).is_dir());
|
||||
let mut ts = s("typescript-language-server", &["--stdio"], &["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"], &["tsconfig.json", "jsconfig.json", "package.json"]);
|
||||
if let Some(lib) = ts_lib {
|
||||
ts.init_options = json!({"tsserver": {"fallbackPath": lib}});
|
||||
}
|
||||
vec![
|
||||
("rust".into(), s("rust-analyzer", &[], &["rs"], &["Cargo.toml"])),
|
||||
("typescript".into(), ts),
|
||||
("python".into(), s("pyright-langserver", &["--stdio"], &["py", "pyi"], &["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pyrightconfig.json"])),
|
||||
("go".into(), s("gopls", &[], &["go"], &["go.mod"])),
|
||||
("c".into(), s("clangd", &[], &["c", "h", "cc", "cpp", "cxx", "hpp", "hh"], &["compile_commands.json", "CMakeLists.txt", "Makefile"])),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub line: u64,
|
||||
pub col: u64,
|
||||
pub severity: u64,
|
||||
pub message: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
struct Client {
|
||||
stdin: tokio::sync::Mutex<ChildStdin>,
|
||||
child: Mutex<Option<Child>>,
|
||||
next_id: AtomicI64,
|
||||
pending: Mutex<HashMap<i64, oneshot::Sender<Value>>>,
|
||||
/// uri -> (publish counter, diagnostics)
|
||||
diags: Mutex<HashMap<String, (u64, Vec<Diagnostic>)>>,
|
||||
published: Notify,
|
||||
/// uri -> version of opened documents
|
||||
open: Mutex<HashMap<String, i64>>,
|
||||
last_used: Mutex<Instant>,
|
||||
dead: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Drop for Client {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut c) = self.child.lock().unwrap().take() {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Lsp {
|
||||
clients: Mutex<HashMap<String, Arc<Client>>>,
|
||||
/// Servers that failed to start (key -> time), not retried for a while.
|
||||
failed: Mutex<HashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
fn file_uri(p: &Path) -> String {
|
||||
let mut s = String::from("file://");
|
||||
for b in p.display().to_string().bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' => s.push(b as char),
|
||||
_ => s.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn language_id(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"rs" => "rust",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" => "typescriptreact",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"jsx" => "javascriptreact",
|
||||
"py" | "pyi" => "python",
|
||||
"go" => "go",
|
||||
"c" | "h" => "c",
|
||||
_ => "cpp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a command on the PATH the process will see (sandbox home or server home).
|
||||
fn find_command(cmd: &str, sandbox: Option<&SandboxSpec>) -> Option<PathBuf> {
|
||||
if cmd.contains('/') {
|
||||
return Path::new(cmd).exists().then(|| PathBuf::from(cmd));
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
match sandbox {
|
||||
Some(s) => {
|
||||
for d in [".local/bin", ".cargo/bin", ".npm-global/bin", "go/bin"] {
|
||||
dirs.push(s.home.join(d));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Ok(p) = std::env::var("PATH") {
|
||||
dirs.extend(std::env::split_paths(&p));
|
||||
}
|
||||
if let Ok(h) = std::env::var("HOME") {
|
||||
dirs.push(Path::new(&h).join("go/bin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs.extend(["/usr/local/bin", "/usr/bin", "/bin"].map(PathBuf::from));
|
||||
dirs.into_iter().map(|d| d.join(cmd)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Nearest directory (from the file upwards, not above `limit`) containing a marker.
|
||||
fn project_root(file: &Path, markers: &[String], limit: &Path) -> PathBuf {
|
||||
let mut dir = file.parent();
|
||||
while let Some(d) = dir {
|
||||
if markers.iter().any(|m| d.join(m).exists()) {
|
||||
return d.to_path_buf();
|
||||
}
|
||||
if d == limit || !d.starts_with(limit) {
|
||||
break;
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
if file.starts_with(limit) { limit.to_path_buf() } else { file.parent().unwrap_or(limit).to_path_buf() }
|
||||
}
|
||||
|
||||
async fn write_msg(stdin: &tokio::sync::Mutex<ChildStdin>, msg: &Value) -> Result<()> {
|
||||
let body = serde_json::to_vec(msg)?;
|
||||
let mut w = stdin.lock().await;
|
||||
w.write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()).await?;
|
||||
w.write_all(&body).await?;
|
||||
w.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Client {
|
||||
async fn notify(&self, method: &str, params: Value) -> Result<()> {
|
||||
write_msg(&self.stdin, &json!({"jsonrpc": "2.0", "method": method, "params": params})).await
|
||||
}
|
||||
|
||||
async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().unwrap().insert(id, tx);
|
||||
write_msg(&self.stdin, &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).await?;
|
||||
let v = tokio::time::timeout(timeout, rx).await.map_err(|_| anyhow!("{method}: no answer"))??;
|
||||
if let Some(e) = v.get("error") {
|
||||
bail!("{method}: {e}");
|
||||
}
|
||||
Ok(v["result"].clone())
|
||||
}
|
||||
|
||||
fn handle(self: &Arc<Self>, msg: Value) {
|
||||
let method = msg["method"].as_str().unwrap_or("");
|
||||
if msg.get("id").is_some() && !method.is_empty() {
|
||||
// Request from the server: answer the few that need it.
|
||||
let result = match method {
|
||||
"workspace/configuration" => Value::Array(vec![Value::Null; msg["params"]["items"].as_array().map_or(0, |a| a.len())]),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let me = self.clone();
|
||||
let id = msg["id"].clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = write_msg(&me.stdin, &json!({"jsonrpc": "2.0", "id": id, "result": result})).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(id) = msg["id"].as_i64() {
|
||||
if let Some(tx) = self.pending.lock().unwrap().remove(&id) {
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if method == "textDocument/publishDiagnostics" {
|
||||
let uri = msg["params"]["uri"].as_str().unwrap_or("").to_string();
|
||||
let list = msg["params"]["diagnostics"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|d| Diagnostic {
|
||||
line: d["range"]["start"]["line"].as_u64().unwrap_or(0) + 1,
|
||||
col: d["range"]["start"]["character"].as_u64().unwrap_or(0) + 1,
|
||||
severity: d["severity"].as_u64().unwrap_or(1),
|
||||
message: d["message"].as_str().unwrap_or("").to_string(),
|
||||
source: d["source"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut diags = self.diags.lock().unwrap();
|
||||
let n = diags.get(&uri).map_or(0, |e| e.0) + 1;
|
||||
diags.insert(uri, (n, list));
|
||||
drop(diags);
|
||||
self.published.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_count(&self, uri: &str) -> u64 {
|
||||
self.diags.lock().unwrap().get(uri).map_or(0, |e| e.0)
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(app: &App, server: &LspServer, root: &Path, sandbox: Option<&SandboxSpec>) -> Result<Arc<Client>> {
|
||||
let program = find_command(&server.command, sandbox).ok_or_else(|| anyhow!("{} is not installed", server.command))?;
|
||||
let program = program.display().to_string();
|
||||
let args: Vec<&str> = server.args.iter().map(String::as_str).collect();
|
||||
let mut cmd = match sandbox {
|
||||
Some(spec) => {
|
||||
if !crate::sandbox::available() {
|
||||
bail!("sandbox unavailable");
|
||||
}
|
||||
// Inside the sandbox the home directory is mounted elsewhere.
|
||||
let inside = program.replace(&spec.home.display().to_string(), &spec.home_inside());
|
||||
crate::sandbox::command(spec, root, &inside, &args)
|
||||
}
|
||||
None => {
|
||||
let mut c = tokio::process::Command::new(&program);
|
||||
c.args(&args).current_dir(root);
|
||||
c
|
||||
}
|
||||
};
|
||||
let _ = app;
|
||||
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true);
|
||||
let mut child = cmd.spawn().with_context(|| format!("starting {}", server.command))?;
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let client = Arc::new(Client {
|
||||
stdin: tokio::sync::Mutex::new(stdin),
|
||||
child: Mutex::new(Some(child)),
|
||||
next_id: AtomicI64::new(1),
|
||||
pending: Mutex::default(),
|
||||
diags: Mutex::default(),
|
||||
published: Notify::new(),
|
||||
open: Mutex::default(),
|
||||
last_used: Mutex::new(Instant::now()),
|
||||
dead: false.into(),
|
||||
});
|
||||
|
||||
// Reader: Content-Length framed JSON messages.
|
||||
let weak = Arc::downgrade(&client);
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
let mut len = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) | Err(_) => {
|
||||
if let Some(c) = weak.upgrade() {
|
||||
c.dead.store(true, Ordering::SeqCst);
|
||||
c.published.notify_waiters();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(v) = line.strip_prefix("Content-Length:") {
|
||||
len = v.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
if len == 0 || len > 64 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
if reader.read_exact(&mut buf).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let Some(c) = weak.upgrade() else { return };
|
||||
if let Ok(msg) = serde_json::from_slice::<Value>(&buf) {
|
||||
c.handle(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let root_uri = file_uri(root);
|
||||
client
|
||||
.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"processId": null,
|
||||
"rootUri": root_uri,
|
||||
"rootPath": root.display().to_string(),
|
||||
"workspaceFolders": [{"uri": root_uri, "name": root.file_name().and_then(|n| n.to_str()).unwrap_or("project")}],
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"synchronization": {"didSave": true, "dynamicRegistration": false},
|
||||
"publishDiagnostics": {"relatedInformation": false, "versionSupport": true}
|
||||
},
|
||||
"workspace": {"configuration": true, "workspaceFolders": true}
|
||||
},
|
||||
"initializationOptions": if server.init_options.is_null() { json!({}) } else { server.init_options.clone() }
|
||||
}),
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await?;
|
||||
client.notify("initialized", json!({})).await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
impl Lsp {
|
||||
/// Stop servers that were not used for a while.
|
||||
pub fn reap(&self) {
|
||||
self.clients.lock().unwrap().retain(|_, c| c.last_used.lock().unwrap().elapsed() < IDLE_TIMEOUT && !c.dead.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured servers (config, else built-in defaults).
|
||||
fn servers(app: &App) -> Vec<(String, LspServer)> {
|
||||
let cfg = app.config();
|
||||
if !cfg.lsp.enabled {
|
||||
return vec![];
|
||||
}
|
||||
let mut list = default_servers();
|
||||
for (name, s) in &cfg.lsp.servers {
|
||||
list.retain(|(n, _)| n != name);
|
||||
list.push((name.clone(), s.clone()));
|
||||
}
|
||||
list.retain(|(_, s)| s.enabled);
|
||||
list
|
||||
}
|
||||
|
||||
/// Sync a file to its language server and return the diagnostics it reports.
|
||||
/// `None` when no server handles the file (or it is not installed).
|
||||
pub async fn diagnostics(app: &Arc<App>, file: &Path, limit: &Path, sandbox: Option<&SandboxSpec>, wait: Duration) -> Option<Result<Vec<Diagnostic>>> {
|
||||
let ext = file.extension()?.to_str()?.to_ascii_lowercase();
|
||||
let (name, server) = servers(app).into_iter().find(|(_, s)| s.extensions.iter().any(|e| e.eq_ignore_ascii_case(&ext)))?;
|
||||
let root = project_root(file, &server.root_markers, limit);
|
||||
let user = sandbox.map(|s| s.username.as_str()).unwrap_or("-");
|
||||
let key = format!("{user}|{name}|{}", root.display());
|
||||
|
||||
if let Some(t) = app.lsp.failed.lock().unwrap().get(&key) {
|
||||
if t.elapsed() < Duration::from_secs(300) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let existing = app.lsp.clients.lock().unwrap().get(&key).cloned().filter(|c| !c.dead.load(Ordering::SeqCst));
|
||||
let (client, fresh) = match existing {
|
||||
Some(c) => (c, false),
|
||||
None => match start(app, &server, &root, sandbox).await {
|
||||
Ok(c) => {
|
||||
app.lsp.clients.lock().unwrap().insert(key.clone(), c.clone());
|
||||
(c, true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("lsp {name}: {e:#}");
|
||||
app.lsp.failed.lock().unwrap().insert(key, Instant::now());
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
*client.last_used.lock().unwrap() = Instant::now();
|
||||
Some(sync_and_wait(&client, file, &ext, if fresh { wait.max(Duration::from_secs(20)) } else { wait }).await)
|
||||
}
|
||||
|
||||
async fn sync_and_wait(client: &Arc<Client>, file: &Path, ext: &str, wait: Duration) -> Result<Vec<Diagnostic>> {
|
||||
let text = tokio::fs::read_to_string(file).await.unwrap_or_default();
|
||||
let uri = file_uri(file);
|
||||
let before = client.publish_count(&uri);
|
||||
let version = {
|
||||
let mut open = client.open.lock().unwrap();
|
||||
let v = open.get(&uri).map_or(1, |v| v + 1);
|
||||
open.insert(uri.clone(), v);
|
||||
v
|
||||
};
|
||||
if version == 1 {
|
||||
client
|
||||
.notify("textDocument/didOpen", json!({"textDocument": {"uri": uri, "languageId": language_id(ext), "version": 1, "text": text}}))
|
||||
.await?;
|
||||
} else {
|
||||
client
|
||||
.notify("textDocument/didChange", json!({"textDocument": {"uri": uri, "version": version}, "contentChanges": [{"text": text}]}))
|
||||
.await?;
|
||||
}
|
||||
client.notify("textDocument/didSave", json!({"textDocument": {"uri": uri}, "text": text})).await?;
|
||||
|
||||
// Wait for a fresh publish; servers often send an early empty one and the
|
||||
// real result shortly after, so keep listening briefly after the first.
|
||||
let deadline = Instant::now() + wait;
|
||||
let mut settle: Option<Instant> = None;
|
||||
loop {
|
||||
if client.dead.load(Ordering::SeqCst) {
|
||||
bail!("language server exited");
|
||||
}
|
||||
let now = Instant::now();
|
||||
let count = client.publish_count(&uri);
|
||||
if count > before && settle.is_none() {
|
||||
settle = Some(now + Duration::from_millis(700));
|
||||
}
|
||||
let until = settle.map_or(deadline, |s| s.min(deadline));
|
||||
if now >= until {
|
||||
break;
|
||||
}
|
||||
let _ = tokio::time::timeout(until - now, client.published.notified()).await;
|
||||
if let Some(s) = settle {
|
||||
if client.publish_count(&uri) > count {
|
||||
settle = Some(Instant::now().max(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
if client.publish_count(&uri) == before {
|
||||
bail!("no diagnostics within {}s (server still indexing?)", wait.as_secs());
|
||||
}
|
||||
Ok(client.diags.lock().unwrap().get(&uri).map(|e| e.1.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Format errors (and a few warnings) for a tool result.
|
||||
pub fn format(file: &str, diags: &[Diagnostic], warnings: bool) -> String {
|
||||
let mut list: Vec<&Diagnostic> = diags.iter().filter(|d| d.severity == 1 || (warnings && d.severity == 2)).collect();
|
||||
list.sort_by_key(|d| (d.severity, d.line, d.col));
|
||||
if list.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let errors = diags.iter().filter(|d| d.severity == 1).count();
|
||||
let mut out = format!("\n\n<diagnostics file=\"{file}\" errors=\"{errors}\">\n");
|
||||
for d in list.iter().take(MAX_SHOWN) {
|
||||
let sev = if d.severity == 1 { "error" } else { "warning" };
|
||||
let src = if d.source.is_empty() { String::new() } else { format!(" [{}]", d.source) };
|
||||
out.push_str(&format!("{}:{} {sev}{src}: {}\n", d.line, d.col, d.message.lines().next().unwrap_or("")));
|
||||
}
|
||||
if list.len() > MAX_SHOWN {
|
||||
out.push_str(&format!("… {} more\n", list.len() - MAX_SHOWN));
|
||||
}
|
||||
out.push_str("</diagnostics>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Background task: stop idle servers.
|
||||
pub fn spawn_reaper(app: Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
app.lsp.reap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tool: diagnostics of a file on demand.
|
||||
pub struct Diagnostics;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::tools::Tool for Diagnostics {
|
||||
fn name(&self) -> &str {
|
||||
"diagnostics"
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
"Get compiler/linter diagnostics (errors and warnings) for a source file from its language server \
|
||||
(rust-analyzer, typescript-language-server, pyright, gopls, clangd – if installed). Edits already \
|
||||
report new errors automatically; use this to check a file you did not edit."
|
||||
.into()
|
||||
}
|
||||
fn schema(&self) -> Value {
|
||||
json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn title(&self, input: &Value) -> String {
|
||||
format!("Diagnostics {}", input["path"].as_str().unwrap_or(""))
|
||||
}
|
||||
async fn run(&self, input: Value, ctx: &crate::tools::ToolCtx) -> Result<crate::tools::ToolOutput> {
|
||||
let p = crate::tools::arg_str(&input, "path")?;
|
||||
let path = ctx.resolve_read(p)?;
|
||||
match diagnostics(&ctx.app, &path, &ctx.cwd, ctx.sandbox.as_ref(), Duration::from_secs(10)).await {
|
||||
None => Ok(crate::tools::ToolOutput::text(
|
||||
"No language server available for this file type. Install one (e.g. `npm i -g typescript-language-server typescript`, \
|
||||
`npm i -g pyright`, `rustup component add rust-analyzer`, `go install golang.org/x/tools/gopls@latest`).",
|
||||
)),
|
||||
Some(Err(e)) => Ok(crate::tools::ToolOutput::error(format!("Language server: {e:#}"))),
|
||||
Some(Ok(d)) => {
|
||||
let s = format(p, &d, true);
|
||||
Ok(crate::tools::ToolOutput::text(if s.is_empty() { "No errors or warnings.".to_string() } else { s.trim().to_string() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod docs;
|
||||
mod files;
|
||||
mod git;
|
||||
mod images;
|
||||
mod lsp;
|
||||
mod mcp;
|
||||
mod permission;
|
||||
mod prompts;
|
||||
@@ -42,6 +43,7 @@ async fn main() -> Result<()> {
|
||||
let _ = sandbox::probe().await;
|
||||
let bind = cfg.server.bind.clone();
|
||||
let app = app::App::new(path, cfg).await?;
|
||||
lsp::spawn_reaper(app.clone());
|
||||
let router = api::router(app);
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
tracing::info!("listening on http://{bind}");
|
||||
|
||||
+87
-19
@@ -462,12 +462,26 @@ async fn execute_call(
|
||||
}
|
||||
}
|
||||
}
|
||||
let input_path = input["path"].as_str().map(String::from);
|
||||
let res = if name == "task" {
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
tool.run(input, &ctx).await
|
||||
};
|
||||
// Diagnostics: report new errors of the changed file right away.
|
||||
let res = match res {
|
||||
Ok(mut out) if tool.is_edit() && !out.is_error => {
|
||||
if let Some(path) = input_path.as_deref().and_then(|p| ctx.resolve(p).ok()) {
|
||||
let wait = std::time::Duration::from_secs(4);
|
||||
if let Some(Ok(d)) = crate::lsp::diagnostics(app, &path, &ctx.cwd, ctx.sandbox.as_ref(), wait).await {
|
||||
out.content.push_str(&crate::lsp::format(input_path.as_deref().unwrap_or(""), &d, false));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
match res {
|
||||
Ok(out) => Part::ToolResult {
|
||||
call_id: call_id.into(),
|
||||
@@ -860,6 +874,54 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
|
||||
const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points.";
|
||||
|
||||
/// Remove a leading `<think>…</think>` block some models put into the text.
|
||||
fn strip_think(text: &str) -> String {
|
||||
let t = text.trim_start();
|
||||
if t.starts_with("<think>") {
|
||||
if let Some(end) = t.find("</think>") {
|
||||
return t[end + 8..].trim().to_string();
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
text.trim().to_string()
|
||||
}
|
||||
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{} […{} more bytes]", &s[..end], s.len() - end)
|
||||
}
|
||||
|
||||
/// The history as readable text for summarizing (tool output shortened).
|
||||
fn transcript_of(history: &[Message]) -> String {
|
||||
let mut out = String::new();
|
||||
for m in history {
|
||||
let role = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||
let mut body = String::new();
|
||||
for p in &m.parts {
|
||||
match p {
|
||||
Part::Text { text } => body.push_str(&format!("{text}\n")),
|
||||
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
|
||||
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
|
||||
Part::ToolResult { name, content, is_error, .. } => {
|
||||
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
|
||||
}
|
||||
Part::Image { .. } => body.push_str("[image]\n"),
|
||||
Part::Reasoning { .. } => {}
|
||||
}
|
||||
}
|
||||
if !body.trim().is_empty() {
|
||||
out.push_str(&format!("### {role}\n{}\n", body.trim_end()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the active history with a summary (the old messages stay visible in the UI).
|
||||
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
|
||||
let session = app.session(session_id)?;
|
||||
@@ -869,27 +931,33 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
||||
}
|
||||
let spec = session.model.clone();
|
||||
let (provider, model) = app.registry().resolve(&spec)?;
|
||||
let mut messages = history;
|
||||
// Strip images to keep the summary request small.
|
||||
for m in &mut messages {
|
||||
m.parts.retain(|p| !matches!(p, Part::Image { .. }));
|
||||
for p in &mut m.parts {
|
||||
if let Part::ToolResult { images, .. } = p {
|
||||
images.clear();
|
||||
}
|
||||
// The history goes in as a plain transcript: with real tool calls in the
|
||||
// history (and no tools offered) some models answer with a tool call or
|
||||
// only think, leaving the summary empty.
|
||||
let transcript = transcript_of(&history);
|
||||
let mut summary = String::new();
|
||||
for attempt in 0..2 {
|
||||
let prompt = if attempt == 0 {
|
||||
format!("<transcript>\n{transcript}\n</transcript>\n\n{COMPACT_PROMPT}")
|
||||
} else {
|
||||
format!("<transcript>\n{transcript}\n</transcript>\n\n{COMPACT_PROMPT}\n\nAnswer directly with the summary as plain Markdown text. Do not call tools.")
|
||||
};
|
||||
let req = ChatRequest {
|
||||
system: "You are a precise summarizer of software engineering sessions. You never call tools; you only write the requested summary.".into(),
|
||||
messages: vec![Message::new(Role::User, vec![Part::Text { text: prompt }])],
|
||||
tools: vec![],
|
||||
max_tokens: 16_000,
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||
summary = strip_think(&text);
|
||||
if !summary.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
tracing::warn!("compaction of {session_id}: empty summary (attempt {})", attempt + 1);
|
||||
}
|
||||
messages.push(Message::new(Role::User, vec![Part::Text { text: COMPACT_PROMPT.into() }]));
|
||||
let req = ChatRequest {
|
||||
system: "You are a precise summarizer of software engineering sessions.".into(),
|
||||
messages,
|
||||
tools: vec![],
|
||||
max_tokens: 16_000,
|
||||
reasoning: false,
|
||||
model: model.clone(),
|
||||
};
|
||||
let (summary, usage) = complete_text(&app, &spec, provider, req, cancel).await?;
|
||||
record_side_usage(&app, &session, &spec, &model, &usage, "compaction");
|
||||
if summary.trim().is_empty() {
|
||||
bail!("compaction produced an empty summary");
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
|
||||
Arc::new(crate::shells::KillShell),
|
||||
Arc::new(web::WebFetch),
|
||||
Arc::new(web::WebSearch),
|
||||
Arc::new(crate::lsp::Diagnostics),
|
||||
Arc::new(agent::TodoWrite),
|
||||
Arc::new(agent::AskUser),
|
||||
Arc::new(agent::Task),
|
||||
|
||||
Reference in new issue
Block a user