fix(team): read provider().model() not feature model for sub-agent default

load_main_model_from_config() read config.model() — the feature-config
model from settings.json (e.g. 'openai/glm-5.1-fast') — instead of
config.provider().model() — the /setup-saved active provider model
(e.g. 'custom/openclaw'). The latter is what the main session actually
connects with and what the status bar shows. Sub-agents inherited the
wrong model, which might not exist on the custom endpoint.

Fix: use config.provider().model() so sub-agents default to the same
provider that the user's main session is connected to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-06-17 13:09:06 -05:00
parent 7dc422de9c
commit 8beb1b8614
27 changed files with 1452 additions and 588 deletions

View File

@ -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!(),
}

View File

@ -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<LspServerState> {
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<LspDiagnostic> {
@ -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);

View File

@ -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() {

View File

@ -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);
}

View File

@ -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<InstallInstruction> },
Missing {
language: String,
instructions: Vec<InstallInstruction>,
},
/// 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<InstallInstruction> {
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<LspServerDescriptor> {
/// 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
);
}
}
}

View File

@ -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(),

View File

@ -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<crate::lsp_client::LspCodeAction> {
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<crate::lsp_client::LspWorkspaceEdit> {
pub(super) fn parse_workspace_edit(
value: &JsonValue,
) -> Option<crate::lsp_client::LspWorkspaceEdit> {
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<crate::lsp_client::LspTextEdit> = 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<crate::lsp_client::LspTextEdit> = 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<crate::lsp_client::LspCommand> {
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<crate::lsp_client::LspSignatureHelpResult> {
pub(super) fn parse_signature_help(
value: &JsonValue,
) -> Option<crate::lsp_client::LspSignatureHelpResult> {
let signatures_arr = value.get("signatures")?.as_array()?;
let signatures: Vec<crate::lsp_client::LspSignatureInformation> = 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<crate::lsp_client::LspSignatureInformation> = 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<crate::lsp_client::LspCo
let Some(items) = value.as_array() else {
return Vec::new();
};
items.iter().filter_map(|item| {
let range = item.get("range")?;
let start = range.get("start")?;
let line = start.get("line")?.as_u64()? as u32;
let character = start.get("character")?.as_u64()? as u32;
let command = item.get("command").and_then(parse_command);
let data = item.get("data").cloned();
Some(crate::lsp_client::LspCodeLens { line, character, command, data })
}).collect()
items
.iter()
.filter_map(|item| {
let range = item.get("range")?;
let start = range.get("start")?;
let line = start.get("line")?.as_u64()? as u32;
let character = start.get("character")?.as_u64()? as u32;
let command = item.get("command").and_then(parse_command);
let data = item.get("data").cloned();
Some(crate::lsp_client::LspCodeLens {
line,
character,
command,
data,
})
})
.collect()
}
pub(super) fn parse_workspace_symbols(value: &JsonValue) -> Vec<crate::lsp_client::LspSymbol> {
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 {

View File

@ -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;
}

View File

@ -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::<usize>()
.map_err(|error| LspTransportError::Io(io::Error::new(
io::ErrorKind::InvalidData,
error,
)))?;
let parsed = value.trim().parse::<usize>().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<Self> {
pub fn connect_tcp_with_timeout(address: &str, request_timeout: Duration) -> io::Result<Self> {
let addr = address.trim_start_matches("tcp://");
// Try socat first (reliable bidirectional bridge)
@ -485,8 +478,5 @@ impl LspTransport {
}
}
#[cfg(test)]
mod tests;

View File

@ -190,7 +190,9 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx {
for op in ops {
if (op.op_type == FileOp::Read || op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
if (op.op_type == FileOp::Read
|| op.op_type == FileOp::Write
|| op.op_type == FileOp::Edit)
&& op.index < last_write
{
obsolete_indices.insert(op.index);

View File

@ -73,7 +73,9 @@ mod tests {
#[test]
fn test_ask_mode_restrains() {
assert!(ChatMode::Ask.system_prompt_suffix().contains("Do NOT modify"));
assert!(ChatMode::Ask
.system_prompt_suffix()
.contains("Do NOT modify"));
}
#[test]

View File

@ -91,9 +91,12 @@ impl MarkdownRenderer {
current_spans.push(Span::raw(format!("{indent}")));
}
Tag::BlockQuote(_) => {
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::<String>()
}).collect();
let text: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.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::<String>()
}).collect();
let text: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.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())
);
}
}

View File

@ -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<Self> {
@ -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]

View File

@ -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<ListItem> = 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<ListItem> = 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
}
}

View File

@ -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<Line> = cache.wrapped_lines.iter().skip(start).take(pane_rows).cloned().collect();
let visible: Vec<Line> = 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<Line<'a>> {
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::<Vec<_>>())
Line::from(
l.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect::<Vec<_>>(),
)
}));
}
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::<Vec<_>>())
Line::from(
l.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect::<Vec<_>>(),
)
}));
}
}
@ -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::<usize>()
span.content
.chars()
.map(|c| c.width().unwrap_or(0))
.sum::<usize>()
})
.sum();
assert!(
line_width <= width,
"Wrapped line width {line_width} exceeds target {width}: {:?}",
line.spans.iter().map(|s| s.content.clone()).collect::<Vec<_>>()
line.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
);
}
}
@ -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);

