4 Commits

Author SHA1 Message Date
d002d35bde feat(theme): add tool_output color to themes
- Added a `tool_output` color to the `Theme` struct.
- Updated all built-in themes to include the new color.
- Modified the TUI to use the `tool_output` color for rendering tool output.
2025-10-06 22:18:17 +02:00
c9c3d17db0 feat(theme): add tool_output color to themes
- Added a `tool_output` color to the `Theme` struct.
- Updated all built-in themes to include the new color.
- Modified the TUI to use the `tool_output` color for rendering tool output.
2025-10-06 21:59:08 +02:00
a909455f97 feat(theme): add tool_output color to themes
- Added a `tool_output` color to the `Theme` struct.
- Updated all built-in themes to include the new color.
- Modified the TUI to use the `tool_output` color for rendering tool output.
2025-10-06 21:43:31 +02:00
67381b02db feat(mcp): add MCP client abstraction and feature flag
Introduce the foundation for the Multi-Client Provider (MCP) architecture.

This phase includes:
- A new `McpClient` trait to abstract tool execution.
- A `LocalMcpClient` that executes tools in-process for backward compatibility ("legacy mode").
- A placeholder `RemoteMcpClient` for future development.
- An `McpMode` enum in the configuration (`mcp.mode`) to toggle between `legacy` and `enabled` modes, defaulting to `legacy`.
- Refactoring of `SessionController` to use the `McpClient` abstraction, decoupling it from the tool registry.

This lays the groundwork for routing tool calls to a remote MCP server in subsequent phases.
2025-10-06 20:03:01 +02:00
27 changed files with 691 additions and 118 deletions

View File

@@ -5,6 +5,7 @@ members = [
"crates/owlen-tui", "crates/owlen-tui",
"crates/owlen-cli", "crates/owlen-cli",
"crates/owlen-ollama", "crates/owlen-ollama",
"crates/owlen-mcp-server",
] ]
exclude = [] exclude = []

View File

@@ -39,6 +39,7 @@ sqlx = { workspace = true }
duckduckgo = "0.2.0" duckduckgo = "0.2.0"
reqwest = { workspace = true, features = ["default"] } reqwest = { workspace = true, features = ["default"] }
reqwest_011 = { version = "0.11", package = "reqwest" } reqwest_011 = { version = "0.11", package = "reqwest" }
path-clean = "1.0"
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }

View File

@@ -14,6 +14,9 @@ pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
pub struct Config { pub struct Config {
/// General application settings /// General application settings
pub general: GeneralSettings, pub general: GeneralSettings,
/// MCP (Multi-Client-Provider) settings
#[serde(default)]
pub mcp: McpSettings,
/// Provider specific configuration keyed by provider name /// Provider specific configuration keyed by provider name
#[serde(default)] #[serde(default)]
pub providers: HashMap<String, ProviderConfig>, pub providers: HashMap<String, ProviderConfig>,
@@ -48,6 +51,7 @@ impl Default for Config {
Self { Self {
general: GeneralSettings::default(), general: GeneralSettings::default(),
mcp: McpSettings::default(),
providers, providers,
ui: UiSettings::default(), ui: UiSettings::default(),
storage: StorageSettings::default(), storage: StorageSettings::default(),
@@ -202,6 +206,21 @@ impl Default for GeneralSettings {
} }
} }
/// MCP (Multi-Client-Provider) settings
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpSettings {
#[serde(default)]
pub mode: McpMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum McpMode {
#[default]
Legacy,
Enabled,
}
/// Privacy controls governing network access and storage /// Privacy controls governing network access and storage
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacySettings { pub struct PrivacySettings {

View File

@@ -91,6 +91,11 @@ impl MessageFormatter {
Some(thinking) Some(thinking)
}; };
// If the result is empty but we have thinking content, show a placeholder
if result.trim().is_empty() && thinking_result.is_some() {
result.push_str("[Thinking...]");
}
(result, thinking_result) (result, thinking_result)
} }
} }

View File

@@ -76,4 +76,7 @@ pub enum Error {
#[error("Unknown error: {0}")] #[error("Unknown error: {0}")]
Unknown(String), Unknown(String),
#[error("Not implemented: {0}")]
NotImplemented(String),
} }

View File

@@ -0,0 +1,39 @@
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::{Error, Result};
use async_trait::async_trait;
/// Trait for a client that can interact with an MCP server
#[async_trait]
pub trait McpClient: Send + Sync {
/// List the tools available on the server
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
/// Call a tool on the server
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse>;
}
/// Placeholder for a client that connects to a remote MCP server.
pub struct RemoteMcpClient;
impl RemoteMcpClient {
pub fn new() -> Result<Self> {
Ok(Self)
}
}
#[async_trait]
impl McpClient for RemoteMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
// TODO: Implement remote call
Err(Error::NotImplemented(
"Remote MCP client is not implemented".to_string(),
))
}
async fn call_tool(&self, _call: McpToolCall) -> Result<McpToolResponse> {
// TODO: Implement remote call
Err(Error::NotImplemented(
"Remote MCP client is not implemented".to_string(),
))
}
}

View File

