From 5bcf56e847038b8c940f1f0b7dc84940680f58c6 Mon Sep 17 00:00:00 2001 From: Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:01:45 -0700 Subject: [PATCH 1/5] feat(tui): add multi-file read_lints operation Expose bounded, workspace-relative LSP diagnostics for multiple existing files through the model-visible lsp tool. Reuse the shared transport pool, fail clearly when LSP is unavailable, and preserve the frozen tool catalog budget for #4070. --- CHANGELOG.md | 6 + crates/tui/CHANGELOG.md | 6 + crates/tui/src/lsp/mod.rs | 31 ++++ crates/tui/src/tools/lsp.rs | 301 ++++++++++++++++++++++++++++++- crates/tui/src/tools/registry.rs | 6 +- 5 files changed, 339 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14df1be898..6d88212020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `release-artifacts.yml` builds `--profile dist` with fat LTO and `codegen-units = 1`. +### Added + +- The model-facing `lsp` tool now supports a bounded `read_lints` operation for + multi-file, workspace-relative LSP diagnostics without adding another tool + catalog entry (#4070). + ## [0.9.10] - 2026-08-19 - Show the full slash-command or `/model` completion row in a bounded, wrapping hover popover whenever narrow terminals truncate it, closing the remaining scoped gap from [#998](https://github.com/Hmbown/CodeWhale/issues/998). Thanks [@AiurArtanis](https://github.com/AiurArtanis) and [@formp3](https://github.com/formp3) for identifying the affected surfaces. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 814c4293e1..41400bc891 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `release-artifacts.yml` builds `--profile dist` with fat LTO and `codegen-units = 1`. +### Added + +- The model-facing `lsp` tool now supports a bounded `read_lints` operation for + multi-file, workspace-relative LSP diagnostics without adding another tool + catalog entry (#4070). + ## [0.9.10] - 2026-08-19 - Show the full slash-command or `/model` completion row in a bounded, wrapping hover popover whenever narrow terminals truncate it, closing the remaining scoped gap from [#998](https://github.com/Hmbown/CodeWhale/issues/998). Thanks [@AiurArtanis](https://github.com/AiurArtanis) and [@formp3](https://github.com/formp3) for identifying the affected surfaces. diff --git a/crates/tui/src/lsp/mod.rs b/crates/tui/src/lsp/mod.rs index 5d89bf106f..c916302d01 100644 --- a/crates/tui/src/lsp/mod.rs +++ b/crates/tui/src/lsp/mod.rs @@ -501,6 +501,37 @@ impl LspManager { } } + /// Read diagnostics for several existing files through the shared LSP + /// transport pool. Empty diagnostics remain represented as empty blocks; + /// an unavailable language server is returned as an actionable error. + pub async fn diagnostics_for_paths( + &self, + files: &[PathBuf], + ) -> Result, String> { + if !self.config.enabled { + return Err("LSP is disabled ([lsp] enabled = false)".to_string()); + } + + let mut blocks = Vec::with_capacity(files.len()); + for file in files { + if self.transport_for_path(file).await.is_none() { + return Err(format!( + "no LSP server is available for {}", + relative_to_workspace(&self.workspace, file).display() + )); + } + blocks.push( + self.diagnostics_for(file, 0) + .await + .unwrap_or_else(|| DiagnosticBlock { + file: relative_to_workspace(&self.workspace, file), + items: Vec::new(), + }), + ); + } + Ok(blocks) + } + /// Best-effort shutdown of every spawned transport. Called when the /// session ends. #[allow(dead_code)] diff --git a/crates/tui/src/tools/lsp.rs b/crates/tui/src/tools/lsp.rs index 61de82d42d..cb6c7e84b5 100644 --- a/crates/tui/src/tools/lsp.rs +++ b/crates/tui/src/tools/lsp.rs @@ -1,11 +1,12 @@ //! Model-facing LSP code-intelligence tool. //! //! Extends the existing [`crate::lsp::LspManager`] lifecycle — never spawns a -//! competing server pool. Operations: diagnostics, symbols, definition, -//! references. +//! competing server pool. Operations: diagnostics, read_lints, symbols, +//! definition, references. use async_trait::async_trait; use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, @@ -34,26 +35,25 @@ impl ToolSpec for LspTool { "properties": { "operation": { "type": "string", - "enum": ["diagnostics", "symbols", "definition", "references"], + "enum": ["diagnostics", "read_lints", "symbols", "definition", "references"], "description": "Intelligence operation to run." }, "path": { "type": "string", - "description": "Workspace-relative or absolute path to the source file." + "description": "Workspace-relative or absolute source file path. For read_lints, pass newline-separated workspace-relative paths." }, "line": { "type": "integer", "minimum": 1, - "description": "1-based line for definition/references." + "description": "1-based line." }, "character": { "type": "integer", "minimum": 1, - "description": "1-based column for definition/references (default 1)." + "description": "1-based column." }, "query": { - "type": "string", - "description": "Optional workspace symbol query when operation=symbols." + "type": "string" } }, "required": ["operation", "path"] @@ -78,6 +78,18 @@ impl ToolSpec for LspTool { .map(|n| n as u32); let query = optional_str(&input, "query")?; + if operation == "read_lints" { + let paths = path_raw + .split('\n') + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + return ReadLintsTool + .execute(json!({"paths": paths}), context) + .await; + } + let manager = context.lsp_manager.as_ref().ok_or_else(|| { ToolError::execution_failed( "LSP manager is not attached to this tool context (LSP unavailable for this session)", @@ -96,6 +108,180 @@ impl ToolSpec for LspTool { } } +const MAX_LINT_PATHS: usize = 16; +const MAX_LINT_DIAGNOSTICS: usize = 100; +const MAX_LINT_MESSAGE_CHARS: usize = 512; +const MAX_LINT_OUTPUT_CHARS: usize = 12_000; + +/// Model-callable on-demand diagnostics surface. Unlike the post-edit hook, +/// this can inspect several existing files without requiring a preceding edit. +pub struct ReadLintsTool; + +#[async_trait] +impl ToolSpec for ReadLintsTool { + fn name(&self) -> &'static str { + "read_lints" + } + + fn description(&self) -> &'static str { + "Read bounded structured LSP diagnostics for one or more existing workspace-relative files. Requires [lsp] enabled and a configured language server." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "paths": { + "type": "array", + "minItems": 1, + "maxItems": MAX_LINT_PATHS, + "items": { + "type": "string", + "description": "Existing workspace-relative source file." + }, + "description": "One or more existing workspace-relative files to diagnose. Results are capped at 12,000 characters." + } + }, + "required": ["paths"], + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + fn supports_parallel(&self) -> bool { + true + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let raw_paths = input + .get("paths") + .and_then(Value::as_array) + .ok_or_else(|| ToolError::invalid_input("paths must be a non-empty array"))?; + if raw_paths.is_empty() || raw_paths.len() > MAX_LINT_PATHS { + return Err(ToolError::invalid_input(format!( + "paths must contain between 1 and {MAX_LINT_PATHS} files" + ))); + } + + let paths = raw_paths + .iter() + .map(|value| { + let raw = value + .as_str() + .ok_or_else(|| ToolError::invalid_input("each paths entry must be a string"))?; + resolve_lint_path(&context.workspace, raw) + }) + .collect::, _>>()?; + + let manager = context.lsp_manager.as_ref().ok_or_else(|| { + ToolError::execution_failed( + "LSP manager is not attached to this tool context; enable LSP for this session", + ) + })?; + let blocks = manager + .diagnostics_for_paths(&paths) + .await + .map_err(ToolError::execution_failed)?; + + let mut files = Vec::with_capacity(blocks.len()); + let mut diagnostic_count = 0usize; + let mut truncated = false; + for block in blocks { + let mut items = Vec::new(); + for diagnostic in block.items { + if diagnostic_count >= MAX_LINT_DIAGNOSTICS { + truncated = true; + break; + } + diagnostic_count += 1; + items.push(json!({ + "line": diagnostic.line, + "column": diagnostic.column, + "severity": format!("{:?}", diagnostic.severity).to_ascii_lowercase(), + "message": diagnostic + .message + .chars() + .take(MAX_LINT_MESSAGE_CHARS) + .collect::(), + })); + } + files.push(json!({ + "file": block.file.display().to_string(), + "diagnostics": items, + })); + } + + let mut output = json!({ + "files": files, + "diagnostic_count": diagnostic_count, + "truncated": truncated, + }); + while serde_json::to_string(&output) + .map(|value| value.len() > MAX_LINT_OUTPUT_CHARS) + .unwrap_or(false) + { + let Some(files) = output.get_mut("files").and_then(Value::as_array_mut) else { + break; + }; + let Some(last) = files.last_mut() else { + break; + }; + if let Some(items) = last.get_mut("diagnostics").and_then(Value::as_array_mut) + && items.pop().is_some() + { + output["truncated"] = Value::Bool(true); + } else { + files.pop(); + output["truncated"] = Value::Bool(true); + } + } + + ToolResult::json(&output).map_err(|error| ToolError::execution_failed(error.to_string())) + } +} + +fn resolve_lint_path(workspace: &Path, raw: &str) -> Result { + let raw = raw.trim(); + let candidate = Path::new(raw); + if raw.is_empty() || candidate.is_absolute() { + return Err(ToolError::permission_denied( + "read_lints paths must be non-empty workspace-relative files", + )); + } + if candidate + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(ToolError::permission_denied( + "read_lints paths cannot contain '..' traversal", + )); + } + let workspace = workspace.canonicalize().map_err(|error| { + ToolError::execution_failed(format!("failed to resolve workspace: {error}")) + })?; + let path = workspace.join(candidate).canonicalize().map_err(|error| { + ToolError::execution_failed(format!("failed to read_lints path {raw}: {error}")) + })?; + if !path.starts_with(&workspace) { + return Err(ToolError::permission_denied( + "read_lints path resolves outside the workspace", + )); + } + if !path.is_file() { + return Err(ToolError::invalid_input(format!( + "read_lints path is not a file: {raw}" + ))); + } + Ok(path) +} + fn resolve_workspace_path(workspace: &std::path::Path, raw: &str) -> std::path::PathBuf { let candidate = std::path::PathBuf::from(raw); if candidate.is_absolute() { @@ -152,6 +338,31 @@ mod tests { async fn shutdown(&self) {} } + struct EmptyTransport; + + #[async_trait] + impl crate::lsp::LspTransport for EmptyTransport { + async fn diagnostics_for( + &self, + _path: &Path, + _text: &str, + _wait: Duration, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn request( + &self, + _method: &str, + _params: Value, + _wait: Duration, + ) -> anyhow::Result { + Ok(json!({})) + } + + async fn shutdown(&self) {} + } + #[tokio::test] async fn tool_reuses_single_manager_transport_for_definition() { let dir = tempdir().unwrap(); @@ -228,6 +439,71 @@ mod tests { assert_eq!(transport.calls.load(Ordering::Relaxed), 1); } + #[tokio::test] + async fn read_lints_returns_structured_diagnostics_for_multiple_files() { + let dir = tempdir().unwrap(); + let first = dir.path().join("lib.rs"); + let second = dir.path().join("main.rs"); + tokio::fs::write(&first, b"fn lib() {}\n").await.unwrap(); + tokio::fs::write(&second, b"fn main() {}\n").await.unwrap(); + + let mgr = Arc::new(LspManager::new( + LspConfig::default(), + dir.path().to_path_buf(), + )); + mgr.install_test_transport( + Language::Rust, + Arc::new(CountingTransport { + calls: AtomicUsize::new(0), + request_calls: AtomicUsize::new(0), + }), + ) + .await; + let mut ctx = ToolContext::new(dir.path()); + ctx = ctx.with_lsp_manager(mgr); + + let result = LspTool + .execute( + json!({ + "operation": "read_lints", + "path": "lib.rs\nmain.rs" + }), + &ctx, + ) + .await + .expect("read_lints"); + let payload: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["files"].as_array().unwrap().len(), 2); + assert_eq!(payload["diagnostic_count"], 2); + assert_eq!(payload["files"][0]["diagnostics"][0]["line"], 1); + assert_eq!(payload["files"][0]["diagnostics"][0]["severity"], "error"); + assert_eq!(payload["files"][0]["diagnostics"][0]["message"], "boom"); + } + + #[tokio::test] + async fn read_lints_preserves_files_with_empty_diagnostics() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lib.rs"); + tokio::fs::write(&path, b"fn main() {}\n").await.unwrap(); + + let mgr = Arc::new(LspManager::new( + LspConfig::default(), + dir.path().to_path_buf(), + )); + mgr.install_test_transport(Language::Rust, Arc::new(EmptyTransport)) + .await; + let mut ctx = ToolContext::new(dir.path()); + ctx = ctx.with_lsp_manager(mgr); + + let result = LspTool + .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx) + .await + .expect("empty diagnostics are a successful read"); + let payload: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["diagnostic_count"], 0); + assert_eq!(payload["files"][0]["diagnostics"], json!([])); + } + #[tokio::test] async fn disabled_lsp_hard_blocks_tool() { let dir = tempdir().unwrap(); @@ -253,5 +529,14 @@ mod tests { err.to_string().contains("disabled"), "unexpected error: {err}" ); + + let path_error = LspTool + .execute( + json!({"operation": "read_lints", "path": "../outside.rs"}), + &ctx, + ) + .await + .expect_err("path traversal must fail closed"); + assert!(path_error.to_string().contains("cannot contain")); } } diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 8f6b6ae130..983c3b49ee 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -1152,9 +1152,9 @@ impl ToolRegistryBuilder { .with_tool(Arc::new(MemoryGetTool)) } - /// Include the model-facing `lsp` intelligence tool. Reuses the session - /// [`crate::lsp::LspManager`] attached to `ToolContext` — never spawns a - /// second server lifecycle. + /// Include the model-facing LSP intelligence tools. They reuse the + /// session [`crate::lsp::LspManager`] attached to `ToolContext` and never + /// spawn a second server lifecycle. #[must_use] pub fn with_lsp_tool(self) -> Self { use super::lsp::LspTool; From e4b51d961d552a184e95b2dfb9837ed5e65f6491 Mon Sep 17 00:00:00 2001 From: Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:32:42 -0700 Subject: [PATCH 2/5] chore(web): refresh generated tool facts Regenerate the committed website facts after exposing read_lints through the existing lsp tool. --- web/lib/facts.generated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index b78929d779..900d146cee 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -27,7 +27,7 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { - "generatedAt": "2026-08-19T10:14:22.107Z", + "generatedAt": "2026-08-20T15:31:58.971Z", "sourceRevision": null, "sourceCommittedAt": null, "version": "0.9.10", @@ -287,7 +287,7 @@ export const FACTS: RepoFacts = { ], "defaultModel": "deepseek-v4-pro", "nodeEngines": ">=18", - "toolCount": 75, + "toolCount": 76, "license": "MIT", "latestPublishedRelease": { "tag": "v0.9.9", From 1708a3f69e386a0e0e93b493e59621b34800ccd9 Mon Sep 17 00:00:00 2001 From: Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:41:18 -0700 Subject: [PATCH 3/5] fix(tui): keep read_lints inside lsp catalog Avoid counting the private diagnostics helper as a second model-visible tool and refresh generated web facts back to the real 75-tool catalog. --- crates/tui/src/tools/lsp.rs | 202 ++++++++++++++---------------------- web/lib/facts.generated.ts | 4 +- 2 files changed, 80 insertions(+), 126 deletions(-) diff --git a/crates/tui/src/tools/lsp.rs b/crates/tui/src/tools/lsp.rs index cb6c7e84b5..c22c053a7a 100644 --- a/crates/tui/src/tools/lsp.rs +++ b/crates/tui/src/tools/lsp.rs @@ -85,9 +85,7 @@ impl ToolSpec for LspTool { .filter(|path| !path.is_empty()) .map(ToOwned::to_owned) .collect::>(); - return ReadLintsTool - .execute(json!({"paths": paths}), context) - .await; + return execute_read_lints(json!({"paths": paths}), context).await; } let manager = context.lsp_manager.as_ref().ok_or_else(|| { @@ -113,138 +111,94 @@ const MAX_LINT_DIAGNOSTICS: usize = 100; const MAX_LINT_MESSAGE_CHARS: usize = 512; const MAX_LINT_OUTPUT_CHARS: usize = 12_000; -/// Model-callable on-demand diagnostics surface. Unlike the post-edit hook, -/// this can inspect several existing files without requiring a preceding edit. -pub struct ReadLintsTool; - -#[async_trait] -impl ToolSpec for ReadLintsTool { - fn name(&self) -> &'static str { - "read_lints" - } - - fn description(&self) -> &'static str { - "Read bounded structured LSP diagnostics for one or more existing workspace-relative files. Requires [lsp] enabled and a configured language server." +/// Read bounded diagnostics for several existing files without requiring a +/// preceding edit. The model-facing entry point is the `lsp` operation above; +/// keeping this as a helper avoids adding a second catalog tool name. +async fn execute_read_lints(input: Value, context: &ToolContext) -> Result { + let raw_paths = input + .get("paths") + .and_then(Value::as_array) + .ok_or_else(|| ToolError::invalid_input("paths must be a non-empty array"))?; + if raw_paths.is_empty() || raw_paths.len() > MAX_LINT_PATHS { + return Err(ToolError::invalid_input(format!( + "paths must contain between 1 and {MAX_LINT_PATHS} files" + ))); } - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "paths": { - "type": "array", - "minItems": 1, - "maxItems": MAX_LINT_PATHS, - "items": { - "type": "string", - "description": "Existing workspace-relative source file." - }, - "description": "One or more existing workspace-relative files to diagnose. Results are capped at 12,000 characters." - } - }, - "required": ["paths"], - "additionalProperties": false + let paths = raw_paths + .iter() + .map(|value| { + let raw = value + .as_str() + .ok_or_else(|| ToolError::invalid_input("each paths entry must be a string"))?; + resolve_lint_path(&context.workspace, raw) }) - } - - fn capabilities(&self) -> Vec { - vec![ToolCapability::ReadOnly] - } - - fn approval_requirement(&self) -> ApprovalRequirement { - ApprovalRequirement::Auto - } - - fn supports_parallel(&self) -> bool { - true - } - - async fn execute(&self, input: Value, context: &ToolContext) -> Result { - let raw_paths = input - .get("paths") - .and_then(Value::as_array) - .ok_or_else(|| ToolError::invalid_input("paths must be a non-empty array"))?; - if raw_paths.is_empty() || raw_paths.len() > MAX_LINT_PATHS { - return Err(ToolError::invalid_input(format!( - "paths must contain between 1 and {MAX_LINT_PATHS} files" - ))); - } - - let paths = raw_paths - .iter() - .map(|value| { - let raw = value - .as_str() - .ok_or_else(|| ToolError::invalid_input("each paths entry must be a string"))?; - resolve_lint_path(&context.workspace, raw) - }) - .collect::, _>>()?; - - let manager = context.lsp_manager.as_ref().ok_or_else(|| { - ToolError::execution_failed( - "LSP manager is not attached to this tool context; enable LSP for this session", - ) - })?; - let blocks = manager - .diagnostics_for_paths(&paths) - .await - .map_err(ToolError::execution_failed)?; + .collect::, _>>()?; - let mut files = Vec::with_capacity(blocks.len()); - let mut diagnostic_count = 0usize; - let mut truncated = false; - for block in blocks { - let mut items = Vec::new(); - for diagnostic in block.items { - if diagnostic_count >= MAX_LINT_DIAGNOSTICS { - truncated = true; - break; - } - diagnostic_count += 1; - items.push(json!({ - "line": diagnostic.line, - "column": diagnostic.column, - "severity": format!("{:?}", diagnostic.severity).to_ascii_lowercase(), - "message": diagnostic - .message - .chars() - .take(MAX_LINT_MESSAGE_CHARS) - .collect::(), - })); + let manager = context.lsp_manager.as_ref().ok_or_else(|| { + ToolError::execution_failed( + "LSP manager is not attached to this tool context; enable LSP for this session", + ) + })?; + let blocks = manager + .diagnostics_for_paths(&paths) + .await + .map_err(ToolError::execution_failed)?; + + let mut files = Vec::with_capacity(blocks.len()); + let mut diagnostic_count = 0usize; + let mut truncated = false; + for block in blocks { + let mut items = Vec::new(); + for diagnostic in block.items { + if diagnostic_count >= MAX_LINT_DIAGNOSTICS { + truncated = true; + break; } - files.push(json!({ - "file": block.file.display().to_string(), - "diagnostics": items, + diagnostic_count += 1; + items.push(json!({ + "line": diagnostic.line, + "column": diagnostic.column, + "severity": format!("{:?}", diagnostic.severity).to_ascii_lowercase(), + "message": diagnostic + .message + .chars() + .take(MAX_LINT_MESSAGE_CHARS) + .collect::(), })); } + files.push(json!({ + "file": block.file.display().to_string(), + "diagnostics": items, + })); + } - let mut output = json!({ - "files": files, - "diagnostic_count": diagnostic_count, - "truncated": truncated, - }); - while serde_json::to_string(&output) - .map(|value| value.len() > MAX_LINT_OUTPUT_CHARS) - .unwrap_or(false) + let mut output = json!({ + "files": files, + "diagnostic_count": diagnostic_count, + "truncated": truncated, + }); + while serde_json::to_string(&output) + .map(|value| value.len() > MAX_LINT_OUTPUT_CHARS) + .unwrap_or(false) + { + let Some(files) = output.get_mut("files").and_then(Value::as_array_mut) else { + break; + }; + let Some(last) = files.last_mut() else { + break; + }; + if let Some(items) = last.get_mut("diagnostics").and_then(Value::as_array_mut) + && items.pop().is_some() { - let Some(files) = output.get_mut("files").and_then(Value::as_array_mut) else { - break; - }; - let Some(last) = files.last_mut() else { - break; - }; - if let Some(items) = last.get_mut("diagnostics").and_then(Value::as_array_mut) - && items.pop().is_some() - { - output["truncated"] = Value::Bool(true); - } else { - files.pop(); - output["truncated"] = Value::Bool(true); - } + output["truncated"] = Value::Bool(true); + } else { + files.pop(); + output["truncated"] = Value::Bool(true); } - - ToolResult::json(&output).map_err(|error| ToolError::execution_failed(error.to_string())) } + + ToolResult::json(&output).map_err(|error| ToolError::execution_failed(error.to_string())) } fn resolve_lint_path(workspace: &Path, raw: &str) -> Result { diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index 900d146cee..f65fad3a9e 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -27,7 +27,7 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { - "generatedAt": "2026-08-20T15:31:58.971Z", + "generatedAt": "2026-08-20T16:26:48.503Z", "sourceRevision": null, "sourceCommittedAt": null, "version": "0.9.10", @@ -287,7 +287,7 @@ export const FACTS: RepoFacts = { ], "defaultModel": "deepseek-v4-pro", "nodeEngines": ">=18", - "toolCount": 76, + "toolCount": 75, "license": "MIT", "latestPublishedRelease": { "tag": "v0.9.9", From 97dfeaa502616243870bf684b696032a34a41410 Mon Sep 17 00:00:00 2001 From: Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:55:24 -0700 Subject: [PATCH 4/5] chore(tui): satisfy stable clippy lints Apply mechanical Clippy 1.98 compatibility fixes in unrelated baseline paths so the required PR lint gate can run cleanly alongside the #4070 read_lints change. --- crates/tui/src/commands/groups/core/voice.rs | 6 ++++-- crates/tui/src/llm_client/mod.rs | 1 + crates/tui/src/tui/ui/event_loop.rs | 2 +- crates/tui/src/tui/views/status_picker.rs | 8 ++------ 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/commands/groups/core/voice.rs b/crates/tui/src/commands/groups/core/voice.rs index abbc541cef..8ddb4b41a7 100644 --- a/crates/tui/src/commands/groups/core/voice.rs +++ b/crates/tui/src/commands/groups/core/voice.rs @@ -234,8 +234,10 @@ fn record_audio() -> Option<(Vec, Duration)> { match reader.read_exact(&mut buf) { Ok(()) => { let chunk: Vec = buf - .chunks_exact(2) - .map(|b| i16::from_le_bytes([b[0], b[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|b| i16::from_le_bytes(*b)) .collect(); // Simple RMS-based VAD diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index 83e556299a..d8165afd3f 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -1164,6 +1164,7 @@ pub type RetryCallback = Box; /// })), /// ).await; /// ``` +#[allow(clippy::result_large_err)] pub async fn with_retry( config: &RetryConfig, mut operation: F, diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index e6951a8965..c433d04cd9 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -1153,7 +1153,7 @@ pub(crate) async fn run_event_loop( } let thinking = app.last_reasoning.take(); - let tool_uses = app.pending_tool_uses.drain(..).collect::>(); + let tool_uses = std::mem::take(&mut app.pending_tool_uses); let history_index = completed_message_index; if app.translation_enabled diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 98fdf62912..314880c0cb 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -153,16 +153,12 @@ impl ModalView for StatusPickerView { { // Quality-of-life: 'a' selects all so the user can quickly // see every chip available before paring back. - for slot in &mut self.selected { - *slot = true; - } + self.selected.fill(true); ViewAction::Emit(self.live_preview_event()) } KeyCode::Char('n') | KeyCode::Char('N') => { // 'n' clears all so the user can build up from scratch. - for slot in &mut self.selected { - *slot = false; - } + self.selected.fill(false); ViewAction::Emit(self.live_preview_event()) } _ => ViewAction::None, From 90ac629e41d1bcc0e6b5c5ecddd80c6a2998b25f Mon Sep 17 00:00:00 2001 From: Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:46:39 -0700 Subject: [PATCH 5/5] fix(tui): make read_lints degradation and filtering visible Distinguish honest empty results from timed-out, failed, or server-less polls via per-file status and note fields; surface warnings_included and per-file config-cap truncation so capped lists are never mistaken for complete ones; restore query and character schema docs within the frozen catalog budget. --- crates/tui/src/lsp/mod.rs | 148 ++++++++++++++---- crates/tui/src/tools/lsp.rs | 300 +++++++++++++++++++++++++++++++++--- 2 files changed, 397 insertions(+), 51 deletions(-) diff --git a/crates/tui/src/lsp/mod.rs b/crates/tui/src/lsp/mod.rs index c916302d01..35b3231765 100644 --- a/crates/tui/src/lsp/mod.rs +++ b/crates/tui/src/lsp/mod.rs @@ -121,6 +121,26 @@ impl LspConfig { } } +/// Outcome of one bounded diagnostics poll. Distinguishes an honest +/// server-reported empty result from a degraded poll (server error or +/// timeout) so callers never render a failure as a clean file. +pub(crate) enum DiagnosticsPoll { + /// At least one diagnostic survived filtering. The bool records whether + /// `[lsp] max_diagnostics_per_file` cut items from this poll. + Ready(DiagnosticBlock, bool), + /// The poll completed and nothing survived the severity filter. + CleanEmpty, + /// The poll could not complete; the reason is actionable. + Unavailable(String), +} + +/// Per-file result of [`LspManager::diagnostics_for_paths`]. +pub(crate) struct FileLints { + pub block: DiagnosticBlock, + pub unavailable: Option, + pub truncated: bool, +} + /// The LspManager holds a lazily populated map of `Language -> Transport`. /// One transport is reused across files of the same language for the /// session's lifetime. @@ -207,32 +227,39 @@ impl LspManager { None => return None, }; - self.poll_diagnostics(file, &text, transport).await + match self.poll_diagnostics(file, &text, transport).await { + DiagnosticsPoll::Ready(block, _) => Some(block), + _ => None, + } } /// Shared diagnostics polling: send didOpen/didChange, wait, filter, - /// sort, and truncate. + /// sort, and truncate. The outcome keeps degraded polls (server error, + /// timeout) distinct from an honest server-reported empty result. async fn poll_diagnostics( &self, file: &Path, text: &str, transport: Arc, - ) -> Option { + ) -> DiagnosticsPoll { let wait = Duration::from_millis(self.config.poll_after_edit_ms); let inner_wait = wait; let raw = match timeout(wait, transport.diagnostics_for(file, text, inner_wait)).await { Ok(Ok(items)) => items, Ok(Err(err)) => { tracing::debug!(?err, file = %file.display(), "lsp: diagnostics call failed"); - return None; + return DiagnosticsPoll::Unavailable(format!("language server error: {err}")); } Err(_) => { tracing::debug!(file = %file.display(), "lsp: diagnostics timed out"); - return None; + return DiagnosticsPoll::Unavailable(format!( + "timed out after {}ms waiting for diagnostics", + wait.as_millis() + )); } }; - // Filter, sort, and truncate. + // Filter and sort by severity. let include_warnings = self.config.include_warnings; let mut items: Vec = raw .into_iter() @@ -248,15 +275,16 @@ impl LspManager { Severity::Information => 2u8, Severity::Hint => 3u8, }); + let truncated = items.len() > self.config.max_diagnostics_per_file; let mut block = DiagnosticBlock { file: relative_to_workspace(&self.workspace, file), items, }; block.truncate(self.config.max_diagnostics_per_file); if block.items.is_empty() { - None + DiagnosticsPoll::CleanEmpty } else { - Some(block) + DiagnosticsPoll::Ready(block, truncated) } } @@ -278,7 +306,10 @@ impl LspManager { Some(t) => t, None => return None, }; - self.poll_diagnostics(file, &text, transport).await + match self.poll_diagnostics(file, &text, transport).await { + DiagnosticsPoll::Ready(block, _) => Some(block), + _ => None, + } } /// Lazy-spawn a custom LSP server for an extension. @@ -501,35 +532,98 @@ impl LspManager { } } - /// Read diagnostics for several existing files through the shared LSP - /// transport pool. Empty diagnostics remain represented as empty blocks; - /// an unavailable language server is returned as an actionable error. + /// Read lints for several existing files through the shared transport + /// pool. Unlike the post-edit hook, every failure mode stays visible to + /// the caller: per-file `unavailable` notes cover missing servers, + /// unreadable files, server errors, and timed-out polls, and `truncated` + /// flags `[lsp] max_diagnostics_per_file` cuts so a capped list is never + /// mistaken for a complete one. Returns `(include_warnings, reports)`. pub async fn diagnostics_for_paths( &self, files: &[PathBuf], - ) -> Result, String> { + ) -> Result<(bool, Vec), String> { if !self.config.enabled { return Err("LSP is disabled ([lsp] enabled = false)".to_string()); } - let mut blocks = Vec::with_capacity(files.len()); + let mut reports = Vec::with_capacity(files.len()); for file in files { - if self.transport_for_path(file).await.is_none() { - return Err(format!( - "no LSP server is available for {}", - relative_to_workspace(&self.workspace, file).display() - )); - } - blocks.push( - self.diagnostics_for(file, 0) - .await - .unwrap_or_else(|| DiagnosticBlock { + let lang = registry::detect_language(file); + let custom = if lang == Language::Other { + self.config.custom_for_extension(file) + } else { + None + }; + let transport = if let Some(custom) = custom { + let ext = file + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase); + match ext { + Some(ext) => self.transport_for_custom(&ext, custom).await, + None => None, + } + } else if lang == Language::Other { + None + } else { + self.transport_for(lang).await + }; + let Some(transport) = transport else { + reports.push(FileLints { + block: DiagnosticBlock { file: relative_to_workspace(&self.workspace, file), items: Vec::new(), - }), - ); + }, + unavailable: Some(format!( + "no language server is configured for {}", + relative_to_workspace(&self.workspace, file).display() + )), + truncated: false, + }); + continue; + }; + + let text = match tokio::fs::read_to_string(file).await { + Ok(text) => text, + Err(err) => { + tracing::debug!(?err, file = %file.display(), "lsp: read file failed"); + reports.push(FileLints { + block: DiagnosticBlock { + file: relative_to_workspace(&self.workspace, file), + items: Vec::new(), + }, + unavailable: Some(format!("could not read file: {err}")), + truncated: false, + }); + continue; + } + }; + + reports.push(match self.poll_diagnostics(file, &text, transport).await { + DiagnosticsPoll::Ready(block, truncated) => FileLints { + block, + unavailable: None, + truncated, + }, + DiagnosticsPoll::CleanEmpty => FileLints { + block: DiagnosticBlock { + file: relative_to_workspace(&self.workspace, file), + items: Vec::new(), + }, + unavailable: None, + truncated: false, + }, + DiagnosticsPoll::Unavailable(note) => FileLints { + block: DiagnosticBlock { + file: relative_to_workspace(&self.workspace, file), + items: Vec::new(), + }, + unavailable: Some(note), + truncated: false, + }, + }); } - Ok(blocks) + Ok((self.config.include_warnings, reports)) } /// Best-effort shutdown of every spawned transport. Called when the diff --git a/crates/tui/src/tools/lsp.rs b/crates/tui/src/tools/lsp.rs index c22c053a7a..dbc0a1eebd 100644 --- a/crates/tui/src/tools/lsp.rs +++ b/crates/tui/src/tools/lsp.rs @@ -36,7 +36,7 @@ impl ToolSpec for LspTool { "operation": { "type": "string", "enum": ["diagnostics", "read_lints", "symbols", "definition", "references"], - "description": "Intelligence operation to run." + "description": "Operation to run." }, "path": { "type": "string", @@ -50,10 +50,11 @@ impl ToolSpec for LspTool { "character": { "type": "integer", "minimum": 1, - "description": "1-based column." + "description": "1-based column, default 1." }, "query": { - "type": "string" + "type": "string", + "description": "Workspace symbol query when operation=symbols." } }, "required": ["operation", "path"] @@ -140,43 +141,63 @@ async fn execute_read_lints(input: Value, context: &ToolContext) -> Result= MAX_LINT_DIAGNOSTICS { - truncated = true; - break; + let mut file_truncated = report.truncated; + if report.unavailable.is_none() { + for diagnostic in report.block.items { + if diagnostic_count >= MAX_LINT_DIAGNOSTICS { + truncated = true; + file_truncated = true; + break; + } + diagnostic_count += 1; + items.push(json!({ + "line": diagnostic.line, + "column": diagnostic.column, + "severity": format!("{:?}", diagnostic.severity).to_ascii_lowercase(), + "message": diagnostic + .message + .chars() + .take(MAX_LINT_MESSAGE_CHARS) + .collect::(), + })); } - diagnostic_count += 1; - items.push(json!({ - "line": diagnostic.line, - "column": diagnostic.column, - "severity": format!("{:?}", diagnostic.severity).to_ascii_lowercase(), - "message": diagnostic - .message - .chars() - .take(MAX_LINT_MESSAGE_CHARS) - .collect::(), - })); } - files.push(json!({ - "file": block.file.display().to_string(), + let mut entry = json!({ + "file": file_display, + "status": if report.unavailable.is_some() { + "unavailable" + } else if items.is_empty() { + "clean" + } else { + "ok" + }, "diagnostics": items, - })); + }); + if let Some(note) = report.unavailable { + entry["note"] = json!(note); + } + if file_truncated { + entry["truncated"] = Value::Bool(true); + } + files.push(entry); } let mut output = json!({ "files": files, "diagnostic_count": diagnostic_count, "truncated": truncated, + "warnings_included": include_warnings, }); while serde_json::to_string(&output) .map(|value| value.len() > MAX_LINT_OUTPUT_CHARS) @@ -429,6 +450,8 @@ mod tests { let payload: Value = serde_json::from_str(&result.content).unwrap(); assert_eq!(payload["files"].as_array().unwrap().len(), 2); assert_eq!(payload["diagnostic_count"], 2); + assert_eq!(payload["files"][0]["status"], "ok"); + assert_eq!(payload["warnings_included"], false); assert_eq!(payload["files"][0]["diagnostics"][0]["line"], 1); assert_eq!(payload["files"][0]["diagnostics"][0]["severity"], "error"); assert_eq!(payload["files"][0]["diagnostics"][0]["message"], "boom"); @@ -456,6 +479,7 @@ mod tests { let payload: Value = serde_json::from_str(&result.content).unwrap(); assert_eq!(payload["diagnostic_count"], 0); assert_eq!(payload["files"][0]["diagnostics"], json!([])); + assert_eq!(payload["files"][0]["status"], "clean"); } #[tokio::test] @@ -493,4 +517,232 @@ mod tests { .expect_err("path traversal must fail closed"); assert!(path_error.to_string().contains("cannot contain")); } + + struct StubTransport { + diagnostics: Vec, + fail: bool, + stall: Duration, + } + + #[async_trait] + impl crate::lsp::LspTransport for StubTransport { + async fn diagnostics_for( + &self, + _path: &Path, + _text: &str, + _wait: Duration, + ) -> anyhow::Result> { + if !self.stall.is_zero() { + tokio::time::sleep(self.stall).await; + } + if self.fail { + return Err(anyhow::anyhow!("kaboom")); + } + Ok(self.diagnostics.clone()) + } + + async fn request( + &self, + _method: &str, + _params: Value, + _wait: Duration, + ) -> anyhow::Result { + Ok(json!({})) + } + + async fn shutdown(&self) {} + } + + #[tokio::test] + async fn read_lints_reports_timeout_as_unavailable_not_clean() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lib.rs"); + tokio::fs::write(&path, b"fn main() {}\n").await.unwrap(); + + let mgr = Arc::new(LspManager::new( + LspConfig { + poll_after_edit_ms: 40, + ..LspConfig::default() + }, + dir.path().to_path_buf(), + )); + mgr.install_test_transport( + Language::Rust, + Arc::new(StubTransport { + diagnostics: Vec::new(), + fail: false, + stall: Duration::from_millis(250), + }), + ) + .await; + let mut ctx = ToolContext::new(dir.path()); + ctx = ctx.with_lsp_manager(mgr); + + let result = LspTool + .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx) + .await + .expect("degraded polls still succeed with a visible note"); + let payload: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["files"][0]["status"], "unavailable"); + assert!( + payload["files"][0]["note"] + .as_str() + .is_some_and(|note| note.contains("timed out")), + "unexpected note: {payload}" + ); + assert_eq!(payload["diagnostic_count"], 0); + } + + #[tokio::test] + async fn read_lints_reports_transport_errors_as_unavailable() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lib.rs"); + tokio::fs::write(&path, b"fn main() {}\n").await.unwrap(); + + let mgr = Arc::new(LspManager::new( + LspConfig::default(), + dir.path().to_path_buf(), + )); + mgr.install_test_transport( + Language::Rust, + Arc::new(StubTransport { + diagnostics: Vec::new(), + fail: true, + stall: Duration::ZERO, + }), + ) + .await; + let mut ctx = ToolContext::new(dir.path()); + ctx = ctx.with_lsp_manager(mgr); + + let result = LspTool + .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx) + .await + .expect("transport errors surface as unavailable, not clean"); + let payload: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["files"][0]["status"], "unavailable"); + assert!( + payload["files"][0]["note"] + .as_str() + .is_some_and(|note| note.contains("kaboom")), + "unexpected note: {payload}" + ); + } + + #[tokio::test] + async fn read_lints_flags_config_cap_truncation() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lib.rs"); + tokio::fs::write(&path, b"fn main() {}\n").await.unwrap(); + + let mgr = Arc::new(LspManager::new( + LspConfig { + max_diagnostics_per_file: 2, + ..LspConfig::default() + }, + dir.path().to_path_buf(), + )); + mgr.install_test_transport( + Language::Rust, + Arc::new(StubTransport { + diagnostics: (1..=3) + .map(|line| Diagnostic { + line, + column: 1, + severity: Severity::Error, + message: format!("err {line}"), + }) + .collect(), + fail: false, + stall: Duration::ZERO, + }), + ) + .await; + let mut ctx = ToolContext::new(dir.path()); + ctx = ctx.with_lsp_manager(mgr); + + let result = LspTool + .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx) + .await + .expect("capped lists stay readable"); + let payload: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!( + payload["files"][0]["diagnostics"].as_array().unwrap().len(), + 2 + ); + assert_eq!(payload["files"][0]["truncated"], true); + assert_eq!(payload["diagnostic_count"], 2); + assert_eq!(payload["truncated"], false, "tool caps were not reached"); + } + + #[tokio::test] + async fn read_lints_surfaces_warning_visibility() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lib.rs"); + tokio::fs::write(&path, b"fn main() {}\n").await.unwrap(); + let stub = || -> Arc { + Arc::new(StubTransport { + diagnostics: vec![ + Diagnostic { + line: 1, + column: 1, + severity: Severity::Error, + message: "e1".into(), + }, + Diagnostic { + line: 2, + column: 1, + severity: Severity::Warning, + message: "w1".into(), + }, + ], + fail: false, + stall: Duration::ZERO, + }) + }; + + let errors_only = Arc::new(LspManager::new( + LspConfig::default(), + dir.path().to_path_buf(), + )); + errors_only + .install_test_transport(Language::Rust, stub()) + .await; + let mut ctx = ToolContext::new(dir.path()); + ctx = ctx.with_lsp_manager(Arc::clone(&errors_only)); + let result = LspTool + .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx) + .await + .expect("default filter read"); + let payload: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["warnings_included"], false); + assert_eq!( + payload["files"][0]["diagnostics"].as_array().unwrap().len(), + 1 + ); + assert_eq!(payload["files"][0]["diagnostics"][0]["severity"], "error"); + + let with_warnings = Arc::new(LspManager::new( + LspConfig { + include_warnings: true, + ..LspConfig::default() + }, + dir.path().to_path_buf(), + )); + with_warnings + .install_test_transport(Language::Rust, stub()) + .await; + let mut ctx = ToolContext::new(dir.path()); + ctx = ctx.with_lsp_manager(with_warnings); + let result = LspTool + .execute(json!({"operation": "read_lints", "path": "lib.rs"}), &ctx) + .await + .expect("warnings-included read"); + let payload: Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["warnings_included"], true); + assert_eq!( + payload["files"][0]["diagnostics"].as_array().unwrap().len(), + 2 + ); + } }