View File

@ -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!(" {:<KV_KEY_WIDTH$}", label), Style::default().fg(c)),
Span::styled(
format!(" {:<KV_KEY_WIDTH$}", label),
Style::default().fg(c),
),
Span::styled(detail, Style::default().fg(theme.dashboard_key.to_color())),
]));
}
@ -125,7 +203,12 @@ impl Component for Dashboard {
// Session
lines.push(section("Session", theme));
lines.push(kv("ID", state.session_id.as_deref().unwrap_or("-"), theme.dashboard_key.to_color(), theme));
lines.push(kv(
"ID",
state.session_id.as_deref().unwrap_or("-"),
theme.dashboard_key.to_color(),
theme,
));
// Status / spinner
if !state.status_message.is_empty() {
@ -139,16 +222,31 @@ impl Component for Dashboard {
// Key hints
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("─ Keys ─", Style::default().fg(theme.key_hint.to_color()))));
lines.push(Line::from(Span::styled(" Enter Submit Shift+Enter ↵", Style::default().fg(theme.key_hint.to_color()))));
lines.push(Line::from(Span::styled(" ^P Swap ^T Team ^C ⊘ ^D Exit", Style::default().fg(theme.key_hint.to_color()))));
lines.push(Line::from(Span::styled(
"─ Keys ─",
Style::default().fg(theme.key_hint.to_color()),
)));
lines.push(Line::from(Span::styled(
" Enter Submit Shift+Enter ↵",
Style::default().fg(theme.key_hint.to_color()),
)));
lines.push(Line::from(Span::styled(
" ^P Swap ^T Team ^C ⊘ ^D Exit",
Style::default().fg(theme.key_hint.to_color()),
)));
let widget = Paragraph::new(lines)
.block(Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(theme.border.to_color()))
.title(Span::styled(" Dashboard ",
Style::default().fg(theme.dashboard_header.to_color()).add_modifier(Modifier::BOLD))))
.block(
Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(theme.border.to_color()))
.title(Span::styled(
" Dashboard ",
Style::default()
.fg(theme.dashboard_header.to_color())
.add_modifier(Modifier::BOLD),
)),
)
.wrap(Wrap { trim: false });
frame.render_widget(widget, area);
@ -160,12 +258,24 @@ impl Component for Dashboard {
width: area.width.saturating_sub(4),
height: 1,
};
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_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!(" {:<KV_KEY_WIDTH$}", key), Style::default().fg(theme.dashboard_key.to_color())),
Span::styled(
format!(" {:<KV_KEY_WIDTH$}", key),
Style::default().fg(theme.dashboard_key.to_color()),
),
Span::styled(val.to_string(), Style::default().fg(val_color)),
])
}

View File

@ -1,8 +1,8 @@
//! TUI components — each visual region as a Component impl.
pub mod conversation;
pub mod input_bar;
pub mod dashboard;
pub mod command_palette;
pub mod agent_view;
pub mod command_palette;
pub mod conversation;
pub mod dashboard;
pub mod input_bar;
pub mod status_bar;

View File

@ -25,7 +25,11 @@ pub struct StatusBar {
impl StatusBar {
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) {
@ -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),
));
}

View File

@ -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);

View File

@ -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)]

View File

@ -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<CodeLine> {
.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<CodeLine> {
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<FenceLine> {
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<Option<FenceLine>> = 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<StackEntry> = 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<usize, Rewrite> = 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<usize> {
}
#[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<FenceMarker> {
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);
}
}

View File

@ -3,10 +3,10 @@
//! Uses the shared `MarkdownAst` to produce `Vec<Line<'static>>` 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)));
}

View File

@ -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)]

View File

@ -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;

View File

@ -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
);
}
}

View File

@ -34,7 +34,8 @@ pub fn install_panic_hook() -> Box<dyn Fn(&panic::PanicHookInfo<'_>) + Send + Sy
"Box<dyn Any>".to_string()
};
let location = info.location()
let location = info
.location()
.map(|l| format!(" at {}:{}", l.file(), l.line()))
.unwrap_or_default();

View File

@ -5654,7 +5654,7 @@ fn resolve_agent_model(model: Option<&str>) -> String {
fn load_main_model_from_config() -> Option<String> {
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