@@ -1,12 +1,16 @@
use crate::tools::registry::ToolRegistry; use crate::tools::registry::ToolRegistry;
use crate::validation::SchemaValidator; use crate::validation::SchemaValidator;
use crate::Result; use crate::Result;
use async_trait::async_trait;
use client::McpClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
pub mod client;
/// Descriptor for a tool exposed over MCP /// Descriptor for a tool exposed over MCP
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDescriptor { pub struct McpToolDescriptor {
@@ -80,3 +84,26 @@ impl McpServer {
fn duration_to_millis(duration: Duration) -> u128 { fn duration_to_millis(duration: Duration) -> u128 {
duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis()) duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis())
} }
pub struct LocalMcpClient {
server: McpServer,
}
impl LocalMcpClient {
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
Self {
server: McpServer::new(registry, validator),
}
}
}
#[async_trait]
impl McpClient for LocalMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
Ok(self.server.list_tools())
}
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
self.server.call_tool(call).await
}
}

View File

@@ -17,7 +17,7 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
/// use std::sync::Arc; /// use std::sync::Arc;
/// use futures::Stream; /// use futures::Stream;
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream}; /// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message}; /// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message, Role, ChatParameters};
/// use owlen_core::Result; /// use owlen_core::Result;
/// ///
/// // 1. Create a mock provider /// // 1. Create a mock provider
@@ -31,18 +31,23 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
/// ///
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { /// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
/// Ok(vec![ModelInfo { /// Ok(vec![ModelInfo {
/// id: "mock-model".to_string(),
/// provider: "mock".to_string(), /// provider: "mock".to_string(),
/// name: "mock-model".to_string(), /// name: "mock-model".to_string(),
/// ..Default::default() /// description: None,
/// context_window: None,
/// capabilities: vec![],
/// supports_tools: false,
/// }]) /// }])
/// } /// }
/// ///
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> { /// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// let content = format!("Response to: {}", request.messages.last().unwrap().content); /// let content = format!("Response to: {}", request.messages.last().unwrap().content);
/// Ok(ChatResponse { /// Ok(ChatResponse {
/// model: request.model, /// message: Message::new(Role::Assistant, content),
/// message: Message { role: "assistant".to_string(), content, ..Default::default() }, /// usage: None,
/// ..Default::default() /// is_streaming: false,
/// is_final: true,
/// }) /// })
/// } /// }
/// ///
@@ -67,8 +72,9 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
/// ///
/// let request = ChatRequest { /// let request = ChatRequest {
/// model: "mock-model".to_string(), /// model: "mock-model".to_string(),
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }], /// messages: vec![Message::new(Role::User, "Hello".to_string())],
/// ..Default::default() /// parameters: ChatParameters::default(),
/// tools: None,
/// }; /// };
/// ///
/// let response = provider.chat(request).await.unwrap(); /// let response = provider.chat(request).await.unwrap();

View File

