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.
This commit is contained in:
2025-10-06 22:18:17 +02:00
parent c9c3d17db0
commit d002d35bde
11 changed files with 137 additions and 36 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

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

@@ -15,6 +15,12 @@ pub trait McpClient: Send + Sync {
/// Placeholder for a client that connects to a remote MCP server. /// Placeholder for a client that connects to a remote MCP server.
pub struct RemoteMcpClient; pub struct RemoteMcpClient;
impl RemoteMcpClient {
pub fn new() -> Result<Self> {
Ok(Self)
}
}
#[async_trait] #[async_trait]
impl McpClient for RemoteMcpClient { impl McpClient for RemoteMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> { async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {

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

@@ -11,8 +11,12 @@ 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,
@@ -48,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
@@ -59,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!() }
@@ -186,6 +191,13 @@ fn build_tools(
registry.register(tool); 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))) Ok((Arc::new(registry), Arc::new(validator)))
} }
@@ -268,7 +280,7 @@ impl SessionController {
tool_registry.clone(), tool_registry.clone(),
schema_validator.clone(), schema_validator.clone(),
)), )),
McpMode::Enabled => Arc::new(RemoteMcpClient {}), McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
}; };
let controller = Self { let controller = Self {
@@ -510,6 +522,26 @@ 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 (registry, validator) = build_tools( let (registry, validator) = build_tools(
&self.config, &self.config,
@@ -526,7 +558,7 @@ impl SessionController {
self.tool_registry.clone(), self.tool_registry.clone(),
self.schema_validator.clone(), self.schema_validator.clone(),
)), )),
McpMode::Enabled => Arc::new(RemoteMcpClient {}), McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
}; };
Ok(()) Ok(())
@@ -793,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 {
"..." "..."
@@ -855,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 {
"..." "..."
@@ -867,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
}; };
@@ -878,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

@@ -1,12 +1,12 @@
use crate::tools::{Tool, ToolResult}; use crate::tools::{Tool, ToolResult};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use path_clean::PathClean;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use path_clean::PathClean;
#[derive(Deserialize)] #[derive(Deserialize)]
struct FileArgs { struct FileArgs {

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

@@ -65,7 +65,7 @@ fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
.map_err(|_| JsonRpcError { .map_err(|_| JsonRpcError {
code: -32602, code: -32602,
message: "Invalid path".to_string(), message: "Invalid path".to_string(),
})? })?
.to_path_buf() .to_path_buf()
} else { } else {
path.to_path_buf() path.to_path_buf()
@@ -86,11 +86,10 @@ fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> { async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
let full_path = sanitize_path(path, root)?; let full_path = sanitize_path(path, root)?;
let entries = fs::read_dir(full_path) let entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
.map_err(|e| JsonRpcError { code: -32000,
code: -32000, message: format!("Failed to read directory: {}", e),
message: format!("Failed to read directory: {}", e), })?;
})?;
let mut result = Vec::new(); let mut result = Vec::new();
for entry in entries { for entry in entries {
@@ -150,13 +149,19 @@ async fn main() -> anyhow::Result<()> {
match handle_request(req, &root).await { match handle_request(req, &root).await {
Ok(result) => { Ok(result) => {
let resp = Response { id: request_id, result }; let resp = Response {
id: request_id,
result,
};
let resp_str = serde_json::to_string(&resp)?; let resp_str = serde_json::to_string(&resp)?;
stdout.write_all(resp_str.as_bytes()).await?; stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?; stdout.write_all(b"\n").await?;
} }
Err(error) => { 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)?; let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?; stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?; stdout.write_all(b"\n").await?;
@@ -172,4 +177,4 @@ async fn main() -> anyhow::Result<()> {
} }
Ok(()) 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());