fix(core,mcp,security)!: resolve critical P0/P1 issues

BREAKING CHANGES:
- owlen-core no longer depends on ratatui/crossterm
- RemoteMcpClient constructors are now async
- MCP path validation is stricter (security hardening)

This commit resolves three critical issues identified in project analysis:

## P0-1: Extract TUI dependencies from owlen-core

Create owlen-ui-common crate to hold UI-agnostic color and theme
abstractions, removing architectural boundary violation.

Changes:
- Create new owlen-ui-common crate with abstract Color enum
- Move theme.rs from owlen-core to owlen-ui-common
- Define Color with Rgb and Named variants (no ratatui dependency)
- Create color conversion layer in owlen-tui (color_convert.rs)
- Update 35+ color usages with conversion wrappers
- Remove ratatui/crossterm from owlen-core dependencies

Benefits:
- owlen-core usable in headless/CLI contexts
- Enables future GUI frontends
- Reduces binary size for core library consumers

## P0-2: Fix blocking WebSocket connections

Convert RemoteMcpClient constructors to async, eliminating runtime
blocking that froze TUI for 30+ seconds on slow connections.

Changes:
- Make new_with_runtime(), new_with_config(), new() async
- Remove block_in_place wrappers for I/O operations
- Add 30-second connection timeout with tokio::time::timeout
- Update 15+ call sites across 10 files to await constructors
- Convert 4 test functions to #[tokio::test]

Benefits:
- TUI remains responsive during WebSocket connections
- Proper async I/O follows Rust best practices
- No more indefinite hangs

## P1-1: Secure path traversal vulnerabilities

Implement comprehensive path validation with 7 defense layers to
prevent file access outside workspace boundaries.

Changes:
- Create validate_safe_path() with multi-layer security:
  * URL decoding (prevents %2E%2E bypasses)
  * Absolute path rejection
  * Null byte protection
  * Windows-specific checks (UNC/device paths)
  * Lexical path cleaning (removes .. components)
  * Symlink resolution via canonicalization
  * Boundary verification with starts_with check
- Update 4 MCP resource functions (get/list/write/delete)
- Add 11 comprehensive security tests

Benefits:
- Blocks URL-encoded, absolute, UNC path attacks
- Prevents null byte injection
- Stops symlink escape attempts
- Cross-platform security (Windows/Linux/macOS)

## Test Results

- owlen-core: 109/109 tests pass (100%)
- owlen-tui: 52/53 tests pass (98%, 1 pre-existing failure)
- owlen-providers: 2/2 tests pass (100%)
- Build: cargo build --all succeeds

## Verification

- ✓ cargo tree -p owlen-core shows no TUI dependencies
- ✓ No block_in_place calls remain in MCP I/O code
- ✓ All 11 security tests pass

Fixes: #P0-1, #P0-2, #P1-1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-29 12:31:20 +01:00
parent 7aa80fb0a4
commit 0728262a9e
31 changed files with 5121 additions and 1166 deletions

309
CLAUDE.md Normal file
View File

