From d002d35bde0c20ede3df0f38c6cb97bf99f1b7a4 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 6 Oct 2025 22:18:17 +0200 Subject: [PATCH] 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. --- Cargo.toml | 1 + crates/owlen-core/Cargo.toml | 1 + crates/owlen-core/src/formatting.rs | 5 +++ crates/owlen-core/src/mcp/client.rs | 6 +++ crates/owlen-core/src/provider.rs | 20 +++++---- crates/owlen-core/src/session.rs | 55 ++++++++++++++++++++----- crates/owlen-core/src/tools/fs_tools.rs | 2 +- crates/owlen-core/src/tools/mod.rs | 1 + crates/owlen-mcp-server/src/main.rs | 23 +++++++---- crates/owlen-ollama/src/lib.rs | 12 +++--- crates/owlen-tui/src/chat_app.rs | 47 ++++++++++++++++++++- 11 files changed, 137 insertions(+), 36 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5fc26b3..77bca1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/owlen-tui", "crates/owlen-cli", "crates/owlen-ollama", + "crates/owlen-mcp-server", ] exclude = [] diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index 842e319..6fc3253 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -39,6 +39,7 @@ sqlx = { workspace = true } duckduckgo = "0.2.0" reqwest = { workspace = true, features = ["default"] } reqwest_011 = { version = "0.11", package = "reqwest" } +path-clean = "1.0" [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/owlen-core/src/formatting.rs b/crates/owlen-core/src/formatting.rs index 39d211a..55d0a48 100644 --- a/crates/owlen-core/src/formatting.rs +++ b/crates/owlen-core/src/formatting.rs @@ -91,6 +91,11 @@ impl MessageFormatter { 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) } } diff --git a/crates/owlen-core/src/mcp/client.rs b/crates/owlen-core/src/mcp/client.rs index 281daf5..ce83f2e 100644 --- a/crates/owlen-core/src/mcp/client.rs +++ b/crates/owlen-core/src/mcp/client.rs @@ -15,6 +15,12 @@ pub trait McpClient: Send + Sync { /// Placeholder for a client that connects to a remote MCP server. pub struct RemoteMcpClient; +impl RemoteMcpClient { + pub fn new() -> Result { + Ok(Self) + } +} + #[async_trait] impl McpClient for RemoteMcpClient { async fn list_tools(&self) -> Result> { diff --git a/crates/owlen-core/src/provider.rs b/crates/owlen-core/src/provider.rs index 5252a0b..cfd678f 100644 --- a/crates/owlen-core/src/provider.rs +++ b/crates/owlen-core/src/provider.rs @@ -17,7 +17,7 @@ pub type ChatStream = Pin> + Send>>; /// use std::sync::Arc; /// use futures::Stream; /// 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; /// /// // 1. Create a mock provider @@ -31,18 +31,23 @@ pub type ChatStream = Pin> + Send>>; /// /// async fn list_models(&self) -> Result> { /// Ok(vec![ModelInfo { +/// id: "mock-model".to_string(), /// provider: "mock".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 { /// let content = format!("Response to: {}", request.messages.last().unwrap().content); /// Ok(ChatResponse { -/// model: request.model, -/// message: Message { role: "assistant".to_string(), content, ..Default::default() }, -/// ..Default::default() +/// message: Message::new(Role::Assistant, content), +/// usage: None, +/// is_streaming: false, +/// is_final: true, /// }) /// } /// @@ -67,8 +72,9 @@ pub type ChatStream = Pin> + Send>>; /// /// let request = ChatRequest { /// model: "mock-model".to_string(), -/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }], -/// ..Default::default() +/// messages: vec![Message::new(Role::User, "Hello".to_string())], +/// parameters: ChatParameters::default(), +/// tools: None, /// }; /// /// let response = provider.chat(request).await.unwrap(); diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index 835f7af..5f3d8af 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -11,8 +11,12 @@ use crate::model::ModelManager; use crate::provider::{ChatStream, Provider}; use crate::storage::{SessionMeta, StorageManager}; use crate::tools::{ - code_exec::CodeExecTool, registry::ToolRegistry, web_search::WebSearchTool, - web_search_detailed::WebSearchDetailedTool, Tool, + code_exec::CodeExecTool, + fs_tools::{ResourcesGetTool, ResourcesListTool}, + registry::ToolRegistry, + web_search::WebSearchTool, + web_search_detailed::WebSearchDetailedTool, + Tool, }; use crate::types::{ ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall, @@ -48,7 +52,7 @@ pub enum SessionOutcome { /// use owlen_core::provider::{Provider, ChatStream}; /// use owlen_core::session::{SessionController, SessionOutcome}; /// 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; /// /// // Mock provider for the example @@ -59,9 +63,10 @@ pub enum SessionOutcome { /// async fn list_models(&self) -> Result> { Ok(vec![]) } /// async fn chat(&self, request: ChatRequest) -> Result { /// Ok(ChatResponse { -/// model: request.model, /// 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 { unimplemented!() } @@ -186,6 +191,13 @@ fn build_tools( 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))) } @@ -268,7 +280,7 @@ impl SessionController { tool_registry.clone(), schema_validator.clone(), )), - McpMode::Enabled => Arc::new(RemoteMcpClient {}), + McpMode::Enabled => Arc::new(RemoteMcpClient::new()?), }; let controller = Self { @@ -510,6 +522,26 @@ impl SessionController { self.credential_manager.as_ref().map(Arc::clone) } + pub async fn read_file(&self, path: &str) -> Result { + 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> { + 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 = serde_json::from_value(response.output)?; + Ok(content) + } + fn rebuild_tools(&mut self) -> Result<()> { let (registry, validator) = build_tools( &self.config, @@ -526,7 +558,7 @@ impl SessionController { self.tool_registry.clone(), self.schema_validator.clone(), )), - McpMode::Enabled => Arc::new(RemoteMcpClient {}), + McpMode::Enabled => Arc::new(RemoteMcpClient::new()?), }; Ok(()) @@ -793,7 +825,7 @@ impl SessionController { let first_msg = &conv.messages[0]; let preview = first_msg.content.chars().take(50).collect::(); return Ok(format!( - "{}{}", + "{}{} ", preview, if first_msg.content.len() > 50 { "..." @@ -855,7 +887,7 @@ impl SessionController { let first_msg = &conv.messages[0]; let preview = first_msg.content.chars().take(50).collect::(); return Ok(format!( - "{}{}", + "{}{} ", preview, if first_msg.content.len() > 50 { "..." @@ -867,7 +899,8 @@ impl SessionController { // Truncate if too long let truncated = if description.len() > 100 { - format!("{}...", description.chars().take(97).collect::()) + description.chars().take(97).collect::() + // Removed trailing '...' as it's already handled by the format! macro } else { description }; @@ -878,7 +911,7 @@ impl SessionController { let first_msg = &conv.messages[0]; let preview = first_msg.content.chars().take(50).collect::(); Ok(format!( - "{}{}", + "{}{} ", preview, if first_msg.content.len() > 50 { "..." diff --git a/crates/owlen-core/src/tools/fs_tools.rs b/crates/owlen-core/src/tools/fs_tools.rs index 17d48e2..2f09d44 100644 --- a/crates/owlen-core/src/tools/fs_tools.rs +++ b/crates/owlen-core/src/tools/fs_tools.rs @@ -1,12 +1,12 @@ 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}; -use path_clean::PathClean; #[derive(Deserialize)] struct FileArgs { diff --git a/crates/owlen-core/src/tools/mod.rs b/crates/owlen-core/src/tools/mod.rs index 6849be2..b5f43e2 100644 --- a/crates/owlen-core/src/tools/mod.rs +++ b/crates/owlen-core/src/tools/mod.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use serde_json::Value; pub mod code_exec; +pub mod fs_tools; pub mod registry; pub mod web_search; pub mod web_search_detailed; diff --git a/crates/owlen-mcp-server/src/main.rs b/crates/owlen-mcp-server/src/main.rs index f36f12f..9a34097 100644 --- a/crates/owlen-mcp-server/src/main.rs +++ b/crates/owlen-mcp-server/src/main.rs @@ -65,7 +65,7 @@ fn sanitize_path(path: &str, root: &Path) -> Result { .map_err(|_| JsonRpcError { code: -32602, message: "Invalid path".to_string(), - })? + })? .to_path_buf() } else { path.to_path_buf() @@ -86,11 +86,10 @@ fn sanitize_path(path: &str, root: &Path) -> Result { async fn resources_list(path: &str, root: &Path) -> Result { 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 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 { @@ -150,13 +149,19 @@ async fn main() -> anyhow::Result<()> { match handle_request(req, &root).await { Ok(result) => { - let resp = Response { id: request_id, 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 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?; @@ -172,4 +177,4 @@ async fn main() -> anyhow::Result<()> { } Ok(()) -} \ No newline at end of file +} diff --git a/crates/owlen-ollama/src/lib.rs b/crates/owlen-ollama/src/lib.rs index b9955f8..58dffc3 100644 --- a/crates/owlen-ollama/src/lib.rs +++ b/crates/owlen-ollama/src/lib.rs @@ -956,21 +956,21 @@ mod tests { #[test] 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!( - resolve_api_key(Some("${OWLEN_TEST_KEY}".into())), + resolve_api_key(Some("${OWLEN_TEST_KEY_BRACED}".into())), Some("super-secret".into()) ); - std::env::remove_var("OWLEN_TEST_KEY"); + std::env::remove_var("OWLEN_TEST_KEY_BRACED"); } #[test] 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!( - resolve_api_key(Some("$OWLEN_TEST_KEY".into())), + resolve_api_key(Some("$OWLEN_TEST_KEY_UNBRACED".into())), Some("another-secret".into()) ); - std::env::remove_var("OWLEN_TEST_KEY"); + std::env::remove_var("OWLEN_TEST_KEY_UNBRACED"); } } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 36e1763..3e2b41d 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -353,7 +353,6 @@ impl ChatApp { ("open", "Alias for load"), ("o", "Alias for load"), ("sessions", "List saved sessions"), - ("ls", "Alias for sessions"), ("help", "Show help documentation"), ("h", "Alias for help"), ("model", "Select a model"), @@ -363,6 +362,9 @@ impl ChatApp { ("theme", "Switch theme"), ("themes", "List available 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-disable", "Disable a privacy-sensitive tool"), ("privacy-clear", "Clear stored secure data"), @@ -1385,7 +1387,7 @@ impl ChatApp { } } } - "sessions" | "ls" => { + "sessions" => { // List saved sessions match self.controller.list_saved_sessions().await { Ok(sessions) => { @@ -1419,6 +1421,47 @@ impl ChatApp { self.controller.start_new_conversation(None, None); 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 ".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" => { if args.is_empty() { self.error = Some("Usage: :theme ".to_string());