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:
309
CLAUDE.md
Normal file
309
CLAUDE.md
Normal 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
|
||||
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/owlen-core",
|
||||
"crates/owlen-ui-common",
|
||||
"crates/owlen-tui",
|
||||
"crates/owlen-cli",
|
||||
"crates/owlen-providers",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,14 +256,22 @@ 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 {
|
||||
@@ -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 path‑traversal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
57
crates/owlen-tui/src/color_convert.rs
Normal file
57
crates/owlen-tui/src/color_convert.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
212
crates/owlen-tui/src/theme_helpers.rs
Normal file
212
crates/owlen-tui/src/theme_helpers.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
116
crates/owlen-tui/tests/queue_tests.rs
Normal file
116
crates/owlen-tui/tests/queue_tests.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
18
crates/owlen-ui-common/Cargo.toml
Normal file
18
crates/owlen-ui-common/Cargo.toml
Normal 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]
|
||||
206
crates/owlen-ui-common/src/color.rs
Normal file
206
crates/owlen-ui-common/src/color.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
14
crates/owlen-ui-common/src/lib.rs
Normal file
14
crates/owlen-ui-common/src/lib.rs
Normal 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,
|
||||
};
|
||||
@@ -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]
|
||||
@@ -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
1802
project-analysis.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user