@@ -0,0 +1,309 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
OWLEN is a Rust-powered, terminal-first interface for interacting with local and cloud language models. It uses a multi-provider architecture with vim-style navigation and session management.
**Status**: Alpha (v0.2.0) - core features functional but expect occasional bugs and breaking changes.
## Build, Test & Development Commands
### Building
```bash
# Build all crates
cargo build
# Build release binary
cargo build --release
# Run the TUI (requires Ollama running)
./target/release/owlen
# or
cargo run -p owlen-cli
# Build for specific target (cross-compilation)
dev/local_build.sh x86_64-unknown-linux-gnu
```
### Testing
```bash
# Run all tests
cargo test --all
# Test specific crate
cargo test -p owlen-core
cargo test -p owlen-tui
cargo test -p owlen-providers
# Linting and formatting
cargo clippy --all -- -D warnings
cargo fmt --all -- --check
# Pre-commit hooks (install once with `pre-commit install`)
pre-commit run --all-files
```
### Developer Tasks
```bash
# Regenerate screenshots for documentation
cargo xtask screenshots
cargo xtask screenshots --no-png # skip PNG generation
cargo xtask screenshots --output images/
# Regenerate repository map after structural changes
scripts/gen-repo-map.sh
# Platform compatibility checks
scripts/check-windows.sh # Windows GNU toolchain smoke test
```
### Running Individual Tests
```bash
# Run a specific test by name
cargo test test_name
# Run tests with output
cargo test -- --nocapture
# Run tests in a specific file
cargo test --test integration_test_name
```
## Architecture & Key Concepts
### Workspace Structure (Cargo workspace with 13+ crates)
- **owlen-core**: Core abstractions, provider traits, session management, MCP client layer (UI-agnostic)
- **owlen-tui**: Terminal UI built with ratatui (event loop, rendering, vim modes)
- **owlen-cli**: Entry point that parses args, loads config, launches TUI or headless flows
- **owlen-providers**: Concrete provider adapters (Ollama local, Ollama Cloud)
- **owlen-markdown**: Markdown parsing and rendering
- **crates/mcp/**: Model Context Protocol infrastructure
- **llm-server**: Wraps owlen-providers behind MCP boundary (generate_text tools)
- **server**: Generic MCP server for file ops and workspace tools
- **client**: MCP client implementation
- **code-server**: Code execution sandboxing
- **prompt-server**: Template rendering
- **xtask**: Development automation tasks (screenshots, etc.)
### Dependency Boundaries
- **owlen-core is the dependency ceiling**: Must stay free of terminal logic, CLIs, or provider HTTP clients
- **owlen-cli only orchestrates startup/shutdown**: Business logic belongs in owlen-core or library crates
- **owlen-mcp-llm-server is the only crate that directly talks to providers**: UI/CLI communicate through MCP clients
### Multi-Provider Architecture
```
[owlen-tui / owlen-cli]
│ chat + model requests
[owlen-core::ProviderManager] ──> Arc<dyn ModelProvider>
│ ▲
│ │ implements ModelProvider
▼ │
[owlen-core::mcp::RemoteMcpClient] ────────┘
│ (JSON-RPC over stdio)
┌────────────────────────────────────────────────┐
│ MCP Process Boundary (spawned per provider) │
│ │
│ crates/mcp/llm-server ──> owlen-providers::* │
└────────────────────────────────────────────────┘
```
Key points:
- **ProviderManager** tracks health, merges model catalogs, and dispatches requests
- **RemoteMcpClient** bridges MCP protocol to ModelProvider trait
- **MCP servers** isolate provider-specific code in separate processes
- **Health & availability** tracked via background workers and surfaced in TUI picker
### Event Flow & TUI Architecture
1. User input → Event loop → Message handler → Session controller → Provider manager → Provider
2. Non-blocking design: TUI remains responsive during streaming (see `agents.md` for planned improvements)
3. Modal workflow: Normal, Insert, Visual, Command modes (vim-inspired)
4. AppMessage stream carries async events (provider responses, health checks)
### Session & Conversation Management
- **Conversation** (owlen-core): Holds messages and metadata
- **SessionController**: High-level orchestrator managing history, context, model switching
- Conversations stored in platform-specific data directory (can be encrypted with AES-GCM)
### Configuration
Platform-specific locations:
- Linux: `~/.config/owlen/config.toml`
- macOS: `~/Library/Application Support/owlen/config.toml`
- Windows: `%APPDATA%\owlen\config.toml`
Commands:
```bash
owlen config init # Create default config
owlen config init --force # Overwrite existing
owlen config path # Print config location
owlen config doctor # Migrate legacy configs
```
## Coding Conventions
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`
Example: `feat(provider): add support for Gemini Pro`
### Pre-commit Hooks
Hooks automatically run on commit (install with `pre-commit install`):
- `cargo fmt`
- `cargo check`
- `cargo clippy --all-features`
- File hygiene (trailing whitespace, EOF newlines)
To bypass (not recommended): `git commit --no-verify`
### Style Guidelines
- Run `cargo fmt` before committing
- Address all `cargo clippy` warnings
- Use `#[cfg(test)]` modules for unit tests in same file
- Place integration tests in `tests/` directory
## Provider Development
### Adding a New Provider
Follow `docs/adding-providers.md`:
1. Implement `ModelProvider` trait in `owlen-providers`
2. Set `ProviderMetadata::provider_type` (Local/Cloud)
3. Register with `ProviderManager` in startup code
4. Optionally expose through MCP server
5. Add integration tests following `crates/owlen-providers/tests` pattern
6. Document config in `docs/configuration.md` and default `config.toml`
7. Update `README.md`, `CHANGELOG.md`, `docs/troubleshooting.md`
See `docs/provider-implementation.md` for trait-level details.
### MCP Tool Naming
Enforce spec-compliant identifiers: `^[A-Za-z0-9_-]{1,64}$`
- Use underscores or hyphens (e.g., `web_search`, `filesystem_read`)
- Avoid dotted names (legacy incompatible)
- Qualify with `{server}__{tool}` when multiple servers overlap (e.g., `filesystem__read`)
## Repository Automation
OWLEN includes Git-aware automation for code review and commit templating:
### CLI Commands
```bash
# Generate commit message from staged diff
owlen repo commit-template
owlen repo commit-template --working-tree # inspect unstaged
# Review branch or PR
owlen repo review
owlen repo review --owner Owlibou --repo owlen --number 42 --token-env GITHUB_TOKEN
```
### TUI Commands
```
:repo template # inject commit template into chat
:repo review [--base BRANCH] [--head REF] # review local changes
```
## Key Files & Entry Points
### Main Entry Points
- `crates/owlen-cli/src/main.rs` - CLI entry point (argument parsing, config loading)
- `crates/owlen-tui/src/app/mod.rs` - Main TUI application and event dispatch
- `crates/owlen-core/src/provider.rs` - ModelProvider trait definition
### Configuration & State
- `crates/owlen-core/src/config.rs` - Configuration loading and parsing
- `crates/owlen-core/src/session.rs` - Session and conversation management
- `crates/owlen-core/src/storage.rs` - Persistence layer
### Provider Infrastructure
- `crates/owlen-providers/src/ollama/` - Ollama local and cloud providers
- `crates/mcp/llm-server/src/main.rs` - MCP LLM server process
- `crates/owlen-core/src/mcp/remote_client.rs` - MCP client implementation
## Testing Strategy
### Unit Tests
Place in `#[cfg(test)]` modules within source files for isolated component testing.
### Integration Tests
Place in `tests/` directories:
- `crates/owlen-providers/tests/` - Provider integration tests
- Test registration, model aggregation, request routing, health transitions
### Focus Areas
- Command palette state machine
- Agent response parsing
- MCP protocol abstractions
- Provider manager health cache
- Session controller lifecycle
## Documentation Structure
- `README.md` - User-facing overview, installation, features
- `CONTRIBUTING.md` - Contribution guidelines, development setup
- `docs/architecture.md` - High-level architecture (read first!)
- `docs/repo-map.md` - Workspace layout snapshot
- `docs/adding-providers.md` - Provider implementation checklist
- `docs/provider-implementation.md` - Trait-level provider details
- `docs/testing.md` - Testing guide
- `docs/troubleshooting.md` - Common issues and solutions
- `docs/configuration.md` - Configuration reference
- `docs/platform-support.md` - OS support matrix
## Important Implementation Notes
### When Working on TUI Code
- Modal state machine is critical: Normal ↔ Insert ↔ Visual ↔ Command
- Status line shows current mode (use as regression check)
- Non-blocking event loop planned (see `agents.md`)
- Command palette state lives in `owlen_tui::state`
- Follow Model-View-Update pattern for new features
### When Working on Providers
- Never import providers directly in owlen-tui or owlen-cli
- All provider communication goes through owlen-core abstractions
- Health checks run on background workers
- Model discovery fans out through ProviderManager
### When Working on MCP Integration
- RemoteMcpClient implements both MCP client traits and ModelProvider
- MCP servers are short-lived, narrowly scoped binaries
- Tool calls travel same transport as chat requests
- Consent prompts surface in UI via session events
## Platform Support
- **Primary**: Linux (Arch AUR: `owlen-git`)
- **Supported**: macOS 12+ (requires Command Line Tools for OpenSSL)
- **Experimental**: Windows (GNU toolchain, some Docker features disabled)
Cross-platform testing: Use `dev/local_build.sh` and `scripts/check-windows.sh`
## Dependencies & Async Runtime
- **Async runtime**: tokio with "full" features
- **TUI framework**: ratatui 0.29 with palette features
- **HTTP client**: reqwest with rustls-tls (no native-tls)
- **Database**: SQLx with sqlite, tokio runtime
- **Serialization**: serde + serde_json
- **Testing**: tokio-test for async test utilities
## Security & Privacy
- Local-first: LLM calls route through local Ollama by default
- Session encryption: Set `privacy.encrypt_local_data = true` for AES-GCM storage
- No telemetry sent
- Outbound requests only when explicitly enabling remote tools/providers
- Config migrations carry schema version and warn on deprecated keys

View File

@@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/owlen-core",
"crates/owlen-ui-common",
"crates/owlen-tui",
"crates/owlen-cli",
"crates/owlen-providers",

View File

@@ -75,7 +75,7 @@ async fn main() -> anyhow::Result<()> {
// Initialise the MCP LLM client it implements Provider and talks to the
// MCP LLM server which wraps Ollama. This ensures all communication goes
// through the MCP architecture (Phase 10 requirement).
let provider = Arc::new(RemoteMcpClient::new()?);
let provider = Arc::new(RemoteMcpClient::new().await?);
// The MCP client also serves as the tool client for resource operations
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;

View File

@@ -72,7 +72,7 @@ pub async fn launch(initial_mode: Mode, options: LaunchOptions) -> Result<()> {
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
let tui_controller = Arc::new(TuiController::new(tui_tx));
let provider = build_provider(&cfg)?;
let provider = build_provider(&cfg).await?;
let mut offline_notice: Option<String> = None;
let provider = match provider.health_check().await {
Ok(_) => provider,
@@ -153,13 +153,13 @@ pub async fn launch(initial_mode: Mode, options: LaunchOptions) -> Result<()> {
Ok(())
}
fn build_provider(cfg: &Config) -> Result<Arc<dyn Provider>> {
async fn build_provider(cfg: &Config) -> Result<Arc<dyn Provider>> {
match cfg.mcp.mode {
McpMode::RemotePreferred => {
let remote_result = if let Some(mcp_server) = cfg.effective_mcp_servers().first() {
RemoteMcpClient::new_with_config(mcp_server)
RemoteMcpClient::new_with_config(mcp_server).await
} else {
RemoteMcpClient::new()
RemoteMcpClient::new().await
};
match remote_result {
@@ -178,7 +178,7 @@ fn build_provider(cfg: &Config) -> Result<Arc<dyn Provider>> {
let mcp_server = cfg.effective_mcp_servers().first().ok_or_else(|| {
anyhow!("[[mcp_servers]] must be configured when [mcp].mode = \"remote_only\"")
})?;
let client = RemoteMcpClient::new_with_config(mcp_server)?;
let client = RemoteMcpClient::new_with_config(mcp_server).await?;
Ok(Arc::new(client) as Arc<dyn Provider>)
}
McpMode::LocalOnly | McpMode::Legacy => build_local_provider(cfg),

View File

@@ -14,7 +14,7 @@ use std::sync::Arc;
#[tokio::test]
async fn test_react_parsing_tool_call() {
let executor = create_test_executor();
let executor = create_test_executor().await;
// Test parsing a tool call with JSON arguments
let text = "THOUGHT: I should search for information\nACTION: web_search\nACTION_INPUT: {\"query\": \"rust async programming\"}\n";
@@ -37,7 +37,7 @@ async fn test_react_parsing_tool_call() {
#[tokio::test]
async fn test_react_parsing_final_answer() {
let executor = create_test_executor();
let executor = create_test_executor().await;
let text = "THOUGHT: I have enough information now\nFINAL_ANSWER: The answer is 42\n";
@@ -54,7 +54,7 @@ async fn test_react_parsing_final_answer() {
#[tokio::test]
async fn test_react_parsing_with_multiline_thought() {
let executor = create_test_executor();
let executor = create_test_executor().await;
let text = "THOUGHT: This is a complex\nmulti-line thought\nACTION: list_files\nACTION_INPUT: {\"path\": \".\"}\n";
@@ -75,7 +75,7 @@ async fn test_react_parsing_with_multiline_thought() {
#[ignore] // Requires MCP LLM server to be running
async fn test_agent_single_tool_scenario() {
// This test requires a running MCP LLM server (which wraps Ollama)
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let provider = Arc::new(RemoteMcpClient::new().await.unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
@@ -112,7 +112,7 @@ async fn test_agent_single_tool_scenario() {
#[ignore] // Requires Ollama to be running
async fn test_agent_multi_step_workflow() {
// Test a query that requires multiple tool calls
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let provider = Arc::new(RemoteMcpClient::new().await.unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
@@ -144,7 +144,7 @@ async fn test_agent_multi_step_workflow() {
#[tokio::test]
#[ignore] // Requires Ollama
async fn test_agent_iteration_limit() {
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let provider = Arc::new(RemoteMcpClient::new().await.unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
@@ -186,7 +186,7 @@ async fn test_agent_iteration_limit() {
#[tokio::test]
#[ignore] // Requires Ollama
async fn test_agent_tool_budget_enforcement() {
let provider = Arc::new(RemoteMcpClient::new().unwrap());
let provider = Arc::new(RemoteMcpClient::new().await.unwrap());
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
let config = AgentConfig {
@@ -226,10 +226,10 @@ async fn test_agent_tool_budget_enforcement() {
// Helper function to create a test executor
// For parsing tests, we don't need a real connection
fn create_test_executor() -> AgentExecutor {
async fn create_test_executor() -> AgentExecutor {
// For parsing tests, we can accept the error from RemoteMcpClient::new()
// since we're only testing parse_response which doesn't use the MCP client
let provider = match RemoteMcpClient::new() {
let provider = match RemoteMcpClient::new().await {
Ok(client) => Arc::new(client),
Err(_) => {
// If MCP server binary doesn't exist, parsing tests can still run

View File

@@ -9,6 +9,7 @@ homepage.workspace = true
description = "Core traits and types for OWLEN LLM client"
[dependencies]
owlen-ui-common = { path = "../owlen-ui-common" }
anyhow = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
@@ -26,7 +27,6 @@ async-trait = { workspace = true }
toml = { workspace = true }
shellexpand = { workspace = true }
dirs = { workspace = true }
ratatui = { workspace = true }
tempfile = { workspace = true }
jsonschema = { workspace = true }
which = { workspace = true }
@@ -35,7 +35,6 @@ aes-gcm = { workspace = true }
ring = { workspace = true }
keyring = { workspace = true }
chrono = { workspace = true }
crossterm = { workspace = true }
urlencoding = { workspace = true }
sqlx = { workspace = true }
reqwest = { workspace = true, features = ["default"] }

View File

@@ -29,7 +29,6 @@ pub mod sandbox;
pub mod session;
pub mod state;
pub mod storage;
pub mod theme;
pub mod tools;
pub mod types;
pub mod ui;
@@ -37,6 +36,12 @@ pub mod usage;
pub mod validation;
pub mod wrap_cursor;
// Re-export theme types from owlen-ui-common
pub use owlen_ui_common::{
Color, NamedColor, Theme, ThemePalette, built_in_themes, default_themes_dir, get_theme,
load_all_themes,
};
pub use agent::*;
pub use agent_registry::*;
pub use automation::*;
@@ -66,7 +71,6 @@ pub use router::*;
pub use sandbox::*;
pub use session::*;
pub use state::*;
pub use theme::*;
pub use tools::*;
pub use usage::*;
pub use validation::*;

View File

@@ -35,12 +35,12 @@ impl McpClientFactory {
}
/// Create an MCP client based on the current configuration.
pub fn create(&self) -> Result<Box<dyn McpClient>> {
self.create_with_secrets(None)
pub async fn create(&self) -> Result<Box<dyn McpClient>> {
self.create_with_secrets(None).await
}
/// Create an MCP client using optional runtime secrets (OAuth tokens, env overrides).
pub fn create_with_secrets(
pub async fn create_with_secrets(
&self,
runtime: Option<McpRuntimeSecrets>,
) -> Result<Box<dyn McpClient>> {
@@ -67,6 +67,7 @@ impl McpClientFactory {
})?;
RemoteMcpClient::new_with_runtime(server_cfg, runtime)
.await
.map(|client| Box::new(client) as Box<dyn McpClient>)
.map_err(|e| {
Error::Config(format!(
@@ -77,7 +78,7 @@ impl McpClientFactory {
}
McpMode::RemotePreferred => {
if let Some(server_cfg) = self.config.effective_mcp_servers().first() {
match RemoteMcpClient::new_with_runtime(server_cfg, runtime.clone()) {
match RemoteMcpClient::new_with_runtime(server_cfg, runtime.clone()).await {
Ok(client) => {
info!(
"Connected to remote MCP server '{}' via {} transport.",
@@ -112,8 +113,8 @@ impl McpClientFactory {
}
/// Check if remote MCP mode is available
pub fn is_remote_available() -> bool {
RemoteMcpClient::new().is_ok()
pub async fn is_remote_available() -> bool {
RemoteMcpClient::new().await.is_ok()
}
}
@@ -134,32 +135,32 @@ mod tests {
McpClientFactory::new(Arc::new(config), registry, validator)
}
#[test]
fn test_factory_creates_local_client_when_no_servers_configured() {
#[tokio::test]
async fn test_factory_creates_local_client_when_no_servers_configured() {
let mut config = Config::default();
config.refresh_mcp_servers(None).unwrap();
let factory = build_factory(config);
// Should create without error and fall back to local client
let result = factory.create();
let result = factory.create().await;
assert!(result.is_ok());
}
#[test]
fn test_remote_only_without_servers_errors() {
#[tokio::test]
async fn test_remote_only_without_servers_errors() {
let mut config = Config::default();
config.mcp.mode = McpMode::RemoteOnly;
config.mcp_servers.clear();
config.refresh_mcp_servers(None).unwrap();
let factory = build_factory(config);
let result = factory.create();
let result = factory.create().await;
assert!(matches!(result, Err(Error::Config(_))));
}
#[test]
fn test_remote_preferred_without_fallback_propagates_remote_error() {
#[tokio::test]
async fn test_remote_preferred_without_fallback_propagates_remote_error() {
let mut config = Config::default();
config.mcp.mode = McpMode::RemotePreferred;
config.mcp.allow_fallback = false;
@@ -174,19 +175,19 @@ mod tests {
config.refresh_mcp_servers(None).unwrap();
let factory = build_factory(config);
let result = factory.create();
let result = factory.create().await;
assert!(
matches!(result, Err(Error::Config(message)) if message.contains("Failed to start remote MCP client"))
);
}
#[test]
fn test_legacy_mode_uses_local_client() {
#[tokio::test]
async fn test_legacy_mode_uses_local_client() {
let mut config = Config::default();
config.mcp.mode = McpMode::Legacy;
let factory = build_factory(config);
let result = factory.create();
let result = factory.create().await;
assert!(result.is_ok());
}
}

View File

@@ -308,7 +308,7 @@ mod tests {
oauth: None,
};
if let Ok(client) = RemoteMcpClient::new_with_config(&config) {
if let Ok(client) = RemoteMcpClient::new_with_config(&config).await {
let entry = ServerEntry::new("test".to_string(), Arc::new(client), 1);
assert!(entry.is_available().await);

View File

@@ -11,10 +11,11 @@ use crate::{
send_via_stream,
};
use futures::{StreamExt, future::BoxFuture, stream};
use path_clean::PathClean;
use reqwest::Client as HttpClient;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@@ -52,17 +53,135 @@ pub struct McpRuntimeSecrets {
pub http_header: Option<(String, String)>,
}
/// Validates that a path is safe to access within a base directory.
///
/// This function provides comprehensive protection against path traversal attacks by:
/// 1. Decoding URL-encoded input to prevent bypasses like `%2E%2E%2F`
/// 2. Rejecting absolute paths (including Windows UNC paths)
/// 3. Checking for null bytes (which can truncate paths in some C APIs)
/// 4. Lexically cleaning the path to remove `..` components
/// 5. Canonicalizing paths to resolve symlinks
/// 6. Verifying the final path stays within the allowed base directory
///
/// # Security Guarantees
/// - Prevents directory traversal via `../` sequences (including URL-encoded)
/// - Prevents absolute path access (e.g., `/etc/passwd`, `C:\Windows`)
/// - Prevents symlink-based escapes from the workspace
/// - Prevents null byte injection attacks
///
/// # Arguments
/// * `path` - The user-provided path (may be URL-encoded)
/// * `base_dir` - The base directory that all access must stay within
///
/// # Returns
/// * `Ok(PathBuf)` - A canonicalized path guaranteed to be within `base_dir`
/// * `Err(Error)` - If the path is invalid or attempts to escape the workspace
fn validate_safe_path(path: &str, base_dir: &Path) -> Result<PathBuf> {
// 1. Decode URL-encoded input to prevent bypass attacks like %2E%2E%2Fetc%2Fpasswd
let decoded = urlencoding::decode(path)
.map_err(|_| Error::InvalidInput("Invalid URL encoding in path".into()))?
.into_owned();
// 2. Reject absolute paths early (including Windows paths like C:\, /etc, \\?\UNC)
let input_path = Path::new(&decoded);
if input_path.is_absolute() {
return Err(Error::InvalidInput(
"Absolute paths not allowed - use relative paths only".into(),
));
}
// 3. Check for null bytes (security hazard in C FFI and some filesystems)
if decoded.contains('\0') {
return Err(Error::InvalidInput("Path contains null bytes".into()));
}
// 4. Additional Windows-specific security checks
#[cfg(windows)]
{
// Block Windows UNC paths and device paths
if decoded.starts_with("\\\\") || decoded.starts_with("//") {
return Err(Error::InvalidInput("UNC paths not allowed".into()));
}
// Block Windows device paths
if decoded.to_lowercase().starts_with("\\\\.\\")
|| decoded.to_lowercase().starts_with("//./")
{
return Err(Error::InvalidInput("Device paths not allowed".into()));
}
}
// 5. Lexically clean the path to normalize and remove .. components
let full_path = base_dir.join(input_path);
let cleaned_path = full_path.clean();
// 6. Canonicalize base directory to resolve symlinks
let canonical_base = base_dir.canonicalize().map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to canonicalize workspace base directory: {}", e),
))
})?;
// 7. For the target path, handle both existing and non-existing files
// We need to canonicalize to resolve symlinks, but this fails for non-existent paths
let canonical_path = if cleaned_path.exists() {
// Path exists: fully canonicalize it to resolve all symlinks
cleaned_path.canonicalize().map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to canonicalize path: {}", e),
))
})?
} else {
// Path doesn't exist yet: canonicalize the parent directory and append filename
// This handles the case where we're writing a new file
if let Some(parent) = cleaned_path.parent() {
if parent.exists() {
let canonical_parent = parent.canonicalize().map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to canonicalize parent directory: {}", e),
))
})?;
if let Some(filename) = cleaned_path.file_name() {
canonical_parent.join(filename)
} else {
// No filename component, use cleaned path as-is
cleaned_path
}
} else {
// Parent doesn't exist - this will fail later during actual file operations
// But for security validation, we use the cleaned path
cleaned_path
}
} else {
// No parent directory, use cleaned path
cleaned_path
}
};
// 8. CRITICAL: Verify the final path is within the base directory
// This is the ultimate security boundary check
if !canonical_path.starts_with(&canonical_base) {
return Err(Error::InvalidInput(format!(
"Path escapes workspace boundary: attempted to access '{}'",
canonical_path.display()
)));
}
Ok(canonical_path)
}
impl RemoteMcpClient {
/// Spawn the MCP server binary and prepare communication channels.
/// Spawn an MCP server based on a configuration entry.
/// The `transport` field must be "stdio" (the only supported mode).
/// Spawn an external MCP server based on a configuration entry.
/// The server must communicate over STDIO (the only supported transport).
pub fn new_with_config(config: &crate::config::McpServerConfig) -> Result<Self> {
Self::new_with_runtime(config, None)
pub async fn new_with_config(config: &crate::config::McpServerConfig) -> Result<Self> {
Self::new_with_runtime(config, None).await
}
pub fn new_with_runtime(
pub async fn new_with_runtime(
config: &crate::config::McpServerConfig,
runtime: Option<McpRuntimeSecrets>,
) -> Result<Self> {
@@ -137,15 +256,23 @@ impl RemoteMcpClient {
}
"websocket" => {
// For WebSocket, the `command` field contains the WebSocket URL.
// We need to use a blocking task to establish the connection.
// Establish connection asynchronously with a timeout to avoid blocking the runtime.
let ws_url = config.command.clone();
let (ws_stream, _response) = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
connect_async(&ws_url).await.map_err(|e| {
let connection_timeout = Duration::from_secs(30);
let (ws_stream, _response) =
tokio::time::timeout(connection_timeout, connect_async(&ws_url))
.await
.map_err(|_| {
Error::Timeout(format!(
"WebSocket connection to '{}' timed out after {}s",
ws_url,
connection_timeout.as_secs()
))
})?
.map_err(|e| {
Error::Network(format!("WebSocket connection failed: {}", e))
})
})
})?;
})?;
Ok(Self {
child: None,
@@ -167,7 +294,7 @@ impl RemoteMcpClient {
}
/// Legacy constructor kept for compatibility; attempts to locate a binary.
pub fn new() -> Result<Self> {
pub async fn new() -> Result<Self> {
// Fall back to searching for a binary as before, then delegate to new_with_config.
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
@@ -198,7 +325,7 @@ impl RemoteMcpClient {
env: std::collections::HashMap::new(),
oauth: None,
};
Self::new_with_config(&config)
Self::new_with_config(&config).await
}
async fn send_rpc(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
@@ -368,8 +495,13 @@ impl McpClient for RemoteMcpClient {
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
let content = std::fs::read_to_string(path).map_err(Error::Io)?;
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
// Secure path validation to prevent path traversal attacks
let base_dir = std::env::current_dir().map_err(Error::Io)?;
let safe_path = validate_safe_path(path, &base_dir)?;
let content = std::fs::read_to_string(safe_path).map_err(Error::Io)?;
return Ok(McpToolResponse {
name: call.name,
success: true,
@@ -384,8 +516,13 @@ impl McpClient for RemoteMcpClient {
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
// Secure path validation to prevent path traversal attacks
let base_dir = std::env::current_dir().map_err(Error::Io)?;
let safe_path = validate_safe_path(path, &base_dir)?;
let mut names = Vec::new();
for entry in std::fs::read_dir(path).map_err(Error::Io)?.flatten() {
for entry in std::fs::read_dir(safe_path).map_err(Error::Io)?.flatten() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
@@ -405,16 +542,17 @@ impl McpClient for RemoteMcpClient {
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
// Simple pathtraversal protection: reject any path containing ".." or absolute paths.
if path.contains("..") || Path::new(path).is_absolute() {
return Err(Error::InvalidInput("path traversal".into()));
}
// Secure path validation to prevent path traversal attacks
let base_dir = std::env::current_dir().map_err(Error::Io)?;
let safe_path = validate_safe_path(path, &base_dir)?;
let content = call
.arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidInput("content missing".into()))?;
std::fs::write(path, content).map_err(Error::Io)?;
std::fs::write(safe_path, content).map_err(Error::Io)?;
return Ok(McpToolResponse {
name: call.name,
success: true,
@@ -429,10 +567,12 @@ impl McpClient for RemoteMcpClient {
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
if path.contains("..") || Path::new(path).is_absolute() {
return Err(Error::InvalidInput("path traversal".into()));
}
std::fs::remove_file(path).map_err(Error::Io)?;
// Secure path validation to prevent path traversal attacks
let base_dir = std::env::current_dir().map_err(Error::Io)?;
let safe_path = validate_safe_path(path, &base_dir)?;
std::fs::remove_file(safe_path).map_err(Error::Io)?;
return Ok(McpToolResponse {
name: call.name,
success: true,
@@ -561,3 +701,274 @@ impl LlmClient for RemoteMcpClient {
<Self as McpClient>::call_tool(self, call).await
}
}
#[cfg(test)]
mod path_security_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
/// Test that URL-encoded parent directory traversal attempts are blocked
#[test]
fn test_rejects_url_encoded_parent_dir() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Various URL-encoded attempts to traverse to parent directory
let attack_vectors = vec![
"%2E%2E%2Fetc%2Fpasswd", // ../etc/passwd
"%2E%2E%2F%2E%2E%2Fetc%2Fpasswd", // ../../etc/passwd
"subdir%2F%2E%2E%2F%2E%2E%2Fetc%2Fpasswd", // subdir/../../etc/passwd
"%2e%2e%2f%2e%2e%2fetc", // lowercase encoding
];
for vector in attack_vectors {
let result = validate_safe_path(vector, base);
assert!(
result.is_err(),
"Should reject URL-encoded traversal: {}",
vector
);
}
}
/// Test that absolute paths are rejected
#[test]
fn test_rejects_absolute_paths() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Unix absolute paths
assert!(validate_safe_path("/etc/passwd", base).is_err());
assert!(validate_safe_path("/tmp/evil", base).is_err());
assert!(validate_safe_path("/", base).is_err());
// Windows absolute paths (test on all platforms for consistency)
#[cfg(windows)]
{
assert!(validate_safe_path("C:\\Windows\\System32", base).is_err());
assert!(validate_safe_path("C:/Windows/System32", base).is_err());
assert!(validate_safe_path("D:\\", base).is_err());
}
}
/// Test that Windows UNC paths are rejected
#[test]
#[cfg(windows)]
fn test_rejects_unc_paths() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let unc_paths = vec![
"\\\\server\\share\\file.txt",
"\\\\?\\C:\\Windows\\System32",
"\\\\?\\UNC\\server\\share",
"//server/share/file.txt",
];
for path in unc_paths {
let result = validate_safe_path(path, base);
assert!(result.is_err(), "Should reject UNC path: {}", path);
}
}
/// Test that null byte injection is blocked
#[test]
fn test_rejects_null_bytes() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let null_byte_attacks = vec![
"file.txt\0.jpg",
"safe\0../../etc/passwd",
"\0etc/passwd",
"file\0\0",
];
for attack in null_byte_attacks {
let result = validate_safe_path(attack, base);
assert!(
result.is_err(),
"Should reject null byte injection: {:?}",
attack
);
}
}
/// Test that valid relative paths are accepted
#[test]
fn test_accepts_valid_relative_paths() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Create test subdirectories
let subdir = base.join("subdir");
fs::create_dir(&subdir).unwrap();
let nested = subdir.join("nested");
fs::create_dir(&nested).unwrap();
// Valid paths that should be accepted
let valid_paths = vec![
"file.txt",
"subdir/file.txt",
"subdir/nested/file.txt",
"./file.txt",
"./subdir/file.txt",
];
for path in valid_paths {
let result = validate_safe_path(path, base);
assert!(
result.is_ok(),
"Should accept valid relative path: {}",
path
);
// Verify the result is within base directory
let safe_path = result.unwrap();
assert!(
safe_path.starts_with(base),
"Validated path should be within base directory: {:?}",
safe_path
);
}
}
/// Test that path traversal with .. is blocked
#[test]
fn test_rejects_dot_dot_traversal() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let traversal_attempts = vec![
"../etc/passwd",
"../../etc/passwd",
"subdir/../../etc/passwd",
"./../../etc/passwd",
"subdir/../../../etc/passwd",
];
for attempt in traversal_attempts {
let result = validate_safe_path(attempt, base);
assert!(result.is_err(), "Should reject .. traversal: {}", attempt);
}
}
/// Test that symlink traversal is prevented
#[test]
#[cfg(unix)] // Symlink test only on Unix
fn test_prevents_symlink_escape() {
use std::os::unix::fs as unix_fs;
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Create a directory outside the workspace
let external_dir = TempDir::new().unwrap();
let external_file = external_dir.path().join("secret.txt");
fs::write(&external_file, "secret data").unwrap();
// Create a symlink inside the workspace that points outside
let symlink_path = base.join("evil_link");
unix_fs::symlink(external_dir.path(), &symlink_path).unwrap();
// Attempt to access the external file through the symlink
let result = validate_safe_path("evil_link/secret.txt", base);
// Should be rejected because it resolves to a path outside the workspace
assert!(
result.is_err(),
"Should prevent symlink escape to external directory"
);
}
/// Test that mixed encoding and traversal attempts are blocked
#[test]
fn test_rejects_mixed_attack_vectors() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let mixed_attacks = vec![
"%2E%2E/etc/passwd", // Mix URL-encoded and plain
"../%2E%2E/etc/passwd", // Mix plain and URL-encoded
".%2F..%2Fetc%2Fpasswd", // Encoded slashes with dots
];
for attack in mixed_attacks {
let result = validate_safe_path(attack, base);
assert!(result.is_err(), "Should reject mixed attack: {}", attack);
}
}
/// Test that path validation works for non-existent files (write case)
#[test]
fn test_validates_non_existent_file_in_existing_dir() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Create a subdirectory
let subdir = base.join("subdir");
fs::create_dir(&subdir).unwrap();
// Validate path to non-existent file in existing directory
let result = validate_safe_path("subdir/newfile.txt", base);
assert!(
result.is_ok(),
"Should accept non-existent file in existing directory"
);
let safe_path = result.unwrap();
assert!(
safe_path.starts_with(base),
"Path should be within base directory"
);
assert_eq!(safe_path.file_name().unwrap(), "newfile.txt");
}
/// Test length limits to prevent DoS
#[test]
fn test_handles_excessively_long_paths() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Create an excessively long path (typical filesystem limit is 4096)
let long_component = "a".repeat(300);
let long_path = format!(
"{}/{}/{}/{}",
long_component, long_component, long_component, long_component
);
// This should either succeed or fail gracefully with an error, not panic
let result = validate_safe_path(&long_path, base);
// We don't assert success or failure, just that it doesn't panic
// The behavior depends on filesystem limits
let _ = result;
}
/// Test that canonicalization errors are handled properly
#[test]
fn test_handles_invalid_base_directory() {
let non_existent_base = PathBuf::from("/this/path/does/not/exist/at/all");
let result = validate_safe_path("file.txt", &non_existent_base);
assert!(
result.is_err(),
"Should fail when base directory doesn't exist"
);
}
/// Integration test: Test with actual resource operations
#[tokio::test]
async fn test_integration_resources_write_blocks_traversal() {
let temp_dir = TempDir::new().unwrap();
std::env::set_current_dir(temp_dir.path()).unwrap();
// Verify our validation logic would catch path traversal attempts
// (Full integration testing would require a running MCP client)
let base_dir = std::env::current_dir().unwrap();
let result = validate_safe_path("../../../etc/passwd", &base_dir);
assert!(
result.is_err(),
"Integration: should block path traversal in resources_write"
);
}
}

View File

@@ -752,7 +752,7 @@ impl SessionController {
None
};
let base_client = factory.create_with_secrets(primary_runtime)?;
let base_client = factory.create_with_secrets(primary_runtime).await?;
let primary: Arc<dyn McpClient> =
Arc::new(PermissionLayer::new(base_client, config_arc.clone()));
primary.set_mode(initial_mode).await?;
@@ -769,7 +769,7 @@ impl SessionController {
missing_oauth_servers.push(server_cfg.name.clone());
}
match RemoteMcpClient::new_with_runtime(server_cfg, runtime) {
match RemoteMcpClient::new_with_runtime(server_cfg, runtime).await {
Ok(remote) => {
let client: Arc<dyn McpClient> =
Arc::new(PermissionLayer::new(Box::new(remote), config_arc.clone()));
@@ -1902,7 +1902,7 @@ impl SessionController {
self.tool_registry.clone(),
self.schema_validator.clone(),
);
let base_client = factory.create()?;
let base_client = factory.create().await?;
let permission_client = PermissionLayer::new(base_client, Arc::new(config.clone()));
let client = Arc::new(permission_client);
client.set_mode(self.current_mode).await?;

View File

@@ -181,19 +181,8 @@ pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
Some(pos)
}
use crate::theme::Theme;
use async_trait::async_trait;
use std::io::stdout;
pub fn show_mouse_cursor() {
let mut stdout = stdout();
crossterm::execute!(stdout, crossterm::cursor::Show).ok();
}
pub fn hide_mouse_cursor() {
let mut stdout = stdout();
crossterm::execute!(stdout, crossterm::cursor::Hide).ok();
}
use owlen_ui_common::Theme;
pub fn apply_theme_to_string(s: &str, _theme: &Theme) -> String {
// This is a placeholder. In a real implementation, you'd parse the string

View File

@@ -28,7 +28,7 @@ async fn remote_file_server_read_and_list() {
assert!(build_status.success(), "MCP server build failed");
// Spawn remote client after the cwd is set and binary built
let client = RemoteMcpClient::new().expect("remote client init");
let client = RemoteMcpClient::new().await.expect("remote client init");
// Read file via MCP
let call = McpToolCall {

View File

@@ -15,7 +15,7 @@ async fn remote_write_and_delete() {
let dir = tempdir().expect("tempdir");
std::env::set_current_dir(dir.path()).expect("set cwd");
let client = RemoteMcpClient::new().expect("client init");
let client = RemoteMcpClient::new().await.expect("client init");
// Write a file via MCP
let write_call = McpToolCall {
@@ -49,7 +49,7 @@ async fn write_outside_root_is_rejected() {
// Set cwd to a fresh temp dir
let dir = tempdir().expect("tempdir");
std::env::set_current_dir(dir.path()).expect("set cwd");
let client = RemoteMcpClient::new().expect("client init");
let client = RemoteMcpClient::new().await.expect("client init");
// Attempt to write outside the root using "../evil.txt"
let call = McpToolCall {

View File

@@ -6,7 +6,11 @@ mod worker;
pub mod messages;
pub use worker::background_worker;
use std::{io, sync::Arc, time::Duration};
use std::{
io,
sync::{Arc, Mutex},
time::Duration,
};
use anyhow::Result;
use async_trait::async_trait;
@@ -26,12 +30,15 @@ use crate::{Event, SessionEvent, events};
pub use handler::MessageState;
pub use messages::AppMessage;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug)]
enum AppEvent {
Message(AppMessage),
Session(SessionEvent),
Ui(Event),
FrameTick,
RedrawRequested,
}
#[derive(Debug, Clone, Copy)]
@@ -57,6 +64,7 @@ pub struct App {
message_tx: mpsc::UnboundedSender<AppMessage>,
message_rx: Option<mpsc::UnboundedReceiver<AppMessage>>,
active_generation: Option<ActiveGeneration>,
frame_requester: FrameRequester,
}
impl App {
@@ -69,6 +77,7 @@ impl App {
message_tx,
message_rx: Some(message_rx),
active_generation: None,
frame_requester: FrameRequester::new(),
}
}
@@ -77,6 +86,11 @@ impl App {
self.message_tx.clone()
}
/// Handle used by UI state to request redraws.
pub fn frame_requester(&self) -> FrameRequester {
self.frame_requester.clone()
}
/// Whether a generation task is currently in flight.
pub fn has_active_generation(&self) -> bool {
self.active_generation.is_some()
@@ -118,6 +132,7 @@ impl App {
.expect("App::run called without an available message receiver");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel::<AppEvent>();
self.frame_requester.install(app_event_tx.clone());
let (input_cancel, input_handle) = Self::spawn_input_listener(app_event_tx.clone());
drop(app_event_tx);
@@ -131,19 +146,26 @@ impl App {
self.pump_background(state).await?;
let next_event = tokio::select! {
Some(event) = app_event_rx.recv() => event,
Some(event) = app_event_rx.recv() => {
if matches!(event, AppEvent::RedrawRequested) {
self.frame_requester.consume_pending();
}
event
},
Some(message) = message_rx.recv() => AppEvent::Message(message),
Some(session_event) = session_rx.recv() => AppEvent::Session(session_event),
_ = frame_interval.tick() => AppEvent::FrameTick,
else => break,
};
let is_frame_tick = matches!(next_event, AppEvent::FrameTick);
let should_render =
matches!(next_event, AppEvent::FrameTick | AppEvent::RedrawRequested);
match self.dispatch_app_event(state, next_event).await? {
LoopControl::Continue => {
if is_frame_tick {
if should_render {
render(terminal, state)?;
self.frame_requester.mark_rendered();
}
}
LoopControl::Exit(state_value) => {
@@ -160,6 +182,7 @@ impl App {
handle.abort();
let _ = handle.await;
}
self.frame_requester.detach();
self.message_rx = Some(message_rx);
@@ -231,6 +254,7 @@ impl App {
LoopControl::Continue
}
}
AppEvent::RedrawRequested => LoopControl::Continue,
};
Ok(control)
@@ -295,3 +319,81 @@ impl ActiveGeneration {
self.request_id
}
}
#[derive(Clone, Debug)]
pub struct FrameRequester {
inner: Arc<FrameRequesterInner>,
}
#[derive(Debug)]
struct FrameRequesterInner {
sender: Mutex<Option<mpsc::UnboundedSender<AppEvent>>>,
pending: AtomicBool,
}
impl FrameRequester {
fn new() -> Self {
Self {
inner: Arc::new(FrameRequesterInner {
sender: Mutex::new(None),
pending: AtomicBool::new(false),
}),
}
}
fn install(&self, sender: mpsc::UnboundedSender<AppEvent>) {
let mut guard = self.inner.sender.lock().expect("frame sender poisoned");
*guard = Some(sender);
}
pub fn detach(&self) {
let mut guard = self.inner.sender.lock().expect("frame sender poisoned");
guard.take();
self.inner.pending.store(false, Ordering::SeqCst);
}
pub fn request_frame(&self) {
if self
.inner
.pending
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
let sender = {
self.inner
.sender
.lock()
.expect("frame sender poisoned")
.clone()
};
if let Some(tx) = sender {
if tx.send(AppEvent::RedrawRequested).is_ok() {
return;
}
}
// Failed to dispatch; clear pending flag so future attempts can retry.
self.inner.pending.store(false, Ordering::SeqCst);
}
fn consume_pending(&self) {
// Retain pending flag until we actually render, but ensure a direct request
// without an active sender doesn't lock out future attempts.
if self
.inner
.sender
.lock()
.expect("frame sender poisoned")
.is_none()
{
self.inner.pending.store(false, Ordering::SeqCst);
}
}
fn mark_rendered(&self) {
self.inner.pending.store(false, Ordering::SeqCst);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
//! Color conversion utilities for mapping owlen-ui-common colors to ratatui colors
use owlen_core::{Color as UiColor, NamedColor};
use ratatui::style::Color as RatatuiColor;
/// Convert an abstract UI color to a ratatui color
pub fn to_ratatui_color(color: &UiColor) -> RatatuiColor {
match color {
UiColor::Rgb(r, g, b) => RatatuiColor::Rgb(*r, *g, *b),
UiColor::Named(named) => to_ratatui_named_color(named),
}
}
/// Convert a named color to a ratatui color
fn to_ratatui_named_color(named: &NamedColor) -> RatatuiColor {
match named {
NamedColor::Black => RatatuiColor::Black,
NamedColor::Red => RatatuiColor::Red,
NamedColor::Green => RatatuiColor::Green,
NamedColor::Yellow => RatatuiColor::Yellow,
NamedColor::Blue => RatatuiColor::Blue,
NamedColor::Magenta => RatatuiColor::Magenta,
NamedColor::Cyan => RatatuiColor::Cyan,
NamedColor::Gray => RatatuiColor::Gray,
NamedColor::DarkGray => RatatuiColor::DarkGray,
NamedColor::LightRed => RatatuiColor::LightRed,
NamedColor::LightGreen => RatatuiColor::LightGreen,
NamedColor::LightYellow => RatatuiColor::LightYellow,
NamedColor::LightBlue => RatatuiColor::LightBlue,
NamedColor::LightMagenta => RatatuiColor::LightMagenta,
NamedColor::LightCyan => RatatuiColor::LightCyan,
NamedColor::White => RatatuiColor::White,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rgb_conversion() {
let color = UiColor::Rgb(255, 128, 64);
let ratatui_color = to_ratatui_color(&color);
assert_eq!(ratatui_color, RatatuiColor::Rgb(255, 128, 64));
}
#[test]
fn test_named_color_conversion() {
let color = UiColor::Named(NamedColor::Red);
let ratatui_color = to_ratatui_color(&color);
assert_eq!(ratatui_color, RatatuiColor::Red);
let color = UiColor::Named(NamedColor::LightBlue);
let ratatui_color = to_ratatui_color(&color);
assert_eq!(ratatui_color, RatatuiColor::LightBlue);
}
}

View File

@@ -1,4 +1,4 @@
use owlen_core::{config::LayerSettings, theme::Theme};
use owlen_core::{Theme, config::LayerSettings};
use ratatui::style::{Color, palette::tailwind};
#[derive(Clone, Copy)]
@@ -29,11 +29,11 @@ impl GlassPalette {
layers: &LayerSettings,
) -> Self {
if reduced_chrome {
let base = theme.background;
let label = theme.text;
let track = theme.unfocused_panel_border;
let context_color = theme.mode_normal;
let usage_color = theme.mode_command;
let base = crate::color_convert::to_ratatui_color(&theme.background);
let label = crate::color_convert::to_ratatui_color(&theme.text);
let track = crate::color_convert::to_ratatui_color(&theme.unfocused_panel_border);
let context_color = crate::color_convert::to_ratatui_color(&theme.mode_normal);
let usage_color = crate::color_convert::to_ratatui_color(&theme.mode_command);
return Self {
active: base,
inactive: base,
@@ -45,32 +45,38 @@ impl GlassPalette {
usage_stops: [usage_color, usage_color, usage_color],
frosted: base,
frost_edge: base,
neon_accent: theme.info,
neon_glow: theme.info,
focus_ring: theme.focused_panel_border,
neon_accent: crate::color_convert::to_ratatui_color(&theme.info),
neon_glow: crate::color_convert::to_ratatui_color(&theme.info),
focus_ring: crate::color_convert::to_ratatui_color(&theme.focused_panel_border),
};
}
let luminance = color_luminance(theme.background);
let background_ratatui = crate::color_convert::to_ratatui_color(&theme.background);
let luminance = color_luminance(background_ratatui);
let neon_factor = layers.neon_factor();
let glass_tint = layers.glass_tint_factor();
let focus_enabled = layers.focus_ring;
if luminance < 0.5 {
let frosted = blend_color(tailwind::SLATE.c900, theme.background, glass_tint * 0.65);
let frosted = blend_color(tailwind::SLATE.c900, background_ratatui, glass_tint * 0.65);
let frost_edge = blend_color(frosted, tailwind::SLATE.c700, 0.25);
let inactive = blend_color(frosted, tailwind::SLATE.c800, 0.55);
let highlight = blend_color(frosted, tailwind::SLATE.c700, 0.35);
let track = blend_color(frosted, tailwind::SLATE.c600, 0.25);
let neon_seed = tailwind::SKY.c400;
let neon_accent = blend_color(neon_seed, theme.info, neon_factor);
let info_ratatui = crate::color_convert::to_ratatui_color(&theme.info);
let neon_accent = blend_color(neon_seed, info_ratatui, neon_factor);
let neon_glow = blend_color(neon_accent, Color::White, 0.18);
let focused_border_ratatui =
crate::color_convert::to_ratatui_color(&theme.focused_panel_border);
let unfocused_border_ratatui =
crate::color_convert::to_ratatui_color(&theme.unfocused_panel_border);
let focus_ring = if focus_enabled {
blend_color(neon_accent, theme.focused_panel_border, 0.45)
blend_color(neon_accent, focused_border_ratatui, 0.45)
} else {
blend_color(frosted, theme.unfocused_panel_border, 0.15)
blend_color(frosted, unfocused_border_ratatui, 0.15)
};
let shadow = match layers.shadow_depth() {
0 => blend_color(theme.background, tailwind::SLATE.c800, 0.15),
0 => blend_color(background_ratatui, tailwind::SLATE.c800, 0.15),
1 => tailwind::SLATE.c900,
2 => tailwind::SLATE.c950,
_ => Color::Rgb(2, 4, 12),
@@ -100,21 +106,26 @@ impl GlassPalette {
focus_ring,
}
} else {
let frosted = blend_color(tailwind::ZINC.c100, theme.background, glass_tint * 0.75);
let frosted = blend_color(tailwind::ZINC.c100, background_ratatui, glass_tint * 0.75);
let frost_edge = blend_color(frosted, tailwind::ZINC.c200, 0.4);
let inactive = blend_color(frosted, tailwind::ZINC.c200, 0.65);
let highlight = blend_color(frosted, tailwind::ZINC.c200, 0.35);
let track = blend_color(frosted, tailwind::ZINC.c300, 0.45);
let neon_seed = tailwind::BLUE.c500;
let neon_accent = blend_color(neon_seed, theme.info, neon_factor);
let info_ratatui = crate::color_convert::to_ratatui_color(&theme.info);
let neon_accent = blend_color(neon_seed, info_ratatui, neon_factor);
let neon_glow = blend_color(neon_accent, Color::White, 0.22);
let focused_border_ratatui =
crate::color_convert::to_ratatui_color(&theme.focused_panel_border);
let unfocused_border_ratatui =
crate::color_convert::to_ratatui_color(&theme.unfocused_panel_border);
let focus_ring = if focus_enabled {
blend_color(neon_accent, theme.focused_panel_border, 0.35)
blend_color(neon_accent, focused_border_ratatui, 0.35)
} else {
blend_color(frosted, theme.unfocused_panel_border, 0.1)
blend_color(frosted, unfocused_border_ratatui, 0.1)
};
let shadow = match layers.shadow_depth() {
0 => blend_color(theme.background, tailwind::ZINC.c200, 0.12),
0 => blend_color(background_ratatui, tailwind::ZINC.c200, 0.12),
1 => tailwind::ZINC.c300,
2 => tailwind::ZINC.c400,
_ => Color::Rgb(210, 210, 210),

View File

@@ -17,6 +17,7 @@
pub mod app;
pub mod chat_app;
pub mod code_app;
pub mod color_convert;
pub mod commands;
pub mod config;
pub mod events;

View File

@@ -1,5 +1,5 @@
use owlen_core::Theme;
use owlen_core::model::DetailedModelInfo;
use owlen_core::theme::Theme;
use ratatui::{
Frame,
layout::Rect,
@@ -39,15 +39,21 @@ impl ModelInfoPanel {
let block = Block::default()
.title("Model Information")
.borders(Borders::ALL)
.style(Style::default().bg(theme.background).fg(theme.text))
.border_style(Style::default().fg(theme.focused_panel_border));
.style(
Style::default()
.bg(crate::color_convert::to_ratatui_color(&theme.background))
.fg(crate::color_convert::to_ratatui_color(&theme.text)),
)
.border_style(Style::default().fg(crate::color_convert::to_ratatui_color(
&theme.focused_panel_border,
)));
if let Some(info) = &self.info {
let body = self.format_info(info);
self.total_lines = body.lines().count();
let paragraph = Paragraph::new(body)
.block(block)
.style(Style::default().fg(theme.text))
.style(Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)))
.wrap(Wrap { trim: true })
.scroll((self.scroll_offset as u16, 0));
frame.render_widget(paragraph, area);
@@ -57,7 +63,7 @@ impl ModelInfoPanel {
.block(block)
.style(
Style::default()
.fg(theme.placeholder)
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
.add_modifier(Modifier::ITALIC),
)
.wrap(Wrap { trim: true });

View File

@@ -0,0 +1,212 @@
//! Helper functions for working with themes in the TUI
//!
//! This module provides convenient wrappers and conversions for working with
//! owlen-core themes in ratatui contexts.
use crate::color_convert::to_ratatui_color;
use owlen_core::Theme;
use ratatui::style::Color as RatatuiColor;
/// A wrapper around Theme that provides convenient access to ratatui colors
pub struct RatatuiTheme<'a> {
theme: &'a Theme,
}
impl<'a> RatatuiTheme<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self { theme }
}
// Provide accessor methods that return ratatui colors
pub fn text(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.text)
}
pub fn background(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.background)
}
pub fn focused_panel_border(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.focused_panel_border)
}
pub fn unfocused_panel_border(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.unfocused_panel_border)
}
pub fn focus_beacon_fg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.focus_beacon_fg)
}
pub fn focus_beacon_bg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.focus_beacon_bg)
}
pub fn unfocused_beacon_fg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.unfocused_beacon_fg)
}
pub fn pane_header_active(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.pane_header_active)
}
pub fn pane_header_inactive(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.pane_header_inactive)
}
pub fn pane_hint_text(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.pane_hint_text)
}
pub fn user_message_role(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.user_message_role)
}
pub fn assistant_message_role(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.assistant_message_role)
}
pub fn tool_output(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.tool_output)
}
pub fn thinking_panel_title(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.thinking_panel_title)
}
pub fn command_bar_background(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.command_bar_background)
}
pub fn status_background(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.status_background)
}
pub fn mode_normal(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.mode_normal)
}
pub fn mode_editing(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.mode_editing)
}
pub fn mode_model_selection(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.mode_model_selection)
}
pub fn mode_provider_selection(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.mode_provider_selection)
}
pub fn mode_help(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.mode_help)
}
pub fn mode_visual(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.mode_visual)
}
pub fn mode_command(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.mode_command)
}
pub fn selection_bg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.selection_bg)
}
pub fn selection_fg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.selection_fg)
}
pub fn cursor(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.cursor)
}
pub fn code_block_background(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.code_block_background)
}
pub fn code_block_border(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.code_block_border)
}
pub fn code_block_text(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.code_block_text)
}
pub fn code_block_keyword(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.code_block_keyword)
}
pub fn code_block_string(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.code_block_string)
}
pub fn code_block_comment(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.code_block_comment)
}
pub fn placeholder(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.placeholder)
}
pub fn error(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.error)
}
pub fn info(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.info)
}
pub fn agent_thought(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_thought)
}
pub fn agent_action(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_action)
}
pub fn agent_action_input(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_action_input)
}
pub fn agent_observation(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_observation)
}
pub fn agent_final_answer(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_final_answer)
}
pub fn agent_badge_running_fg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_badge_running_fg)
}
pub fn agent_badge_running_bg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_badge_running_bg)
}
pub fn agent_badge_idle_fg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_badge_idle_fg)
}
pub fn agent_badge_idle_bg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.agent_badge_idle_bg)
}
pub fn operating_chat_fg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.operating_chat_fg)
}
pub fn operating_chat_bg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.operating_chat_bg)
}
pub fn operating_code_fg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.operating_code_fg)
}
pub fn operating_code_bg(&self) -> RatatuiColor {
to_ratatui_color(&self.theme.operating_code_bg)
}
}

View File

@@ -6,7 +6,7 @@ macro_rules! adjust_fields {
};
}
use owlen_core::theme::Theme;
use owlen_core::Theme;
use ratatui::style::Color;
/// Return a clone of `base` with contrast adjustments applied.

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ use crate::chat_app::{
ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo,
ModelSelectorItemKind,
};
use crate::color_convert::to_ratatui_color;
use crate::glass::GlassPalette;
/// Filtering modes for the model picker popup.
@@ -135,7 +136,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
.add_modifier(Modifier::DIM);
let caret_style = if search_active {
Style::default()
.fg(theme.selection_fg)
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
.add_modifier(Modifier::BOLD)
} else {
Style::default()
@@ -152,7 +153,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
search_spans.push(Span::styled(
search_query.clone(),
Style::default()
.fg(theme.selection_fg)
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
.add_modifier(Modifier::BOLD),
));
} else {
@@ -211,8 +212,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
frame.render_widget(search_paragraph, layout[0]);
let highlight_style = Style::default()
.fg(theme.selection_fg)
.bg(theme.selection_bg)
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
.bg(crate::color_convert::to_ratatui_color(&theme.selection_bg))
.add_modifier(Modifier::BOLD);
let highlight_symbol = " ";
@@ -246,7 +247,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
None
},
Style::default()
.fg(theme.mode_command)
.fg(crate::color_convert::to_ratatui_color(&theme.mode_command))
.add_modifier(Modifier::BOLD),
highlight_style,
);
@@ -257,7 +258,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
spans.push(Span::styled(
if *expanded { "" } else { "" },
Style::default()
.fg(theme.placeholder)
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
.add_modifier(Modifier::DIM),
));
@@ -307,7 +308,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
lines.push(clip_line_to_width(
Line::from(Span::styled(
" <model unavailable>",
Style::default().fg(theme.error),
Style::default()
.fg(crate::color_convert::to_ratatui_color(&theme.error)),
)),
max_line_width,
));
@@ -333,8 +335,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
let list = List::new(items)
.highlight_style(
Style::default()
.bg(theme.selection_bg)
.fg(theme.selection_fg)
.bg(crate::color_convert::to_ratatui_color(&theme.selection_bg))
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ")
@@ -359,44 +361,50 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
frame.render_widget(footer, layout[2]);
}
fn status_icon(status: ProviderStatus, theme: &owlen_core::theme::Theme) -> Span<'static> {
fn status_icon(status: ProviderStatus, theme: &owlen_core::Theme) -> Span<'static> {
let (symbol, color) = match status {
ProviderStatus::Available => ("", theme.info),
ProviderStatus::Unavailable => ("", theme.error),
ProviderStatus::RequiresSetup => ("", Color::Yellow),
ProviderStatus::RequiresSetup => (
"",
owlen_core::Color::Named(owlen_core::NamedColor::Yellow),
),
};
Span::styled(
symbol,
Style::default().fg(color).add_modifier(Modifier::BOLD),
Style::default()
.fg(to_ratatui_color(&color))
.add_modifier(Modifier::BOLD),
)
}
fn provider_type_badge(
provider_type: ProviderType,
theme: &owlen_core::theme::Theme,
) -> Span<'static> {
fn provider_type_badge(provider_type: ProviderType, theme: &owlen_core::Theme) -> Span<'static> {
let (label, color) = match provider_type {
ProviderType::Local => ("[Local]", theme.mode_normal),
ProviderType::Cloud => ("[Cloud]", theme.mode_help),
};
Span::styled(
label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
Style::default()
.fg(to_ratatui_color(&color))
.add_modifier(Modifier::BOLD),
)
}
fn scope_status_style(
status: ModelAvailabilityState,
theme: &owlen_core::theme::Theme,
theme: &owlen_core::Theme,
) -> (Style, &'static str) {
match status {
ModelAvailabilityState::Available => (
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
Style::default()
.fg(crate::color_convert::to_ratatui_color(&theme.info))
.add_modifier(Modifier::BOLD),
"",
),
ModelAvailabilityState::Unavailable => (
Style::default()
.fg(theme.error)
.fg(crate::color_convert::to_ratatui_color(&theme.error))
.add_modifier(Modifier::BOLD),
"",
),
@@ -411,18 +419,18 @@ fn scope_status_style(
fn empty_status_style(
status: Option<ModelAvailabilityState>,
theme: &owlen_core::theme::Theme,
theme: &owlen_core::Theme,
) -> (Style, &'static str) {
match status.unwrap_or(ModelAvailabilityState::Unknown) {
ModelAvailabilityState::Available => (
Style::default()
.fg(theme.placeholder)
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
.add_modifier(Modifier::DIM),
"",
),
ModelAvailabilityState::Unavailable => (
Style::default()
.fg(theme.error)
.fg(crate::color_convert::to_ratatui_color(&theme.error))
.add_modifier(Modifier::BOLD),
"",
),
@@ -435,7 +443,7 @@ fn empty_status_style(
}
}
fn filter_badge(mode: FilterMode, theme: &owlen_core::theme::Theme) -> Span<'static> {
fn filter_badge(mode: FilterMode, theme: &owlen_core::Theme) -> Span<'static> {
let label = match mode {
FilterMode::All => return Span::raw(""),
FilterMode::LocalOnly => "Local",
@@ -445,7 +453,9 @@ fn filter_badge(mode: FilterMode, theme: &owlen_core::theme::Theme) -> Span<'sta
Span::styled(
format!("[{label}]"),
Style::default()
.fg(theme.mode_provider_selection)
.fg(crate::color_convert::to_ratatui_color(
&theme.mode_provider_selection,
))
.add_modifier(Modifier::BOLD),
)
}
@@ -509,7 +519,7 @@ struct SearchRenderContext<'a> {
}
fn build_model_selector_lines<'a>(
theme: &owlen_core::theme::Theme,
theme: &owlen_core::Theme,
model: &'a ModelInfo,
annotated: Option<&'a AnnotatedModelInfo>,
badges: &[&'static str],
@@ -536,7 +546,9 @@ fn build_model_selector_lines<'a>(
spans.push(provider_type_badge(provider_type, theme));
spans.push(Span::raw(" "));
let name_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD);
let name_style = Style::default()
.fg(crate::color_convert::to_ratatui_color(&theme.text))
.add_modifier(Modifier::BOLD);
let display_name = ChatApp::display_name_for_model(model);
if !display_name.trim().is_empty() {
let name_spans = render_highlighted_text(
@@ -560,7 +572,7 @@ fn build_model_selector_lines<'a>(
spans.push(Span::raw(" "));
spans.push(Span::styled(
badges.join(" "),
Style::default().fg(theme.placeholder),
Style::default().fg(crate::color_convert::to_ratatui_color(&theme.placeholder)),
));
}
@@ -568,7 +580,9 @@ fn build_model_selector_lines<'a>(
spans.push(Span::raw(" "));
spans.push(Span::styled(
"",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
Style::default()
.fg(crate::color_convert::to_ratatui_color(&theme.info))
.add_modifier(Modifier::BOLD),
));
}
@@ -681,7 +695,7 @@ fn build_model_selector_lines<'a>(
None
} else {
let meta_style = Style::default()
.fg(theme.placeholder)
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
.add_modifier(Modifier::DIM);
let mut segments: Vec<Span<'static>> = Vec::new();
segments.push(Span::styled(" ", meta_style));

View File

@@ -0,0 +1,116 @@
mod common;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use insta::{assert_snapshot, with_settings};
use owlen_tui::ChatApp;
use owlen_tui::events::Event;
use owlen_tui::ui::render_chat;
use ratatui::{Terminal, backend::TestBackend};
use std::time::Duration;
use tokio::time::advance;
use common::build_chat_app;
fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
let mut output = String::new();
for y in 0..buffer.area.height {
output.push('"');
for x in 0..buffer.area.width {
output.push_str(buffer[(x, y)].symbol());
}
output.push('"');
output.push('\n');
}
output
}
fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render_chat(frame, app))
.expect("render chat");
let buffer = terminal.backend().buffer();
buffer_to_string(buffer)
}
async fn send_key(app: &mut ChatApp, code: KeyCode, modifiers: KeyModifiers) {
app.handle_event(Event::Key(KeyEvent::new(code, modifiers)))
.await
.unwrap();
}
async fn type_text(app: &mut ChatApp, text: &str) {
for ch in text.chars() {
send_key(app, KeyCode::Char(ch), KeyModifiers::NONE).await;
}
}
#[tokio::test(start_paused = true)]
async fn render_queued_submission_snapshot() {
let mut app = build_chat_app(|_| {}, |_| {}).await;
// Enter insert mode
send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await;
// Type and send first message
type_text(&mut app, "first message").await;
send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await;
// First message is "in-flight".
// Now, type and send a second message.
send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await;
type_text(&mut app, "second message").await;
send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await;
// The second message should be queued.
with_settings!({ snapshot_suffix => "queued-80x24" }, {
let snapshot = render_snapshot(&mut app, 80, 24);
assert_snapshot!("queued_submission", snapshot);
});
// Now, let the first message complete.
// The stub provider responds immediately, but the processing is async.
// We need to advance time to let the background task run.
advance(Duration::from_secs(1)).await;
// We also need to process the events from the background task.
app.handle_event(Event::Tick).await.unwrap();
app.handle_event(Event::Tick).await.unwrap();
// The second message should now be processing.
with_settings!({ snapshot_suffix => "processing-second-80x24" }, {
let snapshot = render_snapshot(&mut app, 80, 24);
assert_snapshot!("processing_second_submission", snapshot);
});
}
#[tokio::test(start_paused = true)]
async fn test_cancellation_of_queued_request() {
let mut app = build_chat_app(|_| {}, |_| {}).await;
// Enter insert mode
send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await;
// Type and send first message
type_text(&mut app, "first message").await;
send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await;
// First message is "in-flight".
// Now, type and send a second message.
send_key(&mut app, KeyCode::Char('i'), KeyModifiers::NONE).await;
type_text(&mut app, "second message").await;
send_key(&mut app, KeyCode::Enter, KeyModifiers::NONE).await;
// Cancel the active generation
send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
// The first request should be cancelled, and the second one should start.
advance(Duration::from_secs(1)).await;
app.handle_event(Event::Tick).await.unwrap();
app.handle_event(Event::Tick).await.unwrap();
with_settings!({ snapshot_suffix => "cancelled-80x24" }, {
let snapshot = render_snapshot(&mut app, 80, 24);
assert_snapshot!("cancellation_starts_next", snapshot);
});
}

View File

@@ -0,0 +1,18 @@
[package]
name = "owlen-ui-common"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
description = "UI-agnostic color and theme abstractions for OWLEN"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
shellexpand = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]

View File

@@ -0,0 +1,206 @@
//! UI-agnostic color abstraction for OWLEN
//!
//! This module provides a color type that can be used across different UI
//! implementations (TUI, GUI, etc.) without tying the core library to any
//! specific rendering framework.
use serde::{Deserialize, Serialize};
/// An abstract color representation that can be converted to different UI frameworks
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Color {
/// RGB color with red, green, and blue components (0-255)
Rgb(u8, u8, u8),
/// Named ANSI color
Named(NamedColor),
}
/// Standard ANSI color names
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NamedColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
Gray,
DarkGray,
LightRed,
LightGreen,
LightYellow,
LightBlue,
LightMagenta,
LightCyan,
White,
}
impl Color {
/// Create an RGB color
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Color::Rgb(r, g, b)
}
/// Create a named color
pub const fn named(color: NamedColor) -> Self {
Color::Named(color)
}
/// Convenience constructors for common colors
pub const fn black() -> Self {
Color::Named(NamedColor::Black)
}
pub const fn white() -> Self {
Color::Named(NamedColor::White)
}
pub const fn red() -> Self {
Color::Named(NamedColor::Red)
}
pub const fn green() -> Self {
Color::Named(NamedColor::Green)
}
pub const fn yellow() -> Self {
Color::Named(NamedColor::Yellow)
}
pub const fn blue() -> Self {
Color::Named(NamedColor::Blue)
}
pub const fn magenta() -> Self {
Color::Named(NamedColor::Magenta)
}
pub const fn cyan() -> Self {
Color::Named(NamedColor::Cyan)
}
pub const fn gray() -> Self {
Color::Named(NamedColor::Gray)
}
pub const fn dark_gray() -> Self {
Color::Named(NamedColor::DarkGray)
}
pub const fn light_red() -> Self {
Color::Named(NamedColor::LightRed)
}
pub const fn light_green() -> Self {
Color::Named(NamedColor::LightGreen)
}
pub const fn light_yellow() -> Self {
Color::Named(NamedColor::LightYellow)
}
pub const fn light_blue() -> Self {
Color::Named(NamedColor::LightBlue)
}
pub const fn light_magenta() -> Self {
Color::Named(NamedColor::LightMagenta)
}
pub const fn light_cyan() -> Self {
Color::Named(NamedColor::LightCyan)
}
}
/// Parse a color from a string representation
///
/// Supports:
/// - Hex colors: "#ff0000", "#FF0000"
/// - Named colors: "red", "lightblue", etc.
pub fn parse_color(s: &str) -> Result<Color, String> {
if let Some(hex) = s.strip_prefix('#')
&& hex.len() == 6
{
let r =
u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
let g =
u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
let b =
u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
return Ok(Color::Rgb(r, g, b));
}
// Try named colors
match s.to_lowercase().as_str() {
"black" => Ok(Color::Named(NamedColor::Black)),
"red" => Ok(Color::Named(NamedColor::Red)),
"green" => Ok(Color::Named(NamedColor::Green)),
"yellow" => Ok(Color::Named(NamedColor::Yellow)),
"blue" => Ok(Color::Named(NamedColor::Blue)),
"magenta" => Ok(Color::Named(NamedColor::Magenta)),
"cyan" => Ok(Color::Named(NamedColor::Cyan)),
"gray" | "grey" => Ok(Color::Named(NamedColor::Gray)),
"darkgray" | "darkgrey" => Ok(Color::Named(NamedColor::DarkGray)),
"lightred" => Ok(Color::Named(NamedColor::LightRed)),
"lightgreen" => Ok(Color::Named(NamedColor::LightGreen)),
"lightyellow" => Ok(Color::Named(NamedColor::LightYellow)),
"lightblue" => Ok(Color::Named(NamedColor::LightBlue)),
"lightmagenta" => Ok(Color::Named(NamedColor::LightMagenta)),
"lightcyan" => Ok(Color::Named(NamedColor::LightCyan)),
"white" => Ok(Color::Named(NamedColor::White)),
_ => Err(format!("Unknown color: {}", s)),
}
}
/// Convert a color to its string representation
pub fn color_to_string(color: &Color) -> String {
match color {
Color::Named(NamedColor::Black) => "black".to_string(),
Color::Named(NamedColor::Red) => "red".to_string(),
Color::Named(NamedColor::Green) => "green".to_string(),
Color::Named(NamedColor::Yellow) => "yellow".to_string(),
Color::Named(NamedColor::Blue) => "blue".to_string(),
Color::Named(NamedColor::Magenta) => "magenta".to_string(),
Color::Named(NamedColor::Cyan) => "cyan".to_string(),
Color::Named(NamedColor::Gray) => "gray".to_string(),
Color::Named(NamedColor::DarkGray) => "darkgray".to_string(),
Color::Named(NamedColor::LightRed) => "lightred".to_string(),
Color::Named(NamedColor::LightGreen) => "lightgreen".to_string(),
Color::Named(NamedColor::LightYellow) => "lightyellow".to_string(),
Color::Named(NamedColor::LightBlue) => "lightblue".to_string(),
Color::Named(NamedColor::LightMagenta) => "lightmagenta".to_string(),
Color::Named(NamedColor::LightCyan) => "lightcyan".to_string(),
Color::Named(NamedColor::White) => "white".to_string(),
Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_color_parsing() {
assert_eq!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0)));
assert_eq!(parse_color("red"), Ok(Color::Named(NamedColor::Red)));
assert_eq!(
parse_color("lightblue"),
Ok(Color::Named(NamedColor::LightBlue))
);
assert!(parse_color("invalid").is_err());
}
#[test]
fn test_color_to_string() {
assert_eq!(color_to_string(&Color::Named(NamedColor::Red)), "red");
assert_eq!(color_to_string(&Color::Rgb(255, 0, 0)), "#ff0000");
}
#[test]
fn test_color_constructors() {
assert_eq!(Color::black(), Color::Named(NamedColor::Black));
assert_eq!(Color::rgb(255, 0, 0), Color::Rgb(255, 0, 0));
}
}

View File

@@ -0,0 +1,14 @@
//! UI-agnostic abstractions for OWLEN
//!
//! This crate provides color and theme abstractions that can be used across
//! different UI implementations (TUI, GUI, etc.) without tying the core library
//! to any specific rendering framework.
pub mod color;
pub mod theme;
// Re-export commonly used types
pub use color::{Color, NamedColor, color_to_string, parse_color};
pub use theme::{
Theme, ThemePalette, built_in_themes, default_themes_dir, get_theme, load_all_themes,
};

View File

@@ -2,7 +2,7 @@
//!
//! Provides customizable color schemes for all UI components.
use ratatui::style::Color;
use crate::color::{Color, NamedColor, color_to_string, parse_color};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@@ -290,113 +290,111 @@ impl Default for Theme {
impl Theme {
const fn default_code_block_background() -> Color {
Color::Black
Color::Named(NamedColor::Black)
}
const fn default_code_block_border() -> Color {
Color::Gray
Color::Named(NamedColor::Gray)
}
const fn default_code_block_text() -> Color {
Color::White
Color::Named(NamedColor::White)
}
const fn default_code_block_keyword() -> Color {
Color::Yellow
Color::Named(NamedColor::Yellow)
}
const fn default_code_block_string() -> Color {
Color::LightGreen
Color::Named(NamedColor::LightGreen)
}
const fn default_code_block_comment() -> Color {
Color::DarkGray
Color::Named(NamedColor::DarkGray)
}
const fn default_agent_thought() -> Color {
Color::LightBlue
Color::Named(NamedColor::LightBlue)
}
const fn default_agent_action() -> Color {
Color::Yellow
Color::Named(NamedColor::Yellow)
}
const fn default_agent_action_input() -> Color {
Color::LightCyan
Color::Named(NamedColor::LightCyan)
}
const fn default_agent_observation() -> Color {
Color::LightGreen
Color::Named(NamedColor::LightGreen)
}
const fn default_agent_final_answer() -> Color {
Color::Magenta
Color::Named(NamedColor::Magenta)
}
const fn default_agent_badge_running_fg() -> Color {
Color::Black
Color::Named(NamedColor::Black)
}
const fn default_agent_badge_running_bg() -> Color {
Color::Yellow
Color::Named(NamedColor::Yellow)
}
const fn default_agent_badge_idle_fg() -> Color {
Color::Black
Color::Named(NamedColor::Black)
}
const fn default_agent_badge_idle_bg() -> Color {
Color::Cyan
Color::Named(NamedColor::Cyan)
}
const fn default_focus_beacon_fg() -> Color {
Color::LightMagenta
Color::Named(NamedColor::LightMagenta)
}
const fn default_focus_beacon_bg() -> Color {
Color::Black
Color::Named(NamedColor::Black)
}
const fn default_unfocused_beacon_fg() -> Color {
Color::DarkGray
Color::Named(NamedColor::DarkGray)
}
const fn default_pane_header_active() -> Color {
Color::White
Color::Named(NamedColor::White)
}
const fn default_pane_header_inactive() -> Color {
Color::Gray
Color::Named(NamedColor::Gray)
}
const fn default_pane_hint_text() -> Color {
Color::DarkGray
Color::Named(NamedColor::DarkGray)
}
const fn default_operating_chat_fg() -> Color {
Color::Black
Color::Named(NamedColor::Black)
}
const fn default_operating_chat_bg() -> Color {
Color::Blue
Color::Named(NamedColor::Blue)
}
const fn default_operating_code_fg() -> Color {
Color::Black
Color::Named(NamedColor::Black)
}
const fn default_operating_code_bg() -> Color {
Color::Magenta
Color::Named(NamedColor::Magenta)
}
}
/// Get the default themes directory path
/// Note: This uses a hardcoded default path that matches owlen-core's DEFAULT_CONFIG_PATH
pub fn default_themes_dir() -> PathBuf {
let config_dir = PathBuf::from(shellexpand::tilde(crate::config::DEFAULT_CONFIG_PATH).as_ref())
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("~/.config/owlen"));
// Use a hardcoded default path that matches owlen-core's DEFAULT_CONFIG_PATH
let config_dir = PathBuf::from(shellexpand::tilde("~/.config/owlen").as_ref());
config_dir.join("themes")
}
@@ -530,8 +528,8 @@ fn get_fallback_theme(name: &str) -> Option<Theme> {
fn default_dark() -> Theme {
Theme {
name: "default_dark".to_string(),
text: Color::White,
background: Color::Black,
text: Color::Named(NamedColor::White),
background: Color::Named(NamedColor::Black),
focused_panel_border: Color::Rgb(216, 160, 255),
unfocused_panel_border: Color::Rgb(137, 82, 204),
focus_beacon_fg: Color::Rgb(248, 229, 255),
@@ -540,8 +538,8 @@ fn default_dark() -> Theme {
pane_header_active: Theme::default_pane_header_active(),
pane_header_inactive: Color::Rgb(210, 210, 210),
pane_hint_text: Color::Rgb(210, 210, 210),
user_message_role: Color::LightBlue,
assistant_message_role: Color::Yellow,
user_message_role: Color::Named(NamedColor::LightBlue),
assistant_message_role: Color::Named(NamedColor::Yellow),
tool_output: Color::Rgb(200, 200, 200),
thinking_panel_title: Color::Rgb(234, 182, 255),
command_bar_background: Color::Rgb(10, 10, 10),
@@ -554,29 +552,29 @@ fn default_dark() -> Theme {
mode_visual: Color::Rgb(255, 170, 255),
mode_command: Color::Rgb(255, 220, 120),
selection_bg: Color::Rgb(56, 140, 240),
selection_fg: Color::Black,
selection_fg: Color::Named(NamedColor::Black),
cursor: Color::Rgb(255, 196, 255),
code_block_background: Color::Rgb(25, 25, 25),
code_block_border: Color::Rgb(216, 160, 255),
code_block_text: Color::White,
code_block_text: Color::Named(NamedColor::White),
code_block_keyword: Color::Rgb(255, 220, 120),
code_block_string: Color::Rgb(144, 242, 170),
code_block_comment: Color::Rgb(170, 170, 170),
placeholder: Color::Rgb(180, 180, 180),
error: Color::Red,
error: Color::Named(NamedColor::Red),
info: Color::Rgb(144, 242, 170),
agent_thought: Color::Rgb(117, 200, 255),
agent_action: Color::Rgb(255, 220, 120),
agent_action_input: Color::Rgb(164, 235, 255),
agent_observation: Color::Rgb(144, 242, 170),
agent_final_answer: Color::Rgb(255, 170, 255),
agent_badge_running_fg: Color::Black,
agent_badge_running_bg: Color::Yellow,
agent_badge_idle_fg: Color::Black,
agent_badge_idle_bg: Color::Cyan,
operating_chat_fg: Color::Black,
agent_badge_running_fg: Color::Named(NamedColor::Black),
agent_badge_running_bg: Color::Named(NamedColor::Yellow),
agent_badge_idle_fg: Color::Named(NamedColor::Black),
agent_badge_idle_bg: Color::Named(NamedColor::Cyan),
operating_chat_fg: Color::Named(NamedColor::Black),
operating_chat_bg: Color::Rgb(117, 200, 255),
operating_code_fg: Color::Black,
operating_code_fg: Color::Named(NamedColor::Black),
operating_code_bg: Color::Rgb(255, 170, 255),
}
}
@@ -585,8 +583,8 @@ fn default_dark() -> Theme {
fn default_light() -> Theme {
Theme {
name: "default_light".to_string(),
text: Color::Black,
background: Color::White,
text: Color::Named(NamedColor::Black),
background: Color::Named(NamedColor::White),
focused_panel_border: Color::Rgb(74, 144, 226),
unfocused_panel_border: Color::Rgb(221, 221, 221),
focus_beacon_fg: Theme::default_focus_beacon_fg(),
@@ -597,10 +595,10 @@ fn default_light() -> Theme {
pane_hint_text: Theme::default_pane_hint_text(),
user_message_role: Color::Rgb(0, 85, 164),
assistant_message_role: Color::Rgb(142, 68, 173),
tool_output: Color::Gray,
tool_output: Color::Named(NamedColor::Gray),
thinking_panel_title: Color::Rgb(142, 68, 173),
command_bar_background: Color::White,
status_background: Color::White,
command_bar_background: Color::Named(NamedColor::White),
status_background: Color::Named(NamedColor::White),
mode_normal: Color::Rgb(0, 85, 164),
mode_editing: Color::Rgb(46, 139, 87),
mode_model_selection: Color::Rgb(181, 137, 0),
@@ -609,29 +607,29 @@ fn default_light() -> Theme {
mode_visual: Color::Rgb(142, 68, 173),
mode_command: Color::Rgb(181, 137, 0),
selection_bg: Color::Rgb(164, 200, 240),
selection_fg: Color::Black,
selection_fg: Color::Named(NamedColor::Black),
cursor: Color::Rgb(217, 95, 2),
code_block_background: Color::Rgb(245, 245, 245),
code_block_border: Color::Rgb(142, 68, 173),
code_block_text: Color::Black,
code_block_text: Color::Named(NamedColor::Black),
code_block_keyword: Color::Rgb(181, 137, 0),
code_block_string: Color::Rgb(46, 139, 87),
code_block_comment: Color::Gray,
placeholder: Color::Gray,
code_block_comment: Color::Named(NamedColor::Gray),
placeholder: Color::Named(NamedColor::Gray),
error: Color::Rgb(192, 57, 43),
info: Color::Green,
info: Color::Named(NamedColor::Green),
agent_thought: Color::Rgb(0, 85, 164),
agent_action: Color::Rgb(181, 137, 0),
agent_action_input: Color::Rgb(0, 139, 139),
agent_observation: Color::Rgb(46, 139, 87),
agent_final_answer: Color::Rgb(142, 68, 173),
agent_badge_running_fg: Color::White,
agent_badge_running_fg: Color::Named(NamedColor::White),
agent_badge_running_bg: Color::Rgb(241, 196, 15),
agent_badge_idle_fg: Color::White,
agent_badge_idle_fg: Color::Named(NamedColor::White),
agent_badge_idle_bg: Color::Rgb(0, 150, 136),
operating_chat_fg: Color::White,
operating_chat_fg: Color::Named(NamedColor::White),
operating_chat_bg: Color::Rgb(0, 85, 164),
operating_code_fg: Color::White,
operating_code_fg: Color::Named(NamedColor::White),
operating_code_bg: Color::Rgb(142, 68, 173),
}
}
@@ -1065,13 +1063,13 @@ fn material_light() -> Theme {
agent_action_input: Color::Rgb(124, 77, 255),
agent_observation: Color::Rgb(56, 142, 60),
agent_final_answer: Color::Rgb(211, 47, 47),
agent_badge_running_fg: Color::White,
agent_badge_running_fg: Color::Named(NamedColor::White),
agent_badge_running_bg: Color::Rgb(245, 124, 0),
agent_badge_idle_fg: Color::White,
agent_badge_idle_fg: Color::Named(NamedColor::White),
agent_badge_idle_bg: Color::Rgb(0, 150, 136),
operating_chat_fg: Color::White,
operating_chat_fg: Color::Named(NamedColor::White),
operating_chat_bg: Color::Rgb(68, 138, 255),
operating_code_fg: Color::White,
operating_code_fg: Color::Named(NamedColor::White),
operating_code_bg: Color::Rgb(124, 77, 255),
}
}
@@ -1081,8 +1079,8 @@ fn grayscale_high_contrast() -> Theme {
Theme {
name: "grayscale_high_contrast".to_string(),
text: Color::Rgb(247, 247, 247),
background: Color::Black,
focused_panel_border: Color::White,
background: Color::Named(NamedColor::Black),
focused_panel_border: Color::Named(NamedColor::White),
unfocused_panel_border: Color::Rgb(76, 76, 76),
focus_beacon_fg: Theme::default_focus_beacon_fg(),
focus_beacon_bg: Theme::default_focus_beacon_bg(),
@@ -1094,9 +1092,9 @@ fn grayscale_high_contrast() -> Theme {
assistant_message_role: Color::Rgb(214, 214, 214),
tool_output: Color::Rgb(189, 189, 189),
thinking_panel_title: Color::Rgb(224, 224, 224),
command_bar_background: Color::Black,
command_bar_background: Color::Named(NamedColor::Black),
status_background: Color::Rgb(15, 15, 15),
mode_normal: Color::White,
mode_normal: Color::Named(NamedColor::White),
mode_editing: Color::Rgb(230, 230, 230),
mode_model_selection: Color::Rgb(204, 204, 204),
mode_provider_selection: Color::Rgb(179, 179, 179),
@@ -1104,29 +1102,29 @@ fn grayscale_high_contrast() -> Theme {
mode_visual: Color::Rgb(242, 242, 242),
mode_command: Color::Rgb(208, 208, 208),
selection_bg: Color::Rgb(240, 240, 240),
selection_fg: Color::Black,
cursor: Color::White,
selection_fg: Color::Named(NamedColor::Black),
cursor: Color::Named(NamedColor::White),
code_block_background: Color::Rgb(15, 15, 15),
code_block_border: Color::White,
code_block_border: Color::Named(NamedColor::White),
code_block_text: Color::Rgb(247, 247, 247),
code_block_keyword: Color::Rgb(204, 204, 204),
code_block_string: Color::Rgb(214, 214, 214),
code_block_comment: Color::Rgb(122, 122, 122),
placeholder: Color::Rgb(122, 122, 122),
error: Color::White,
error: Color::Named(NamedColor::White),
info: Color::Rgb(200, 200, 200),
agent_thought: Color::Rgb(230, 230, 230),
agent_action: Color::Rgb(204, 204, 204),
agent_action_input: Color::Rgb(176, 176, 176),
agent_observation: Color::Rgb(153, 153, 153),
agent_final_answer: Color::White,
agent_badge_running_fg: Color::Black,
agent_final_answer: Color::Named(NamedColor::White),
agent_badge_running_fg: Color::Named(NamedColor::Black),
agent_badge_running_bg: Color::Rgb(247, 247, 247),
agent_badge_idle_fg: Color::Black,
agent_badge_idle_fg: Color::Named(NamedColor::Black),
agent_badge_idle_bg: Color::Rgb(189, 189, 189),
operating_chat_fg: Color::Black,
operating_chat_fg: Color::Named(NamedColor::Black),
operating_chat_bg: Color::Rgb(242, 242, 242),
operating_code_fg: Color::Black,
operating_code_fg: Color::Named(NamedColor::Black),
operating_code_bg: Color::Rgb(191, 191, 191),
}
}
@@ -1149,64 +1147,6 @@ where
serializer.serialize_str(&s)
}
fn parse_color(s: &str) -> Result<Color, String> {
if let Some(hex) = s.strip_prefix('#')
&& hex.len() == 6
{
let r =
u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
let g =
u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
let b =
u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", s))?;
return Ok(Color::Rgb(r, g, b));
}
// Try named colors
match s.to_lowercase().as_str() {
"black" => Ok(Color::Black),
"red" => Ok(Color::Red),
"green" => Ok(Color::Green),
"yellow" => Ok(Color::Yellow),
"blue" => Ok(Color::Blue),
"magenta" => Ok(Color::Magenta),
"cyan" => Ok(Color::Cyan),
"gray" | "grey" => Ok(Color::Gray),
"darkgray" | "darkgrey" => Ok(Color::DarkGray),
"lightred" => Ok(Color::LightRed),
"lightgreen" => Ok(Color::LightGreen),
"lightyellow" => Ok(Color::LightYellow),
"lightblue" => Ok(Color::LightBlue),
"lightmagenta" => Ok(Color::LightMagenta),
"lightcyan" => Ok(Color::LightCyan),
"white" => Ok(Color::White),
_ => Err(format!("Unknown color: {}", s)),
}
}
fn color_to_string(color: &Color) -> String {
match color {
Color::Black => "black".to_string(),
Color::Red => "red".to_string(),
Color::Green => "green".to_string(),
Color::Yellow => "yellow".to_string(),
Color::Blue => "blue".to_string(),
Color::Magenta => "magenta".to_string(),
Color::Cyan => "cyan".to_string(),
Color::Gray => "gray".to_string(),
Color::DarkGray => "darkgray".to_string(),
Color::LightRed => "lightred".to_string(),
Color::LightGreen => "lightgreen".to_string(),
Color::LightYellow => "lightyellow".to_string(),
Color::LightBlue => "lightblue".to_string(),
Color::LightMagenta => "lightmagenta".to_string(),
Color::LightCyan => "lightcyan".to_string(),
Color::White => "white".to_string(),
Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
_ => "#ffffff".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1214,8 +1154,14 @@ mod tests {
#[test]
fn test_color_parsing() {
assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0))));
assert!(matches!(parse_color("red"), Ok(Color::Red)));
assert!(matches!(parse_color("lightblue"), Ok(Color::LightBlue)));
assert!(matches!(
parse_color("red"),
Ok(Color::Named(NamedColor::Red))
));
assert!(matches!(
parse_color("lightblue"),
Ok(Color::Named(NamedColor::LightBlue))
));
}
#[test]

View File

@@ -20,7 +20,7 @@ async fn main() -> Result<(), anyhow::Error> {
// Create MCP client - this will spawn/connect to the MCP LLM server
println!("Connecting to MCP LLM server...");
let client = Arc::new(RemoteMcpClient::new()?);
let client = Arc::new(RemoteMcpClient::new().await?);
println!("✓ Connected\n");
// List available models

1802
project-analysis.md Normal file

File diff suppressed because it is too large Load Diff