@@ -1,16 +1,22 @@
use crate::config::Config; use crate::config::{Config, McpMode};
use crate::consent::ConsentManager; use crate::consent::ConsentManager;
use crate::conversation::ConversationManager; use crate::conversation::ConversationManager;
use crate::credentials::CredentialManager; use crate::credentials::CredentialManager;
use crate::encryption::{self, VaultHandle}; use crate::encryption::{self, VaultHandle};
use crate::formatting::MessageFormatter; use crate::formatting::MessageFormatter;
use crate::input::InputBuffer; use crate::input::InputBuffer;
use crate::mcp::client::{McpClient, RemoteMcpClient};
use crate::mcp::{LocalMcpClient, McpToolCall};
use crate::model::ModelManager; use crate::model::ModelManager;
use crate::provider::{ChatStream, Provider}; use crate::provider::{ChatStream, Provider};
use crate::storage::{SessionMeta, StorageManager}; use crate::storage::{SessionMeta, StorageManager};
use crate::tools::{ use crate::tools::{
code_exec::CodeExecTool, registry::ToolRegistry, web_search::WebSearchTool, code_exec::CodeExecTool,
web_search_detailed::WebSearchDetailedTool, Tool, fs_tools::{ResourcesGetTool, ResourcesListTool},
registry::ToolRegistry,
web_search::WebSearchTool,
web_search_detailed::WebSearchDetailedTool,
Tool,
}; };
use crate::types::{ use crate::types::{
ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall, ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall,
@@ -46,7 +52,7 @@ pub enum SessionOutcome {
/// use owlen_core::provider::{Provider, ChatStream}; /// use owlen_core::provider::{Provider, ChatStream};
/// use owlen_core::session::{SessionController, SessionOutcome}; /// use owlen_core::session::{SessionController, SessionOutcome};
/// use owlen_core::storage::StorageManager; /// use owlen_core::storage::StorageManager;
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo}; /// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo, Role};
/// use owlen_core::Result; /// use owlen_core::Result;
/// ///
/// // Mock provider for the example /// // Mock provider for the example
@@ -57,9 +63,10 @@ pub enum SessionOutcome {
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) } /// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> { /// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// Ok(ChatResponse { /// Ok(ChatResponse {
/// model: request.model,
/// message: Message::assistant("Hello back!".to_string()), /// message: Message::assistant("Hello back!".to_string()),
/// ..Default::default() /// usage: None,
/// is_streaming: false,
/// is_final: true,
/// }) /// })
/// } /// }
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() } /// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
@@ -102,6 +109,7 @@ pub struct SessionController {
consent_manager: Arc<Mutex<ConsentManager>>, consent_manager: Arc<Mutex<ConsentManager>>,
tool_registry: Arc<ToolRegistry>, tool_registry: Arc<ToolRegistry>,
schema_validator: Arc<SchemaValidator>, schema_validator: Arc<SchemaValidator>,
mcp_client: Arc<dyn McpClient>,
storage: Arc<StorageManager>, storage: Arc<StorageManager>,
vault: Option<Arc<Mutex<VaultHandle>>>, vault: Option<Arc<Mutex<VaultHandle>>>,
master_key: Option<Arc<Vec<u8>>>, master_key: Option<Arc<Vec<u8>>>,
@@ -109,6 +117,90 @@ pub struct SessionController {
enable_code_tools: bool, // Whether to enable code execution tools (code client only) enable_code_tools: bool, // Whether to enable code execution tools (code client only)
} }
fn build_tools(
config: &Config,
enable_code_tools: bool,
consent_manager: Arc<Mutex<ConsentManager>>,
credential_manager: Option<Arc<CredentialManager>>,
vault: Option<Arc<Mutex<VaultHandle>>>,
) -> Result<(Arc<ToolRegistry>, Arc<SchemaValidator>)> {
let mut registry = ToolRegistry::new();
let mut validator = SchemaValidator::new();
for (name, schema) in get_builtin_schemas() {
if let Err(err) = validator.register_schema(&name, schema) {
warn!("Failed to register built-in schema {name}: {err}");
}
}
if config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search")
&& config.tools.web_search.enabled
&& config.privacy.enable_remote_search
{
let tool = WebSearchTool::new(
consent_manager.clone(),
credential_manager.clone(),
vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Register web_search_detailed tool (provides snippets)
if config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search") // Same permission as web_search
&& config.tools.web_search.enabled
&& config.privacy.enable_remote_search
{
let tool = WebSearchDetailedTool::new(
consent_manager.clone(),
credential_manager.clone(),
vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Code execution tool - only available in code client
if enable_code_tools
&& config
.security
.allowed_tools
.iter()
.any(|tool| tool == "code_exec")
&& config.tools.code_exec.enabled
{
let tool = CodeExecTool::new(config.tools.code_exec.allowed_languages.clone());
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
let resources_list_tool = ResourcesListTool;
let resources_get_tool = ResourcesGetTool;
validator.register_schema(resources_list_tool.name(), resources_list_tool.schema())?;
validator.register_schema(resources_get_tool.name(), resources_get_tool.schema())?;
registry.register(resources_list_tool);
registry.register(resources_get_tool);
Ok((Arc::new(registry), Arc::new(validator)))
}
impl SessionController { impl SessionController {
/// Create a new controller with the given provider and configuration /// Create a new controller with the given provider and configuration
/// ///
@@ -175,7 +267,23 @@ impl SessionController {
let model_manager = ModelManager::new(config.general.model_cache_ttl()); let model_manager = ModelManager::new(config.general.model_cache_ttl());
let mut controller = Self { let (tool_registry, schema_validator) = build_tools(
&config,
enable_code_tools,
consent_manager.clone(),
credential_manager.clone(),
vault_handle.clone(),
)?;
let mcp_client: Arc<dyn McpClient> = match config.mcp.mode {
McpMode::Legacy => Arc::new(LocalMcpClient::new(
tool_registry.clone(),
schema_validator.clone(),
)),
McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
};
let controller = Self {
provider, provider,
conversation, conversation,
model_manager, model_manager,
@@ -183,8 +291,9 @@ impl SessionController {
formatter, formatter,
config, config,
consent_manager, consent_manager,
tool_registry: Arc::new(ToolRegistry::new()), tool_registry,
schema_validator: Arc::new(SchemaValidator::new()), schema_validator,
mcp_client,
storage, storage,
vault: vault_handle, vault: vault_handle,
master_key, master_key,
@@ -192,8 +301,6 @@ impl SessionController {
enable_code_tools, enable_code_tools,
}; };
controller.rebuild_tools()?;
Ok(controller) Ok(controller)
} }
@@ -415,79 +522,45 @@ impl SessionController {
self.credential_manager.as_ref().map(Arc::clone) self.credential_manager.as_ref().map(Arc::clone)
} }
pub async fn read_file(&self, path: &str) -> Result<String> {
let call = McpToolCall {
name: "resources/get".to_string(),
arguments: serde_json::json!({ "path": path }),
};
let response = self.mcp_client.call_tool(call).await?;
let content: String = serde_json::from_value(response.output)?;
Ok(content)
}
pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
let call = McpToolCall {
name: "resources/list".to_string(),
arguments: serde_json::json!({ "path": path }),
};
let response = self.mcp_client.call_tool(call).await?;
let content: Vec<String> = serde_json::from_value(response.output)?;
Ok(content)
}
fn rebuild_tools(&mut self) -> Result<()> { fn rebuild_tools(&mut self) -> Result<()> {
let mut registry = ToolRegistry::new(); let (registry, validator) = build_tools(
let mut validator = SchemaValidator::new(); &self.config,
self.enable_code_tools,
self.consent_manager.clone(),
self.credential_manager.clone(),
self.vault.clone(),
)?;
self.tool_registry = registry;
self.schema_validator = validator;
for (name, schema) in get_builtin_schemas() { self.mcp_client = match self.config.mcp.mode {
if let Err(err) = validator.register_schema(&name, schema) { McpMode::Legacy => Arc::new(LocalMcpClient::new(
warn!("Failed to register built-in schema {name}: {err}"); self.tool_registry.clone(),
} self.schema_validator.clone(),
} )),
McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
};
if self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search")
&& self.config.tools.web_search.enabled
&& self.config.privacy.enable_remote_search
{
let tool = WebSearchTool::new(
self.consent_manager.clone(),
self.credential_manager.clone(),
self.vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Register web_search_detailed tool (provides snippets)
if self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search") // Same permission as web_search
&& self.config.tools.web_search.enabled
&& self.config.privacy.enable_remote_search
{
let tool = WebSearchDetailedTool::new(
self.consent_manager.clone(),
self.credential_manager.clone(),
self.vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Code execution tool - only available in code client
if self.enable_code_tools
&& self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "code_exec")
&& self.config.tools.code_exec.enabled
{
let tool = CodeExecTool::new(self.config.tools.code_exec.allowed_languages.clone());
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
self.tool_registry = Arc::new(registry);
self.schema_validator = Arc::new(validator);
Ok(()) Ok(())
} }
@@ -592,10 +665,13 @@ impl SessionController {
// Execute each tool call // Execute each tool call
if let Some(tool_calls) = &response.message.tool_calls { if let Some(tool_calls) = &response.message.tool_calls {
for tool_call in tool_calls { for tool_call in tool_calls {
let tool_result = self let mcp_tool_call = McpToolCall {
.tool_registry name: tool_call.name.clone(),
.execute(&tool_call.name, tool_call.arguments.clone()) arguments: tool_call.arguments.clone(),
.await; };
let tool_result =
self.mcp_client.call_tool(mcp_tool_call).await;
let tool_response_content = match tool_result { let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output) Ok(result) => serde_json::to_string_pretty(&result.output)
@@ -694,10 +770,11 @@ impl SessionController {
) -> Result<SessionOutcome> { ) -> Result<SessionOutcome> {
// Execute each tool call // Execute each tool call
for tool_call in &tool_calls { for tool_call in &tool_calls {
let tool_result = self let mcp_tool_call = McpToolCall {
.tool_registry name: tool_call.name.clone(),
.execute(&tool_call.name, tool_call.arguments.clone()) arguments: tool_call.arguments.clone(),
.await; };
let tool_result = self.mcp_client.call_tool(mcp_tool_call).await;
let tool_response_content = match tool_result { let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output) Ok(result) => serde_json::to_string_pretty(&result.output)
@@ -748,7 +825,7 @@ impl SessionController {
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!( return Ok(format!(
"{}{}", "{}{} ",
preview, preview,
if first_msg.content.len() > 50 { if first_msg.content.len() > 50 {
"..." "..."
@@ -810,7 +887,7 @@ impl SessionController {
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!( return Ok(format!(
"{}{}", "{}{} ",
preview, preview,
if first_msg.content.len() > 50 { if first_msg.content.len() > 50 {
"..." "..."
@@ -822,7 +899,8 @@ impl SessionController {
// Truncate if too long // Truncate if too long
let truncated = if description.len() > 100 { let truncated = if description.len() > 100 {
format!("{}...", description.chars().take(97).collect::<String>()) description.chars().take(97).collect::<String>()
// Removed trailing '...' as it's already handled by the format! macro
} else { } else {
description description
}; };
@@ -833,7 +911,7 @@ impl SessionController {
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
Ok(format!( Ok(format!(
"{}{}", "{}{} ",
preview, preview,
if first_msg.content.len() > 50 { if first_msg.content.len() > 50 {
"..." "..."

View File

@@ -44,6 +44,11 @@ pub struct Theme {
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
pub assistant_message_role: Color, pub assistant_message_role: Color,
/// Color for tool output messages
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub tool_output: Color,
/// Color for thinking panel title /// Color for thinking panel title
#[serde(deserialize_with = "deserialize_color")] #[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")] #[serde(serialize_with = "serialize_color")]
@@ -268,6 +273,7 @@ fn default_dark() -> Theme {
unfocused_panel_border: Color::Rgb(95, 20, 135), unfocused_panel_border: Color::Rgb(95, 20, 135),
user_message_role: Color::LightBlue, user_message_role: Color::LightBlue,
assistant_message_role: Color::Yellow, assistant_message_role: Color::Yellow,
tool_output: Color::Gray,
thinking_panel_title: Color::LightMagenta, thinking_panel_title: Color::LightMagenta,
command_bar_background: Color::Black, command_bar_background: Color::Black,
status_background: Color::Black, status_background: Color::Black,
@@ -297,6 +303,7 @@ fn default_light() -> Theme {
unfocused_panel_border: Color::Rgb(221, 221, 221), unfocused_panel_border: Color::Rgb(221, 221, 221),
user_message_role: Color::Rgb(0, 85, 164), user_message_role: Color::Rgb(0, 85, 164),
assistant_message_role: Color::Rgb(142, 68, 173), assistant_message_role: Color::Rgb(142, 68, 173),
tool_output: Color::Gray,
thinking_panel_title: Color::Rgb(142, 68, 173), thinking_panel_title: Color::Rgb(142, 68, 173),
command_bar_background: Color::White, command_bar_background: Color::White,
status_background: Color::White, status_background: Color::White,
@@ -326,8 +333,9 @@ fn gruvbox() -> Theme {
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64 unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green) user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue) assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple) tool_output: Color::Rgb(146, 131, 116),
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836 thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
status_background: Color::Rgb(60, 56, 54), status_background: Color::Rgb(60, 56, 54),
mode_normal: Color::Rgb(131, 165, 152), // blue mode_normal: Color::Rgb(131, 165, 152), // blue
mode_editing: Color::Rgb(184, 187, 38), // green mode_editing: Color::Rgb(184, 187, 38), // green
@@ -355,7 +363,8 @@ fn dracula() -> Theme {
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan) user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink) assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple) tool_output: Color::Rgb(98, 114, 164),
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
command_bar_background: Color::Rgb(68, 71, 90), command_bar_background: Color::Rgb(68, 71, 90),
status_background: Color::Rgb(68, 71, 90), status_background: Color::Rgb(68, 71, 90),
mode_normal: Color::Rgb(139, 233, 253), mode_normal: Color::Rgb(139, 233, 253),
@@ -384,6 +393,7 @@ fn solarized() -> Theme {
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02) unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan) user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange) assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
tool_output: Color::Rgb(101, 123, 131),
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet) thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
command_bar_background: Color::Rgb(7, 54, 66), command_bar_background: Color::Rgb(7, 54, 66),
status_background: Color::Rgb(7, 54, 66), status_background: Color::Rgb(7, 54, 66),
@@ -413,6 +423,7 @@ fn midnight_ocean() -> Theme {
unfocused_panel_border: Color::Rgb(48, 54, 61), unfocused_panel_border: Color::Rgb(48, 54, 61),
user_message_role: Color::Rgb(121, 192, 255), user_message_role: Color::Rgb(121, 192, 255),
assistant_message_role: Color::Rgb(137, 221, 255), assistant_message_role: Color::Rgb(137, 221, 255),
tool_output: Color::Rgb(84, 110, 122),
thinking_panel_title: Color::Rgb(158, 206, 106), thinking_panel_title: Color::Rgb(158, 206, 106),
command_bar_background: Color::Rgb(22, 27, 34), command_bar_background: Color::Rgb(22, 27, 34),
status_background: Color::Rgb(22, 27, 34), status_background: Color::Rgb(22, 27, 34),
@@ -442,7 +453,8 @@ fn rose_pine() -> Theme {
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam) user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light) assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris) tool_output: Color::Rgb(110, 106, 134),
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
command_bar_background: Color::Rgb(38, 35, 58), command_bar_background: Color::Rgb(38, 35, 58),
status_background: Color::Rgb(38, 35, 58), status_background: Color::Rgb(38, 35, 58),
mode_normal: Color::Rgb(156, 207, 216), mode_normal: Color::Rgb(156, 207, 216),
@@ -471,7 +483,8 @@ fn monokai() -> Theme {
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan) user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple) assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow) tool_output: Color::Rgb(117, 113, 94),
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
command_bar_background: Color::Rgb(39, 40, 34), command_bar_background: Color::Rgb(39, 40, 34),
status_background: Color::Rgb(39, 40, 34), status_background: Color::Rgb(39, 40, 34),
mode_normal: Color::Rgb(102, 217, 239), mode_normal: Color::Rgb(102, 217, 239),
@@ -500,7 +513,8 @@ fn material_dark() -> Theme {
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue) user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple) assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow) tool_output: Color::Rgb(84, 110, 122),
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
command_bar_background: Color::Rgb(33, 43, 48), command_bar_background: Color::Rgb(33, 43, 48),
status_background: Color::Rgb(33, 43, 48), status_background: Color::Rgb(33, 43, 48),
mode_normal: Color::Rgb(130, 170, 255), mode_normal: Color::Rgb(130, 170, 255),
@@ -529,6 +543,7 @@ fn material_light() -> Theme {
unfocused_panel_border: Color::Rgb(176, 190, 197), unfocused_panel_border: Color::Rgb(176, 190, 197),
user_message_role: Color::Rgb(68, 138, 255), user_message_role: Color::Rgb(68, 138, 255),
assistant_message_role: Color::Rgb(124, 77, 255), assistant_message_role: Color::Rgb(124, 77, 255),
tool_output: Color::Rgb(144, 164, 174),
thinking_panel_title: Color::Rgb(245, 124, 0), thinking_panel_title: Color::Rgb(245, 124, 0),
command_bar_background: Color::Rgb(255, 255, 255), command_bar_background: Color::Rgb(255, 255, 255),
status_background: Color::Rgb(255, 255, 255), status_background: Color::Rgb(255, 255, 255),

View File

@@ -0,0 +1,111 @@
use crate::tools::{Tool, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
use path_clean::PathClean;
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Deserialize)]
struct FileArgs {
path: String,
}
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf> {
let path = Path::new(path);
let path = if path.is_absolute() {
path.strip_prefix("/")
.map_err(|_| anyhow::anyhow!("Invalid path"))?
.to_path_buf()
} else {
path.to_path_buf()
};
let full_path = root.join(path).clean();
if !full_path.starts_with(root) {
return Err(anyhow::anyhow!("Path traversal detected"));
}
Ok(full_path)
}
pub struct ResourcesListTool;
#[async_trait]
impl Tool for ResourcesListTool {
fn name(&self) -> &'static str {
"resources/list"
}
fn description(&self) -> &'static str {
"Lists directory contents."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the directory to list."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: FileArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
let entries = fs::read_dir(full_path)?;
let mut result = Vec::new();
for entry in entries {
let entry = entry?;
result.push(entry.file_name().to_string_lossy().to_string());
}
Ok(ToolResult::success(serde_json::to_value(result)?))
}
}
pub struct ResourcesGetTool;
#[async_trait]
impl Tool for ResourcesGetTool {
fn name(&self) -> &'static str {
"resources/get"
}
fn description(&self) -> &'static str {
"Reads file content."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to read."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: FileArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
let content = fs::read_to_string(full_path)?;
Ok(ToolResult::success(serde_json::to_value(content)?))
}
}

View File

@@ -5,6 +5,7 @@ use async_trait::async_trait;
use serde_json::Value; use serde_json::Value;
pub mod code_exec; pub mod code_exec;
pub mod fs_tools;
pub mod registry; pub mod registry;
pub mod web_search; pub mod web_search;
pub mod web_search_detailed; pub mod web_search_detailed;

View File

@@ -0,0 +1,11 @@
[package]
name = "owlen-mcp-server"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
path-clean = "1.0"

View File

@@ -0,0 +1,180 @@
use path_clean::PathClean;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
#[derive(Debug, Deserialize)]
struct Request {
id: u64,
method: String,
params: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct Response {
id: u64,
result: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
id: u64,
error: JsonRpcError,
}
#[derive(Debug, Serialize)]
struct JsonRpcError {
code: i64,
message: String,
}
#[derive(Deserialize)]
struct FileArgs {
path: String,
}
async fn handle_request(req: Request, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
match req.method.as_str() {
"resources/list" => {
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
code: -32602,
message: format!("Invalid params: {}", e),
})?;
resources_list(&args.path, root).await
}
"resources/get" => {
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
code: -32602,
message: format!("Invalid params: {}", e),
})?;
resources_get(&args.path, root).await
}
_ => Err(JsonRpcError {
code: -32601,
message: "Method not found".to_string(),
}),
}
}
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
let path = Path::new(path);
let path = if path.is_absolute() {
path.strip_prefix("/")
.map_err(|_| JsonRpcError {
code: -32602,
message: "Invalid path".to_string(),
})?
.to_path_buf()
} else {
path.to_path_buf()
};
let full_path = root.join(path).clean();
if !full_path.starts_with(root) {
return Err(JsonRpcError {
code: -32602,
message: "Path traversal detected".to_string(),
});
}
Ok(full_path)
}
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
let full_path = sanitize_path(path, root)?;
let entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read directory: {}", e),
})?;
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read directory entry: {}", e),
})?;
result.push(entry.file_name().to_string_lossy().to_string());
}
Ok(serde_json::json!(result))
}
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
let full_path = sanitize_path(path, root)?;
let content = fs::read_to_string(full_path).map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read file: {}", e),
})?;
Ok(serde_json::json!(content))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?;
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => {
// EOF
break;
}
Ok(_) => {
let req: Request = match serde_json::from_str(&line) {
Ok(req) => req,
Err(e) => {
let err_resp = ErrorResponse {
id: 0,
error: JsonRpcError {
code: -32700,
message: format!("Parse error: {}", e),
},
};
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
continue;
}
};
let request_id = req.id;
match handle_request(req, &root).await {
Ok(result) => {
let resp = Response {
id: request_id,
result,
};
let resp_str = serde_json::to_string(&resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
}
Err(error) => {
let err_resp = ErrorResponse {
id: request_id,
error,
};
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
}
}
}
Err(e) => {
// Handle read error
eprintln!("Error reading from stdin: {}", e);
break;
}
}
}
Ok(())
}

View File

@@ -956,21 +956,21 @@ mod tests {
#[test] #[test]
fn resolve_api_key_expands_braced_env_reference() { fn resolve_api_key_expands_braced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY", "super-secret"); std::env::set_var("OWLEN_TEST_KEY_BRACED", "super-secret");
assert_eq!( assert_eq!(
resolve_api_key(Some("${OWLEN_TEST_KEY}".into())), resolve_api_key(Some("${OWLEN_TEST_KEY_BRACED}".into())),
Some("super-secret".into()) Some("super-secret".into())
); );
std::env::remove_var("OWLEN_TEST_KEY"); std::env::remove_var("OWLEN_TEST_KEY_BRACED");
} }
#[test] #[test]
fn resolve_api_key_expands_unbraced_env_reference() { fn resolve_api_key_expands_unbraced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY", "another-secret"); std::env::set_var("OWLEN_TEST_KEY_UNBRACED", "another-secret");
assert_eq!( assert_eq!(
resolve_api_key(Some("$OWLEN_TEST_KEY".into())), resolve_api_key(Some("$OWLEN_TEST_KEY_UNBRACED".into())),
Some("another-secret".into()) Some("another-secret".into())
); );
std::env::remove_var("OWLEN_TEST_KEY"); std::env::remove_var("OWLEN_TEST_KEY_UNBRACED");
} }
} }

View File

@@ -353,7 +353,6 @@ impl ChatApp {
("open", "Alias for load"), ("open", "Alias for load"),
("o", "Alias for load"), ("o", "Alias for load"),
("sessions", "List saved sessions"), ("sessions", "List saved sessions"),
("ls", "Alias for sessions"),
("help", "Show help documentation"), ("help", "Show help documentation"),
("h", "Alias for help"), ("h", "Alias for help"),
("model", "Select a model"), ("model", "Select a model"),
@@ -363,6 +362,9 @@ impl ChatApp {
("theme", "Switch theme"), ("theme", "Switch theme"),
("themes", "List available themes"), ("themes", "List available themes"),
("reload", "Reload configuration and themes"), ("reload", "Reload configuration and themes"),
("e", "Edit a file"),
("edit", "Alias for edit"),
("ls", "List directory contents"),
("privacy-enable", "Enable a privacy-sensitive tool"), ("privacy-enable", "Enable a privacy-sensitive tool"),
("privacy-disable", "Disable a privacy-sensitive tool"), ("privacy-disable", "Disable a privacy-sensitive tool"),
("privacy-clear", "Clear stored secure data"), ("privacy-clear", "Clear stored secure data"),
@@ -1385,7 +1387,7 @@ impl ChatApp {
} }
} }
} }
"sessions" | "ls" => { "sessions" => {
// List saved sessions // List saved sessions
match self.controller.list_saved_sessions().await { match self.controller.list_saved_sessions().await {
Ok(sessions) => { Ok(sessions) => {
@@ -1419,6 +1421,47 @@ impl ChatApp {
self.controller.start_new_conversation(None, None); self.controller.start_new_conversation(None, None);
self.status = "Started new conversation".to_string(); self.status = "Started new conversation".to_string();
} }
"e" | "edit" => {
if let Some(path) = args.first() {
match self.controller.read_file(path).await {
Ok(content) => {
let message = format!(
"The content of file `{}` is:\n```\n{}\n```",
path, content
);
self.controller
.conversation_mut()
.push_user_message(message);
self.pending_llm_request = true;
}
Err(e) => {
self.error =
Some(format!("Failed to read file: {}", e));
}
}
} else {
self.error = Some("Usage: :e <path>".to_string());
}
}
"ls" => {
let path = args.first().copied().unwrap_or(".");
match self.controller.list_dir(path).await {
Ok(entries) => {
let message = format!(
"Directory listing for `{}`:\n```\n{}\n```",
path,
entries.join("\n")
);
self.controller
.conversation_mut()
.push_user_message(message);
}
Err(e) => {
self.error =
Some(format!("Failed to list directory: {}", e));
}
}
}
"theme" => { "theme" => {
if args.is_empty() { if args.is_empty() {
self.error = Some("Usage: :theme <name>".to_string()); self.error = Some("Usage: :theme <name>".to_string());

View File

@@ -670,7 +670,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let chunks_len = chunks.len(); let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() { for (i, seg) in chunks.into_iter().enumerate() {
let mut spans = vec![Span::raw(format!("{indent}{}", seg))]; let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(format!("{indent}{}", seg), style)];
if i == chunks_len - 1 && is_streaming { if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor))); spans.push(Span::styled("", Style::default().fg(theme.cursor)));
} }
@@ -682,7 +688,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let chunks = wrap(&content, content_width as usize); let chunks = wrap(&content, content_width as usize);
let chunks_len = chunks.len(); let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() { for (i, seg) in chunks.into_iter().enumerate() {
let mut spans = vec![Span::raw(seg.into_owned())]; let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(seg.into_owned(), style)];
if i == chunks_len - 1 && is_streaming { if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor))); spans.push(Span::styled("", Style::default().fg(theme.cursor)));
} }
@@ -2075,7 +2087,7 @@ fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
match role { match role {
Role::User => Style::default().fg(theme.user_message_role), Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role), Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.info), Role::System => Style::default().fg(theme.unfocused_panel_border),
Role::Tool => Style::default().fg(theme.info), Role::Tool => Style::default().fg(theme.info),
} }
} }
@@ -2085,14 +2097,17 @@ fn format_tool_output(content: &str) -> String {
// Try to parse as JSON // Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) { if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
let mut output = String::new(); let mut output = String::new();
let mut content_found = false;
// Extract query if present // Extract query if present
if let Some(query) = json.get("query").and_then(|v| v.as_str()) { if let Some(query) = json.get("query").and_then(|v| v.as_str()) {
output.push_str(&format!("Query: \"{}\"\n\n", query)); output.push_str(&format!("Query: \"{}\"\n\n", query));
content_found = true;
} }
// Extract results array // Extract results array
if let Some(results) = json.get("results").and_then(|v| v.as_array()) { if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
content_found = true;
if results.is_empty() { if results.is_empty() {
output.push_str("No results found"); output.push_str("No results found");
return output; return output;
@@ -2158,12 +2173,20 @@ fn format_tool_output(content: &str) -> String {
if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) { if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) {
output.push_str(&format!("Found {} result(s)", total)); output.push_str(&format!("Found {} result(s)", total));
} }
} else if let Some(result) = json.get("result").and_then(|v| v.as_str()) {
content_found = true;
output.push_str(result);
} else if let Some(error) = json.get("error").and_then(|v| v.as_str()) { } else if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
content_found = true;
// Handle error results // Handle error results
output.push_str(&format!("❌ Error: {}", error)); output.push_str(&format!("❌ Error: {}", error));
} }
output if content_found {
output
} else {
content.to_string()
}
} else { } else {
// If not JSON, return as-is // If not JSON, return as-is
content.to_string() content.to_string()

View File

@@ -5,6 +5,7 @@ focused_panel_border = "lightmagenta"
unfocused_panel_border = "#5f1487" unfocused_panel_border = "#5f1487"
user_message_role = "lightblue" user_message_role = "lightblue"
assistant_message_role = "yellow" assistant_message_role = "yellow"
tool_output = "gray"
thinking_panel_title = "lightmagenta" thinking_panel_title = "lightmagenta"
command_bar_background = "black" command_bar_background = "black"
status_background = "black" status_background = "black"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#4a90e2"
unfocused_panel_border = "#dddddd" unfocused_panel_border = "#dddddd"
user_message_role = "#0055a4" user_message_role = "#0055a4"
assistant_message_role = "#8e44ad" assistant_message_role = "#8e44ad"
tool_output = "gray"
thinking_panel_title = "#8e44ad" thinking_panel_title = "#8e44ad"
command_bar_background = "white" command_bar_background = "white"
status_background = "white" status_background = "white"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#ff79c6"
unfocused_panel_border = "#44475a" unfocused_panel_border = "#44475a"
user_message_role = "#8be9fd" user_message_role = "#8be9fd"
assistant_message_role = "#ff79c6" assistant_message_role = "#ff79c6"
tool_output = "#6272a4"
thinking_panel_title = "#bd93f9" thinking_panel_title = "#bd93f9"
command_bar_background = "#44475a" command_bar_background = "#44475a"
status_background = "#44475a" status_background = "#44475a"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#fe8019"
unfocused_panel_border = "#7c6f64" unfocused_panel_border = "#7c6f64"
user_message_role = "#b8bb26" user_message_role = "#b8bb26"
assistant_message_role = "#83a598" assistant_message_role = "#83a598"
tool_output = "#928374"
thinking_panel_title = "#d3869b" thinking_panel_title = "#d3869b"
command_bar_background = "#3c3836" command_bar_background = "#3c3836"
status_background = "#3c3836" status_background = "#3c3836"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#80cbc4"
unfocused_panel_border = "#546e7a" unfocused_panel_border = "#546e7a"
user_message_role = "#82aaff" user_message_role = "#82aaff"
assistant_message_role = "#c792ea" assistant_message_role = "#c792ea"
tool_output = "#546e7a"
thinking_panel_title = "#ffcb6b" thinking_panel_title = "#ffcb6b"
command_bar_background = "#212b30" command_bar_background = "#212b30"
status_background = "#212b30" status_background = "#212b30"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#009688"
unfocused_panel_border = "#b0bec5" unfocused_panel_border = "#b0bec5"
user_message_role = "#448aff" user_message_role = "#448aff"
assistant_message_role = "#7c4dff" assistant_message_role = "#7c4dff"
tool_output = "#90a4ae"
thinking_panel_title = "#f57c00" thinking_panel_title = "#f57c00"
command_bar_background = "#ffffff" command_bar_background = "#ffffff"
status_background = "#ffffff" status_background = "#ffffff"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#58a6ff"
unfocused_panel_border = "#30363d" unfocused_panel_border = "#30363d"
user_message_role = "#79c0ff" user_message_role = "#79c0ff"
assistant_message_role = "#89ddff" assistant_message_role = "#89ddff"
tool_output = "#546e7a"
thinking_panel_title = "#9ece6a" thinking_panel_title = "#9ece6a"
command_bar_background = "#161b22" command_bar_background = "#161b22"
status_background = "#161b22" status_background = "#161b22"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#f92672"
unfocused_panel_border = "#75715e" unfocused_panel_border = "#75715e"
user_message_role = "#66d9ef" user_message_role = "#66d9ef"
assistant_message_role = "#ae81ff" assistant_message_role = "#ae81ff"
tool_output = "#75715e"
thinking_panel_title = "#e6db74" thinking_panel_title = "#e6db74"
command_bar_background = "#272822" command_bar_background = "#272822"
status_background = "#272822" status_background = "#272822"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#eb6f92"
unfocused_panel_border = "#26233a" unfocused_panel_border = "#26233a"
user_message_role = "#31748f" user_message_role = "#31748f"
assistant_message_role = "#9ccfd8" assistant_message_role = "#9ccfd8"
tool_output = "#6e6a86"
thinking_panel_title = "#c4a7e7" thinking_panel_title = "#c4a7e7"
command_bar_background = "#26233a" command_bar_background = "#26233a"
status_background = "#26233a" status_background = "#26233a"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#268bd2"
unfocused_panel_border = "#073642" unfocused_panel_border = "#073642"
user_message_role = "#2aa198" user_message_role = "#2aa198"
assistant_message_role = "#cb4b16" assistant_message_role = "#cb4b16"
tool_output = "#657b83"
thinking_panel_title = "#6c71c4" thinking_panel_title = "#6c71c4"
command_bar_background = "#073642" command_bar_background = "#073642"
status_background = "#073642" status_background = "#073642"