diff --git a/rust/crates/runtime/src/lsp_client/dispatch.rs b/rust/crates/runtime/src/lsp_client/dispatch.rs index a698092e..39ba1bb8 100644 --- a/rust/crates/runtime/src/lsp_client/dispatch.rs +++ b/rust/crates/runtime/src/lsp_client/dispatch.rs @@ -47,7 +47,11 @@ impl super::LspRegistry { let language = if lsp_action == LspAction::WorkspaceSymbols { // Try to find any connected server for workspace symbols let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.keys().next().cloned() + inner + .servers + .keys() + .next() + .cloned() .ok_or_else(|| "no LSP servers available for workspace symbols".to_owned())? } else { let p = path.ok_or("path is required for this LSP action")?; @@ -160,159 +164,189 @@ impl super::LspRegistry { let hover = process.hover(path, line, character).await; hover.map(|opt| { opt.map_or_else( - || serde_json::json!({ - "action": "hover", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "no_result", - }), - |h| serde_json::json!({ - "action": "hover", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "result": h, - }), + || { + serde_json::json!({ + "action": "hover", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "no_result", + }) + }, + |h| { + serde_json::json!({ + "action": "hover", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": h, + }) + }, ) }) } LspAction::Definition => { let locations = process.goto_definition(path, line, character).await; - locations.map(|locs| serde_json::json!({ - "action": "definition", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "locations": locs, - })) + locations.map(|locs| { + serde_json::json!({ + "action": "definition", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "locations": locs, + }) + }) } LspAction::References => { let locations = process.references(path, line, character).await; - locations.map(|locs| serde_json::json!({ - "action": "references", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "locations": locs, - })) + locations.map(|locs| { + serde_json::json!({ + "action": "references", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "locations": locs, + }) + }) } LspAction::Completion => { let items = process.completion(path, line, character).await; - items.map(|completions| serde_json::json!({ - "action": "completion", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "items": completions, - })) + items.map(|completions| { + serde_json::json!({ + "action": "completion", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "items": completions, + }) + }) } LspAction::Symbols => { let symbols = process.document_symbols(path).await; - symbols.map(|syms| serde_json::json!({ - "action": "symbols", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "symbols": syms, - })) + symbols.map(|syms| { + serde_json::json!({ + "action": "symbols", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "symbols": syms, + }) + }) } LspAction::Format => { let edits = process.format(path).await; - edits.map(|text_edits| serde_json::json!({ - "action": "format", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "edits": text_edits, - })) + edits.map(|text_edits| { + serde_json::json!({ + "action": "format", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "edits": text_edits, + }) + }) } LspAction::CodeAction => { let end_line = if line > 0 { Some(line) } else { None }; let end_character = if character > 0 { Some(character) } else { None }; - let actions = process.code_action(path, line, character, end_line, end_character, None).await; - actions.map(|acts| serde_json::json!({ - "action": "code_action", - "path": path, - "line": 0, - "character": 0, - "end_line": end_line, - "end_character": end_character, - "language": language, - "status": "ok", - "actions": acts, - })) + let actions = process + .code_action(path, line, character, end_line, end_character, None) + .await; + actions.map(|acts| { + serde_json::json!({ + "action": "code_action", + "path": path, + "line": 0, + "character": 0, + "end_line": end_line, + "end_character": end_character, + "language": language, + "status": "ok", + "actions": acts, + }) + }) } LspAction::Rename => { - let new_name = _query.ok_or_else(|| LspProcessError::InvalidRequest("new_name required for rename".into()))?; + let new_name = _query.ok_or_else(|| { + LspProcessError::InvalidRequest("new_name required for rename".into()) + })?; let rename_result = process.rename(path, line, character, new_name).await; - rename_result.map(|r| serde_json::json!({ - "action": "rename", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "result": r, - })) + rename_result.map(|r| { + serde_json::json!({ + "action": "rename", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": r, + }) + }) } LspAction::SignatureHelp => { let sig = process.signature_help(path, line, character).await; sig.map(|opt| { opt.map_or_else( - || serde_json::json!({ - "action": "signature_help", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "no_result", - }), - |s| serde_json::json!({ - "action": "signature_help", - "path": path, - "line": line, - "character": character, - "language": language, - "status": "ok", - "result": s, - }), + || { + serde_json::json!({ + "action": "signature_help", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "no_result", + }) + }, + |s| { + serde_json::json!({ + "action": "signature_help", + "path": path, + "line": line, + "character": character, + "language": language, + "status": "ok", + "result": s, + }) + }, ) }) } LspAction::CodeLens => { let lenses = process.code_lens(path).await; - lenses.map(|l| serde_json::json!({ - "action": "code_lens", - "path": path, - "language": language, - "status": "ok", - "lenses": l, - })) + lenses.map(|l| { + serde_json::json!({ + "action": "code_lens", + "path": path, + "language": language, + "status": "ok", + "lenses": l, + }) + }) } LspAction::WorkspaceSymbols => { let query = _query.unwrap_or(""); let symbols = process.workspace_symbols(query).await; - symbols.map(|syms| serde_json::json!({ - "action": "workspace_symbols", - "language": language, - "query": query, - "status": "ok", - "symbols": syms, - })) + symbols.map(|syms| { + serde_json::json!({ + "action": "workspace_symbols", + "language": language, + "query": query, + "status": "ok", + "symbols": syms, + }) + }) } LspAction::Diagnostics => unreachable!(), } diff --git a/rust/crates/runtime/src/lsp_client/mod.rs b/rust/crates/runtime/src/lsp_client/mod.rs index 7a9c7a3b..614b6d02 100644 --- a/rust/crates/runtime/src/lsp_client/mod.rs +++ b/rust/crates/runtime/src/lsp_client/mod.rs @@ -2,17 +2,17 @@ //! LSP (Language Server Protocol) client registry for tool dispatch. mod dispatch; -mod types; #[cfg(test)] mod tests; #[cfg(test)] mod tests_lifecycle; +mod types; pub use types::{ LspAction, LspCodeAction, LspCodeLens, LspCommand, LspCompletionItem, LspDiagnostic, - LspFileEdit, LspHoverResult, LspLocation, LspParameterInfo, LspRenameResult, - LspServerState, LspServerStatus, LspSignatureHelpResult, LspSignatureInformation, - LspSymbol, LspTextEdit, LspWorkspaceEdit, + LspFileEdit, LspHoverResult, LspLocation, LspParameterInfo, LspRenameResult, LspServerState, + LspServerStatus, LspSignatureHelpResult, LspSignatureInformation, LspSymbol, LspTextEdit, + LspWorkspaceEdit, }; use std::collections::{HashMap, HashSet}; @@ -190,7 +190,11 @@ impl LspRegistry { /// List all registered servers. pub fn list_servers(&self) -> Vec { let inner = self.inner.lock().expect("lsp registry lock poisoned"); - inner.servers.values().map(|entry| entry.state.clone()).collect() + inner + .servers + .values() + .map(|entry| entry.state.clone()) + .collect() } /// Add diagnostics to a server. @@ -274,14 +278,14 @@ impl LspRegistry { }; // If no descriptor, try discovery - let descriptor = if let Some(d) = descriptor { d } else { + let descriptor = if let Some(d) = descriptor { + d + } else { let available = discover_available_servers(); available .into_iter() .find(|d| d.language == language) - .ok_or_else(|| { - format!("no LSP server descriptor found for language: {language}") - })? + .ok_or_else(|| format!("no LSP server descriptor found for language: {language}"))? }; let root_path = { @@ -447,7 +451,6 @@ impl LspRegistry { diagnostics } - /// Notify the LSP server that a file was closed. /// Best-effort: returns empty vec if no server is available. pub fn notify_file_close(&self, path: &str) -> Vec { @@ -498,8 +501,7 @@ impl LspRegistry { let new_diags = process.drain_diagnostics(); if !new_diags.is_empty() { let diag_path = path.to_owned(); - let mut inner = - self.inner.lock().expect("lsp registry lock poisoned"); + let mut inner = self.inner.lock().expect("lsp registry lock poisoned"); if let Some(entry) = inner.servers.get_mut(&language) { entry.state.diagnostics.retain(|d| d.path != diag_path); entry.state.diagnostics.extend(new_diags); diff --git a/rust/crates/runtime/src/lsp_client/tests.rs b/rust/crates/runtime/src/lsp_client/tests.rs index 7e2c74d6..0f99726a 100644 --- a/rust/crates/runtime/src/lsp_client/tests.rs +++ b/rust/crates/runtime/src/lsp_client/tests.rs @@ -1,7 +1,7 @@ //! Tests for the LSP client registry: registration, diagnostics, and type unit tests. -use super::*; use super::types::*; +use super::*; #[test] fn registers_and_retrieves_server() { diff --git a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs index 7b2a094b..1c0c89b8 100644 --- a/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs +++ b/rust/crates/runtime/src/lsp_client/tests_lifecycle.rs @@ -1,8 +1,8 @@ //! Tests for the LSP client registry: extension mapping, server lifecycle, //! and diagnostics edge cases. -use super::*; use super::types::*; +use super::*; #[test] fn find_server_for_all_extensions() { @@ -198,7 +198,9 @@ fn register_with_descriptor_stores_entry() { descriptor, ); - let server = registry.get("rust").expect("should exist after register_with_descriptor"); + let server = registry + .get("rust") + .expect("should exist after register_with_descriptor"); assert_eq!(server.language, "rust"); assert_eq!(server.status, LspServerStatus::Connected); assert_eq!(server.root_path.as_deref(), Some("/project")); @@ -209,9 +211,15 @@ fn register_with_descriptor_stores_entry() { fn stop_server_on_nonexistent_errors() { let registry = LspRegistry::new(); let result = registry.stop_server("missing"); - assert!(result.is_err(), "stopping a nonexistent server should error"); + assert!( + result.is_err(), + "stopping a nonexistent server should error" + ); let error = result.unwrap_err(); - assert!(error.contains("missing"), "error message should reference 'missing', got: {error}"); + assert!( + error.contains("missing"), + "error message should reference 'missing', got: {error}" + ); } /// This test requires rust-analyzer to be installed on the system. @@ -222,7 +230,10 @@ fn start_server_without_descriptor_falls_back_to_discovery() { let registry = LspRegistry::new(); registry.register("rust", LspServerStatus::Starting, None, vec![]); let result = registry.start_server("rust"); - assert!(result.is_ok(), "start_server should discover and start rust-analyzer: {result:?}"); + assert!( + result.is_ok(), + "start_server should discover and start rust-analyzer: {result:?}" + ); let server = registry.get("rust").expect("rust should be registered"); assert_eq!(server.status, LspServerStatus::Connected); let _ = registry.stop_server("rust"); @@ -241,13 +252,7 @@ fn dispatch_hover_lazy_starts_server() { extensions: vec!["rs".into()], install_hint: vec![], }; - registry.register_with_descriptor( - "rust", - LspServerStatus::Starting, - None, - vec![], - descriptor, - ); + registry.register_with_descriptor("rust", LspServerStatus::Starting, None, vec![], descriptor); // dispatch should trigger start_server because process is None let result = registry.dispatch("hover", Some("src/main.rs"), Some(0), Some(0), None); // Result may be Ok or Err depending on whether rust-analyzer can actually @@ -275,23 +280,25 @@ fn start_and_stop_server() { extensions: vec!["rs".into()], install_hint: vec![], }; - registry.register_with_descriptor( - "rust", - LspServerStatus::Starting, - None, - vec![], - descriptor, - ); + registry.register_with_descriptor("rust", LspServerStatus::Starting, None, vec![], descriptor); let start_result = registry.start_server("rust"); - assert!(start_result.is_ok(), "start_server should succeed: {start_result:?}"); + assert!( + start_result.is_ok(), + "start_server should succeed: {start_result:?}" + ); let server = registry.get("rust").expect("rust should exist"); assert_eq!(server.status, LspServerStatus::Connected); let stop_result = registry.stop_server("rust"); - assert!(stop_result.is_ok(), "stop_server should succeed: {stop_result:?}"); + assert!( + stop_result.is_ok(), + "stop_server should succeed: {stop_result:?}" + ); - let server = registry.get("rust").expect("rust should still be in registry"); + let server = registry + .get("rust") + .expect("rust should still be in registry"); assert_eq!(server.status, LspServerStatus::Disconnected); } diff --git a/rust/crates/runtime/src/lsp_discovery.rs b/rust/crates/runtime/src/lsp_discovery.rs index 14820ad4..65464164 100644 --- a/rust/crates/runtime/src/lsp_discovery.rs +++ b/rust/crates/runtime/src/lsp_discovery.rs @@ -28,7 +28,10 @@ pub enum LspInstallAction { /// The server is already available. Installed, /// The server is not found; these are the suggested install commands. - Missing { language: String, instructions: Vec }, + Missing { + language: String, + instructions: Vec, + }, /// The server binary exists but is a rustup proxy stub for an uninstalled component. RustupProxyMissing { language: String, component: String }, } @@ -166,89 +169,272 @@ const KNOWN_LSP_SERVERS_TABLE: &[StaticLspServerDescriptor] = &[ fn install_instructions_for(language: &str) -> Vec { match language { "rust" => vec![ - InstallInstruction { label: "rustup".into(), command: "rustup component add rust-analyzer".into() }, - InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install rust-analyzer".into() }, - InstallInstruction { label: "Fedora".into(), command: "sudo dnf install rust-analyzer".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S rust-analyzer".into() }, - InstallInstruction { label: "openSUSE".into(), command: "sudo zypper install rust-analyzer".into() }, - InstallInstruction { label: "Alpine".into(), command: "sudo apk add rust-analyzer".into() }, - InstallInstruction { label: "Void".into(), command: "sudo xbps-install rust-analyzer".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.rust-analyzer".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install rust-analyzer".into() }, - InstallInstruction { label: "pip".into(), command: "pip install rust-analyzer".into() }, + InstallInstruction { + label: "rustup".into(), + command: "rustup component add rust-analyzer".into(), + }, + InstallInstruction { + label: "Ubuntu/Debian".into(), + command: "sudo apt install rust-analyzer".into(), + }, + InstallInstruction { + label: "Fedora".into(), + command: "sudo dnf install rust-analyzer".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S rust-analyzer".into(), + }, + InstallInstruction { + label: "openSUSE".into(), + command: "sudo zypper install rust-analyzer".into(), + }, + InstallInstruction { + label: "Alpine".into(), + command: "sudo apk add rust-analyzer".into(), + }, + InstallInstruction { + label: "Void".into(), + command: "sudo xbps-install rust-analyzer".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.rust-analyzer".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install rust-analyzer".into(), + }, + InstallInstruction { + label: "pip".into(), + command: "pip install rust-analyzer".into(), + }, ], "c/cpp" => vec![ - InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install clangd".into() }, - InstallInstruction { label: "Fedora".into(), command: "sudo dnf install clang-tools-extra".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S clang".into() }, - InstallInstruction { label: "openSUSE".into(), command: "sudo zypper install clang-tools".into() }, - InstallInstruction { label: "Alpine".into(), command: "sudo apk add clang-extra-tools".into() }, - InstallInstruction { label: "Void".into(), command: "sudo xbps-install clang-tools-extra".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.clang-tools".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install llvm".into() }, + InstallInstruction { + label: "Ubuntu/Debian".into(), + command: "sudo apt install clangd".into(), + }, + InstallInstruction { + label: "Fedora".into(), + command: "sudo dnf install clang-tools-extra".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S clang".into(), + }, + InstallInstruction { + label: "openSUSE".into(), + command: "sudo zypper install clang-tools".into(), + }, + InstallInstruction { + label: "Alpine".into(), + command: "sudo apk add clang-extra-tools".into(), + }, + InstallInstruction { + label: "Void".into(), + command: "sudo xbps-install clang-tools-extra".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.clang-tools".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install llvm".into(), + }, ], "python" => vec![ - InstallInstruction { label: "npm".into(), command: "npm install -g pyright".into() }, - InstallInstruction { label: "pip".into(), command: "pip install pyright".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S pyright".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.pyright".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install pyright".into() }, + InstallInstruction { + label: "npm".into(), + command: "npm install -g pyright".into(), + }, + InstallInstruction { + label: "pip".into(), + command: "pip install pyright".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S pyright".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.pyright".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install pyright".into(), + }, ], "go" => vec![ - InstallInstruction { label: "go".into(), command: "go install golang.org/x/tools/gopls@latest".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S gopls".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.gopls".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install gopls".into() }, + InstallInstruction { + label: "go".into(), + command: "go install golang.org/x/tools/gopls@latest".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S gopls".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.gopls".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install gopls".into(), + }, ], "typescript" => vec![ - InstallInstruction { label: "npm".into(), command: "npm install -g typescript-language-server typescript".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S typescript-language-server".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.typescript-language-server".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install typescript-language-server".into() }, + InstallInstruction { + label: "npm".into(), + command: "npm install -g typescript-language-server typescript".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S typescript-language-server".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.typescript-language-server".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install typescript-language-server".into(), + }, ], "java" => vec![ - InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install eclipse-jdtls".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S jdtls".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.eclipse-jdtls".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install jdtls".into() }, + InstallInstruction { + label: "Ubuntu/Debian".into(), + command: "sudo apt install eclipse-jdtls".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S jdtls".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.eclipse-jdtls".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install jdtls".into(), + }, ], "ruby" => vec![ - InstallInstruction { label: "gem".into(), command: "gem install solargraph".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S solargraph".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.solargraph".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install solargraph".into() }, + InstallInstruction { + label: "gem".into(), + command: "gem install solargraph".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S solargraph".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.solargraph".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install solargraph".into(), + }, ], "lua" => vec![ - InstallInstruction { label: "npm".into(), command: "npm install -g lua-language-server".into() }, - InstallInstruction { label: "Ubuntu/Debian".into(), command: "sudo apt install lua-language-server".into() }, - InstallInstruction { label: "Fedora".into(), command: "sudo dnf install lua-language-server".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S lua-language-server".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.lua-language-server".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install lua-language-server".into() }, + InstallInstruction { + label: "npm".into(), + command: "npm install -g lua-language-server".into(), + }, + InstallInstruction { + label: "Ubuntu/Debian".into(), + command: "sudo apt install lua-language-server".into(), + }, + InstallInstruction { + label: "Fedora".into(), + command: "sudo dnf install lua-language-server".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S lua-language-server".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.lua-language-server".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install lua-language-server".into(), + }, ], "html" | "css" | "json" => vec![ - InstallInstruction { label: "npm".into(), command: "npm install -g vscode-langservers-extracted".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S vscode-langservers-extracted".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.vscode-langservers-extracted".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install vscode-langservers-extracted".into() }, + InstallInstruction { + label: "npm".into(), + command: "npm install -g vscode-langservers-extracted".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S vscode-langservers-extracted".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.vscode-langservers-extracted".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install vscode-langservers-extracted".into(), + }, ], "bash" => vec![ - InstallInstruction { label: "npm".into(), command: "npm install -g bash-language-server".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S bash-language-server".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.bash-language-server".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install bash-language-server".into() }, + InstallInstruction { + label: "npm".into(), + command: "npm install -g bash-language-server".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S bash-language-server".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.bash-language-server".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install bash-language-server".into(), + }, ], "yaml" => vec![ - InstallInstruction { label: "npm".into(), command: "npm install -g yaml-language-server".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S yaml-language-server".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.yaml-language-server".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install yaml-language-server".into() }, + InstallInstruction { + label: "npm".into(), + command: "npm install -g yaml-language-server".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S yaml-language-server".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.yaml-language-server".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install yaml-language-server".into(), + }, ], "gdscript" => vec![ - InstallInstruction { label: "Godot Editor".into(), command: "Download from https://godotengine.org".into() }, - InstallInstruction { label: "Arch".into(), command: "sudo pacman -S godot".into() }, - InstallInstruction { label: "NixOS".into(), command: "nix-env -iA nixpkgs.godot".into() }, - InstallInstruction { label: "macOS".into(), command: "brew install godot".into() }, + InstallInstruction { + label: "Godot Editor".into(), + command: "Download from https://godotengine.org".into(), + }, + InstallInstruction { + label: "Arch".into(), + command: "sudo pacman -S godot".into(), + }, + InstallInstruction { + label: "NixOS".into(), + command: "nix-env -iA nixpkgs.godot".into(), + }, + InstallInstruction { + label: "macOS".into(), + command: "brew install godot".into(), + }, ], _ => Vec::new(), } @@ -269,10 +455,7 @@ pub fn known_lsp_servers() -> Vec { /// successfully, `false` otherwise. #[must_use] pub fn command_exists_on_path(command: &str) -> bool { - Command::new(command) - .arg("--version") - .output() - .is_ok() + Command::new(command).arg("--version").output().is_ok() } /// Check if a binary is a rustup proxy by running `--version` and looking for @@ -318,7 +501,11 @@ pub fn detect_platform() -> LinuxDistro { LinuxDistro::Debian } else if contents.contains("Fedora") { LinuxDistro::Fedora - } else if contents.contains("Arch") || contents.contains("archlinux") || contents.contains("Manjaro") || contents.contains("EndeavourOS") { + } else if contents.contains("Arch") + || contents.contains("archlinux") + || contents.contains("Manjaro") + || contents.contains("EndeavourOS") + { LinuxDistro::Arch } else if contents.contains("openSUSE") || contents.contains("SUSE") { LinuxDistro::OpenSuse @@ -407,7 +594,10 @@ pub fn format_install_prompt(actions: &[LspInstallAction]) -> String { for action in actions { match action { LspInstallAction::Installed => continue, - LspInstallAction::Missing { language, instructions } => { + LspInstallAction::Missing { + language, + instructions, + } => { lines.push(format!(" {language}: not found")); let best = instructions .iter() @@ -432,8 +622,13 @@ pub fn format_install_prompt(actions: &[LspInstallAction]) -> String { } } } - LspInstallAction::RustupProxyMissing { language, component } => { - lines.push(format!(" {language}: rustup proxy found but component not installed")); + LspInstallAction::RustupProxyMissing { + language, + component, + } => { + lines.push(format!( + " {language}: rustup proxy found but component not installed" + )); lines.push(format!(" → rustup component add {component}")); } } @@ -492,10 +687,7 @@ mod tests { #[test] fn known_servers_contains_expected_languages() { - let languages: Vec<&str> = KNOWN_LSP_SERVERS_TABLE - .iter() - .map(|s| s.language) - .collect(); + let languages: Vec<&str> = KNOWN_LSP_SERVERS_TABLE.iter().map(|s| s.language).collect(); assert!(languages.contains(&"rust")); assert!(languages.contains(&"c/cpp")); assert!(languages.contains(&"python")); @@ -557,10 +749,16 @@ mod tests { } let languages: Vec<&str> = available.iter().map(|s| s.language.as_str()).collect(); if command_exists_on_path("rust-analyzer") && !is_rustup_proxy("rust-analyzer") { - assert!(languages.contains(&"rust"), "rust-analyzer is on PATH but 'rust' not in discovered servers"); + assert!( + languages.contains(&"rust"), + "rust-analyzer is on PATH but 'rust' not in discovered servers" + ); } if command_exists_on_path("clangd") { - assert!(languages.contains(&"c/cpp"), "clangd is on PATH but 'c/cpp' not in discovered servers"); + assert!( + languages.contains(&"c/cpp"), + "clangd is on PATH but 'c/cpp' not in discovered servers" + ); } } @@ -582,25 +780,43 @@ mod tests { #[test] fn descriptor_has_correct_args() { let servers = known_lsp_servers(); - let rust = servers.iter().find(|s| s.language == "rust").expect("rust server should exist"); + let rust = servers + .iter() + .find(|s| s.language == "rust") + .expect("rust server should exist"); assert!(rust.args.is_empty(), "rust-analyzer should have no args"); - let ts = servers.iter().find(|s| s.language == "typescript").expect("typescript server should exist"); - assert_eq!(ts.args, vec!["--stdio"], "typescript-language-server should have --stdio arg"); + let ts = servers + .iter() + .find(|s| s.language == "typescript") + .expect("typescript server should exist"); + assert_eq!( + ts.args, + vec!["--stdio"], + "typescript-language-server should have --stdio arg" + ); } #[test] fn install_instructions_cover_all_languages() { for desc in KNOWN_LSP_SERVERS_TABLE { let instructions = install_instructions_for(desc.language); - assert!(!instructions.is_empty(), "no install instructions for '{}'", desc.language); + assert!( + !instructions.is_empty(), + "no install instructions for '{}'", + desc.language + ); } } #[test] fn best_install_returns_something_for_known_languages() { for desc in KNOWN_LSP_SERVERS_TABLE { - assert!(best_install_instruction(desc.language).is_some(), "no best install for '{}'", desc.language); + assert!( + best_install_instruction(desc.language).is_some(), + "no best install for '{}'", + desc.language + ); } } @@ -619,7 +835,10 @@ mod tests { }]; let prompt = format_install_prompt(&actions); assert!(prompt.contains("rust"), "should mention rust"); - assert!(prompt.contains("rustup component add rust-analyzer"), "should show rustup command"); + assert!( + prompt.contains("rustup component add rust-analyzer"), + "should show rustup command" + ); } #[test] @@ -647,7 +866,11 @@ mod tests { fn server_descriptors_have_install_hints() { let servers = known_lsp_servers(); for server in &servers { - assert!(!server.install_hint.is_empty(), "server '{}' should have install hints", server.language); + assert!( + !server.install_hint.is_empty(), + "server '{}' should have install hints", + server.language + ); } } } diff --git a/rust/crates/runtime/src/lsp_process/mod.rs b/rust/crates/runtime/src/lsp_process/mod.rs index f8c60a05..345f6fd7 100644 --- a/rust/crates/runtime/src/lsp_process/mod.rs +++ b/rust/crates/runtime/src/lsp_process/mod.rs @@ -18,10 +18,9 @@ use crate::lsp_transport::{LspTransport, LspTransportError}; use parse::{ canonicalize_root, language_id_for_path, parse_code_actions, parse_code_lens, - parse_completions, parse_hover, parse_locations, parse_signature_help, - parse_symbols, parse_workspace_edit, parse_workspace_symbols, path_to_uri, - rename_params, severity_name, text_document_position_params, uri_to_path, - workspace_symbol_params, + parse_completions, parse_hover, parse_locations, parse_signature_help, parse_symbols, + parse_workspace_edit, parse_workspace_symbols, path_to_uri, rename_params, severity_name, + text_document_position_params, uri_to_path, workspace_symbol_params, }; #[derive(Debug)] @@ -374,7 +373,6 @@ impl LspProcess { Ok(()) } - /// Notify the server that a file was closed. Sends `textDocument/didClose`. pub async fn did_close(&mut self, path: &str) -> Result<(), LspProcessError> { if !self.open_files.contains(path) { @@ -523,8 +521,7 @@ impl LspProcess { if let Some(params) = &n.params { if let Some(uri) = params.get("uri").and_then(|v| v.as_str()) { let path = uri_to_path(uri); - if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) - { + if let Some(diags) = params.get("diagnostics").and_then(|v| v.as_array()) { for d in diags { diagnostics.push(LspDiagnostic { path: path.clone(), diff --git a/rust/crates/runtime/src/lsp_process/parse.rs b/rust/crates/runtime/src/lsp_process/parse.rs index fb51df6f..6fdb9e69 100644 --- a/rust/crates/runtime/src/lsp_process/parse.rs +++ b/rust/crates/runtime/src/lsp_process/parse.rs @@ -19,8 +19,8 @@ pub(super) fn path_to_uri(path: &str) -> String { if canonical.is_absolute() { format!("file://{path}") } else { - let resolved = std::env::current_dir() - .map_or_else(|_| canonical.to_path_buf(), |d| d.join(path)); + let resolved = + std::env::current_dir().map_or_else(|_| canonical.to_path_buf(), |d| d.join(path)); let canonicalized = resolved .canonicalize() .unwrap_or(resolved) @@ -310,82 +310,169 @@ pub(super) fn completion_kind_name(kind: u64) -> String { } } - #[allow(clippy::cast_possible_truncation)] pub(super) fn parse_code_actions(value: &JsonValue) -> Vec { let Some(items) = value.as_array() else { return Vec::new(); }; - items.iter().filter_map(|item| { - // Code actions can be Command or CodeAction objects; we only parse CodeAction - let title = item.get("title")?.as_str()?.to_owned(); - let kind = item.get("kind").and_then(JsonValue::as_str).map(str::to_owned); - let is_preferred = item.get("isPreferred").and_then(JsonValue::as_bool).unwrap_or(false); - let edit = item.get("edit").and_then(parse_workspace_edit); - let command = item.get("command").and_then(parse_command); - Some(crate::lsp_client::LspCodeAction { title, kind, is_preferred, edit, command }) - }).collect() + items + .iter() + .filter_map(|item| { + // Code actions can be Command or CodeAction objects; we only parse CodeAction + let title = item.get("title")?.as_str()?.to_owned(); + let kind = item + .get("kind") + .and_then(JsonValue::as_str) + .map(str::to_owned); + let is_preferred = item + .get("isPreferred") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let edit = item.get("edit").and_then(parse_workspace_edit); + let command = item.get("command").and_then(parse_command); + Some(crate::lsp_client::LspCodeAction { + title, + kind, + is_preferred, + edit, + command, + }) + }) + .collect() } -pub(super) fn parse_workspace_edit(value: &JsonValue) -> Option { +pub(super) fn parse_workspace_edit( + value: &JsonValue, +) -> Option { let changes = if let Some(changes_map) = value.get("changes").and_then(JsonValue::as_object) { - changes_map.iter().filter_map(|(uri, edits)| { - let path = uri_to_path(uri); - let edit_list = edits.as_array()?; - let text_edits: Vec = edit_list.iter().filter_map(|e| { - let new_text = e.get("newText")?.as_str()?.to_owned(); - let range = e.get("range")?; - let start = range.get("start")?; - let end = range.get("end")?; - Some(crate::lsp_client::LspTextEdit { - new_text, - start_line: start.get("line")?.as_u64()? as u32, - start_character: start.get("character")?.as_u64()? as u32, - end_line: end.get("line")?.as_u64()? as u32, - end_character: end.get("character")?.as_u64()? as u32, - }) - }).collect(); - if text_edits.is_empty() { None } else { Some(crate::lsp_client::LspFileEdit { path, edits: text_edits }) } - }).collect() + changes_map + .iter() + .filter_map(|(uri, edits)| { + let path = uri_to_path(uri); + let edit_list = edits.as_array()?; + let text_edits: Vec = edit_list + .iter() + .filter_map(|e| { + let new_text = e.get("newText")?.as_str()?.to_owned(); + let range = e.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + Some(crate::lsp_client::LspTextEdit { + new_text, + start_line: start.get("line")?.as_u64()? as u32, + start_character: start.get("character")?.as_u64()? as u32, + end_line: end.get("line")?.as_u64()? as u32, + end_character: end.get("character")?.as_u64()? as u32, + }) + }) + .collect(); + if text_edits.is_empty() { + None + } else { + Some(crate::lsp_client::LspFileEdit { + path, + edits: text_edits, + }) + } + }) + .collect() } else { Vec::new() }; - if changes.is_empty() { None } else { Some(crate::lsp_client::LspWorkspaceEdit { changes }) } + if changes.is_empty() { + None + } else { + Some(crate::lsp_client::LspWorkspaceEdit { changes }) + } } pub(super) fn parse_command(value: &JsonValue) -> Option { let title = value.get("title")?.as_str()?.to_owned(); let command = value.get("command")?.as_str()?.to_owned(); - let arguments = value.get("arguments") + let arguments = value + .get("arguments") .and_then(JsonValue::as_array) .cloned() .unwrap_or_default(); - Some(crate::lsp_client::LspCommand { title, command, arguments }) + Some(crate::lsp_client::LspCommand { + title, + command, + arguments, + }) } #[allow(clippy::cast_possible_truncation)] -pub(super) fn parse_signature_help(value: &JsonValue) -> Option { +pub(super) fn parse_signature_help( + value: &JsonValue, +) -> Option { let signatures_arr = value.get("signatures")?.as_array()?; - let signatures: Vec = signatures_arr.iter().filter_map(|sig| { - let label = sig.get("label")?.as_str()?.to_owned(); - let documentation = sig.get("documentation") - .and_then(|d| d.get("value").and_then(JsonValue::as_str).or_else(|| d.as_str())) - .map(str::to_owned); - let parameters = sig.get("parameters").and_then(JsonValue::as_array) - .map(|arr| arr.iter().map(|p| { - let plabel = p.get("label").and_then(|l| l.as_str().or_else(|| l.get("value").and_then(JsonValue::as_str))).unwrap_or("").to_owned(); - let pdoc = p.get("documentation") - .and_then(|d| d.get("value").and_then(JsonValue::as_str).or_else(|| d.as_str())) - .map(str::to_owned); - crate::lsp_client::LspParameterInfo { label: plabel, documentation: pdoc } - }).collect()) - .unwrap_or_default(); - let active_parameter = sig.get("activeParameter").and_then(JsonValue::as_u64).map(|v| v as u32); - Some(crate::lsp_client::LspSignatureInformation { label, documentation, parameters, active_parameter }) - }).collect(); - let active_signature = value.get("activeSignature").and_then(JsonValue::as_u64).map(|v| v as u32); - let active_parameter = value.get("activeParameter").and_then(JsonValue::as_u64).map(|v| v as u32); - Some(crate::lsp_client::LspSignatureHelpResult { signatures, active_signature, active_parameter }) + let signatures: Vec = signatures_arr + .iter() + .filter_map(|sig| { + let label = sig.get("label")?.as_str()?.to_owned(); + let documentation = sig + .get("documentation") + .and_then(|d| { + d.get("value") + .and_then(JsonValue::as_str) + .or_else(|| d.as_str()) + }) + .map(str::to_owned); + let parameters = sig + .get("parameters") + .and_then(JsonValue::as_array) + .map(|arr| { + arr.iter() + .map(|p| { + let plabel = p + .get("label") + .and_then(|l| { + l.as_str() + .or_else(|| l.get("value").and_then(JsonValue::as_str)) + }) + .unwrap_or("") + .to_owned(); + let pdoc = p + .get("documentation") + .and_then(|d| { + d.get("value") + .and_then(JsonValue::as_str) + .or_else(|| d.as_str()) + }) + .map(str::to_owned); + crate::lsp_client::LspParameterInfo { + label: plabel, + documentation: pdoc, + } + }) + .collect() + }) + .unwrap_or_default(); + let active_parameter = sig + .get("activeParameter") + .and_then(JsonValue::as_u64) + .map(|v| v as u32); + Some(crate::lsp_client::LspSignatureInformation { + label, + documentation, + parameters, + active_parameter, + }) + }) + .collect(); + let active_signature = value + .get("activeSignature") + .and_then(JsonValue::as_u64) + .map(|v| v as u32); + let active_parameter = value + .get("activeParameter") + .and_then(JsonValue::as_u64) + .map(|v| v as u32); + Some(crate::lsp_client::LspSignatureHelpResult { + signatures, + active_signature, + active_parameter, + }) } #[allow(clippy::cast_possible_truncation)] @@ -393,44 +480,67 @@ pub(super) fn parse_code_lens(value: &JsonValue) -> Vec Vec { let Some(items) = value.as_array() else { return Vec::new(); }; - items.iter().filter_map(|item| { - let name = item.get("name")?.as_str()?.to_owned(); - let kind = item.get("kind").and_then(JsonValue::as_u64).map_or_else(|| "Unknown".into(), symbol_kind_name); - let path = item.get("location") - .and_then(|l| l.get("uri")) - .and_then(JsonValue::as_str) - .map(uri_to_path) - .or_else(|| item.get("uri").and_then(JsonValue::as_str).map(uri_to_path)) - .unwrap_or_default(); - let line = item.get("location") - .and_then(|l| l.get("range")) - .and_then(|r| r.get("start")) - .and_then(|s| s.get("line")) - .and_then(JsonValue::as_u64) - .map_or(0, |v| v as u32); - let character = item.get("location") - .and_then(|l| l.get("range")) - .and_then(|r| r.get("start")) - .and_then(|s| s.get("character")) - .and_then(JsonValue::as_u64) - .map_or(0, |v| v as u32); - Some(crate::lsp_client::LspSymbol { name, kind, path, line, character }) - }).collect() + items + .iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_owned(); + let kind = item + .get("kind") + .and_then(JsonValue::as_u64) + .map_or_else(|| "Unknown".into(), symbol_kind_name); + let path = item + .get("location") + .and_then(|l| l.get("uri")) + .and_then(JsonValue::as_str) + .map(uri_to_path) + .or_else(|| item.get("uri").and_then(JsonValue::as_str).map(uri_to_path)) + .unwrap_or_default(); + let line = item + .get("location") + .and_then(|l| l.get("range")) + .and_then(|r| r.get("start")) + .and_then(|s| s.get("line")) + .and_then(JsonValue::as_u64) + .map_or(0, |v| v as u32); + let character = item + .get("location") + .and_then(|l| l.get("range")) + .and_then(|r| r.get("start")) + .and_then(|s| s.get("character")) + .and_then(JsonValue::as_u64) + .map_or(0, |v| v as u32); + Some(crate::lsp_client::LspSymbol { + name, + kind, + path, + line, + character, + }) + }) + .collect() } pub(super) fn rename_params(uri: &str, line: u32, character: u32, new_name: &str) -> JsonValue { diff --git a/rust/crates/runtime/src/lsp_process/tests.rs b/rust/crates/runtime/src/lsp_process/tests.rs index 1d2ab554..f34e6426 100644 --- a/rust/crates/runtime/src/lsp_process/tests.rs +++ b/rust/crates/runtime/src/lsp_process/tests.rs @@ -1,5 +1,5 @@ -use super::*; use super::parse::*; +use super::*; /// Requires rust-analyzer to be installed on the system. /// Run with: cargo test -p runtime -- --ignored @@ -15,7 +15,10 @@ async fn spawn_and_initialize_rust_analyzer() { assert_eq!(process.language(), "rust-analyzer"); let shutdown_result = process.shutdown().await; - assert!(shutdown_result.is_ok(), "shutdown should succeed: {shutdown_result:?}"); + assert!( + shutdown_result.is_ok(), + "shutdown should succeed: {shutdown_result:?}" + ); } /// Requires rust-analyzer to be installed and a Rust project on disk. @@ -33,7 +36,11 @@ async fn hover_on_real_file() { let file_path = root.join("src").join("main.rs"); let path_str = file_path.to_string_lossy(); let result = process.hover(&path_str, 0, 0).await; - assert!(result.is_ok(), "hover should not return an error: {:?}", result.err()); + assert!( + result.is_ok(), + "hover should not return an error: {:?}", + result.err() + ); let _ = process.shutdown().await; } diff --git a/rust/crates/runtime/src/lsp_transport/mod.rs b/rust/crates/runtime/src/lsp_transport/mod.rs index b740f537..438d50b0 100644 --- a/rust/crates/runtime/src/lsp_transport/mod.rs +++ b/rust/crates/runtime/src/lsp_transport/mod.rs @@ -111,7 +111,11 @@ impl std::fmt::Display for LspTransportError { match self { Self::Io(error) => write!(f, "{error}"), Self::Timeout { method, timeout } => { - write!(f, "LSP request `{method}` timed out after {}s", timeout.as_secs()) + write!( + f, + "LSP request `{method}` timed out after {}s", + timeout.as_secs() + ) } Self::JsonRpc(error) => { write!(f, "LSP JSON-RPC error: {} ({})", error.message, error.code) @@ -128,7 +132,10 @@ impl std::error::Error for LspTransportError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), - Self::JsonRpc(_) | Self::Timeout { .. } | Self::InvalidResponse { .. } | Self::ServerExited => None, + Self::JsonRpc(_) + | Self::Timeout { .. } + | Self::InvalidResponse { .. } + | Self::ServerExited => None, } } } @@ -190,10 +197,7 @@ impl LspTransport { /// Primarily useful for testing. #[cfg(test)] fn from_child(mut child: Child, request_timeout: Duration) -> Self { - let stdin = child - .stdin - .take() - .expect("LSP process missing stdin pipe"); + let stdin = child.stdin.take().expect("LSP process missing stdin pipe"); let stdout = child .stdout .take() @@ -279,10 +283,7 @@ impl LspTransport { if response.id != id { return Err(LspTransportError::InvalidResponse { method: method.to_string(), - details: format!( - "mismatched id: expected {:?}, got {:?}", - id, response.id - ), + details: format!("mismatched id: expected {:?}, got {:?}", id, response.id), }); } @@ -347,9 +348,7 @@ impl LspTransport { } pub async fn shutdown(&mut self) -> Result<(), LspTransportError> { - let _ = self - .send_notification("shutdown", None) - .await; + let _ = self.send_notification("shutdown", None).await; let _ = self.send_notification("exit", None).await; @@ -390,33 +389,30 @@ impl LspTransport { let header = line.trim_end_matches(['\r', '\n']); if let Some((name, value)) = header.split_once(':') { if name.trim().eq_ignore_ascii_case("Content-Length") { - let parsed = value - .trim() - .parse::() - .map_err(|error| LspTransportError::Io(io::Error::new( - io::ErrorKind::InvalidData, - error, - )))?; + let parsed = value.trim().parse::().map_err(|error| { + LspTransportError::Io(io::Error::new(io::ErrorKind::InvalidData, error)) + })?; content_length = Some(parsed); } } } - let content_length = content_length.ok_or_else(|| { - LspTransportError::InvalidResponse { - method: "unknown".to_string(), - details: "missing Content-Length header".to_string(), - } + let content_length = content_length.ok_or_else(|| LspTransportError::InvalidResponse { + method: "unknown".to_string(), + details: "missing Content-Length header".to_string(), })?; let mut payload = vec![0u8; content_length]; - self.stdout.read_exact(&mut payload).await.map_err(|error| { - if error.kind() == io::ErrorKind::UnexpectedEof { - LspTransportError::ServerExited - } else { - LspTransportError::Io(error) - } - })?; + self.stdout + .read_exact(&mut payload) + .await + .map_err(|error| { + if error.kind() == io::ErrorKind::UnexpectedEof { + LspTransportError::ServerExited + } else { + LspTransportError::Io(error) + } + })?; Ok(payload) } @@ -429,10 +425,7 @@ impl LspTransport { Self::connect_tcp_with_timeout(address, DEFAULT_REQUEST_TIMEOUT) } - pub fn connect_tcp_with_timeout( - address: &str, - request_timeout: Duration, - ) -> io::Result { + pub fn connect_tcp_with_timeout(address: &str, request_timeout: Duration) -> io::Result { let addr = address.trim_start_matches("tcp://"); // Try socat first (reliable bidirectional bridge) @@ -485,8 +478,5 @@ impl LspTransport { } } - - - #[cfg(test)] mod tests; diff --git a/rust/crates/runtime/src/trident.rs b/rust/crates/runtime/src/trident.rs index 6e5b21f2..f7e488ab 100644 --- a/rust/crates/runtime/src/trident.rs +++ b/rust/crates/runtime/src/trident.rs @@ -190,7 +190,9 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec { - current_spans - .push(Span::styled("│ ", Style::default().fg(self.theme.conversation_dim.to_color()))); - style_stack.push(Style::default().fg(self.theme.conversation_dim.to_color())); + current_spans.push(Span::styled( + "│ ", + Style::default().fg(self.theme.conversation_dim.to_color()), + )); + style_stack + .push(Style::default().fg(self.theme.conversation_dim.to_color())); } Tag::Emphasis => { style_stack.push(Style::default().add_modifier(Modifier::ITALIC)); @@ -152,7 +155,9 @@ impl MarkdownRenderer { } } Event::Code(code) => { - let style = Style::default().fg(self.theme.conversation_system.to_color()).bg(self.theme.code_bg.to_color()); + let style = Style::default() + .fg(self.theme.conversation_system.to_color()) + .bg(self.theme.code_bg.to_color()); current_spans.push(Span::styled(format!(" {code} "), style)); } Event::SoftBreak | Event::HardBreak => { @@ -253,7 +258,10 @@ pub fn looks_like_markdown(text: &str) -> bool { let has_inline_code = text.matches('`').count() >= 2; let multi_line = lines.len() > 3; - has_code_block || (has_header && multi_line) || (has_list && multi_line) || (has_bold && has_inline_code) + has_code_block + || (has_header && multi_line) + || (has_list && multi_line) + || (has_bold && has_inline_code) } /// Render a unified diff with color-coded lines. @@ -310,9 +318,15 @@ mod tests { // At minimum: top border + at least 1 code line + bottom border assert!(lines.len() >= 3); // Should contain code border characters - let text: String = lines.iter().map(|l| { - l.spans.iter().map(|s| s.content.as_ref()).collect::() - }).collect(); + let text: String = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect(); assert!(text.contains("╭─") && text.contains("╰─")); } @@ -321,9 +335,15 @@ mod tests { let r = MarkdownRenderer::new(test_theme()); let lines = r.render("```python\ndef hello():\n pass\n```", 80); assert!(lines.len() >= 3); - let text: String = lines.iter().map(|l| { - l.spans.iter().map(|s| s.content.as_ref()).collect::() - }).collect(); + let text: String = lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect(); assert!(text.contains("python")); } @@ -407,9 +427,7 @@ mod tests { #[test] fn test_markdown_detection_list() { - assert!(looks_like_markdown( - "Items:\n\n- one\n- two\n- three" - )); + assert!(looks_like_markdown("Items:\n\n- one\n- two\n- three")); } #[test] @@ -430,28 +448,43 @@ mod tests { fn test_diff_additions_green() { let theme = test_theme(); let lines = render_diff("+new line", &theme); - assert_eq!(lines[0].spans[0].style.fg, Some(theme.agent_done.to_color())); + assert_eq!( + lines[0].spans[0].style.fg, + Some(theme.agent_done.to_color()) + ); } #[test] fn test_diff_deletions_red() { let theme = test_theme(); let lines = render_diff("-old line", &theme); - assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_error.to_color())); + assert_eq!( + lines[0].spans[0].style.fg, + Some(theme.conversation_error.to_color()) + ); } #[test] fn test_diff_hunk_header() { let theme = test_theme(); let lines = render_diff("@@ -1,3 +1,4 @@", &theme); - assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_user.to_color())); + assert_eq!( + lines[0].spans[0].style.fg, + Some(theme.conversation_user.to_color()) + ); } #[test] fn test_diff_file_headers() { let theme = test_theme(); let lines = render_diff("--- a/file.rs\n+++ b/file.rs", &theme); - assert_eq!(lines[0].spans[0].style.fg, Some(theme.conversation_text.to_color())); - assert_eq!(lines[1].spans[0].style.fg, Some(theme.conversation_text.to_color())); + assert_eq!( + lines[0].spans[0].style.fg, + Some(theme.conversation_text.to_color()) + ); + assert_eq!( + lines[1].spans[0].style.fg, + Some(theme.conversation_text.to_color()) + ); } } diff --git a/rust/crates/rusty-claude-cli/src/theme.rs b/rust/crates/rusty-claude-cli/src/theme.rs index 7720cb9a..3d0eb4ce 100644 --- a/rust/crates/rusty-claude-cli/src/theme.rs +++ b/rust/crates/rusty-claude-cli/src/theme.rs @@ -88,8 +88,12 @@ impl ColorDef { } } -fn named(s: &str) -> ColorDef { ColorDef::Named(s.to_string()) } -fn rgb(r: u8, g: u8, b: u8) -> ColorDef { ColorDef::Rgb { r, g, b } } +fn named(s: &str) -> ColorDef { + ColorDef::Named(s.to_string()) +} +fn rgb(r: u8, g: u8, b: u8) -> ColorDef { + ColorDef::Rgb { r, g, b } +} impl TuiTheme { pub fn builtin(name: &str) -> Option { @@ -111,9 +115,17 @@ impl TuiTheme { pub fn all_builtin_names() -> Vec<&'static str> { vec![ - "default", "tokyonight", "catppuccin-mocha", "catppuccin-latte", - "nord", "gruvbox", "dracula", "solarized-dark", "solarized-light", - "monokai", "system", + "default", + "tokyonight", + "catppuccin-mocha", + "catppuccin-latte", + "nord", + "gruvbox", + "dracula", + "solarized-dark", + "solarized-light", + "monokai", + "system", ] } @@ -626,7 +638,10 @@ mod tests { #[test] fn test_color_def_rgb() { - assert_eq!(ColorDef::Rgb { r: 255, g: 0, b: 0 }.to_color(), Color::Rgb(255, 0, 0)); + assert_eq!( + ColorDef::Rgb { r: 255, g: 0, b: 0 }.to_color(), + Color::Rgb(255, 0, 0) + ); } #[test] diff --git a/rust/crates/rusty-claude-cli/src/tui/components/agent_view.rs b/rust/crates/rusty-claude-cli/src/tui/components/agent_view.rs index 75900020..c0c847e5 100644 --- a/rust/crates/rusty-claude-cli/src/tui/components/agent_view.rs +++ b/rust/crates/rusty-claude-cli/src/tui/components/agent_view.rs @@ -9,11 +9,11 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::Frame; +use crate::agent_view::{AgentSession, AgentStatus, AgentView, FilterState, SortField}; use crate::keybindings::KeyMap; use crate::theme::TuiTheme; use crate::tui::component::{Component, Overlay}; use crate::tui::event::TuiEvent; -use crate::agent_view::{AgentSession, AgentStatus, AgentView, FilterState, SortField}; /// Agent view overlay — wraps the existing AgentView. pub struct AgentViewOverlay { @@ -37,7 +37,9 @@ impl AgentViewOverlay { impl Component for AgentViewOverlay { fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) { - if !self.view.active { return; } + if !self.view.active { + return; + } let chunks = Layout::default() .direction(Direction::Vertical) @@ -52,62 +54,155 @@ impl Component for AgentViewOverlay { let filter_label = format!("Filter: {:?}", self.view.filter); let sort_label = format!("Sort: {:?}", self.view.sort_by); let header = Paragraph::new(Line::from(vec![ - Span::styled(" Agent View ", Style::default().fg(theme.dashboard_header.to_color()).add_modifier(Modifier::BOLD)), - Span::styled(format!(" [{filter_label}] [{sort_label}] Tab:filter S:sort Esc:close"), Style::default().fg(theme.key_hint.to_color())), + Span::styled( + " Agent View ", + Style::default() + .fg(theme.dashboard_header.to_color()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" [{filter_label}] [{sort_label}] Tab:filter S:sort Esc:close"), + Style::default().fg(theme.key_hint.to_color()), + ), ])) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(theme.border_active.to_color()))); + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_active.to_color())), + ); frame.render_widget(header, chunks[0]); // Session list let sessions: Vec<&AgentSession> = self.view.filtered_sessions(); let selected = self.view.selected; - let items: Vec = sessions.iter().enumerate().map(|(i, s)| { - let status_color = match s.status { - AgentStatus::Running => theme.agent_running.to_color(), - AgentStatus::WaitingForInput => theme.agent_waiting.to_color(), - AgentStatus::Done => theme.agent_done.to_color(), - AgentStatus::Failed => theme.agent_failed.to_color(), - AgentStatus::Cancelled => theme.agent_cancelled.to_color(), - }; - let elapsed = s.started_at.elapsed().as_secs(); - let elapsed_str = if elapsed < 60 { format!("{elapsed}s") } else { format!("{}m{}s", elapsed / 60, elapsed % 60) }; - let line = Line::from(vec![ - Span::styled(format!(" {} ", s.status.icon()), Style::default().fg(status_color)), - Span::styled(format!("{:<20}", s.name), Style::default().fg(theme.conversation_text.to_color())), - Span::styled(format!("{:<16}", s.model), Style::default().fg(theme.dashboard_key.to_color())), - Span::styled(format!("{} turns ", s.turn_count), Style::default().fg(theme.dashboard_value.to_color())), - Span::styled(elapsed_str, Style::default().fg(theme.key_hint.to_color())), - ]); - let style = if i == selected { Style::default().bg(theme.completion_selected_bg.to_color()) } else { Style::default() }; - ListItem::new(line).style(style) - }).collect(); + let items: Vec = sessions + .iter() + .enumerate() + .map(|(i, s)| { + let status_color = match s.status { + AgentStatus::Running => theme.agent_running.to_color(), + AgentStatus::WaitingForInput => theme.agent_waiting.to_color(), + AgentStatus::Done => theme.agent_done.to_color(), + AgentStatus::Failed => theme.agent_failed.to_color(), + AgentStatus::Cancelled => theme.agent_cancelled.to_color(), + }; + let elapsed = s.started_at.elapsed().as_secs(); + let elapsed_str = if elapsed < 60 { + format!("{elapsed}s") + } else { + format!("{}m{}s", elapsed / 60, elapsed % 60) + }; + let line = Line::from(vec![ + Span::styled( + format!(" {} ", s.status.icon()), + Style::default().fg(status_color), + ), + Span::styled( + format!("{:<20}", s.name), + Style::default().fg(theme.conversation_text.to_color()), + ), + Span::styled( + format!("{:<16}", s.model), + Style::default().fg(theme.dashboard_key.to_color()), + ), + Span::styled( + format!("{} turns ", s.turn_count), + Style::default().fg(theme.dashboard_value.to_color()), + ), + Span::styled(elapsed_str, Style::default().fg(theme.key_hint.to_color())), + ]); + let style = if i == selected { + Style::default().bg(theme.completion_selected_bg.to_color()) + } else { + Style::default() + }; + ListItem::new(line).style(style) + }) + .collect(); let list = List::new(items).block( - Block::default().borders(Borders::ALL).border_style(Style::default().fg(theme.border.to_color())).title(" Sessions "), + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border.to_color())) + .title(" Sessions "), ); frame.render_widget(list, chunks[1]); // Detail if let Some(session) = sessions.get(selected) { let detail = Paragraph::new(vec![ - Line::from(vec![Span::styled(" ID: ", Style::default().fg(theme.dashboard_key.to_color())), Span::styled(&session.id, Style::default().fg(theme.dashboard_value.to_color()))]), - Line::from(vec![Span::styled(" Task: ", Style::default().fg(theme.dashboard_key.to_color())), Span::styled(session.task_subject.as_deref().unwrap_or("(none)"), Style::default().fg(theme.dashboard_value.to_color()))]), - Line::from(vec![Span::styled(" Dir: ", Style::default().fg(theme.dashboard_key.to_color())), Span::styled(&session.working_dir, Style::default().fg(theme.dashboard_value.to_color()))]), + Line::from(vec![ + Span::styled( + " ID: ", + Style::default().fg(theme.dashboard_key.to_color()), + ), + Span::styled( + &session.id, + Style::default().fg(theme.dashboard_value.to_color()), + ), + ]), + Line::from(vec![ + Span::styled( + " Task: ", + Style::default().fg(theme.dashboard_key.to_color()), + ), + Span::styled( + session.task_subject.as_deref().unwrap_or("(none)"), + Style::default().fg(theme.dashboard_value.to_color()), + ), + ]), + Line::from(vec![ + Span::styled( + " Dir: ", + Style::default().fg(theme.dashboard_key.to_color()), + ), + Span::styled( + &session.working_dir, + Style::default().fg(theme.dashboard_value.to_color()), + ), + ]), ]) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(theme.border.to_color())).title(" Details ")) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border.to_color())) + .title(" Details "), + ) .wrap(Wrap { trim: false }); frame.render_widget(detail, chunks[2]); } } fn handle_key(&mut self, key: KeyEvent, _keymap: &KeyMap) -> bool { - if !self.view.active { return false; } + if !self.view.active { + return false; + } match key.code { - KeyCode::Esc | KeyCode::Char('q') => { self.view.close(); self.dirty = true; true } - KeyCode::Tab => { self.view.cycle_filter(); self.dirty = true; true } - KeyCode::Char('s') => { self.view.cycle_sort(); self.dirty = true; true } - KeyCode::Down | KeyCode::Char('j') => { self.view.select_next(); self.dirty = true; true } - KeyCode::Up | KeyCode::Char('k') => { self.view.select_prev(); self.dirty = true; true } + KeyCode::Esc | KeyCode::Char('q') => { + self.view.close(); + self.dirty = true; + true + } + KeyCode::Tab => { + self.view.cycle_filter(); + self.dirty = true; + true + } + KeyCode::Char('s') => { + self.view.cycle_sort(); + self.dirty = true; + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.view.select_next(); + self.dirty = true; + true + } + KeyCode::Up | KeyCode::Char('k') => { + self.view.select_prev(); + self.dirty = true; + true + } _ => true, // Consume all keys when active } } diff --git a/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs b/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs index 2e8deaed..d94b1a02 100644 --- a/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs +++ b/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs @@ -18,8 +18,8 @@ use crate::theme::TuiTheme; use crate::tui::component::Component; use crate::tui::event::TuiEvent; use crate::tui::legacy::{ConversationContent, ConversationLine}; -use crate::tui::markdown::render_diff; use crate::tui::markdown::ratatui_renderer::MarkdownRenderer; +use crate::tui::markdown::render_diff; const MAX_CONVERSATION_LINES: usize = 10_000; @@ -71,20 +71,23 @@ impl ConversationPane { // ------------------------------------------------------------------- pub fn push_banner(&mut self, text: String, color: Color) { - self.conversation.push(ConversationLine::plain(text, color, true)); + self.conversation + .push(ConversationLine::plain(text, color, true)); self.auto_scroll(); } pub fn push_user_input(&mut self, text: &str, color: Color) { for raw_line in text.lines() { - self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, true)); + self.conversation + .push(ConversationLine::plain(raw_line.to_string(), color, true)); } self.auto_scroll(); } pub fn push_system_message(&mut self, text: &str, color: Color) { for raw_line in text.lines() { - self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false)); + self.conversation + .push(ConversationLine::plain(raw_line.to_string(), color, false)); } self.auto_scroll(); } @@ -97,21 +100,24 @@ impl ConversationPane { if is_error { let color = theme.conversation_error.to_color(); for raw_line in clean.lines() { - self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false)); + self.conversation + .push(ConversationLine::plain(raw_line.to_string(), color, false)); } } else if crate::tui::markdown::looks_like_markdown(&clean) { self.conversation.push(ConversationLine::markdown(clean)); } else { let color = theme.conversation_text.to_color(); for raw_line in clean.lines() { - self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false)); + self.conversation + .push(ConversationLine::plain(raw_line.to_string(), color, false)); } } self.auto_scroll(); } pub fn push_diff(&mut self, diff: &str) { - self.conversation.push(ConversationLine::diff(diff.to_string())); + self.conversation + .push(ConversationLine::diff(diff.to_string())); self.auto_scroll(); } @@ -157,12 +163,8 @@ impl ConversationPane { return; } let content_width = (width as usize).saturating_sub(2); - let (wrapped, expand_counts) = build_wrapped_conversation( - &self.conversation, - content_width, - &self.renderer, - theme, - ); + let (wrapped, expand_counts) = + build_wrapped_conversation(&self.conversation, content_width, &self.renderer, theme); cache.wrapped_lines = wrapped; cache.expand_counts = expand_counts; cache.built_width = width; @@ -189,7 +191,13 @@ impl Component for ConversationPane { let max_offset = total_visual.saturating_sub(pane_rows); let offset = scroll.min(max_offset); let start = total_visual.saturating_sub(pane_rows + offset); - let visible: Vec = cache.wrapped_lines.iter().skip(start).take(pane_rows).cloned().collect(); + let visible: Vec = cache + .wrapped_lines + .iter() + .skip(start) + .take(pane_rows) + .cloned() + .collect(); let conversation_widget = Paragraph::new(visible).block( Block::default() @@ -211,7 +219,10 @@ impl Component for ConversationPane { height: 1, }; frame.render_widget( - Paragraph::new(Span::styled(scroll_label, Style::default().fg(theme.conversation_dim.to_color()))), + Paragraph::new(Span::styled( + scroll_label, + Style::default().fg(theme.conversation_dim.to_color()), + )), scroll_area, ); } @@ -250,7 +261,10 @@ fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec> { current_line = rest; last_break_byte = 0; } else { - result.push(Line::from(Span::styled(current_line.clone(), style.clone()))); + result.push(Line::from(Span::styled( + current_line.clone(), + style.clone(), + ))); current_line.clear(); current_width = 0; last_break_byte = 0; @@ -284,7 +298,9 @@ fn build_wrapped_conversation<'a>( match &cl.content { ConversationContent::Plain { text, color, bold } => { let mut style = Style::default().fg(*color); - if *bold { style = style.add_modifier(Modifier::BOLD); } + if *bold { + style = style.add_modifier(Modifier::BOLD); + } let wrapped = wrap_line(text, content_width, style); let count = wrapped.len().max(1); expand_counts.push(count); @@ -295,9 +311,12 @@ fn build_wrapped_conversation<'a>( let count = rendered.len().max(1); expand_counts.push(count); all_lines.extend(rendered.into_iter().map(|l: Line<'static>| { - Line::from(l.spans.into_iter().map(|s| { - Span::styled(s.content.into_owned(), s.style) - }).collect::>()) + Line::from( + l.spans + .into_iter() + .map(|s| Span::styled(s.content.into_owned(), s.style)) + .collect::>(), + ) })); } ConversationContent::CodeDiff { diff } => { @@ -305,9 +324,12 @@ fn build_wrapped_conversation<'a>( let count = rendered.len().max(1); expand_counts.push(count); all_lines.extend(rendered.into_iter().map(|l: Line<'static>| { - Line::from(l.spans.into_iter().map(|s| { - Span::styled(s.content.into_owned(), s.style) - }).collect::>()) + Line::from( + l.spans + .into_iter() + .map(|s| Span::styled(s.content.into_owned(), s.style)) + .collect::>(), + ) })); } } @@ -392,15 +414,23 @@ mod tests { let lines = wrap_line(&long_str, width, Style::default()); for line in &lines { - let line_width: usize = line.spans.iter() + let line_width: usize = line + .spans + .iter() .map(|span| { - span.content.chars().map(|c| c.width().unwrap_or(0)).sum::() + span.content + .chars() + .map(|c| c.width().unwrap_or(0)) + .sum::() }) .sum(); assert!( line_width <= width, "Wrapped line width {line_width} exceeds target {width}: {:?}", - line.spans.iter().map(|s| s.content.clone()).collect::>() + line.spans + .iter() + .map(|s| s.content.clone()) + .collect::>() ); } } @@ -413,7 +443,10 @@ mod tests { // With width=1, each char gets its own line let lines = wrap_line("ab", 1, Style::default()); - assert!(lines.len() >= 2, "2 chars at width=1 should produce >= 2 lines"); + assert!( + lines.len() >= 2, + "2 chars at width=1 should produce >= 2 lines" + ); } /// Regression: cache is invalidated when width changes. @@ -422,7 +455,10 @@ mod tests { fn test_cache_invalidated_on_width_change() { let theme = test_theme(); let mut pane = ConversationPane::new(theme.clone()); - pane.push_user_input("Hello world this is a test", theme.conversation_user.to_color()); + pane.push_user_input( + "Hello world this is a test", + theme.conversation_user.to_color(), + ); // Build at width 80 pane.rebuild_cache(80, &theme); diff --git a/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs b/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs index ecae6e57..2b2fa218 100644 --- a/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs +++ b/rust/crates/rusty-claude-cli/src/tui/components/dashboard.rs @@ -26,7 +26,11 @@ pub struct Dashboard { impl Dashboard { pub fn new(state: SharedDashboardState) -> Self { - Self { state, spinner_frame: 0, dirty: true } + Self { + state, + spinner_frame: 0, + dirty: true, + } } pub fn tick_spinner(&mut self) { @@ -55,10 +59,30 @@ impl Component for Dashboard { // Connection lines.push(section("Connection", theme)); - lines.push(kv("Model", &state.model, theme.dashboard_value.to_color(), theme)); - lines.push(kv("Provider", &state.provider, theme.dashboard_key.to_color(), theme)); - lines.push(kv("URL", &state.provider_url, theme.conversation_dim.to_color(), theme)); - lines.push(kv("Mode", &state.permission_mode, theme.conversation_system.to_color(), theme)); + lines.push(kv( + "Model", + &state.model, + theme.dashboard_value.to_color(), + theme, + )); + lines.push(kv( + "Provider", + &state.provider, + theme.dashboard_key.to_color(), + theme, + )); + lines.push(kv( + "URL", + &state.provider_url, + theme.conversation_dim.to_color(), + theme, + )); + lines.push(kv( + "Mode", + &state.permission_mode, + theme.conversation_system.to_color(), + theme, + )); if let Some(ref branch) = state.git_branch { lines.push(kv("Branch", branch, theme.agent_done.to_color(), theme)); } @@ -66,24 +90,68 @@ impl Component for Dashboard { // Tokens lines.push(section("Tokens", theme)); - lines.push(kv("Turns", &state.turn_count.to_string(), theme.dashboard_value.to_color(), theme)); - lines.push(kv("Input", &state.input_tokens.to_string(), theme.dashboard_value.to_color(), theme)); - lines.push(kv("Output", &state.output_tokens.to_string(), theme.dashboard_value.to_color(), theme)); - lines.push(kv("Cache R", &state.cache_read_tokens.to_string(), theme.dashboard_key.to_color(), theme)); - lines.push(kv("Cache W", &state.cache_creation_tokens.to_string(), theme.dashboard_key.to_color(), theme)); - lines.push(kv("Cost", &format!("${:.4}", state.cost_usd), theme.conversation_system.to_color(), theme)); + lines.push(kv( + "Turns", + &state.turn_count.to_string(), + theme.dashboard_value.to_color(), + theme, + )); + lines.push(kv( + "Input", + &state.input_tokens.to_string(), + theme.dashboard_value.to_color(), + theme, + )); + lines.push(kv( + "Output", + &state.output_tokens.to_string(), + theme.dashboard_value.to_color(), + theme, + )); + lines.push(kv( + "Cache R", + &state.cache_read_tokens.to_string(), + theme.dashboard_key.to_color(), + theme, + )); + lines.push(kv( + "Cache W", + &state.cache_creation_tokens.to_string(), + theme.dashboard_key.to_color(), + theme, + )); + lines.push(kv( + "Cost", + &format!("${:.4}", state.cost_usd), + theme.conversation_system.to_color(), + theme, + )); lines.push(Line::from("")); // Context let pct = state.context_percent; - let _gauge_color = if pct > 80.0 { theme.gauge_fill_red.to_color() } - else if pct > 50.0 { theme.gauge_fill_yellow.to_color() } - else { theme.gauge_fill_green.to_color() }; + let _gauge_color = if pct > 80.0 { + theme.gauge_fill_red.to_color() + } else if pct > 50.0 { + theme.gauge_fill_yellow.to_color() + } else { + theme.gauge_fill_green.to_color() + }; lines.push(section("Context", theme)); - lines.push(kv("Used", &format!("{:.1}% of {}", pct, state.context_window), theme.dashboard_value.to_color(), theme)); + lines.push(kv( + "Used", + &format!("{:.1}% of {}", pct, state.context_window), + theme.dashboard_value.to_color(), + theme, + )); gauge_row = Some(lines.len()); lines.push(Line::from("")); - lines.push(kv("Compactions", &state.compaction_count.to_string(), theme.dashboard_key.to_color(), theme)); + lines.push(kv( + "Compactions", + &state.compaction_count.to_string(), + theme.dashboard_key.to_color(), + theme, + )); lines.push(Line::from("")); // LSP @@ -103,9 +171,16 @@ impl Component for Dashboard { // Team if let Some(ref team) = state.team { lines.push(section("Team", theme)); - lines.push(kv("Name", &team.team_name, theme.dashboard_value.to_color(), theme)); - let progress = format!("{}/{} done, {} fail, {} run", - team.completed_agents, team.total_agents, team.failed_agents, team.running_agents); + lines.push(kv( + "Name", + &team.team_name, + theme.dashboard_value.to_color(), + theme, + )); + let progress = format!( + "{}/{} done, {} fail, {} run", + team.completed_agents, team.total_agents, team.failed_agents, team.running_agents + ); lines.push(kv("Status", &progress, theme.agent_done.to_color(), theme)); for agent in &team.agents { let c = match agent.status.as_str() { @@ -116,7 +191,10 @@ impl Component for Dashboard { let label = format!("● {}", agent.name); let detail = format!("({})", agent.subagent_type.as_deref().unwrap_or("?")); lines.push(Line::from(vec![ - Span::styled(format!(" {: 80.0 { theme.gauge_fill_red.to_color() } - else if pct > 50.0 { theme.gauge_fill_yellow.to_color() } - else { theme.gauge_fill_green.to_color() }; + let gauge_fill = if pct > 80.0 { + theme.gauge_fill_red.to_color() + } else if pct > 50.0 { + theme.gauge_fill_yellow.to_color() + } else { + theme.gauge_fill_green.to_color() + }; let gauge = Gauge::default() - .gauge_style(Style::default().fg(gauge_fill).bg(theme.gauge_bg.to_color())) - .ratio(if pct > 0.0 { (pct / 100.0).min(1.0) } else { 0.0 }); + .gauge_style( + Style::default() + .fg(gauge_fill) + .bg(theme.gauge_bg.to_color()), + ) + .ratio(if pct > 0.0 { + (pct / 100.0).min(1.0) + } else { + 0.0 + }); frame.render_widget(gauge, gauge_area); } } @@ -182,13 +292,18 @@ impl Component for Dashboard { fn section<'a>(label: &str, theme: &TuiTheme) -> Line<'a> { Line::from(Span::styled( format!("─ {label} ─"), - Style::default().fg(theme.dashboard_header.to_color()).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.dashboard_header.to_color()) + .add_modifier(Modifier::BOLD), )) } fn kv<'a>(key: &str, val: &str, val_color: ratatui::style::Color, theme: &TuiTheme) -> Line<'a> { Line::from(vec![ - Span::styled(format!(" {: Self { - Self { state, spinner_frame: 0, dirty: true } + Self { + state, + spinner_frame: 0, + dirty: true, + } } pub fn tick_spinner(&mut self) { @@ -43,9 +47,15 @@ impl Component for StatusBar { let state = self.state.read().unwrap_or_else(|e| e.into_inner()); let mut spans = vec![ Span::styled(" ", Style::default()), - Span::styled(state.model.clone(), Style::default().fg(theme.dashboard_value.to_color())), + Span::styled( + state.model.clone(), + Style::default().fg(theme.dashboard_value.to_color()), + ), Span::styled(" ", Style::default()), - Span::styled(state.provider.clone(), Style::default().fg(theme.dashboard_key.to_color())), + Span::styled( + state.provider.clone(), + Style::default().fg(theme.dashboard_key.to_color()), + ), ]; if state.turn_count > 0 { @@ -66,7 +76,9 @@ impl Component for StatusBar { let frame = SPINNER_FRAMES[self.spinner_frame]; spans.push(Span::styled( format!(" {frame} {}", state.status_message), - Style::default().fg(theme.spinner.to_color()).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.spinner.to_color()) + .add_modifier(Modifier::BOLD), )); } diff --git a/rust/crates/rusty-claude-cli/src/tui/event.rs b/rust/crates/rusty-claude-cli/src/tui/event.rs index 429ce83a..faebe776 100644 --- a/rust/crates/rusty-claude-cli/src/tui/event.rs +++ b/rust/crates/rusty-claude-cli/src/tui/event.rs @@ -4,25 +4,39 @@ //! loop drains the channel non-blocking each tick. Components process //! events during the update phase before rendering. -use crate::tui::legacy::DashboardState; use crate::agent_view::AgentSession; -use crate::theme::TuiTheme; -use crate::keybindings::KeyPreset; use crate::chat_mode::ChatMode; +use crate::keybindings::KeyPreset; +use crate::theme::TuiTheme; +use crate::tui::legacy::DashboardState; /// Events that flow through the TUI event bus. #[derive(Debug, Clone)] pub enum TuiEvent { // --- Streaming events (Phase 4) --- - StreamTextDelta { text: String }, - StreamThinking { thinking: String }, - StreamToolUse { id: String, name: String }, - StreamUsage { input_tokens: u32, output_tokens: u32 }, + StreamTextDelta { + text: String, + }, + StreamThinking { + thinking: String, + }, + StreamToolUse { + id: String, + name: String, + }, + StreamUsage { + input_tokens: u32, + output_tokens: u32, + }, StreamMessageStop, // --- Turn lifecycle --- - TurnComplete { assistant_text: String }, - TurnError { error: String }, + TurnComplete { + assistant_text: String, + }, + TurnError { + error: String, + }, TurnStarted, // --- Dashboard --- @@ -30,7 +44,9 @@ pub enum TuiEvent { // --- Agent lifecycle --- AgentSessionUpdate(AgentSession), - AgentSessionRemove { id: String }, + AgentSessionRemove { + id: String, + }, // --- UI state changes --- ThemeChanged(TuiTheme), @@ -38,7 +54,10 @@ pub enum TuiEvent { ChatModeChanged(ChatMode), // --- System --- - Resize { width: u16, height: u16 }, + Resize { + width: u16, + height: u16, + }, } /// The central event channel. @@ -96,7 +115,9 @@ mod tests { fn test_event_bus_send_recv() { let bus = EventBus::new(); bus.send(TuiEvent::TurnStarted); - bus.send(TuiEvent::TurnError { error: "test".into() }); + bus.send(TuiEvent::TurnError { + error: "test".into(), + }); let events = bus.drain(); assert_eq!(events.len(), 2); diff --git a/rust/crates/rusty-claude-cli/src/tui/markdown/ansi_renderer.rs b/rust/crates/rusty-claude-cli/src/tui/markdown/ansi_renderer.rs index 1f66b41e..72b251bc 100644 --- a/rust/crates/rusty-claude-cli/src/tui/markdown/ansi_renderer.rs +++ b/rust/crates/rusty-claude-cli/src/tui/markdown/ansi_renderer.rs @@ -3,8 +3,8 @@ //! Uses the shared `MarkdownAst` to produce ANSI-escaped `String` for the //! plain REPL (non-TUI) mode. This replaces the old `render.rs` TerminalRenderer. -use crate::theme::TuiTheme; use super::parse_markdown; +use crate::theme::TuiTheme; /// ANSI markdown renderer. #[derive(Debug, Default)] diff --git a/rust/crates/rusty-claude-cli/src/tui/markdown/mod.rs b/rust/crates/rusty-claude-cli/src/tui/markdown/mod.rs index 39d26136..eb3d61a1 100644 --- a/rust/crates/rusty-claude-cli/src/tui/markdown/mod.rs +++ b/rust/crates/rusty-claude-cli/src/tui/markdown/mod.rs @@ -12,8 +12,8 @@ //! - `render_diff` — unified diff renderer //! - `strip_ansi` — ANSI escape stripping -pub mod ratatui_renderer; pub mod ansi_renderer; +pub mod ratatui_renderer; pub mod stream; // --------------------------------------------------------------------------- @@ -143,7 +143,11 @@ pub fn parse_markdown(markdown: &str) -> MarkdownAst { code_block_lang = match kind { CodeBlockKind::Fenced(lang) => { let s = lang.to_string(); - if s.is_empty() { None } else { Some(s) } + if s.is_empty() { + None + } else { + Some(s) + } } _ => None, }; @@ -164,7 +168,9 @@ pub fn parse_markdown(markdown: &str) -> MarkdownAst { style_stack.push(SemanticStyle::Strong); } Tag::Link { dest_url, .. } => { - style_stack.push(SemanticStyle::Link { destination: dest_url.to_string() }); + style_stack.push(SemanticStyle::Link { + destination: dest_url.to_string(), + }); } _ => {} }, @@ -178,7 +184,8 @@ pub fn parse_markdown(markdown: &str) -> MarkdownAst { nodes.push(MarkdownNode::BlankLine); } TagEnd::CodeBlock => { - let code_lines = highlight_code(&code_block_content, code_block_lang.as_deref()); + let code_lines = + highlight_code(&code_block_content, code_block_lang.as_deref()); nodes.push(MarkdownNode::CodeBlock { language: code_block_lang.take(), lines: code_lines, @@ -249,14 +256,21 @@ fn highlight_code(code: &str, language: Option<&str>) -> Vec { .into_iter() .map(|(style, text)| CodeSegment { text: text.to_string(), - fg: Some(Rgb { r: style.foreground.r, g: style.foreground.g, b: style.foreground.b }), + fg: Some(Rgb { + r: style.foreground.r, + g: style.foreground.g, + b: style.foreground.b, + }), }) .collect(); lines.push(CodeLine { segments }); } Err(_) => { lines.push(CodeLine { - segments: vec![CodeSegment { text: line.to_string(), fg: None }], + segments: vec![CodeSegment { + text: line.to_string(), + fg: None, + }], }); } } @@ -265,9 +279,9 @@ fn highlight_code(code: &str, language: Option<&str>) -> Vec { lines } +use crate::theme::TuiTheme; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use crate::theme::TuiTheme; // --------------------------------------------------------------------------- // Markdown detection heuristic (shared) @@ -278,11 +292,16 @@ pub fn looks_like_markdown(text: &str) -> bool { let lines: Vec<&str> = text.lines().collect(); let has_header = lines.iter().any(|l| l.starts_with('#')); let has_code_block = text.contains("```"); - let has_list = lines.iter().any(|l| l.starts_with("- ") || l.starts_with("* ")); + let has_list = lines + .iter() + .any(|l| l.starts_with("- ") || l.starts_with("* ")); let has_bold = text.contains("**"); let has_inline_code = text.matches('`').count() >= 2; let multi_line = lines.len() > 3; - has_code_block || (has_header && multi_line) || (has_list && multi_line) || (has_bold && has_inline_code) + has_code_block + || (has_header && multi_line) + || (has_list && multi_line) + || (has_bold && has_inline_code) } // --------------------------------------------------------------------------- @@ -332,56 +351,103 @@ pub fn normalize_nested_fences(markdown: &str) -> String { fn parse_fence_line(line: &str) -> Option { let trimmed = line.trim_end_matches('\n').trim_end_matches('\r'); let indent = trimmed.chars().take_while(|c| *c == ' ').count(); - if indent > 3 { return None; } + if indent > 3 { + return None; + } let rest = &trimmed[indent..]; let ch = rest.chars().next()?; - if ch != '`' && ch != '~' { return None; } + if ch != '`' && ch != '~' { + return None; + } let len = rest.chars().take_while(|c| *c == ch).count(); - if len < 3 { return None; } + if len < 3 { + return None; + } let after = &rest[len..]; - if ch == '`' && after.contains('`') { return None; } + if ch == '`' && after.contains('`') { + return None; + } let has_info = !after.trim().is_empty(); - Some(FenceLine { char: ch, len, has_info, indent }) + Some(FenceLine { + char: ch, + len, + has_info, + indent, + }) } let lines: Vec<&str> = markdown.split_inclusive('\n').collect(); let fence_info: Vec> = lines.iter().map(|l| parse_fence_line(l)).collect(); - struct StackEntry { line_idx: usize, fence: FenceLine } + struct StackEntry { + line_idx: usize, + fence: FenceLine, + } let mut stack: Vec = Vec::new(); let mut pairs: Vec<(usize, usize, usize)> = Vec::new(); for (i, fi) in fence_info.iter().enumerate() { let Some(fl) = fi else { continue }; if fl.has_info { - stack.push(StackEntry { line_idx: i, fence: fl.clone() }); + stack.push(StackEntry { + line_idx: i, + fence: fl.clone(), + }); } else { - let closes_top = stack.last().is_some_and(|top| top.fence.char == fl.char && fl.len >= top.fence.len); + let closes_top = stack + .last() + .is_some_and(|top| top.fence.char == fl.char && fl.len >= top.fence.len); if closes_top { let opener = stack.pop().unwrap(); let inner_max = fence_info[opener.line_idx + 1..i] - .iter().filter_map(|fi| fi.as_ref().map(|f| f.len)).max().unwrap_or(0); + .iter() + .filter_map(|fi| fi.as_ref().map(|f| f.len)) + .max() + .unwrap_or(0); pairs.push((opener.line_idx, i, inner_max)); } else { - stack.push(StackEntry { line_idx: i, fence: fl.clone() }); + stack.push(StackEntry { + line_idx: i, + fence: fl.clone(), + }); } } } - struct Rewrite { char: char, new_len: usize, indent: usize } + struct Rewrite { + char: char, + new_len: usize, + indent: usize, + } let mut rewrites: std::collections::HashMap = std::collections::HashMap::new(); for (opener_idx, closer_idx, inner_max) in &pairs { let opener_fl = fence_info[*opener_idx].as_ref().unwrap(); if opener_fl.len <= *inner_max { let new_len = inner_max + 1; - rewrites.insert(*opener_idx, Rewrite { char: opener_fl.char, new_len, indent: opener_fl.indent }); + rewrites.insert( + *opener_idx, + Rewrite { + char: opener_fl.char, + new_len, + indent: opener_fl.indent, + }, + ); let closer_fl = fence_info[*closer_idx].as_ref().unwrap(); - rewrites.insert(*closer_idx, Rewrite { char: closer_fl.char, new_len, indent: closer_fl.indent }); + rewrites.insert( + *closer_idx, + Rewrite { + char: closer_fl.char, + new_len, + indent: closer_fl.indent, + }, + ); } } - if rewrites.is_empty() { return markdown.to_string(); } + if rewrites.is_empty() { + return markdown.to_string(); + } let mut out = String::with_capacity(markdown.len() + rewrites.len() * 4); for (i, line) in lines.iter().enumerate() { @@ -437,27 +503,42 @@ pub fn find_stream_safe_boundary(markdown: &str) -> Option { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct FenceMarker { character: char, length: usize } +struct FenceMarker { + character: char, + length: usize, +} fn parse_fence_opener(line: &str) -> Option { let indent = line.chars().take_while(|c| *c == ' ').count(); - if indent > 3 { return None; } + if indent > 3 { + return None; + } let rest = &line[indent..]; let character = rest.chars().next()?; - if character != '`' && character != '~' { return None; } + if character != '`' && character != '~' { + return None; + } let length = rest.chars().take_while(|c| *c == character).count(); - if length < 3 { return None; } + if length < 3 { + return None; + } let info_string = &rest[length..]; - if character == '`' && info_string.contains('`') { return None; } + if character == '`' && info_string.contains('`') { + return None; + } Some(FenceMarker { character, length }) } fn line_closes_fence(line: &str, opener: FenceMarker) -> bool { let indent = line.chars().take_while(|c| *c == ' ').count(); - if indent > 3 { return false; } + if indent > 3 { + return false; + } let rest = &line[indent..]; let length = rest.chars().take_while(|c| *c == opener.character).count(); - if length < opener.length { return false; } + if length < opener.length { + return false; + } rest[length..].chars().all(|c| c == ' ' || c == '\t') } @@ -474,24 +555,39 @@ pub fn strip_ansi(text: &str) -> String { match chars.peek() { Some('[') => { chars.next(); - while let Some(&c) = chars.peek() { chars.next(); if ('\x40'..='\x7e').contains(&c) { break; } } + while let Some(&c) = chars.peek() { + chars.next(); + if ('\x40'..='\x7e').contains(&c) { + break; + } + } } Some(']') => { chars.next(); while let Some(&c) = chars.peek() { chars.next(); - if c == '\x07' { break; } - if c == '\x1b' && chars.peek() == Some(&'\\') { chars.next(); break; } + if c == '\x07' { + break; + } + if c == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } } } Some('P') | Some('_') | Some('^') => { chars.next(); while let Some(&c) = chars.peek() { chars.next(); - if c == '\x1b' && chars.peek() == Some(&'\\') { chars.next(); break; } + if c == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } } } - _ => { chars.next(); } + _ => { + chars.next(); + } } } else { result.push(ch); @@ -506,11 +602,15 @@ mod tests { #[test] fn test_looks_like_markdown_code_block() { - assert!(looks_like_markdown("some text\n```rust\ncode\n```\nmore text")); + assert!(looks_like_markdown( + "some text\n```rust\ncode\n```\nmore text" + )); } #[test] fn test_looks_like_markdown_plain() { - assert!(!looks_like_markdown("Just a simple response without any formatting.")); + assert!(!looks_like_markdown( + "Just a simple response without any formatting." + )); } #[test] fn test_strip_ansi_basic() { @@ -529,7 +629,10 @@ mod tests { #[test] fn test_render_diff() { let theme = crate::theme::TuiTheme::builtin("default").unwrap(); - let lines = render_diff("+added\n-removed\n@@ hunk @@\n--- a/file.rs\n+++ b/file.rs", &theme); + let lines = render_diff( + "+added\n-removed\n@@ hunk @@\n--- a/file.rs\n+++ b/file.rs", + &theme, + ); assert_eq!(lines.len(), 5); } } diff --git a/rust/crates/rusty-claude-cli/src/tui/markdown/ratatui_renderer.rs b/rust/crates/rusty-claude-cli/src/tui/markdown/ratatui_renderer.rs index 67c2e187..3e7ddf24 100644 --- a/rust/crates/rusty-claude-cli/src/tui/markdown/ratatui_renderer.rs +++ b/rust/crates/rusty-claude-cli/src/tui/markdown/ratatui_renderer.rs @@ -3,10 +3,10 @@ //! Uses the shared `MarkdownAst` to produce `Vec>` for the //! TUI conversation pane. This replaces the old `markdown.rs` module. +use super::{parse_markdown, CodeLine, MarkdownNode, Rgb, SemanticStyle}; +use crate::theme::TuiTheme; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use crate::theme::TuiTheme; -use super::{parse_markdown, MarkdownNode, SemanticStyle, CodeLine, Rgb}; /// Ratatui markdown renderer. #[derive(Clone)] @@ -39,7 +39,10 @@ impl MarkdownRenderer { for node in &ast.nodes { match node { - MarkdownNode::CodeBlock { language, lines: code_lines } => { + MarkdownNode::CodeBlock { + language, + lines: code_lines, + } => { let lang_label = language.as_deref().unwrap_or("text"); lines.push(Line::from(Span::styled( format!("╭─ {lang_label} ─"), @@ -51,7 +54,9 @@ impl MarkdownRenderer { Style::default().fg(self.theme.code_border.to_color()), )]; for seg in &cl.segments { - let color = seg.fg.map(|rgb| Color::Rgb(rgb.r, rgb.g, rgb.b)) + let color = seg + .fg + .map(|rgb| Color::Rgb(rgb.r, rgb.g, rgb.b)) .unwrap_or(self.theme.code_fg.to_color()); spans.push(Span::styled(seg.text.clone(), Style::default().fg(color))); } diff --git a/rust/crates/rusty-claude-cli/src/tui/markdown/stream.rs b/rust/crates/rusty-claude-cli/src/tui/markdown/stream.rs index c5dd7486..35d09626 100644 --- a/rust/crates/rusty-claude-cli/src/tui/markdown/stream.rs +++ b/rust/crates/rusty-claude-cli/src/tui/markdown/stream.rs @@ -10,7 +10,9 @@ pub struct StreamingMarkdownState { impl StreamingMarkdownState { pub fn new() -> Self { - Self { pending: String::new() } + Self { + pending: String::new(), + } } pub fn push_delta(&mut self, delta: &str) -> bool { @@ -28,7 +30,9 @@ impl StreamingMarkdownState { } impl Default for StreamingMarkdownState { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } #[cfg(test)] diff --git a/rust/crates/rusty-claude-cli/src/tui/mod.rs b/rust/crates/rusty-claude-cli/src/tui/mod.rs index a8e6e6dc..11db5893 100644 --- a/rust/crates/rusty-claude-cli/src/tui/mod.rs +++ b/rust/crates/rusty-claude-cli/src/tui/mod.rs @@ -10,10 +10,10 @@ pub mod legacy; pub use legacy::*; // New sub-modules — built incrementally alongside the legacy code. -pub mod component; -pub mod event; -pub mod slash_commands; pub mod app; -pub mod components; -pub mod markdown; pub mod capture; +pub mod component; +pub mod components; +pub mod event; +pub mod markdown; +pub mod slash_commands; diff --git a/rust/crates/rusty-claude-cli/src/tui/slash_commands.rs b/rust/crates/rusty-claude-cli/src/tui/slash_commands.rs index 5262ddeb..e3c14670 100644 --- a/rust/crates/rusty-claude-cli/src/tui/slash_commands.rs +++ b/rust/crates/rusty-claude-cli/src/tui/slash_commands.rs @@ -41,9 +41,7 @@ pub fn parse_slash_command(input: &str) -> Option<(&str, &str)> { /// This is a pure function that doesn't depend on TuiApp or LiveCli — /// side effects (pushing messages, changing theme) are delegated to the /// caller through the return value and optional context. -pub fn dispatch_slash_command<'a>( - input: &'a str, -) -> SlashCommandAction<'a> { +pub fn dispatch_slash_command<'a>(input: &'a str) -> SlashCommandAction<'a> { let Some((command, args)) = parse_slash_command(input) else { return SlashCommandAction::NotACommand; }; @@ -52,11 +50,19 @@ pub fn dispatch_slash_command<'a>( "/exit" | "/quit" => SlashCommandAction::Exit, "/theme" => SlashCommandAction::SetTheme { name: args }, "/keys" => SlashCommandAction::SetKeymap { preset: args }, - "/code" => SlashCommandAction::SetChatMode { mode: ChatMode::Code }, - "/ask" => SlashCommandAction::SetChatMode { mode: ChatMode::Ask }, - "/architect" | "/arch" => SlashCommandAction::SetChatMode { mode: ChatMode::Architect }, + "/code" => SlashCommandAction::SetChatMode { + mode: ChatMode::Code, + }, + "/ask" => SlashCommandAction::SetChatMode { + mode: ChatMode::Ask, + }, + "/architect" | "/arch" => SlashCommandAction::SetChatMode { + mode: ChatMode::Architect, + }, "/diff" => SlashCommandAction::ShowDiff, - "/undo" => SlashCommandAction::Undo { confirm: args == "--confirm" || args == "-y" }, + "/undo" => SlashCommandAction::Undo { + confirm: args == "--confirm" || args == "-y", + }, "/ls" => SlashCommandAction::ShowFile { path: args }, "/help" => SlashCommandAction::ShowHelp, _ => SlashCommandAction::Unknown { command }, @@ -94,7 +100,10 @@ mod tests { #[test] fn test_parse_slash_command() { - assert_eq!(parse_slash_command("/theme tokyonight"), Some(("/theme", "tokyonight"))); + assert_eq!( + parse_slash_command("/theme tokyonight"), + Some(("/theme", "tokyonight")) + ); assert_eq!(parse_slash_command("/keys"), Some(("/keys", ""))); assert_eq!(parse_slash_command("/help me"), Some(("/help", "me"))); assert_eq!(parse_slash_command("not a command"), None); @@ -121,32 +130,70 @@ mod tests { #[test] fn test_dispatch_chat_mode() { - assert_eq!(dispatch_slash_command("/code"), SlashCommandAction::SetChatMode { mode: ChatMode::Code }); - assert_eq!(dispatch_slash_command("/ask"), SlashCommandAction::SetChatMode { mode: ChatMode::Ask }); - assert_eq!(dispatch_slash_command("/architect"), SlashCommandAction::SetChatMode { mode: ChatMode::Architect }); - assert_eq!(dispatch_slash_command("/arch"), SlashCommandAction::SetChatMode { mode: ChatMode::Architect }); + assert_eq!( + dispatch_slash_command("/code"), + SlashCommandAction::SetChatMode { + mode: ChatMode::Code + } + ); + assert_eq!( + dispatch_slash_command("/ask"), + SlashCommandAction::SetChatMode { + mode: ChatMode::Ask + } + ); + assert_eq!( + dispatch_slash_command("/architect"), + SlashCommandAction::SetChatMode { + mode: ChatMode::Architect + } + ); + assert_eq!( + dispatch_slash_command("/arch"), + SlashCommandAction::SetChatMode { + mode: ChatMode::Architect + } + ); } #[test] fn test_dispatch_diff() { - assert_eq!(dispatch_slash_command("/diff"), SlashCommandAction::ShowDiff); + assert_eq!( + dispatch_slash_command("/diff"), + SlashCommandAction::ShowDiff + ); } #[test] fn test_dispatch_undo() { - assert_eq!(dispatch_slash_command("/undo"), SlashCommandAction::Undo { confirm: false }); - assert_eq!(dispatch_slash_command("/undo --confirm"), SlashCommandAction::Undo { confirm: true }); - assert_eq!(dispatch_slash_command("/undo -y"), SlashCommandAction::Undo { confirm: true }); + assert_eq!( + dispatch_slash_command("/undo"), + SlashCommandAction::Undo { confirm: false } + ); + assert_eq!( + dispatch_slash_command("/undo --confirm"), + SlashCommandAction::Undo { confirm: true } + ); + assert_eq!( + dispatch_slash_command("/undo -y"), + SlashCommandAction::Undo { confirm: true } + ); } #[test] fn test_dispatch_ls() { - assert_eq!(dispatch_slash_command("/ls /some/path"), SlashCommandAction::ShowFile { path: "/some/path" }); + assert_eq!( + dispatch_slash_command("/ls /some/path"), + SlashCommandAction::ShowFile { path: "/some/path" } + ); } #[test] fn test_dispatch_help() { - assert_eq!(dispatch_slash_command("/help"), SlashCommandAction::ShowHelp); + assert_eq!( + dispatch_slash_command("/help"), + SlashCommandAction::ShowHelp + ); } #[test] @@ -157,6 +204,9 @@ mod tests { #[test] fn test_dispatch_not_a_command() { - assert_eq!(dispatch_slash_command("hello world"), SlashCommandAction::NotACommand); + assert_eq!( + dispatch_slash_command("hello world"), + SlashCommandAction::NotACommand + ); } } diff --git a/rust/crates/rusty-claude-cli/src/tui_update.rs b/rust/crates/rusty-claude-cli/src/tui_update.rs index 608fea48..4911bcb2 100644 --- a/rust/crates/rusty-claude-cli/src/tui_update.rs +++ b/rust/crates/rusty-claude-cli/src/tui_update.rs @@ -34,7 +34,8 @@ pub fn install_panic_hook() -> Box) + Send + Sy "Box".to_string() }; - let location = info.location() + let location = info + .location() .map(|l| format!(" at {}:{}", l.file(), l.line())) .unwrap_or_default(); diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index aab2231f..29ae9229 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -5654,7 +5654,7 @@ fn resolve_agent_model(model: Option<&str>) -> String { fn load_main_model_from_config() -> Option { let cwd = std::env::current_dir().ok()?; let config = ConfigLoader::default_for(&cwd).load().ok()?; - config.model().map(str::to_string) + config.provider().model().map(str::to_string) } /// Read the `subagentModel` setting from merged config so the Agent tool