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:
parent
7dc422de9c
commit
8beb1b8614
|
|
@ -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!(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue