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"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/owlen-core",
|
"crates/owlen-core",
|
||||||
|
"crates/owlen-ui-common",
|
||||||
"crates/owlen-tui",
|
"crates/owlen-tui",
|
||||||
"crates/owlen-cli",
|
"crates/owlen-cli",
|
||||||
"crates/owlen-providers",
|
"crates/owlen-providers",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Initialise the MCP LLM client – it implements Provider and talks to the
|
// Initialise the MCP LLM client – it implements Provider and talks to the
|
||||||
// MCP LLM server which wraps Ollama. This ensures all communication goes
|
// MCP LLM server which wraps Ollama. This ensures all communication goes
|
||||||
// through the MCP architecture (Phase 10 requirement).
|
// 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
|
// The MCP client also serves as the tool client for resource operations
|
||||||
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
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_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
|
||||||
let tui_controller = Arc::new(TuiController::new(tui_tx));
|
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 mut offline_notice: Option<String> = None;
|
||||||
let provider = match provider.health_check().await {
|
let provider = match provider.health_check().await {
|
||||||
Ok(_) => provider,
|
Ok(_) => provider,
|
||||||
@@ -153,13 +153,13 @@ pub async fn launch(initial_mode: Mode, options: LaunchOptions) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_provider(cfg: &Config) -> Result<Arc<dyn Provider>> {
|
async fn build_provider(cfg: &Config) -> Result<Arc<dyn Provider>> {
|
||||||
match cfg.mcp.mode {
|
match cfg.mcp.mode {
|
||||||
McpMode::RemotePreferred => {
|
McpMode::RemotePreferred => {
|
||||||
let remote_result = if let Some(mcp_server) = cfg.effective_mcp_servers().first() {
|
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 {
|
} else {
|
||||||
RemoteMcpClient::new()
|
RemoteMcpClient::new().await
|
||||||
};
|
};
|
||||||
|
|
||||||
match remote_result {
|
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(|| {
|
let mcp_server = cfg.effective_mcp_servers().first().ok_or_else(|| {
|
||||||
anyhow!("[[mcp_servers]] must be configured when [mcp].mode = \"remote_only\"")
|
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>)
|
Ok(Arc::new(client) as Arc<dyn Provider>)
|
||||||
}
|
}
|
||||||
McpMode::LocalOnly | McpMode::Legacy => build_local_provider(cfg),
|
McpMode::LocalOnly | McpMode::Legacy => build_local_provider(cfg),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_react_parsing_tool_call() {
|
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
|
// 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";
|
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]
|
#[tokio::test]
|
||||||
async fn test_react_parsing_final_answer() {
|
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";
|
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]
|
#[tokio::test]
|
||||||
async fn test_react_parsing_with_multiline_thought() {
|
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";
|
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
|
#[ignore] // Requires MCP LLM server to be running
|
||||||
async fn test_agent_single_tool_scenario() {
|
async fn test_agent_single_tool_scenario() {
|
||||||
// This test requires a running MCP LLM server (which wraps Ollama)
|
// 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 mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||||
|
|
||||||
let config = AgentConfig {
|
let config = AgentConfig {
|
||||||
@@ -112,7 +112,7 @@ async fn test_agent_single_tool_scenario() {
|
|||||||
#[ignore] // Requires Ollama to be running
|
#[ignore] // Requires Ollama to be running
|
||||||
async fn test_agent_multi_step_workflow() {
|
async fn test_agent_multi_step_workflow() {
|
||||||
// Test a query that requires multiple tool calls
|
// 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 mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||||
|
|
||||||
let config = AgentConfig {
|
let config = AgentConfig {
|
||||||
@@ -144,7 +144,7 @@ async fn test_agent_multi_step_workflow() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore] // Requires Ollama
|
#[ignore] // Requires Ollama
|
||||||
async fn test_agent_iteration_limit() {
|
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 mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||||
|
|
||||||
let config = AgentConfig {
|
let config = AgentConfig {
|
||||||
@@ -186,7 +186,7 @@ async fn test_agent_iteration_limit() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore] // Requires Ollama
|
#[ignore] // Requires Ollama
|
||||||
async fn test_agent_tool_budget_enforcement() {
|
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 mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||||
|
|
||||||
let config = AgentConfig {
|
let config = AgentConfig {
|
||||||
@@ -226,10 +226,10 @@ async fn test_agent_tool_budget_enforcement() {
|
|||||||
|
|
||||||
// Helper function to create a test executor
|
// Helper function to create a test executor
|
||||||
// For parsing tests, we don't need a real connection
|
// 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()
|
// 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
|
// 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),
|
Ok(client) => Arc::new(client),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// If MCP server binary doesn't exist, parsing tests can still run
|
// 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"
|
description = "Core traits and types for OWLEN LLM client"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
owlen-ui-common = { path = "../owlen-ui-common" }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
@@ -26,7 +27,6 @@ async-trait = { workspace = true }
|
|||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
shellexpand = { workspace = true }
|
shellexpand = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
ratatui = { workspace = true }
|
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
jsonschema = { workspace = true }
|
jsonschema = { workspace = true }
|
||||||
which = { workspace = true }
|
which = { workspace = true }
|
||||||
@@ -35,7 +35,6 @@ aes-gcm = { workspace = true }
|
|||||||
ring = { workspace = true }
|
ring = { workspace = true }
|
||||||
keyring = { workspace = true }
|
keyring = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
|
||||||
urlencoding = { workspace = true }
|
urlencoding = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["default"] }
|
reqwest = { workspace = true, features = ["default"] }
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ pub mod sandbox;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod theme;
|
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
@@ -37,6 +36,12 @@ pub mod usage;
|
|||||||
pub mod validation;
|
pub mod validation;
|
||||||
pub mod wrap_cursor;
|
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::*;
|
||||||
pub use agent_registry::*;
|
pub use agent_registry::*;
|
||||||
pub use automation::*;
|
pub use automation::*;
|
||||||
@@ -66,7 +71,6 @@ pub use router::*;
|
|||||||
pub use sandbox::*;
|
pub use sandbox::*;
|
||||||
pub use session::*;
|
pub use session::*;
|
||||||
pub use state::*;
|
pub use state::*;
|
||||||
pub use theme::*;
|
|
||||||
pub use tools::*;
|
pub use tools::*;
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
pub use validation::*;
|
pub use validation::*;
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ impl McpClientFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create an MCP client based on the current configuration.
|
/// Create an MCP client based on the current configuration.
|
||||||
pub fn create(&self) -> Result<Box<dyn McpClient>> {
|
pub async fn create(&self) -> Result<Box<dyn McpClient>> {
|
||||||
self.create_with_secrets(None)
|
self.create_with_secrets(None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an MCP client using optional runtime secrets (OAuth tokens, env overrides).
|
/// Create an MCP client using optional runtime secrets (OAuth tokens, env overrides).
|
||||||
pub fn create_with_secrets(
|
pub async fn create_with_secrets(
|
||||||
&self,
|
&self,
|
||||||
runtime: Option<McpRuntimeSecrets>,
|
runtime: Option<McpRuntimeSecrets>,
|
||||||
) -> Result<Box<dyn McpClient>> {
|
) -> Result<Box<dyn McpClient>> {
|
||||||
@@ -67,6 +67,7 @@ impl McpClientFactory {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
RemoteMcpClient::new_with_runtime(server_cfg, runtime)
|
RemoteMcpClient::new_with_runtime(server_cfg, runtime)
|
||||||
|
.await
|
||||||
.map(|client| Box::new(client) as Box<dyn McpClient>)
|
.map(|client| Box::new(client) as Box<dyn McpClient>)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
Error::Config(format!(
|
Error::Config(format!(
|
||||||
@@ -77,7 +78,7 @@ impl McpClientFactory {
|
|||||||
}
|
}
|
||||||
McpMode::RemotePreferred => {
|
McpMode::RemotePreferred => {
|
||||||
if let Some(server_cfg) = self.config.effective_mcp_servers().first() {
|
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) => {
|
Ok(client) => {
|
||||||
info!(
|
info!(
|
||||||
"Connected to remote MCP server '{}' via {} transport.",
|
"Connected to remote MCP server '{}' via {} transport.",
|
||||||
@@ -112,8 +113,8 @@ impl McpClientFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if remote MCP mode is available
|
/// Check if remote MCP mode is available
|
||||||
pub fn is_remote_available() -> bool {
|
pub async fn is_remote_available() -> bool {
|
||||||
RemoteMcpClient::new().is_ok()
|
RemoteMcpClient::new().await.is_ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,32 +135,32 @@ mod tests {
|
|||||||
McpClientFactory::new(Arc::new(config), registry, validator)
|
McpClientFactory::new(Arc::new(config), registry, validator)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_factory_creates_local_client_when_no_servers_configured() {
|
async fn test_factory_creates_local_client_when_no_servers_configured() {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.refresh_mcp_servers(None).unwrap();
|
config.refresh_mcp_servers(None).unwrap();
|
||||||
|
|
||||||
let factory = build_factory(config);
|
let factory = build_factory(config);
|
||||||
|
|
||||||
// Should create without error and fall back to local client
|
// Should create without error and fall back to local client
|
||||||
let result = factory.create();
|
let result = factory.create().await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_remote_only_without_servers_errors() {
|
async fn test_remote_only_without_servers_errors() {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.mcp.mode = McpMode::RemoteOnly;
|
config.mcp.mode = McpMode::RemoteOnly;
|
||||||
config.mcp_servers.clear();
|
config.mcp_servers.clear();
|
||||||
config.refresh_mcp_servers(None).unwrap();
|
config.refresh_mcp_servers(None).unwrap();
|
||||||
|
|
||||||
let factory = build_factory(config);
|
let factory = build_factory(config);
|
||||||
let result = factory.create();
|
let result = factory.create().await;
|
||||||
assert!(matches!(result, Err(Error::Config(_))));
|
assert!(matches!(result, Err(Error::Config(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_remote_preferred_without_fallback_propagates_remote_error() {
|
async fn test_remote_preferred_without_fallback_propagates_remote_error() {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.mcp.mode = McpMode::RemotePreferred;
|
config.mcp.mode = McpMode::RemotePreferred;
|
||||||
config.mcp.allow_fallback = false;
|
config.mcp.allow_fallback = false;
|
||||||
@@ -174,19 +175,19 @@ mod tests {
|
|||||||
config.refresh_mcp_servers(None).unwrap();
|
config.refresh_mcp_servers(None).unwrap();
|
||||||
|
|
||||||
let factory = build_factory(config);
|
let factory = build_factory(config);
|
||||||
let result = factory.create();
|
let result = factory.create().await;
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result, Err(Error::Config(message)) if message.contains("Failed to start remote MCP client"))
|
matches!(result, Err(Error::Config(message)) if message.contains("Failed to start remote MCP client"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn test_legacy_mode_uses_local_client() {
|
async fn test_legacy_mode_uses_local_client() {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.mcp.mode = McpMode::Legacy;
|
config.mcp.mode = McpMode::Legacy;
|
||||||
|
|
||||||
let factory = build_factory(config);
|
let factory = build_factory(config);
|
||||||
let result = factory.create();
|
let result = factory.create().await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ mod tests {
|
|||||||
oauth: None,
|
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);
|
let entry = ServerEntry::new("test".to_string(), Arc::new(client), 1);
|
||||||
|
|
||||||
assert!(entry.is_available().await);
|
assert!(entry.is_available().await);
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ use crate::{
|
|||||||
send_via_stream,
|
send_via_stream,
|
||||||
};
|
};
|
||||||
use futures::{StreamExt, future::BoxFuture, stream};
|
use futures::{StreamExt, future::BoxFuture, stream};
|
||||||
|
use path_clean::PathClean;
|
||||||
use reqwest::Client as HttpClient;
|
use reqwest::Client as HttpClient;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -52,17 +53,135 @@ pub struct McpRuntimeSecrets {
|
|||||||
pub http_header: Option<(String, String)>,
|
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 {
|
impl RemoteMcpClient {
|
||||||
/// Spawn the MCP server binary and prepare communication channels.
|
/// Spawn the MCP server binary and prepare communication channels.
|
||||||
/// Spawn an MCP server based on a configuration entry.
|
/// Spawn an MCP server based on a configuration entry.
|
||||||
/// The `transport` field must be "stdio" (the only supported mode).
|
/// The `transport` field must be "stdio" (the only supported mode).
|
||||||
/// Spawn an external MCP server based on a configuration entry.
|
/// Spawn an external MCP server based on a configuration entry.
|
||||||
/// The server must communicate over STDIO (the only supported transport).
|
/// The server must communicate over STDIO (the only supported transport).
|
||||||
pub fn new_with_config(config: &crate::config::McpServerConfig) -> Result<Self> {
|
pub async fn new_with_config(config: &crate::config::McpServerConfig) -> Result<Self> {
|
||||||
Self::new_with_runtime(config, None)
|
Self::new_with_runtime(config, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_with_runtime(
|
pub async fn new_with_runtime(
|
||||||
config: &crate::config::McpServerConfig,
|
config: &crate::config::McpServerConfig,
|
||||||
runtime: Option<McpRuntimeSecrets>,
|
runtime: Option<McpRuntimeSecrets>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
@@ -137,14 +256,22 @@ impl RemoteMcpClient {
|
|||||||
}
|
}
|
||||||
"websocket" => {
|
"websocket" => {
|
||||||
// For WebSocket, the `command` field contains the WebSocket URL.
|
// 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_url = config.command.clone();
|
||||||
let (ws_stream, _response) = tokio::task::block_in_place(|| {
|
let connection_timeout = Duration::from_secs(30);
|
||||||
tokio::runtime::Handle::current().block_on(async {
|
|
||||||
connect_async(&ws_url).await.map_err(|e| {
|
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))
|
Error::Network(format!("WebSocket connection failed: {}", e))
|
||||||
})
|
|
||||||
})
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -167,7 +294,7 @@ impl RemoteMcpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy constructor kept for compatibility; attempts to locate a binary.
|
/// 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.
|
// 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"))
|
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("../..")
|
.join("../..")
|
||||||
@@ -198,7 +325,7 @@ impl RemoteMcpClient {
|
|||||||
env: std::collections::HashMap::new(),
|
env: std::collections::HashMap::new(),
|
||||||
oauth: None,
|
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> {
|
async fn send_rpc(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
|
||||||
@@ -368,8 +495,13 @@ impl McpClient for RemoteMcpClient {
|
|||||||
.arguments
|
.arguments
|
||||||
.get("path")
|
.get("path")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
|
||||||
let content = std::fs::read_to_string(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)?;
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(safe_path).map_err(Error::Io)?;
|
||||||
return Ok(McpToolResponse {
|
return Ok(McpToolResponse {
|
||||||
name: call.name,
|
name: call.name,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -384,8 +516,13 @@ impl McpClient for RemoteMcpClient {
|
|||||||
.get("path")
|
.get("path")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(".");
|
.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();
|
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() {
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
names.push(name.to_string());
|
names.push(name.to_string());
|
||||||
}
|
}
|
||||||
@@ -405,16 +542,17 @@ impl McpClient for RemoteMcpClient {
|
|||||||
.get("path")
|
.get("path")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
|
.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() {
|
// Secure path validation to prevent path traversal attacks
|
||||||
return Err(Error::InvalidInput("path traversal".into()));
|
let base_dir = std::env::current_dir().map_err(Error::Io)?;
|
||||||
}
|
let safe_path = validate_safe_path(path, &base_dir)?;
|
||||||
|
|
||||||
let content = call
|
let content = call
|
||||||
.arguments
|
.arguments
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| Error::InvalidInput("content missing".into()))?;
|
.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 {
|
return Ok(McpToolResponse {
|
||||||
name: call.name,
|
name: call.name,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -429,10 +567,12 @@ impl McpClient for RemoteMcpClient {
|
|||||||
.get("path")
|
.get("path")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
|
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
|
||||||
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)?;
|
||||||
std::fs::remove_file(path).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 {
|
return Ok(McpToolResponse {
|
||||||
name: call.name,
|
name: call.name,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -561,3 +701,274 @@ impl LlmClient for RemoteMcpClient {
|
|||||||
<Self as McpClient>::call_tool(self, call).await
|
<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
|
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> =
|
let primary: Arc<dyn McpClient> =
|
||||||
Arc::new(PermissionLayer::new(base_client, config_arc.clone()));
|
Arc::new(PermissionLayer::new(base_client, config_arc.clone()));
|
||||||
primary.set_mode(initial_mode).await?;
|
primary.set_mode(initial_mode).await?;
|
||||||
@@ -769,7 +769,7 @@ impl SessionController {
|
|||||||
missing_oauth_servers.push(server_cfg.name.clone());
|
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) => {
|
Ok(remote) => {
|
||||||
let client: Arc<dyn McpClient> =
|
let client: Arc<dyn McpClient> =
|
||||||
Arc::new(PermissionLayer::new(Box::new(remote), config_arc.clone()));
|
Arc::new(PermissionLayer::new(Box::new(remote), config_arc.clone()));
|
||||||
@@ -1902,7 +1902,7 @@ impl SessionController {
|
|||||||
self.tool_registry.clone(),
|
self.tool_registry.clone(),
|
||||||
self.schema_validator.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 permission_client = PermissionLayer::new(base_client, Arc::new(config.clone()));
|
||||||
let client = Arc::new(permission_client);
|
let client = Arc::new(permission_client);
|
||||||
client.set_mode(self.current_mode).await?;
|
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)
|
Some(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::theme::Theme;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::io::stdout;
|
use owlen_ui_common::Theme;
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_theme_to_string(s: &str, _theme: &Theme) -> String {
|
pub fn apply_theme_to_string(s: &str, _theme: &Theme) -> String {
|
||||||
// This is a placeholder. In a real implementation, you'd parse the 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");
|
assert!(build_status.success(), "MCP server build failed");
|
||||||
|
|
||||||
// Spawn remote client after the cwd is set and binary built
|
// 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
|
// Read file via MCP
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ async fn remote_write_and_delete() {
|
|||||||
let dir = tempdir().expect("tempdir");
|
let dir = tempdir().expect("tempdir");
|
||||||
std::env::set_current_dir(dir.path()).expect("set cwd");
|
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
|
// Write a file via MCP
|
||||||
let write_call = McpToolCall {
|
let write_call = McpToolCall {
|
||||||
@@ -49,7 +49,7 @@ async fn write_outside_root_is_rejected() {
|
|||||||
// Set cwd to a fresh temp dir
|
// Set cwd to a fresh temp dir
|
||||||
let dir = tempdir().expect("tempdir");
|
let dir = tempdir().expect("tempdir");
|
||||||
std::env::set_current_dir(dir.path()).expect("set cwd");
|
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"
|
// Attempt to write outside the root using "../evil.txt"
|
||||||
let call = McpToolCall {
|
let call = McpToolCall {
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ mod worker;
|
|||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub use worker::background_worker;
|
pub use worker::background_worker;
|
||||||
|
|
||||||
use std::{io, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
io,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -26,12 +30,15 @@ use crate::{Event, SessionEvent, events};
|
|||||||
pub use handler::MessageState;
|
pub use handler::MessageState;
|
||||||
pub use messages::AppMessage;
|
pub use messages::AppMessage;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum AppEvent {
|
enum AppEvent {
|
||||||
Message(AppMessage),
|
Message(AppMessage),
|
||||||
Session(SessionEvent),
|
Session(SessionEvent),
|
||||||
Ui(Event),
|
Ui(Event),
|
||||||
FrameTick,
|
FrameTick,
|
||||||
|
RedrawRequested,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -57,6 +64,7 @@ pub struct App {
|
|||||||
message_tx: mpsc::UnboundedSender<AppMessage>,
|
message_tx: mpsc::UnboundedSender<AppMessage>,
|
||||||
message_rx: Option<mpsc::UnboundedReceiver<AppMessage>>,
|
message_rx: Option<mpsc::UnboundedReceiver<AppMessage>>,
|
||||||
active_generation: Option<ActiveGeneration>,
|
active_generation: Option<ActiveGeneration>,
|
||||||
|
frame_requester: FrameRequester,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -69,6 +77,7 @@ impl App {
|
|||||||
message_tx,
|
message_tx,
|
||||||
message_rx: Some(message_rx),
|
message_rx: Some(message_rx),
|
||||||
active_generation: None,
|
active_generation: None,
|
||||||
|
frame_requester: FrameRequester::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +86,11 @@ impl App {
|
|||||||
self.message_tx.clone()
|
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.
|
/// Whether a generation task is currently in flight.
|
||||||
pub fn has_active_generation(&self) -> bool {
|
pub fn has_active_generation(&self) -> bool {
|
||||||
self.active_generation.is_some()
|
self.active_generation.is_some()
|
||||||
@@ -118,6 +132,7 @@ impl App {
|
|||||||
.expect("App::run called without an available message receiver");
|
.expect("App::run called without an available message receiver");
|
||||||
|
|
||||||
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel::<AppEvent>();
|
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());
|
let (input_cancel, input_handle) = Self::spawn_input_listener(app_event_tx.clone());
|
||||||
drop(app_event_tx);
|
drop(app_event_tx);
|
||||||
|
|
||||||
@@ -131,19 +146,26 @@ impl App {
|
|||||||
self.pump_background(state).await?;
|
self.pump_background(state).await?;
|
||||||
|
|
||||||
let next_event = tokio::select! {
|
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(message) = message_rx.recv() => AppEvent::Message(message),
|
||||||
Some(session_event) = session_rx.recv() => AppEvent::Session(session_event),
|
Some(session_event) = session_rx.recv() => AppEvent::Session(session_event),
|
||||||
_ = frame_interval.tick() => AppEvent::FrameTick,
|
_ = frame_interval.tick() => AppEvent::FrameTick,
|
||||||
else => break,
|
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? {
|
match self.dispatch_app_event(state, next_event).await? {
|
||||||
LoopControl::Continue => {
|
LoopControl::Continue => {
|
||||||
if is_frame_tick {
|
if should_render {
|
||||||
render(terminal, state)?;
|
render(terminal, state)?;
|
||||||
|
self.frame_requester.mark_rendered();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LoopControl::Exit(state_value) => {
|
LoopControl::Exit(state_value) => {
|
||||||
@@ -160,6 +182,7 @@ impl App {
|
|||||||
handle.abort();
|
handle.abort();
|
||||||
let _ = handle.await;
|
let _ = handle.await;
|
||||||
}
|
}
|
||||||
|
self.frame_requester.detach();
|
||||||
|
|
||||||
self.message_rx = Some(message_rx);
|
self.message_rx = Some(message_rx);
|
||||||
|
|
||||||
@@ -231,6 +254,7 @@ impl App {
|
|||||||
LoopControl::Continue
|
LoopControl::Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEvent::RedrawRequested => LoopControl::Continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(control)
|
Ok(control)
|
||||||
@@ -295,3 +319,81 @@ impl ActiveGeneration {
|
|||||||
self.request_id
|
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};
|
use ratatui::style::{Color, palette::tailwind};
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -29,11 +29,11 @@ impl GlassPalette {
|
|||||||
layers: &LayerSettings,
|
layers: &LayerSettings,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
if reduced_chrome {
|
if reduced_chrome {
|
||||||
let base = theme.background;
|
let base = crate::color_convert::to_ratatui_color(&theme.background);
|
||||||
let label = theme.text;
|
let label = crate::color_convert::to_ratatui_color(&theme.text);
|
||||||
let track = theme.unfocused_panel_border;
|
let track = crate::color_convert::to_ratatui_color(&theme.unfocused_panel_border);
|
||||||
let context_color = theme.mode_normal;
|
let context_color = crate::color_convert::to_ratatui_color(&theme.mode_normal);
|
||||||
let usage_color = theme.mode_command;
|
let usage_color = crate::color_convert::to_ratatui_color(&theme.mode_command);
|
||||||
return Self {
|
return Self {
|
||||||
active: base,
|
active: base,
|
||||||
inactive: base,
|
inactive: base,
|
||||||
@@ -45,32 +45,38 @@ impl GlassPalette {
|
|||||||
usage_stops: [usage_color, usage_color, usage_color],
|
usage_stops: [usage_color, usage_color, usage_color],
|
||||||
frosted: base,
|
frosted: base,
|
||||||
frost_edge: base,
|
frost_edge: base,
|
||||||
neon_accent: theme.info,
|
neon_accent: crate::color_convert::to_ratatui_color(&theme.info),
|
||||||
neon_glow: theme.info,
|
neon_glow: crate::color_convert::to_ratatui_color(&theme.info),
|
||||||
focus_ring: theme.focused_panel_border,
|
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 neon_factor = layers.neon_factor();
|
||||||
let glass_tint = layers.glass_tint_factor();
|
let glass_tint = layers.glass_tint_factor();
|
||||||
let focus_enabled = layers.focus_ring;
|
let focus_enabled = layers.focus_ring;
|
||||||
if luminance < 0.5 {
|
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 frost_edge = blend_color(frosted, tailwind::SLATE.c700, 0.25);
|
||||||
let inactive = blend_color(frosted, tailwind::SLATE.c800, 0.55);
|
let inactive = blend_color(frosted, tailwind::SLATE.c800, 0.55);
|
||||||
let highlight = blend_color(frosted, tailwind::SLATE.c700, 0.35);
|
let highlight = blend_color(frosted, tailwind::SLATE.c700, 0.35);
|
||||||
let track = blend_color(frosted, tailwind::SLATE.c600, 0.25);
|
let track = blend_color(frosted, tailwind::SLATE.c600, 0.25);
|
||||||
let neon_seed = tailwind::SKY.c400;
|
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 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 {
|
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 {
|
} else {
|
||||||
blend_color(frosted, theme.unfocused_panel_border, 0.15)
|
blend_color(frosted, unfocused_border_ratatui, 0.15)
|
||||||
};
|
};
|
||||||
let shadow = match layers.shadow_depth() {
|
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,
|
1 => tailwind::SLATE.c900,
|
||||||
2 => tailwind::SLATE.c950,
|
2 => tailwind::SLATE.c950,
|
||||||
_ => Color::Rgb(2, 4, 12),
|
_ => Color::Rgb(2, 4, 12),
|
||||||
@@ -100,21 +106,26 @@ impl GlassPalette {
|
|||||||
focus_ring,
|
focus_ring,
|
||||||
}
|
}
|
||||||
} else {
|
} 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 frost_edge = blend_color(frosted, tailwind::ZINC.c200, 0.4);
|
||||||
let inactive = blend_color(frosted, tailwind::ZINC.c200, 0.65);
|
let inactive = blend_color(frosted, tailwind::ZINC.c200, 0.65);
|
||||||
let highlight = blend_color(frosted, tailwind::ZINC.c200, 0.35);
|
let highlight = blend_color(frosted, tailwind::ZINC.c200, 0.35);
|
||||||
let track = blend_color(frosted, tailwind::ZINC.c300, 0.45);
|
let track = blend_color(frosted, tailwind::ZINC.c300, 0.45);
|
||||||
let neon_seed = tailwind::BLUE.c500;
|
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 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 {
|
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 {
|
} else {
|
||||||
blend_color(frosted, theme.unfocused_panel_border, 0.1)
|
blend_color(frosted, unfocused_border_ratatui, 0.1)
|
||||||
};
|
};
|
||||||
let shadow = match layers.shadow_depth() {
|
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,
|
1 => tailwind::ZINC.c300,
|
||||||
2 => tailwind::ZINC.c400,
|
2 => tailwind::ZINC.c400,
|
||||||
_ => Color::Rgb(210, 210, 210),
|
_ => Color::Rgb(210, 210, 210),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod chat_app;
|
pub mod chat_app;
|
||||||
pub mod code_app;
|
pub mod code_app;
|
||||||
|
pub mod color_convert;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
use owlen_core::Theme;
|
||||||
use owlen_core::model::DetailedModelInfo;
|
use owlen_core::model::DetailedModelInfo;
|
||||||
use owlen_core::theme::Theme;
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
@@ -39,15 +39,21 @@ impl ModelInfoPanel {
|
|||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title("Model Information")
|
.title("Model Information")
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.style(Style::default().bg(theme.background).fg(theme.text))
|
.style(
|
||||||
.border_style(Style::default().fg(theme.focused_panel_border));
|
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 {
|
if let Some(info) = &self.info {
|
||||||
let body = self.format_info(info);
|
let body = self.format_info(info);
|
||||||
self.total_lines = body.lines().count();
|
self.total_lines = body.lines().count();
|
||||||
let paragraph = Paragraph::new(body)
|
let paragraph = Paragraph::new(body)
|
||||||
.block(block)
|
.block(block)
|
||||||
.style(Style::default().fg(theme.text))
|
.style(Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text)))
|
||||||
.wrap(Wrap { trim: true })
|
.wrap(Wrap { trim: true })
|
||||||
.scroll((self.scroll_offset as u16, 0));
|
.scroll((self.scroll_offset as u16, 0));
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
@@ -57,7 +63,7 @@ impl ModelInfoPanel {
|
|||||||
.block(block)
|
.block(block)
|
||||||
.style(
|
.style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.placeholder)
|
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
|
||||||
.add_modifier(Modifier::ITALIC),
|
.add_modifier(Modifier::ITALIC),
|
||||||
)
|
)
|
||||||
.wrap(Wrap { trim: true });
|
.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;
|
use ratatui::style::Color;
|
||||||
|
|
||||||
/// Return a clone of `base` with contrast adjustments applied.
|
/// 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,
|
ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo,
|
||||||
ModelSelectorItemKind,
|
ModelSelectorItemKind,
|
||||||
};
|
};
|
||||||
|
use crate::color_convert::to_ratatui_color;
|
||||||
use crate::glass::GlassPalette;
|
use crate::glass::GlassPalette;
|
||||||
|
|
||||||
/// Filtering modes for the model picker popup.
|
/// 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);
|
.add_modifier(Modifier::DIM);
|
||||||
let caret_style = if search_active {
|
let caret_style = if search_active {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.selection_fg)
|
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -152,7 +153,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
search_spans.push(Span::styled(
|
search_spans.push(Span::styled(
|
||||||
search_query.clone(),
|
search_query.clone(),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.selection_fg)
|
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
@@ -211,8 +212,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
frame.render_widget(search_paragraph, layout[0]);
|
frame.render_widget(search_paragraph, layout[0]);
|
||||||
|
|
||||||
let highlight_style = Style::default()
|
let highlight_style = Style::default()
|
||||||
.fg(theme.selection_fg)
|
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
|
||||||
.bg(theme.selection_bg)
|
.bg(crate::color_convert::to_ratatui_color(&theme.selection_bg))
|
||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
let highlight_symbol = " ";
|
let highlight_symbol = " ";
|
||||||
@@ -246,7 +247,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.mode_command)
|
.fg(crate::color_convert::to_ratatui_color(&theme.mode_command))
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
highlight_style,
|
highlight_style,
|
||||||
);
|
);
|
||||||
@@ -257,7 +258,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
if *expanded { "▼" } else { "▶" },
|
if *expanded { "▼" } else { "▶" },
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.placeholder)
|
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
|
||||||
.add_modifier(Modifier::DIM),
|
.add_modifier(Modifier::DIM),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -307,7 +308,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
lines.push(clip_line_to_width(
|
lines.push(clip_line_to_width(
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" <model unavailable>",
|
" <model unavailable>",
|
||||||
Style::default().fg(theme.error),
|
Style::default()
|
||||||
|
.fg(crate::color_convert::to_ratatui_color(&theme.error)),
|
||||||
)),
|
)),
|
||||||
max_line_width,
|
max_line_width,
|
||||||
));
|
));
|
||||||
@@ -333,8 +335,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.highlight_style(
|
.highlight_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.bg(theme.selection_bg)
|
.bg(crate::color_convert::to_ratatui_color(&theme.selection_bg))
|
||||||
.fg(theme.selection_fg)
|
.fg(crate::color_convert::to_ratatui_color(&theme.selection_fg))
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
.highlight_symbol(" ")
|
.highlight_symbol(" ")
|
||||||
@@ -359,44 +361,50 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
frame.render_widget(footer, layout[2]);
|
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 {
|
let (symbol, color) = match status {
|
||||||
ProviderStatus::Available => ("✓", theme.info),
|
ProviderStatus::Available => ("✓", theme.info),
|
||||||
ProviderStatus::Unavailable => ("✗", theme.error),
|
ProviderStatus::Unavailable => ("✗", theme.error),
|
||||||
ProviderStatus::RequiresSetup => ("⚙", Color::Yellow),
|
ProviderStatus::RequiresSetup => (
|
||||||
|
"⚙",
|
||||||
|
owlen_core::Color::Named(owlen_core::NamedColor::Yellow),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
Span::styled(
|
Span::styled(
|
||||||
symbol,
|
symbol,
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(to_ratatui_color(&color))
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provider_type_badge(
|
fn provider_type_badge(provider_type: ProviderType, theme: &owlen_core::Theme) -> Span<'static> {
|
||||||
provider_type: ProviderType,
|
|
||||||
theme: &owlen_core::theme::Theme,
|
|
||||||
) -> Span<'static> {
|
|
||||||
let (label, color) = match provider_type {
|
let (label, color) = match provider_type {
|
||||||
ProviderType::Local => ("[Local]", theme.mode_normal),
|
ProviderType::Local => ("[Local]", theme.mode_normal),
|
||||||
ProviderType::Cloud => ("[Cloud]", theme.mode_help),
|
ProviderType::Cloud => ("[Cloud]", theme.mode_help),
|
||||||
};
|
};
|
||||||
Span::styled(
|
Span::styled(
|
||||||
label,
|
label,
|
||||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(to_ratatui_color(&color))
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scope_status_style(
|
fn scope_status_style(
|
||||||
status: ModelAvailabilityState,
|
status: ModelAvailabilityState,
|
||||||
theme: &owlen_core::theme::Theme,
|
theme: &owlen_core::Theme,
|
||||||
) -> (Style, &'static str) {
|
) -> (Style, &'static str) {
|
||||||
match status {
|
match status {
|
||||||
ModelAvailabilityState::Available => (
|
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 => (
|
ModelAvailabilityState::Unavailable => (
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.error)
|
.fg(crate::color_convert::to_ratatui_color(&theme.error))
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
"✗",
|
"✗",
|
||||||
),
|
),
|
||||||
@@ -411,18 +419,18 @@ fn scope_status_style(
|
|||||||
|
|
||||||
fn empty_status_style(
|
fn empty_status_style(
|
||||||
status: Option<ModelAvailabilityState>,
|
status: Option<ModelAvailabilityState>,
|
||||||
theme: &owlen_core::theme::Theme,
|
theme: &owlen_core::Theme,
|
||||||
) -> (Style, &'static str) {
|
) -> (Style, &'static str) {
|
||||||
match status.unwrap_or(ModelAvailabilityState::Unknown) {
|
match status.unwrap_or(ModelAvailabilityState::Unknown) {
|
||||||
ModelAvailabilityState::Available => (
|
ModelAvailabilityState::Available => (
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.placeholder)
|
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
|
||||||
.add_modifier(Modifier::DIM),
|
.add_modifier(Modifier::DIM),
|
||||||
"•",
|
"•",
|
||||||
),
|
),
|
||||||
ModelAvailabilityState::Unavailable => (
|
ModelAvailabilityState::Unavailable => (
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.error)
|
.fg(crate::color_convert::to_ratatui_color(&theme.error))
|
||||||
.add_modifier(Modifier::BOLD),
|
.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 {
|
let label = match mode {
|
||||||
FilterMode::All => return Span::raw(""),
|
FilterMode::All => return Span::raw(""),
|
||||||
FilterMode::LocalOnly => "Local",
|
FilterMode::LocalOnly => "Local",
|
||||||
@@ -445,7 +453,9 @@ fn filter_badge(mode: FilterMode, theme: &owlen_core::theme::Theme) -> Span<'sta
|
|||||||
Span::styled(
|
Span::styled(
|
||||||
format!("[{label}]"),
|
format!("[{label}]"),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.mode_provider_selection)
|
.fg(crate::color_convert::to_ratatui_color(
|
||||||
|
&theme.mode_provider_selection,
|
||||||
|
))
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -509,7 +519,7 @@ struct SearchRenderContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_model_selector_lines<'a>(
|
fn build_model_selector_lines<'a>(
|
||||||
theme: &owlen_core::theme::Theme,
|
theme: &owlen_core::Theme,
|
||||||
model: &'a ModelInfo,
|
model: &'a ModelInfo,
|
||||||
annotated: Option<&'a AnnotatedModelInfo>,
|
annotated: Option<&'a AnnotatedModelInfo>,
|
||||||
badges: &[&'static str],
|
badges: &[&'static str],
|
||||||
@@ -536,7 +546,9 @@ fn build_model_selector_lines<'a>(
|
|||||||
spans.push(provider_type_badge(provider_type, theme));
|
spans.push(provider_type_badge(provider_type, theme));
|
||||||
spans.push(Span::raw(" "));
|
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);
|
let display_name = ChatApp::display_name_for_model(model);
|
||||||
if !display_name.trim().is_empty() {
|
if !display_name.trim().is_empty() {
|
||||||
let name_spans = render_highlighted_text(
|
let name_spans = render_highlighted_text(
|
||||||
@@ -560,7 +572,7 @@ fn build_model_selector_lines<'a>(
|
|||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
badges.join(" "),
|
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::raw(" "));
|
||||||
spans.push(Span::styled(
|
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
|
None
|
||||||
} else {
|
} else {
|
||||||
let meta_style = Style::default()
|
let meta_style = Style::default()
|
||||||
.fg(theme.placeholder)
|
.fg(crate::color_convert::to_ratatui_color(&theme.placeholder))
|
||||||
.add_modifier(Modifier::DIM);
|
.add_modifier(Modifier::DIM);
|
||||||
let mut segments: Vec<Span<'static>> = Vec::new();
|
let mut segments: Vec<Span<'static>> = Vec::new();
|
||||||
segments.push(Span::styled(" ", meta_style));
|
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.
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -290,113 +290,111 @@ impl Default for Theme {
|
|||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
const fn default_code_block_background() -> Color {
|
const fn default_code_block_background() -> Color {
|
||||||
Color::Black
|
Color::Named(NamedColor::Black)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_code_block_border() -> Color {
|
const fn default_code_block_border() -> Color {
|
||||||
Color::Gray
|
Color::Named(NamedColor::Gray)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_code_block_text() -> Color {
|
const fn default_code_block_text() -> Color {
|
||||||
Color::White
|
Color::Named(NamedColor::White)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_code_block_keyword() -> Color {
|
const fn default_code_block_keyword() -> Color {
|
||||||
Color::Yellow
|
Color::Named(NamedColor::Yellow)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_code_block_string() -> Color {
|
const fn default_code_block_string() -> Color {
|
||||||
Color::LightGreen
|
Color::Named(NamedColor::LightGreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_code_block_comment() -> Color {
|
const fn default_code_block_comment() -> Color {
|
||||||
Color::DarkGray
|
Color::Named(NamedColor::DarkGray)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_thought() -> Color {
|
const fn default_agent_thought() -> Color {
|
||||||
Color::LightBlue
|
Color::Named(NamedColor::LightBlue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_action() -> Color {
|
const fn default_agent_action() -> Color {
|
||||||
Color::Yellow
|
Color::Named(NamedColor::Yellow)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_action_input() -> Color {
|
const fn default_agent_action_input() -> Color {
|
||||||
Color::LightCyan
|
Color::Named(NamedColor::LightCyan)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_observation() -> Color {
|
const fn default_agent_observation() -> Color {
|
||||||
Color::LightGreen
|
Color::Named(NamedColor::LightGreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_final_answer() -> Color {
|
const fn default_agent_final_answer() -> Color {
|
||||||
Color::Magenta
|
Color::Named(NamedColor::Magenta)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_badge_running_fg() -> Color {
|
const fn default_agent_badge_running_fg() -> Color {
|
||||||
Color::Black
|
Color::Named(NamedColor::Black)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_badge_running_bg() -> Color {
|
const fn default_agent_badge_running_bg() -> Color {
|
||||||
Color::Yellow
|
Color::Named(NamedColor::Yellow)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_badge_idle_fg() -> Color {
|
const fn default_agent_badge_idle_fg() -> Color {
|
||||||
Color::Black
|
Color::Named(NamedColor::Black)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_agent_badge_idle_bg() -> Color {
|
const fn default_agent_badge_idle_bg() -> Color {
|
||||||
Color::Cyan
|
Color::Named(NamedColor::Cyan)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_focus_beacon_fg() -> Color {
|
const fn default_focus_beacon_fg() -> Color {
|
||||||
Color::LightMagenta
|
Color::Named(NamedColor::LightMagenta)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_focus_beacon_bg() -> Color {
|
const fn default_focus_beacon_bg() -> Color {
|
||||||
Color::Black
|
Color::Named(NamedColor::Black)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_unfocused_beacon_fg() -> Color {
|
const fn default_unfocused_beacon_fg() -> Color {
|
||||||
Color::DarkGray
|
Color::Named(NamedColor::DarkGray)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_pane_header_active() -> Color {
|
const fn default_pane_header_active() -> Color {
|
||||||
Color::White
|
Color::Named(NamedColor::White)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_pane_header_inactive() -> Color {
|
const fn default_pane_header_inactive() -> Color {
|
||||||
Color::Gray
|
Color::Named(NamedColor::Gray)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_pane_hint_text() -> Color {
|
const fn default_pane_hint_text() -> Color {
|
||||||
Color::DarkGray
|
Color::Named(NamedColor::DarkGray)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_operating_chat_fg() -> Color {
|
const fn default_operating_chat_fg() -> Color {
|
||||||
Color::Black
|
Color::Named(NamedColor::Black)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_operating_chat_bg() -> Color {
|
const fn default_operating_chat_bg() -> Color {
|
||||||
Color::Blue
|
Color::Named(NamedColor::Blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_operating_code_fg() -> Color {
|
const fn default_operating_code_fg() -> Color {
|
||||||
Color::Black
|
Color::Named(NamedColor::Black)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_operating_code_bg() -> Color {
|
const fn default_operating_code_bg() -> Color {
|
||||||
Color::Magenta
|
Color::Named(NamedColor::Magenta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the default themes directory path
|
/// 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 {
|
pub fn default_themes_dir() -> PathBuf {
|
||||||
let config_dir = PathBuf::from(shellexpand::tilde(crate::config::DEFAULT_CONFIG_PATH).as_ref())
|
// Use a hardcoded default path that matches owlen-core's DEFAULT_CONFIG_PATH
|
||||||
.parent()
|
let config_dir = PathBuf::from(shellexpand::tilde("~/.config/owlen").as_ref());
|
||||||
.map(|p| p.to_path_buf())
|
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.config/owlen"));
|
|
||||||
|
|
||||||
config_dir.join("themes")
|
config_dir.join("themes")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,8 +528,8 @@ fn get_fallback_theme(name: &str) -> Option<Theme> {
|
|||||||
fn default_dark() -> Theme {
|
fn default_dark() -> Theme {
|
||||||
Theme {
|
Theme {
|
||||||
name: "default_dark".to_string(),
|
name: "default_dark".to_string(),
|
||||||
text: Color::White,
|
text: Color::Named(NamedColor::White),
|
||||||
background: Color::Black,
|
background: Color::Named(NamedColor::Black),
|
||||||
focused_panel_border: Color::Rgb(216, 160, 255),
|
focused_panel_border: Color::Rgb(216, 160, 255),
|
||||||
unfocused_panel_border: Color::Rgb(137, 82, 204),
|
unfocused_panel_border: Color::Rgb(137, 82, 204),
|
||||||
focus_beacon_fg: Color::Rgb(248, 229, 255),
|
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_active: Theme::default_pane_header_active(),
|
||||||
pane_header_inactive: Color::Rgb(210, 210, 210),
|
pane_header_inactive: Color::Rgb(210, 210, 210),
|
||||||
pane_hint_text: Color::Rgb(210, 210, 210),
|
pane_hint_text: Color::Rgb(210, 210, 210),
|
||||||
user_message_role: Color::LightBlue,
|
user_message_role: Color::Named(NamedColor::LightBlue),
|
||||||
assistant_message_role: Color::Yellow,
|
assistant_message_role: Color::Named(NamedColor::Yellow),
|
||||||
tool_output: Color::Rgb(200, 200, 200),
|
tool_output: Color::Rgb(200, 200, 200),
|
||||||
thinking_panel_title: Color::Rgb(234, 182, 255),
|
thinking_panel_title: Color::Rgb(234, 182, 255),
|
||||||
command_bar_background: Color::Rgb(10, 10, 10),
|
command_bar_background: Color::Rgb(10, 10, 10),
|
||||||
@@ -554,29 +552,29 @@ fn default_dark() -> Theme {
|
|||||||
mode_visual: Color::Rgb(255, 170, 255),
|
mode_visual: Color::Rgb(255, 170, 255),
|
||||||
mode_command: Color::Rgb(255, 220, 120),
|
mode_command: Color::Rgb(255, 220, 120),
|
||||||
selection_bg: Color::Rgb(56, 140, 240),
|
selection_bg: Color::Rgb(56, 140, 240),
|
||||||
selection_fg: Color::Black,
|
selection_fg: Color::Named(NamedColor::Black),
|
||||||
cursor: Color::Rgb(255, 196, 255),
|
cursor: Color::Rgb(255, 196, 255),
|
||||||
code_block_background: Color::Rgb(25, 25, 25),
|
code_block_background: Color::Rgb(25, 25, 25),
|
||||||
code_block_border: Color::Rgb(216, 160, 255),
|
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_keyword: Color::Rgb(255, 220, 120),
|
||||||
code_block_string: Color::Rgb(144, 242, 170),
|
code_block_string: Color::Rgb(144, 242, 170),
|
||||||
code_block_comment: Color::Rgb(170, 170, 170),
|
code_block_comment: Color::Rgb(170, 170, 170),
|
||||||
placeholder: Color::Rgb(180, 180, 180),
|
placeholder: Color::Rgb(180, 180, 180),
|
||||||
error: Color::Red,
|
error: Color::Named(NamedColor::Red),
|
||||||
info: Color::Rgb(144, 242, 170),
|
info: Color::Rgb(144, 242, 170),
|
||||||
agent_thought: Color::Rgb(117, 200, 255),
|
agent_thought: Color::Rgb(117, 200, 255),
|
||||||
agent_action: Color::Rgb(255, 220, 120),
|
agent_action: Color::Rgb(255, 220, 120),
|
||||||
agent_action_input: Color::Rgb(164, 235, 255),
|
agent_action_input: Color::Rgb(164, 235, 255),
|
||||||
agent_observation: Color::Rgb(144, 242, 170),
|
agent_observation: Color::Rgb(144, 242, 170),
|
||||||
agent_final_answer: Color::Rgb(255, 170, 255),
|
agent_final_answer: Color::Rgb(255, 170, 255),
|
||||||
agent_badge_running_fg: Color::Black,
|
agent_badge_running_fg: Color::Named(NamedColor::Black),
|
||||||
agent_badge_running_bg: Color::Yellow,
|
agent_badge_running_bg: Color::Named(NamedColor::Yellow),
|
||||||
agent_badge_idle_fg: Color::Black,
|
agent_badge_idle_fg: Color::Named(NamedColor::Black),
|
||||||
agent_badge_idle_bg: Color::Cyan,
|
agent_badge_idle_bg: Color::Named(NamedColor::Cyan),
|
||||||
operating_chat_fg: Color::Black,
|
operating_chat_fg: Color::Named(NamedColor::Black),
|
||||||
operating_chat_bg: Color::Rgb(117, 200, 255),
|
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),
|
operating_code_bg: Color::Rgb(255, 170, 255),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,8 +583,8 @@ fn default_dark() -> Theme {
|
|||||||
fn default_light() -> Theme {
|
fn default_light() -> Theme {
|
||||||
Theme {
|
Theme {
|
||||||
name: "default_light".to_string(),
|
name: "default_light".to_string(),
|
||||||
text: Color::Black,
|
text: Color::Named(NamedColor::Black),
|
||||||
background: Color::White,
|
background: Color::Named(NamedColor::White),
|
||||||
focused_panel_border: Color::Rgb(74, 144, 226),
|
focused_panel_border: Color::Rgb(74, 144, 226),
|
||||||
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
||||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||||
@@ -597,10 +595,10 @@ fn default_light() -> Theme {
|
|||||||
pane_hint_text: Theme::default_pane_hint_text(),
|
pane_hint_text: Theme::default_pane_hint_text(),
|
||||||
user_message_role: Color::Rgb(0, 85, 164),
|
user_message_role: Color::Rgb(0, 85, 164),
|
||||||
assistant_message_role: Color::Rgb(142, 68, 173),
|
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),
|
thinking_panel_title: Color::Rgb(142, 68, 173),
|
||||||
command_bar_background: Color::White,
|
command_bar_background: Color::Named(NamedColor::White),
|
||||||
status_background: Color::White,
|
status_background: Color::Named(NamedColor::White),
|
||||||
mode_normal: Color::Rgb(0, 85, 164),
|
mode_normal: Color::Rgb(0, 85, 164),
|
||||||
mode_editing: Color::Rgb(46, 139, 87),
|
mode_editing: Color::Rgb(46, 139, 87),
|
||||||
mode_model_selection: Color::Rgb(181, 137, 0),
|
mode_model_selection: Color::Rgb(181, 137, 0),
|
||||||
@@ -609,29 +607,29 @@ fn default_light() -> Theme {
|
|||||||
mode_visual: Color::Rgb(142, 68, 173),
|
mode_visual: Color::Rgb(142, 68, 173),
|
||||||
mode_command: Color::Rgb(181, 137, 0),
|
mode_command: Color::Rgb(181, 137, 0),
|
||||||
selection_bg: Color::Rgb(164, 200, 240),
|
selection_bg: Color::Rgb(164, 200, 240),
|
||||||
selection_fg: Color::Black,
|
selection_fg: Color::Named(NamedColor::Black),
|
||||||
cursor: Color::Rgb(217, 95, 2),
|
cursor: Color::Rgb(217, 95, 2),
|
||||||
code_block_background: Color::Rgb(245, 245, 245),
|
code_block_background: Color::Rgb(245, 245, 245),
|
||||||
code_block_border: Color::Rgb(142, 68, 173),
|
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_keyword: Color::Rgb(181, 137, 0),
|
||||||
code_block_string: Color::Rgb(46, 139, 87),
|
code_block_string: Color::Rgb(46, 139, 87),
|
||||||
code_block_comment: Color::Gray,
|
code_block_comment: Color::Named(NamedColor::Gray),
|
||||||
placeholder: Color::Gray,
|
placeholder: Color::Named(NamedColor::Gray),
|
||||||
error: Color::Rgb(192, 57, 43),
|
error: Color::Rgb(192, 57, 43),
|
||||||
info: Color::Green,
|
info: Color::Named(NamedColor::Green),
|
||||||
agent_thought: Color::Rgb(0, 85, 164),
|
agent_thought: Color::Rgb(0, 85, 164),
|
||||||
agent_action: Color::Rgb(181, 137, 0),
|
agent_action: Color::Rgb(181, 137, 0),
|
||||||
agent_action_input: Color::Rgb(0, 139, 139),
|
agent_action_input: Color::Rgb(0, 139, 139),
|
||||||
agent_observation: Color::Rgb(46, 139, 87),
|
agent_observation: Color::Rgb(46, 139, 87),
|
||||||
agent_final_answer: Color::Rgb(142, 68, 173),
|
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_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),
|
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_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),
|
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_action_input: Color::Rgb(124, 77, 255),
|
||||||
agent_observation: Color::Rgb(56, 142, 60),
|
agent_observation: Color::Rgb(56, 142, 60),
|
||||||
agent_final_answer: Color::Rgb(211, 47, 47),
|
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_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),
|
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_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),
|
operating_code_bg: Color::Rgb(124, 77, 255),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1081,8 +1079,8 @@ fn grayscale_high_contrast() -> Theme {
|
|||||||
Theme {
|
Theme {
|
||||||
name: "grayscale_high_contrast".to_string(),
|
name: "grayscale_high_contrast".to_string(),
|
||||||
text: Color::Rgb(247, 247, 247),
|
text: Color::Rgb(247, 247, 247),
|
||||||
background: Color::Black,
|
background: Color::Named(NamedColor::Black),
|
||||||
focused_panel_border: Color::White,
|
focused_panel_border: Color::Named(NamedColor::White),
|
||||||
unfocused_panel_border: Color::Rgb(76, 76, 76),
|
unfocused_panel_border: Color::Rgb(76, 76, 76),
|
||||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
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),
|
assistant_message_role: Color::Rgb(214, 214, 214),
|
||||||
tool_output: Color::Rgb(189, 189, 189),
|
tool_output: Color::Rgb(189, 189, 189),
|
||||||
thinking_panel_title: Color::Rgb(224, 224, 224),
|
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),
|
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_editing: Color::Rgb(230, 230, 230),
|
||||||
mode_model_selection: Color::Rgb(204, 204, 204),
|
mode_model_selection: Color::Rgb(204, 204, 204),
|
||||||
mode_provider_selection: Color::Rgb(179, 179, 179),
|
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_visual: Color::Rgb(242, 242, 242),
|
||||||
mode_command: Color::Rgb(208, 208, 208),
|
mode_command: Color::Rgb(208, 208, 208),
|
||||||
selection_bg: Color::Rgb(240, 240, 240),
|
selection_bg: Color::Rgb(240, 240, 240),
|
||||||
selection_fg: Color::Black,
|
selection_fg: Color::Named(NamedColor::Black),
|
||||||
cursor: Color::White,
|
cursor: Color::Named(NamedColor::White),
|
||||||
code_block_background: Color::Rgb(15, 15, 15),
|
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_text: Color::Rgb(247, 247, 247),
|
||||||
code_block_keyword: Color::Rgb(204, 204, 204),
|
code_block_keyword: Color::Rgb(204, 204, 204),
|
||||||
code_block_string: Color::Rgb(214, 214, 214),
|
code_block_string: Color::Rgb(214, 214, 214),
|
||||||
code_block_comment: Color::Rgb(122, 122, 122),
|
code_block_comment: Color::Rgb(122, 122, 122),
|
||||||
placeholder: 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),
|
info: Color::Rgb(200, 200, 200),
|
||||||
agent_thought: Color::Rgb(230, 230, 230),
|
agent_thought: Color::Rgb(230, 230, 230),
|
||||||
agent_action: Color::Rgb(204, 204, 204),
|
agent_action: Color::Rgb(204, 204, 204),
|
||||||
agent_action_input: Color::Rgb(176, 176, 176),
|
agent_action_input: Color::Rgb(176, 176, 176),
|
||||||
agent_observation: Color::Rgb(153, 153, 153),
|
agent_observation: Color::Rgb(153, 153, 153),
|
||||||
agent_final_answer: Color::White,
|
agent_final_answer: Color::Named(NamedColor::White),
|
||||||
agent_badge_running_fg: Color::Black,
|
agent_badge_running_fg: Color::Named(NamedColor::Black),
|
||||||
agent_badge_running_bg: Color::Rgb(247, 247, 247),
|
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),
|
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_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),
|
operating_code_bg: Color::Rgb(191, 191, 191),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1149,64 +1147,6 @@ where
|
|||||||
serializer.serialize_str(&s)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1214,8 +1154,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_color_parsing() {
|
fn test_color_parsing() {
|
||||||
assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0))));
|
assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0))));
|
||||||
assert!(matches!(parse_color("red"), Ok(Color::Red)));
|
assert!(matches!(
|
||||||
assert!(matches!(parse_color("lightblue"), Ok(Color::LightBlue)));
|
parse_color("red"),
|
||||||
|
Ok(Color::Named(NamedColor::Red))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_color("lightblue"),
|
||||||
|
Ok(Color::Named(NamedColor::LightBlue))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -20,7 +20,7 @@ async fn main() -> Result<(), anyhow::Error> {
|
|||||||
|
|
||||||
// Create MCP client - this will spawn/connect to the MCP LLM server
|
// Create MCP client - this will spawn/connect to the MCP LLM server
|
||||||
println!("Connecting to 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");
|
println!("✓ Connected\n");
|
||||||
|
|
||||||
// List available models
|
// 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