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-cli",
"crates/owlen-ollama",
"crates/owlen-mcp-server",
]
exclude = []

View File

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

View File

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

View File

@@ -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<Self> {
Ok(Self)
}
}
#[async_trait]
impl McpClient for RemoteMcpClient {
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 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<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
///
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
/// 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<ChatResponse> {
/// 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<Box<dyn Stream<Item = Result<ChatResponse>> + 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();

View File

@@ -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<Vec<ModelInfo>> { Ok(vec![]) }
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// 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<ChatStream> { 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<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<()> {
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(())
@@ -867,7 +899,8 @@ impl SessionController {
// Truncate if too long
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 {
description
};

View File

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

View File

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

View File

@@ -86,8 +86,7 @@ fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
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 {
let entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read directory: {}", e),
})?;
@@ -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?;

View File

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

View File

@@ -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 <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" => {
if args.is_empty() {
self.error = Some("Usage: :theme <name>".to_string());