Compare commits
18 Commits
3271697f6b
...
c49e7f4b22
| Author | SHA1 | Date | |
|---|---|---|---|
| c49e7f4b22 | |||
| 9588c8c562 | |||
| 1948ac1284 | |||
| 3f92b7d963 | |||
| 5553e61dbf | |||
| 7f987737f9 | |||
| 5182f86133 | |||
| a50099ad74 | |||
| 20ba5523ee | |||
| 0b2b3701dc | |||
| 438b05b8a3 | |||
| e2a31b192f | |||
| b827d3d047 | |||
| 9c0cf274a3 | |||
| 85ae319690 | |||
| 449f133a1f | |||
| 2f6b03ef65 | |||
| d4030dc598 |
34
.github/workflows/macos-check.yml
vendored
Normal file
34
.github/workflows/macos-check.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: macos-check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: cargo check (macOS)
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Cargo check
|
||||
run: cargo check --workspace --all-features
|
||||
@@ -9,6 +9,7 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
args: ['--allow-multiple-documents']
|
||||
- id: check-toml
|
||||
- id: check-merge-conflict
|
||||
- id: check-added-large-files
|
||||
|
||||
@@ -1,3 +1,61 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: pr-checks
|
||||
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
steps:
|
||||
- name: fmt-clippy-test
|
||||
image: rust:1.83
|
||||
commands:
|
||||
- rustup component add rustfmt clippy
|
||||
- cargo fmt --all -- --check
|
||||
- cargo clippy --workspace --all-features -- -D warnings
|
||||
- cargo test --workspace --all-features
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: security-audit
|
||||
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- cron
|
||||
branch:
|
||||
- dev
|
||||
cron: weekly-security
|
||||
|
||||
steps:
|
||||
- name: cargo-audit
|
||||
image: rust:1.83
|
||||
commands:
|
||||
- cargo install cargo-audit --locked
|
||||
- cargo audit
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: release-tests
|
||||
|
||||
when:
|
||||
event: tag
|
||||
tag: v*
|
||||
|
||||
steps:
|
||||
- name: workspace-tests
|
||||
image: rust:1.83
|
||||
commands:
|
||||
- rustup component add llvm-tools-preview
|
||||
- cargo install cargo-llvm-cov --locked
|
||||
- cargo llvm-cov --workspace --all-features --summary-only
|
||||
- cargo llvm-cov --workspace --all-features --lcov --output-path coverage.lcov --no-run
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: release
|
||||
|
||||
when:
|
||||
event: tag
|
||||
tag: v*
|
||||
@@ -5,6 +63,9 @@ when:
|
||||
variables:
|
||||
- &rust_image 'rust:1.83'
|
||||
|
||||
depends_on:
|
||||
- release-tests
|
||||
|
||||
matrix:
|
||||
include:
|
||||
# Linux
|
||||
@@ -39,14 +100,6 @@ matrix:
|
||||
EXT: ".exe"
|
||||
|
||||
steps:
|
||||
- name: tests
|
||||
image: *rust_image
|
||||
commands:
|
||||
- rustup component add llvm-tools-preview
|
||||
- cargo install cargo-llvm-cov --locked
|
||||
- cargo llvm-cov --workspace --all-features --summary-only
|
||||
- cargo llvm-cov --workspace --all-features --lcov --output-path coverage.lcov --no-run
|
||||
|
||||
- name: build
|
||||
image: *rust_image
|
||||
commands:
|
||||
@@ -124,6 +177,11 @@ steps:
|
||||
sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
||||
fi
|
||||
|
||||
- name: release-notes
|
||||
image: *rust_image
|
||||
commands:
|
||||
- scripts/release-notes.sh "${CI_COMMIT_TAG}" release-notes.md
|
||||
|
||||
- name: release
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
@@ -136,4 +194,4 @@ steps:
|
||||
- ${ARTIFACT}.zip
|
||||
- ${ARTIFACT}.zip.sha256
|
||||
title: Release ${CI_COMMIT_TAG}
|
||||
note: "Release ${CI_COMMIT_TAG}"
|
||||
note_file: release-notes.md
|
||||
|
||||
@@ -10,6 +10,10 @@ This project and everyone participating in it is governed by the [Owlen Code of
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Repository map
|
||||
|
||||
Need a quick orientation before diving in? Start with the curated [repo map](docs/repo-map.md) for a two-level directory overview. If you move folders around, regenerate it with `scripts/gen-repo-map.sh`.
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
This is one of the most helpful ways you can contribute. Before creating a bug report, please check a few things:
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -5,12 +5,13 @@ members = [
|
||||
"crates/owlen-tui",
|
||||
"crates/owlen-cli",
|
||||
"crates/owlen-providers",
|
||||
"crates/owlen-mcp-server",
|
||||
"crates/owlen-mcp-llm-server",
|
||||
"crates/owlen-mcp-client",
|
||||
"crates/owlen-mcp-code-server",
|
||||
"crates/owlen-mcp-prompt-server",
|
||||
"crates/mcp/server",
|
||||
"crates/mcp/llm-server",
|
||||
"crates/mcp/client",
|
||||
"crates/mcp/code-server",
|
||||
"crates/mcp/prompt-server",
|
||||
"crates/owlen-markdown",
|
||||
"xtask",
|
||||
]
|
||||
exclude = []
|
||||
|
||||
|
||||
@@ -112,8 +112,10 @@ For more detailed information, please refer to the following documents:
|
||||
- **[CHANGELOG.md](CHANGELOG.md)**: A log of changes for each version.
|
||||
- **[docs/architecture.md](docs/architecture.md)**: An overview of the project's architecture.
|
||||
- **[docs/troubleshooting.md](docs/troubleshooting.md)**: Help with common issues.
|
||||
- **[docs/repo-map.md](docs/repo-map.md)**: Snapshot of the workspace layout and key crates.
|
||||
- **[docs/provider-implementation.md](docs/provider-implementation.md)**: Trait-level details for implementing providers.
|
||||
- **[docs/adding-providers.md](docs/adding-providers.md)**: Step-by-step checklist for wiring a provider into the multi-provider architecture and test suite.
|
||||
- **Experimental providers staging area**: [crates/providers/experimental/README.md](crates/providers/experimental/README.md) records the placeholder crates (OpenAI, Anthropic, Gemini) and their current status.
|
||||
- **[docs/platform-support.md](docs/platform-support.md)**: Current OS support matrix and cross-check instructions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -6,7 +6,7 @@ description = "Dedicated MCP client library for Owlen, exposing remote MCP serve
|
||||
license = "AGPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-core = { path = "../../owlen-core" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -6,7 +6,7 @@ description = "MCP server exposing safe code execution tools for Owlen"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-core = { path = "../../owlen-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-core = { path = "../../owlen-core" }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -6,7 +6,7 @@ description = "MCP server that renders prompt templates (YAML) for Owlen"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-core = { path = "../../owlen-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
@@ -9,4 +9,4 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
path-clean = "1.0"
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-core = { path = "../../owlen-core" }
|
||||
@@ -17,6 +17,11 @@ name = "owlen"
|
||||
path = "src/main.rs"
|
||||
required-features = ["chat-client"]
|
||||
|
||||
[[bin]]
|
||||
name = "owlen-code"
|
||||
path = "src/code_main.rs"
|
||||
required-features = ["chat-client"]
|
||||
|
||||
[[bin]]
|
||||
name = "owlen-agent"
|
||||
path = "src/agent_main.rs"
|
||||
|
||||
326
crates/owlen-cli/src/bootstrap.rs
Normal file
326
crates/owlen-cli/src/bootstrap.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use std::borrow::Cow;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use crossterm::{
|
||||
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use futures::stream;
|
||||
use owlen_core::{
|
||||
ChatStream, Error, Provider,
|
||||
config::{Config, McpMode},
|
||||
mcp::remote_client::RemoteMcpClient,
|
||||
mode::Mode,
|
||||
provider::ProviderManager,
|
||||
providers::OllamaProvider,
|
||||
session::{ControllerEvent, SessionController},
|
||||
storage::StorageManager,
|
||||
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||
};
|
||||
use owlen_tui::{
|
||||
ChatApp, SessionEvent,
|
||||
app::App as RuntimeApp,
|
||||
config,
|
||||
tui_controller::{TuiController, TuiRequest},
|
||||
ui,
|
||||
};
|
||||
use ratatui::{Terminal, prelude::CrosstermBackend};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::commands::cloud::{load_runtime_credentials, set_env_var};
|
||||
|
||||
pub async fn launch(initial_mode: Mode) -> Result<()> {
|
||||
set_env_var("OWLEN_AUTO_CONSENT", "1");
|
||||
|
||||
let color_support = detect_terminal_color_support();
|
||||
let mut cfg = config::try_load_config().unwrap_or_default();
|
||||
let _ = cfg.refresh_mcp_servers(None);
|
||||
|
||||
if let Some(previous_theme) = apply_terminal_theme(&mut cfg, &color_support) {
|
||||
let term_label = match &color_support {
|
||||
TerminalColorSupport::Limited { term } => Cow::from(term.as_str()),
|
||||
TerminalColorSupport::Full => Cow::from("current terminal"),
|
||||
};
|
||||
eprintln!(
|
||||
"Terminal '{}' lacks full 256-color support. Using '{}' theme instead of '{}'.",
|
||||
term_label, BASIC_THEME_NAME, previous_theme
|
||||
);
|
||||
} else if let TerminalColorSupport::Limited { term } = &color_support {
|
||||
eprintln!(
|
||||
"Warning: terminal '{}' may not fully support 256-color themes.",
|
||||
term
|
||||
);
|
||||
}
|
||||
|
||||
cfg.validate()?;
|
||||
let storage = Arc::new(StorageManager::new().await?);
|
||||
load_runtime_credentials(&mut cfg, storage.clone()).await?;
|
||||
|
||||
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
|
||||
let tui_controller = Arc::new(TuiController::new(tui_tx));
|
||||
|
||||
let provider = build_provider(&cfg)?;
|
||||
let mut offline_notice: Option<String> = None;
|
||||
let provider = match provider.health_check().await {
|
||||
Ok(_) => provider,
|
||||
Err(err) => {
|
||||
let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly)
|
||||
&& !cfg.effective_mcp_servers().is_empty()
|
||||
{
|
||||
"Ensure the configured MCP server is running and reachable."
|
||||
} else {
|
||||
"Ensure Ollama is running (`ollama serve`) and reachable at the configured base_url."
|
||||
};
|
||||
let notice =
|
||||
format!("Provider health check failed: {err}. {hint} Continuing in offline mode.");
|
||||
eprintln!("{notice}");
|
||||
offline_notice = Some(notice.clone());
|
||||
let fallback_model = cfg
|
||||
.general
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "offline".to_string());
|
||||
Arc::new(OfflineProvider::new(notice, fallback_model)) as Arc<dyn Provider>
|
||||
}
|
||||
};
|
||||
|
||||
let (controller_event_tx, controller_event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
|
||||
let controller = SessionController::new(
|
||||
provider,
|
||||
cfg,
|
||||
storage.clone(),
|
||||
tui_controller,
|
||||
false,
|
||||
Some(controller_event_tx),
|
||||
)
|
||||
.await?;
|
||||
let provider_manager = Arc::new(ProviderManager::default());
|
||||
let mut runtime = RuntimeApp::new(provider_manager);
|
||||
let (mut app, mut session_rx) = ChatApp::new(controller, controller_event_rx).await?;
|
||||
app.initialize_models().await?;
|
||||
if let Some(notice) = offline_notice.clone() {
|
||||
app.set_status_message(¬ice);
|
||||
app.set_system_status(notice);
|
||||
}
|
||||
|
||||
app.set_mode(initial_mode).await;
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(
|
||||
stdout,
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste
|
||||
)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let result = run_app(&mut terminal, &mut runtime, &mut app, &mut session_rx).await;
|
||||
|
||||
config::save_config(&app.config())?;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture,
|
||||
DisableBracketedPaste
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = result {
|
||||
println!("{err:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_provider(cfg: &Config) -> Result<Arc<dyn Provider>> {
|
||||
match cfg.mcp.mode {
|
||||
McpMode::RemotePreferred => {
|
||||
let remote_result = if let Some(mcp_server) = cfg.effective_mcp_servers().first() {
|
||||
RemoteMcpClient::new_with_config(mcp_server)
|
||||
} else {
|
||||
RemoteMcpClient::new()
|
||||
};
|
||||
|
||||
match remote_result {
|
||||
Ok(client) => Ok(Arc::new(client) as Arc<dyn Provider>),
|
||||
Err(err) if cfg.mcp.allow_fallback => {
|
||||
log::warn!(
|
||||
"Remote MCP client unavailable ({}); falling back to local provider.",
|
||||
err
|
||||
);
|
||||
build_local_provider(cfg)
|
||||
}
|
||||
Err(err) => Err(anyhow!(err)),
|
||||
}
|
||||
}
|
||||
McpMode::RemoteOnly => {
|
||||
let mcp_server = cfg.effective_mcp_servers().first().ok_or_else(|| {
|
||||
anyhow!("[[mcp_servers]] must be configured when [mcp].mode = \"remote_only\"")
|
||||
})?;
|
||||
let client = RemoteMcpClient::new_with_config(mcp_server)?;
|
||||
Ok(Arc::new(client) as Arc<dyn Provider>)
|
||||
}
|
||||
McpMode::LocalOnly | McpMode::Legacy => build_local_provider(cfg),
|
||||
McpMode::Disabled => Err(anyhow!(
|
||||
"MCP mode 'disabled' is not supported by the owlen TUI"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_local_provider(cfg: &Config) -> Result<Arc<dyn Provider>> {
|
||||
let provider_name = cfg.general.default_provider.clone();
|
||||
let provider_cfg = cfg.provider(&provider_name).ok_or_else(|| {
|
||||
anyhow!(format!(
|
||||
"No provider configuration found for '{provider_name}' in [providers]"
|
||||
))
|
||||
})?;
|
||||
|
||||
match provider_cfg.provider_type.as_str() {
|
||||
"ollama" | "ollama_cloud" => {
|
||||
let provider = OllamaProvider::from_config(provider_cfg, Some(&cfg.general))?;
|
||||
Ok(Arc::new(provider) as Arc<dyn Provider>)
|
||||
}
|
||||
other => Err(anyhow!(format!(
|
||||
"Provider type '{other}' is not supported in legacy/local MCP mode"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
const BASIC_THEME_NAME: &str = "ansi_basic";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum TerminalColorSupport {
|
||||
Full,
|
||||
Limited { term: String },
|
||||
}
|
||||
|
||||
fn detect_terminal_color_support() -> TerminalColorSupport {
|
||||
let term = std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string());
|
||||
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
||||
let term_lower = term.to_lowercase();
|
||||
let color_lower = colorterm.to_lowercase();
|
||||
|
||||
let supports_extended = term_lower.contains("256color")
|
||||
|| color_lower.contains("truecolor")
|
||||
|| color_lower.contains("24bit")
|
||||
|| color_lower.contains("fullcolor");
|
||||
|
||||
if supports_extended {
|
||||
TerminalColorSupport::Full
|
||||
} else {
|
||||
TerminalColorSupport::Limited { term }
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_terminal_theme(cfg: &mut Config, support: &TerminalColorSupport) -> Option<String> {
|
||||
match support {
|
||||
TerminalColorSupport::Full => None,
|
||||
TerminalColorSupport::Limited { .. } => {
|
||||
if cfg.ui.theme != BASIC_THEME_NAME {
|
||||
let previous = std::mem::replace(&mut cfg.ui.theme, BASIC_THEME_NAME.to_string());
|
||||
Some(previous)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OfflineProvider {
|
||||
reason: String,
|
||||
placeholder_model: String,
|
||||
}
|
||||
|
||||
impl OfflineProvider {
|
||||
fn new(reason: String, placeholder_model: String) -> Self {
|
||||
Self {
|
||||
reason,
|
||||
placeholder_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly_response(&self, requested_model: &str) -> ChatResponse {
|
||||
let mut message = String::new();
|
||||
message.push_str("⚠️ Owlen is running in offline mode.\n\n");
|
||||
message.push_str(&self.reason);
|
||||
if !requested_model.is_empty() && requested_model != self.placeholder_model {
|
||||
message.push_str(&format!(
|
||||
"\n\nYou requested model '{}', but no providers are reachable.",
|
||||
requested_model
|
||||
));
|
||||
}
|
||||
message.push_str(
|
||||
"\n\nStart your preferred provider (e.g. `ollama serve`) or switch providers with `:provider` once connectivity is restored.",
|
||||
);
|
||||
|
||||
ChatResponse {
|
||||
message: Message::assistant(message),
|
||||
usage: None,
|
||||
is_streaming: false,
|
||||
is_final: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OfflineProvider {
|
||||
fn name(&self) -> &str {
|
||||
"offline"
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>, Error> {
|
||||
Ok(vec![ModelInfo {
|
||||
id: self.placeholder_model.clone(),
|
||||
provider: "offline".to_string(),
|
||||
name: format!("Offline (fallback: {})", self.placeholder_model),
|
||||
description: Some("Placeholder model used while no providers are reachable".into()),
|
||||
context_window: None,
|
||||
capabilities: vec![],
|
||||
supports_tools: false,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn send_prompt(&self, request: ChatRequest) -> Result<ChatResponse, Error> {
|
||||
Ok(self.friendly_response(&request.model))
|
||||
}
|
||||
|
||||
async fn stream_prompt(&self, request: ChatRequest) -> Result<ChatStream, Error> {
|
||||
let response = self.friendly_response(&request.model);
|
||||
Ok(Box::pin(stream::iter(vec![Ok(response)])))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), Error> {
|
||||
Err(Error::Provider(anyhow!(
|
||||
"offline provider cannot reach any backing models"
|
||||
)))
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_app(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
runtime: &mut RuntimeApp,
|
||||
app: &mut ChatApp,
|
||||
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||
) -> Result<()> {
|
||||
let mut render = |terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
state: &mut ChatApp|
|
||||
-> Result<()> {
|
||||
terminal.draw(|f| ui::render_chat(f, state))?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
runtime.run(terminal, app, session_rx, &mut render).await?;
|
||||
Ok(())
|
||||
}
|
||||
16
crates/owlen-cli/src/code_main.rs
Normal file
16
crates/owlen-cli/src/code_main.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! Owlen CLI entrypoint optimised for code-first workflows.
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
mod bootstrap;
|
||||
mod commands;
|
||||
mod mcp;
|
||||
|
||||
use anyhow::Result;
|
||||
use owlen_core::config as core_config;
|
||||
use owlen_core::mode::Mode;
|
||||
use owlen_tui::config;
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
bootstrap::launch(Mode::Code).await
|
||||
}
|
||||
@@ -195,13 +195,13 @@ async fn list_models(filter: Option<&str>) -> Result<()> {
|
||||
}
|
||||
|
||||
fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> {
|
||||
if let Some(filter) = filter {
|
||||
if !config.providers.contains_key(filter) {
|
||||
return Err(anyhow!(
|
||||
"Provider '{}' is not defined in configuration.",
|
||||
filter
|
||||
));
|
||||
}
|
||||
if let Some(filter) = filter
|
||||
&& !config.providers.contains_key(filter)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Provider '{}' is not defined in configuration.",
|
||||
filter
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -254,10 +254,10 @@ fn toggle_provider(provider: &str, enable: bool) -> Result<()> {
|
||||
entry.enabled = previous_enabled;
|
||||
}
|
||||
config.general.default_provider = previous_default;
|
||||
if let Some(enabled) = previous_fallback_enabled {
|
||||
if let Some(entry) = config.providers.get_mut("ollama_local") {
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
if let Some(enabled) = previous_fallback_enabled
|
||||
&& let Some(entry) = config.providers.get_mut("ollama_local")
|
||||
{
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
@@ -273,12 +273,11 @@ fn toggle_provider(provider: &str, enable: bool) -> Result<()> {
|
||||
}
|
||||
|
||||
fn choose_fallback_provider(config: &Config, exclude: &str) -> Option<String> {
|
||||
if exclude != "ollama_local" {
|
||||
if let Some(cfg) = config.providers.get("ollama_local") {
|
||||
if cfg.enabled {
|
||||
return Some("ollama_local".to_string());
|
||||
}
|
||||
}
|
||||
if exclude != "ollama_local"
|
||||
&& let Some(cfg) = config.providers.get("ollama_local")
|
||||
&& cfg.enabled
|
||||
{
|
||||
return Some("ollama_local".to_string());
|
||||
}
|
||||
|
||||
let mut candidates: Vec<String> = config
|
||||
@@ -300,10 +299,10 @@ async fn register_enabled_providers(
|
||||
let mut records = Vec::new();
|
||||
|
||||
for (id, cfg) in &config.providers {
|
||||
if let Some(filter) = filter {
|
||||
if id != filter {
|
||||
continue;
|
||||
}
|
||||
if let Some(filter) = filter
|
||||
&& id != filter
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut record = ProviderRecord::from_config(id, cfg, id == &default_provider);
|
||||
@@ -537,10 +536,10 @@ fn print_models(
|
||||
} else {
|
||||
for entry in entries {
|
||||
let mut line = format!(" - {}", entry.model.name);
|
||||
if let Some(description) = &entry.model.description {
|
||||
if !description.trim().is_empty() {
|
||||
line.push_str(&format!(" — {}", description.trim()));
|
||||
}
|
||||
if let Some(description) = &entry.model.description
|
||||
&& !description.trim().is_empty()
|
||||
{
|
||||
line.push_str(&format!(" — {}", description.trim()));
|
||||
}
|
||||
println!("{}", line);
|
||||
}
|
||||
@@ -549,10 +548,10 @@ fn print_models(
|
||||
println!(" (no models reported)");
|
||||
}
|
||||
|
||||
if let Some(ProviderStatus::RequiresSetup) = status_value {
|
||||
if record.requires_auth {
|
||||
println!(" configure provider credentials or API key");
|
||||
}
|
||||
if let Some(ProviderStatus::RequiresSetup) = status_value
|
||||
&& record.requires_auth
|
||||
{
|
||||
println!(" configure provider credentials or API key");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -2,44 +2,21 @@
|
||||
|
||||
//! OWLEN CLI - Chat TUI client
|
||||
|
||||
mod bootstrap;
|
||||
mod commands;
|
||||
mod mcp;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use commands::{
|
||||
cloud::{CloudCommand, load_runtime_credentials, run_cloud_command, set_env_var},
|
||||
cloud::{CloudCommand, run_cloud_command},
|
||||
providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command},
|
||||
};
|
||||
use mcp::{McpCommand, run_mcp_command};
|
||||
use owlen_core::config as core_config;
|
||||
use owlen_core::{
|
||||
ChatStream, Error, Provider,
|
||||
config::{Config, McpMode},
|
||||
mcp::remote_client::RemoteMcpClient,
|
||||
mode::Mode,
|
||||
provider::ProviderManager,
|
||||
providers::OllamaProvider,
|
||||
session::SessionController,
|
||||
storage::StorageManager,
|
||||
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||
};
|
||||
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
||||
use owlen_tui::{ChatApp, SessionEvent, app::App as RuntimeApp, config, ui};
|
||||
use std::any::Any;
|
||||
use std::borrow::Cow;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crossterm::{
|
||||
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use futures::stream;
|
||||
use ratatui::{Terminal, prelude::CrosstermBackend};
|
||||
use owlen_core::config::McpMode;
|
||||
use owlen_core::mode::Mode;
|
||||
use owlen_tui::config;
|
||||
|
||||
/// Owlen - Terminal UI for LLM chat
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -81,66 +58,6 @@ enum ConfigCommand {
|
||||
Path,
|
||||
}
|
||||
|
||||
fn build_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
|
||||
match cfg.mcp.mode {
|
||||
McpMode::RemotePreferred => {
|
||||
let remote_result = if let Some(mcp_server) = cfg.effective_mcp_servers().first() {
|
||||
RemoteMcpClient::new_with_config(mcp_server)
|
||||
} else {
|
||||
RemoteMcpClient::new()
|
||||
};
|
||||
|
||||
match remote_result {
|
||||
Ok(client) => {
|
||||
let provider: Arc<dyn Provider> = Arc::new(client);
|
||||
Ok(provider)
|
||||
}
|
||||
Err(err) if cfg.mcp.allow_fallback => {
|
||||
log::warn!(
|
||||
"Remote MCP client unavailable ({}); falling back to local provider.",
|
||||
err
|
||||
);
|
||||
build_local_provider(cfg)
|
||||
}
|
||||
Err(err) => Err(anyhow::Error::from(err)),
|
||||
}
|
||||
}
|
||||
McpMode::RemoteOnly => {
|
||||
let mcp_server = cfg.effective_mcp_servers().first().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"[[mcp_servers]] must be configured when [mcp].mode = \"remote_only\""
|
||||
)
|
||||
})?;
|
||||
let client = RemoteMcpClient::new_with_config(mcp_server)?;
|
||||
let provider: Arc<dyn Provider> = Arc::new(client);
|
||||
Ok(provider)
|
||||
}
|
||||
McpMode::LocalOnly | McpMode::Legacy => build_local_provider(cfg),
|
||||
McpMode::Disabled => Err(anyhow::anyhow!(
|
||||
"MCP mode 'disabled' is not supported by the owlen TUI"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_local_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
|
||||
let provider_name = cfg.general.default_provider.clone();
|
||||
let provider_cfg = cfg.provider(&provider_name).ok_or_else(|| {
|
||||
anyhow::anyhow!(format!(
|
||||
"No provider configuration found for '{provider_name}' in [providers]"
|
||||
))
|
||||
})?;
|
||||
|
||||
match provider_cfg.provider_type.as_str() {
|
||||
"ollama" | "ollama_cloud" => {
|
||||
let provider = OllamaProvider::from_config(provider_cfg, Some(&cfg.general))?;
|
||||
Ok(Arc::new(provider) as Arc<dyn Provider>)
|
||||
}
|
||||
other => Err(anyhow::anyhow!(format!(
|
||||
"Provider type '{other}' is not supported in legacy/local MCP mode"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_command(command: OwlenCommand) -> Result<()> {
|
||||
match command {
|
||||
OwlenCommand::Config(config_cmd) => run_config_command(config_cmd),
|
||||
@@ -299,120 +216,6 @@ fn run_config_doctor() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const BASIC_THEME_NAME: &str = "ansi_basic";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum TerminalColorSupport {
|
||||
Full,
|
||||
Limited { term: String },
|
||||
}
|
||||
|
||||
fn detect_terminal_color_support() -> TerminalColorSupport {
|
||||
let term = std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string());
|
||||
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
||||
let term_lower = term.to_lowercase();
|
||||
let color_lower = colorterm.to_lowercase();
|
||||
|
||||
let supports_extended = term_lower.contains("256color")
|
||||
|| color_lower.contains("truecolor")
|
||||
|| color_lower.contains("24bit")
|
||||
|| color_lower.contains("fullcolor");
|
||||
|
||||
if supports_extended {
|
||||
TerminalColorSupport::Full
|
||||
} else {
|
||||
TerminalColorSupport::Limited { term }
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_terminal_theme(cfg: &mut Config, support: &TerminalColorSupport) -> Option<String> {
|
||||
match support {
|
||||
TerminalColorSupport::Full => None,
|
||||
TerminalColorSupport::Limited { .. } => {
|
||||
if cfg.ui.theme != BASIC_THEME_NAME {
|
||||
let previous = std::mem::replace(&mut cfg.ui.theme, BASIC_THEME_NAME.to_string());
|
||||
Some(previous)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OfflineProvider {
|
||||
reason: String,
|
||||
placeholder_model: String,
|
||||
}
|
||||
|
||||
impl OfflineProvider {
|
||||
fn new(reason: String, placeholder_model: String) -> Self {
|
||||
Self {
|
||||
reason,
|
||||
placeholder_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly_response(&self, requested_model: &str) -> ChatResponse {
|
||||
let mut message = String::new();
|
||||
message.push_str("⚠️ Owlen is running in offline mode.\n\n");
|
||||
message.push_str(&self.reason);
|
||||
if !requested_model.is_empty() && requested_model != self.placeholder_model {
|
||||
message.push_str(&format!(
|
||||
"\n\nYou requested model '{}', but no providers are reachable.",
|
||||
requested_model
|
||||
));
|
||||
}
|
||||
message.push_str(
|
||||
"\n\nStart your preferred provider (e.g. `ollama serve`) or switch providers with `:provider` once connectivity is restored.",
|
||||
);
|
||||
|
||||
ChatResponse {
|
||||
message: Message::assistant(message),
|
||||
usage: None,
|
||||
is_streaming: false,
|
||||
is_final: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OfflineProvider {
|
||||
fn name(&self) -> &str {
|
||||
"offline"
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>, Error> {
|
||||
Ok(vec![ModelInfo {
|
||||
id: self.placeholder_model.clone(),
|
||||
provider: "offline".to_string(),
|
||||
name: format!("Offline (fallback: {})", self.placeholder_model),
|
||||
description: Some("Placeholder model used while no providers are reachable".into()),
|
||||
context_window: None,
|
||||
capabilities: vec![],
|
||||
supports_tools: false,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn send_prompt(&self, request: ChatRequest) -> Result<ChatResponse, Error> {
|
||||
Ok(self.friendly_response(&request.model))
|
||||
}
|
||||
|
||||
async fn stream_prompt(&self, request: ChatRequest) -> Result<ChatStream, Error> {
|
||||
let response = self.friendly_response(&request.model);
|
||||
Ok(Box::pin(stream::iter(vec![Ok(response)])))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), Error> {
|
||||
Err(Error::Provider(anyhow!(
|
||||
"offline provider cannot reach any backing models"
|
||||
)))
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
// Parse command-line arguments
|
||||
@@ -421,122 +224,5 @@ async fn main() -> Result<()> {
|
||||
return run_command(command).await;
|
||||
}
|
||||
let initial_mode = if code { Mode::Code } else { Mode::Chat };
|
||||
|
||||
// Set auto-consent for TUI mode to prevent blocking stdin reads
|
||||
set_env_var("OWLEN_AUTO_CONSENT", "1");
|
||||
|
||||
let color_support = detect_terminal_color_support();
|
||||
// Load configuration (or fall back to defaults) for the session controller.
|
||||
let mut cfg = config::try_load_config().unwrap_or_default();
|
||||
let _ = cfg.refresh_mcp_servers(None);
|
||||
if let Some(previous_theme) = apply_terminal_theme(&mut cfg, &color_support) {
|
||||
let term_label = match &color_support {
|
||||
TerminalColorSupport::Limited { term } => Cow::from(term.as_str()),
|
||||
TerminalColorSupport::Full => Cow::from("current terminal"),
|
||||
};
|
||||
eprintln!(
|
||||
"Terminal '{}' lacks full 256-color support. Using '{}' theme instead of '{}'.",
|
||||
term_label, BASIC_THEME_NAME, previous_theme
|
||||
);
|
||||
} else if let TerminalColorSupport::Limited { term } = &color_support {
|
||||
eprintln!(
|
||||
"Warning: terminal '{}' may not fully support 256-color themes.",
|
||||
term
|
||||
);
|
||||
}
|
||||
cfg.validate()?;
|
||||
let storage = Arc::new(StorageManager::new().await?);
|
||||
load_runtime_credentials(&mut cfg, storage.clone()).await?;
|
||||
|
||||
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
|
||||
let tui_controller = Arc::new(TuiController::new(tui_tx));
|
||||
|
||||
// Create provider according to MCP configuration (supports legacy/local fallback)
|
||||
let provider = build_provider(&cfg)?;
|
||||
let mut offline_notice: Option<String> = None;
|
||||
let provider = match provider.health_check().await {
|
||||
Ok(_) => provider,
|
||||
Err(err) => {
|
||||
let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly)
|
||||
&& !cfg.effective_mcp_servers().is_empty()
|
||||
{
|
||||
"Ensure the configured MCP server is running and reachable."
|
||||
} else {
|
||||
"Ensure Ollama is running (`ollama serve`) and reachable at the configured base_url."
|
||||
};
|
||||
let notice =
|
||||
format!("Provider health check failed: {err}. {hint} Continuing in offline mode.");
|
||||
eprintln!("{notice}");
|
||||
offline_notice = Some(notice.clone());
|
||||
let fallback_model = cfg
|
||||
.general
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "offline".to_string());
|
||||
Arc::new(OfflineProvider::new(notice, fallback_model)) as Arc<dyn Provider>
|
||||
}
|
||||
};
|
||||
|
||||
let controller =
|
||||
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
||||
let provider_manager = Arc::new(ProviderManager::default());
|
||||
let mut runtime = RuntimeApp::new(provider_manager);
|
||||
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
||||
app.initialize_models().await?;
|
||||
if let Some(notice) = offline_notice {
|
||||
app.set_status_message(¬ice);
|
||||
app.set_system_status(notice);
|
||||
}
|
||||
|
||||
// Set the initial mode
|
||||
app.set_mode(initial_mode).await;
|
||||
|
||||
// Terminal setup
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(
|
||||
stdout,
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste
|
||||
)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let result = run_app(&mut terminal, &mut runtime, &mut app, &mut session_rx).await;
|
||||
|
||||
// Persist configuration updates (e.g., selected model)
|
||||
config::save_config(&app.config())?;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture,
|
||||
DisableBracketedPaste
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = result {
|
||||
println!("{err:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_app(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
runtime: &mut RuntimeApp,
|
||||
app: &mut ChatApp,
|
||||
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||
) -> Result<()> {
|
||||
let mut render = |terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
state: &mut ChatApp|
|
||||
-> Result<()> {
|
||||
terminal.draw(|f| ui::render_chat(f, state))?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
runtime.run(terminal, app, session_rx, &mut render).await?;
|
||||
Ok(())
|
||||
bootstrap::launch(initial_mode).await
|
||||
}
|
||||
|
||||
@@ -1584,6 +1584,8 @@ pub struct UiSettings {
|
||||
pub show_timestamps: bool,
|
||||
#[serde(default = "UiSettings::default_icon_mode")]
|
||||
pub icon_mode: IconMode,
|
||||
#[serde(default)]
|
||||
pub keymap_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Preference for which symbol set to render in the terminal UI.
|
||||
@@ -1721,6 +1723,7 @@ impl Default for UiSettings {
|
||||
render_markdown: Self::default_render_markdown(),
|
||||
show_timestamps: Self::default_show_timestamps(),
|
||||
icon_mode: Self::default_icon_mode(),
|
||||
keymap_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
crates/owlen-core/src/facade/llm_client.rs
Normal file
32
crates/owlen-core/src/facade/llm_client.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
Result,
|
||||
llm::ChatStream,
|
||||
mcp::{McpToolCall, McpToolDescriptor, McpToolResponse},
|
||||
types::{ChatRequest, ChatResponse, ModelInfo},
|
||||
};
|
||||
|
||||
/// Object-safe facade for interacting with LLM backends.
|
||||
#[async_trait]
|
||||
pub trait LlmClient: Send + Sync {
|
||||
/// List the models exposed by this client.
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>>;
|
||||
|
||||
/// Issue a one-shot chat request and wait for the complete response.
|
||||
async fn send_chat(&self, request: ChatRequest) -> Result<ChatResponse>;
|
||||
|
||||
/// Stream chat responses incrementally.
|
||||
async fn stream_chat(&self, request: ChatRequest) -> Result<ChatStream>;
|
||||
|
||||
/// Enumerate tools exposed by the backing provider.
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
|
||||
|
||||
/// Invoke a tool exposed by the provider.
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse>;
|
||||
}
|
||||
|
||||
/// Convenience alias for trait-object clients.
|
||||
pub type DynLlmClient = Arc<dyn LlmClient>;
|
||||
1
crates/owlen-core/src/facade/mod.rs
Normal file
1
crates/owlen-core/src/facade/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod llm_client;
|
||||
@@ -11,6 +11,7 @@ pub mod consent;
|
||||
pub mod conversation;
|
||||
pub mod credentials;
|
||||
pub mod encryption;
|
||||
pub mod facade;
|
||||
pub mod formatting;
|
||||
pub mod input;
|
||||
pub mod llm;
|
||||
@@ -42,6 +43,7 @@ pub use formatting::*;
|
||||
pub use input::*;
|
||||
pub use oauth::*;
|
||||
// Export MCP types but exclude test_utils to avoid ambiguity
|
||||
pub use facade::llm_client::*;
|
||||
pub use llm::{
|
||||
ChatStream, LlmProvider, Provider, ProviderConfig, ProviderRegistry, send_via_stream,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,10 @@ use crate::consent::{ConsentManager, ConsentScope};
|
||||
use crate::tools::{Tool, WebScrapeTool, WebSearchTool};
|
||||
use crate::types::ModelInfo;
|
||||
use crate::types::{ChatResponse, Message, Role};
|
||||
use crate::{Error, LlmProvider, Result, mode::Mode, send_via_stream};
|
||||
use crate::{
|
||||
ChatStream, Error, LlmProvider, Result, facade::llm_client::LlmClient, mode::Mode,
|
||||
send_via_stream,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use futures::{StreamExt, future::BoxFuture, stream};
|
||||
use reqwest::Client as HttpClient;
|
||||
@@ -564,3 +567,27 @@ impl LlmProvider for RemoteMcpClient {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LlmClient for RemoteMcpClient {
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||
<Self as LlmProvider>::list_models(self).await
|
||||
}
|
||||
|
||||
async fn send_chat(&self, request: crate::types::ChatRequest) -> Result<ChatResponse> {
|
||||
<Self as LlmProvider>::send_prompt(self, request).await
|
||||
}
|
||||
|
||||
async fn stream_chat(&self, request: crate::types::ChatRequest) -> Result<ChatStream> {
|
||||
let stream = <Self as LlmProvider>::stream_prompt(self, request).await?;
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
<Self as McpClient>::list_tools(self).await
|
||||
}
|
||||
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
<Self as McpClient>::call_tool(self, call).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ struct ScopeSnapshot {
|
||||
availability: ScopeAvailability,
|
||||
last_error: Option<String>,
|
||||
last_checked: Option<Instant>,
|
||||
last_success_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for ScopeSnapshot {
|
||||
@@ -98,10 +99,29 @@ impl Default for ScopeSnapshot {
|
||||
availability: ScopeAvailability::Unknown,
|
||||
last_error: None,
|
||||
last_checked: None,
|
||||
last_success_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopeSnapshot {
|
||||
fn is_stale(&self, ttl: Duration) -> bool {
|
||||
match self.fetched_at {
|
||||
Some(ts) => ts.elapsed() >= ttl,
|
||||
None => !self.models.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn last_checked_age_secs(&self) -> Option<u64> {
|
||||
self.last_checked.map(|instant| instant.elapsed().as_secs())
|
||||
}
|
||||
|
||||
fn last_success_age_secs(&self) -> Option<u64> {
|
||||
self.last_success_at
|
||||
.map(|instant| instant.elapsed().as_secs())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct OllamaOptions {
|
||||
mode: OllamaMode,
|
||||
@@ -410,22 +430,29 @@ impl OllamaProvider {
|
||||
return None;
|
||||
}
|
||||
|
||||
entry.fetched_at.and_then(|ts| {
|
||||
if entry.models.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(ts) = entry.fetched_at {
|
||||
if ts.elapsed() < self.model_cache_ttl {
|
||||
Some(entry.models.clone())
|
||||
} else {
|
||||
None
|
||||
return Some(entry.models.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback to last good models even if stale; UI will mark as degraded
|
||||
Some(entry.models.clone())
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_scope_success(&self, scope: OllamaMode, models: &[ModelInfo]) {
|
||||
let mut cache = self.scope_cache.write().await;
|
||||
let entry = cache.entry(scope).or_default();
|
||||
let now = Instant::now();
|
||||
entry.models = models.to_vec();
|
||||
entry.fetched_at = Some(Instant::now());
|
||||
entry.last_checked = Some(Instant::now());
|
||||
entry.fetched_at = Some(now);
|
||||
entry.last_checked = Some(now);
|
||||
entry.last_success_at = Some(now);
|
||||
entry.availability = ScopeAvailability::Available;
|
||||
entry.last_error = None;
|
||||
}
|
||||
@@ -461,6 +488,45 @@ impl OllamaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
let stale = snapshot.is_stale(self.model_cache_ttl);
|
||||
let stale_capability = format!(
|
||||
"scope-status-stale:{}:{}",
|
||||
scope_key,
|
||||
if stale { "1" } else { "0" }
|
||||
);
|
||||
for model in models.iter_mut() {
|
||||
if !model
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|cap| cap == &stale_capability)
|
||||
{
|
||||
model.capabilities.push(stale_capability.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(age) = snapshot.last_checked_age_secs() {
|
||||
let age_capability = format!("scope-status-age:{}:{}", scope_key, age);
|
||||
for model in models.iter_mut() {
|
||||
if !model.capabilities.iter().any(|cap| cap == &age_capability) {
|
||||
model.capabilities.push(age_capability.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(success_age) = snapshot.last_success_age_secs() {
|
||||
let success_capability =
|
||||
format!("scope-status-success-age:{}:{}", scope_key, success_age);
|
||||
for model in models.iter_mut() {
|
||||
if !model
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|cap| cap == &success_capability)
|
||||
{
|
||||
model.capabilities.push(success_capability.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(raw_reason) = snapshot.last_error.as_ref() {
|
||||
let cleaned = raw_reason.replace('\n', " ").trim().to_string();
|
||||
if !cleaned.is_empty() {
|
||||
@@ -1658,6 +1724,7 @@ fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() {
|
||||
let entry = cache.entry(OllamaMode::Cloud).or_default();
|
||||
entry.availability = ScopeAvailability::Unavailable;
|
||||
entry.last_error = Some("Cloud endpoint unreachable".to_string());
|
||||
entry.last_checked = Some(Instant::now());
|
||||
}
|
||||
|
||||
provider.annotate_scope_status(&mut models).await;
|
||||
@@ -1674,4 +1741,14 @@ fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() {
|
||||
.iter()
|
||||
.any(|cap| cap.starts_with("scope-status-message:cloud:"))
|
||||
);
|
||||
assert!(
|
||||
capabilities
|
||||
.iter()
|
||||
.any(|cap| cap.starts_with("scope-status-age:cloud:"))
|
||||
);
|
||||
assert!(
|
||||
capabilities
|
||||
.iter()
|
||||
.any(|cap| cap == "scope-status-stale:cloud:0")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::config::{Config, McpResourceConfig, McpServerConfig};
|
||||
use crate::consent::ConsentManager;
|
||||
use crate::consent::{ConsentManager, ConsentScope};
|
||||
use crate::conversation::ConversationManager;
|
||||
use crate::credentials::CredentialManager;
|
||||
use crate::encryption::{self, VaultHandle};
|
||||
@@ -34,6 +34,7 @@ use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum SessionOutcome {
|
||||
@@ -44,6 +45,36 @@ pub enum SessionOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ControllerEvent {
|
||||
ToolRequested {
|
||||
request_id: Uuid,
|
||||
message_id: Uuid,
|
||||
tool_name: String,
|
||||
data_types: Vec<String>,
|
||||
endpoints: Vec<String>,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PendingToolRequest {
|
||||
message_id: Uuid,
|
||||
tool_name: String,
|
||||
data_types: Vec<String>,
|
||||
endpoints: Vec<String>,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolConsentResolution {
|
||||
pub request_id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub tool_name: String,
|
||||
pub scope: ConsentScope,
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
}
|
||||
|
||||
fn extract_resource_content(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => Some(String::new()),
|
||||
@@ -111,6 +142,8 @@ pub struct SessionController {
|
||||
enable_code_tools: bool,
|
||||
current_mode: Mode,
|
||||
missing_oauth_servers: Vec<String>,
|
||||
event_tx: Option<UnboundedSender<ControllerEvent>>,
|
||||
pending_tool_requests: HashMap<Uuid, PendingToolRequest>,
|
||||
}
|
||||
|
||||
async fn build_tools(
|
||||
@@ -331,6 +364,7 @@ impl SessionController {
|
||||
storage: Arc<StorageManager>,
|
||||
ui: Arc<dyn UiController>,
|
||||
enable_code_tools: bool,
|
||||
event_tx: Option<UnboundedSender<ControllerEvent>>,
|
||||
) -> Result<Self> {
|
||||
let config_arc = Arc::new(TokioMutex::new(config));
|
||||
// Acquire the config asynchronously to avoid blocking the runtime.
|
||||
@@ -435,6 +469,8 @@ impl SessionController {
|
||||
enable_code_tools,
|
||||
current_mode: initial_mode,
|
||||
missing_oauth_servers,
|
||||
event_tx,
|
||||
pending_tool_requests: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1222,14 +1258,84 @@ impl SessionController {
|
||||
.append_stream_chunk(message_id, &chunk.message.content, chunk.is_final)
|
||||
}
|
||||
|
||||
pub fn check_streaming_tool_calls(&self, message_id: Uuid) -> Option<Vec<ToolCall>> {
|
||||
self.conversation
|
||||
pub fn check_streaming_tool_calls(&mut self, message_id: Uuid) -> Option<Vec<ToolCall>> {
|
||||
let maybe_calls = self
|
||||
.conversation
|
||||
.active()
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.id == message_id)
|
||||
.and_then(|m| m.tool_calls.clone())
|
||||
.filter(|calls| !calls.is_empty())
|
||||
.filter(|calls| !calls.is_empty());
|
||||
|
||||
let calls = maybe_calls?;
|
||||
|
||||
if !self
|
||||
.pending_tool_requests
|
||||
.values()
|
||||
.any(|pending| pending.message_id == message_id)
|
||||
{
|
||||
if let Some((tool_name, data_types, endpoints)) =
|
||||
self.check_tools_consent_needed(&calls).into_iter().next()
|
||||
{
|
||||
let request_id = Uuid::new_v4();
|
||||
let pending = PendingToolRequest {
|
||||
message_id,
|
||||
tool_name: tool_name.clone(),
|
||||
data_types: data_types.clone(),
|
||||
endpoints: endpoints.clone(),
|
||||
tool_calls: calls.clone(),
|
||||
};
|
||||
self.pending_tool_requests.insert(request_id, pending);
|
||||
|
||||
if let Some(tx) = &self.event_tx {
|
||||
let _ = tx.send(ControllerEvent::ToolRequested {
|
||||
request_id,
|
||||
message_id,
|
||||
tool_name,
|
||||
data_types,
|
||||
endpoints,
|
||||
tool_calls: calls.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(calls)
|
||||
}
|
||||
|
||||
pub fn resolve_tool_consent(
|
||||
&mut self,
|
||||
request_id: Uuid,
|
||||
scope: ConsentScope,
|
||||
) -> Result<ToolConsentResolution> {
|
||||
let pending = self
|
||||
.pending_tool_requests
|
||||
.remove(&request_id)
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidInput(format!("Unknown tool consent request: {}", request_id))
|
||||
})?;
|
||||
|
||||
let PendingToolRequest {
|
||||
message_id,
|
||||
tool_name,
|
||||
data_types,
|
||||
endpoints,
|
||||
tool_calls,
|
||||
..
|
||||
} = pending;
|
||||
|
||||
if !matches!(scope, ConsentScope::Denied) {
|
||||
self.grant_consent_with_scope(&tool_name, data_types, endpoints, scope.clone());
|
||||
}
|
||||
|
||||
Ok(ToolConsentResolution {
|
||||
request_id,
|
||||
message_id,
|
||||
tool_name,
|
||||
scope,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel_stream(&mut self, message_id: Uuid, notice: &str) -> Result<()> {
|
||||
@@ -1352,7 +1458,7 @@ mod tests {
|
||||
let provider: Arc<dyn Provider> = Arc::new(MockProvider::default()) as Arc<dyn Provider>;
|
||||
let ui = Arc::new(NoOpUiController);
|
||||
|
||||
let session = SessionController::new(provider, config, storage, ui, false)
|
||||
let session = SessionController::new(provider, config, storage, ui, false, None)
|
||||
.await
|
||||
.expect("session");
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
use std::fmt;
|
||||
|
||||
/// High-level application state reported by the UI loop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum AppState {
|
||||
Running,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Vim-style input modes supported by the TUI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum InputMode {
|
||||
Normal,
|
||||
Editing,
|
||||
@@ -45,7 +45,7 @@ impl fmt::Display for InputMode {
|
||||
}
|
||||
|
||||
/// Represents which panel is currently focused in the TUI layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FocusedPanel {
|
||||
Files,
|
||||
Chat,
|
||||
|
||||
310
crates/owlen-core/tests/agent_tool_flow.rs
Normal file
310
crates/owlen-core/tests/agent_tool_flow.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::{any::Any, collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use owlen_core::{
|
||||
Config, Error, Mode, Provider,
|
||||
config::McpMode,
|
||||
consent::ConsentScope,
|
||||
mcp::{
|
||||
McpClient, McpToolCall, McpToolDescriptor, McpToolResponse,
|
||||
failover::{FailoverMcpClient, ServerEntry},
|
||||
},
|
||||
session::{ControllerEvent, SessionController, SessionOutcome},
|
||||
storage::StorageManager,
|
||||
types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall},
|
||||
ui::NoOpUiController,
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
struct StreamingToolProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StreamingToolProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock-streaming-provider"
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> owlen_core::Result<Vec<ModelInfo>> {
|
||||
Ok(vec![ModelInfo {
|
||||
id: "mock-model".into(),
|
||||
name: "Mock Model".into(),
|
||||
description: Some("A mock model that emits tool calls".into()),
|
||||
provider: self.name().into(),
|
||||
context_window: Some(4096),
|
||||
capabilities: vec!["chat".into(), "tools".into()],
|
||||
supports_tools: true,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn send_prompt(&self, _request: ChatRequest) -> owlen_core::Result<ChatResponse> {
|
||||
let mut message = Message::assistant("tool-call".to_string());
|
||||
message.tool_calls = Some(vec![ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "resources/write".to_string(),
|
||||
arguments: serde_json::json!({"path": "README.md", "content": "hello"}),
|
||||
}]);
|
||||
|
||||
Ok(ChatResponse {
|
||||
message,
|
||||
usage: None,
|
||||
is_streaming: false,
|
||||
is_final: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream_prompt(
|
||||
&self,
|
||||
_request: ChatRequest,
|
||||
) -> owlen_core::Result<owlen_core::ChatStream> {
|
||||
let mut first_chunk = Message::assistant(
|
||||
"Thought: need to update README.\nAction: resources/write".to_string(),
|
||||
);
|
||||
first_chunk.tool_calls = Some(vec![ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "resources/write".to_string(),
|
||||
arguments: serde_json::json!({"path": "README.md", "content": "hello"}),
|
||||
}]);
|
||||
|
||||
let chunk = ChatResponse {
|
||||
message: first_chunk,
|
||||
usage: None,
|
||||
is_streaming: true,
|
||||
is_final: false,
|
||||
};
|
||||
|
||||
Ok(Box::pin(futures::stream::iter(vec![Ok(chunk)])))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> owlen_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_descriptor() -> McpToolDescriptor {
|
||||
McpToolDescriptor {
|
||||
name: "web_search".to_string(),
|
||||
description: "search".to_string(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
requires_network: true,
|
||||
requires_filesystem: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
struct TimeoutClient;
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for TimeoutClient {
|
||||
async fn list_tools(&self) -> owlen_core::Result<Vec<McpToolDescriptor>> {
|
||||
Ok(vec![tool_descriptor()])
|
||||
}
|
||||
|
||||
async fn call_tool(&self, _call: McpToolCall) -> owlen_core::Result<McpToolResponse> {
|
||||
Err(Error::Network(
|
||||
"timeout while contacting remote web search endpoint".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedResponseClient {
|
||||
response: Arc<McpToolResponse>,
|
||||
}
|
||||
|
||||
impl CachedResponseClient {
|
||||
fn new() -> Self {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("source".to_string(), "cache".to_string());
|
||||
metadata.insert("cached".to_string(), "true".to_string());
|
||||
|
||||
let response = McpToolResponse {
|
||||
name: "web_search".to_string(),
|
||||
success: true,
|
||||
output: serde_json::json!({
|
||||
"query": "rust",
|
||||
"results": [
|
||||
{"title": "Rust Programming Language", "url": "https://www.rust-lang.org"}
|
||||
],
|
||||
"note": "cached result"
|
||||
}),
|
||||
metadata,
|
||||
duration_ms: 0,
|
||||
};
|
||||
|
||||
Self {
|
||||
response: Arc::new(response),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for CachedResponseClient {
|
||||
async fn list_tools(&self) -> owlen_core::Result<Vec<McpToolDescriptor>> {
|
||||
Ok(vec![tool_descriptor()])
|
||||
}
|
||||
|
||||
async fn call_tool(&self, _call: McpToolCall) -> owlen_core::Result<McpToolResponse> {
|
||||
Ok((*self.response).clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn streaming_file_write_consent_denied_returns_resolution() {
|
||||
let temp_dir = tempdir().expect("temp dir");
|
||||
let storage = StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db"))
|
||||
.await
|
||||
.expect("storage");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.general.enable_streaming = true;
|
||||
config.privacy.encrypt_local_data = false;
|
||||
config.privacy.require_consent_per_session = true;
|
||||
config.general.default_model = Some("mock-model".into());
|
||||
config.mcp.mode = McpMode::LocalOnly;
|
||||
config
|
||||
.refresh_mcp_servers(None)
|
||||
.expect("refresh MCP servers");
|
||||
|
||||
let provider: Arc<dyn Provider> = Arc::new(StreamingToolProvider);
|
||||
let ui = Arc::new(NoOpUiController);
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
|
||||
|
||||
let mut session = SessionController::new(
|
||||
provider,
|
||||
config,
|
||||
Arc::new(storage),
|
||||
ui,
|
||||
true,
|
||||
Some(event_tx),
|
||||
)
|
||||
.await
|
||||
.expect("session controller");
|
||||
|
||||
session
|
||||
.set_operating_mode(Mode::Code)
|
||||
.await
|
||||
.expect("code mode");
|
||||
|
||||
let outcome = session
|
||||
.send_message(
|
||||
"Please write to README".to_string(),
|
||||
ChatParameters {
|
||||
stream: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("send message");
|
||||
|
||||
let (response_id, mut stream) = if let SessionOutcome::Streaming {
|
||||
response_id,
|
||||
stream,
|
||||
} = outcome
|
||||
{
|
||||
(response_id, stream)
|
||||
} else {
|
||||
panic!("expected streaming outcome");
|
||||
};
|
||||
|
||||
session
|
||||
.mark_stream_placeholder(response_id, "▌")
|
||||
.expect("placeholder");
|
||||
|
||||
let chunk = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("stream chunk")
|
||||
.expect("chunk result");
|
||||
session
|
||||
.apply_stream_chunk(response_id, &chunk)
|
||||
.expect("apply chunk");
|
||||
|
||||
let tool_calls = session
|
||||
.check_streaming_tool_calls(response_id)
|
||||
.expect("tool calls");
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].name, "resources/write");
|
||||
|
||||
let event = event_rx.recv().await.expect("controller event");
|
||||
let request_id = match event {
|
||||
ControllerEvent::ToolRequested {
|
||||
request_id,
|
||||
tool_name,
|
||||
data_types,
|
||||
endpoints,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(tool_name, "resources/write");
|
||||
assert!(data_types.iter().any(|t| t.contains("file")));
|
||||
assert!(endpoints.iter().any(|e| e.contains("filesystem")));
|
||||
request_id
|
||||
}
|
||||
};
|
||||
|
||||
let resolution = session
|
||||
.resolve_tool_consent(request_id, ConsentScope::Denied)
|
||||
.expect("resolution");
|
||||
assert_eq!(resolution.scope, ConsentScope::Denied);
|
||||
assert_eq!(resolution.tool_name, "resources/write");
|
||||
assert_eq!(resolution.tool_calls.len(), tool_calls.len());
|
||||
|
||||
let err = session
|
||||
.resolve_tool_consent(request_id, ConsentScope::Denied)
|
||||
.expect_err("second resolution should fail");
|
||||
matches!(err, Error::InvalidInput(_));
|
||||
|
||||
let conversation = session.conversation().clone();
|
||||
let assistant = conversation
|
||||
.messages
|
||||
.iter()
|
||||
.find(|message| message.role == Role::Assistant)
|
||||
.expect("assistant message present");
|
||||
assert!(
|
||||
assistant
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.and_then(|calls| calls.first())
|
||||
.is_some_and(|call| call.name == "resources/write"),
|
||||
"stream chunk should capture the tool call on the assistant message"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_tool_timeout_fails_over_to_cached_result() {
|
||||
let primary: Arc<dyn McpClient> = Arc::new(TimeoutClient);
|
||||
let cached = CachedResponseClient::new();
|
||||
let backup: Arc<dyn McpClient> = Arc::new(cached.clone());
|
||||
|
||||
let client = FailoverMcpClient::with_servers(vec![
|
||||
ServerEntry::new("primary".into(), primary, 1),
|
||||
ServerEntry::new("cache".into(), backup, 2),
|
||||
]);
|
||||
|
||||
let call = McpToolCall {
|
||||
name: "web_search".to_string(),
|
||||
arguments: serde_json::json!({ "query": "rust", "max_results": 3 }),
|
||||
};
|
||||
|
||||
let response = client.call_tool(call.clone()).await.expect("fallback");
|
||||
|
||||
assert_eq!(response.name, "web_search");
|
||||
assert_eq!(
|
||||
response.metadata.get("source").map(String::as_str),
|
||||
Some("cache")
|
||||
);
|
||||
assert_eq!(
|
||||
response.output.get("note").and_then(|value| value.as_str()),
|
||||
Some("cached result")
|
||||
);
|
||||
|
||||
let statuses = client.get_server_status().await;
|
||||
assert!(statuses.iter().any(|(name, health)| name == "primary"
|
||||
&& !matches!(health, owlen_core::mcp::failover::ServerHealth::Healthy)));
|
||||
assert!(statuses.iter().any(|(name, health)| name == "cache"
|
||||
&& matches!(health, owlen_core::mcp::failover::ServerHealth::Healthy)));
|
||||
}
|
||||
@@ -30,6 +30,7 @@ toml = { workspace = true }
|
||||
syntect = "5.3"
|
||||
once_cell = "1.19"
|
||||
owlen-markdown = { path = "../owlen-markdown" }
|
||||
shellexpand = { workspace = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
74
crates/owlen-tui/keymap.toml
Normal file
74
crates/owlen-tui/keymap.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["m"]
|
||||
command = "model.open_all"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+Shift+L"]
|
||||
command = "model.open_local"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+Shift+C"]
|
||||
command = "model.open_cloud"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+Shift+P"]
|
||||
command = "model.open_available"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+P"]
|
||||
command = "palette.open"
|
||||
|
||||
[[binding]]
|
||||
mode = "editing"
|
||||
keys = ["Ctrl+P"]
|
||||
command = "palette.open"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Tab"]
|
||||
command = "focus.next"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Shift+Tab"]
|
||||
command = "focus.prev"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+1"]
|
||||
command = "focus.files"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+2"]
|
||||
command = "focus.chat"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+3"]
|
||||
command = "focus.code"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+4"]
|
||||
command = "focus.thinking"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+5"]
|
||||
command = "focus.input"
|
||||
|
||||
[[binding]]
|
||||
mode = "editing"
|
||||
keys = ["Enter"]
|
||||
command = "composer.submit"
|
||||
|
||||
[[binding]]
|
||||
mode = "normal"
|
||||
keys = ["Ctrl+;"]
|
||||
command = "mode.command"
|
||||
@@ -1,5 +1,6 @@
|
||||
mod generation;
|
||||
mod handler;
|
||||
pub mod mvu;
|
||||
mod worker;
|
||||
|
||||
pub mod messages;
|
||||
@@ -33,6 +34,7 @@ pub trait UiRuntime: MessageState {
|
||||
async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()>;
|
||||
async fn process_pending_llm_request(&mut self) -> Result<()>;
|
||||
async fn process_pending_tool_execution(&mut self) -> Result<()>;
|
||||
fn poll_controller_events(&mut self) -> Result<()>;
|
||||
fn advance_loading_animation(&mut self);
|
||||
fn streaming_count(&self) -> usize;
|
||||
}
|
||||
@@ -115,6 +117,7 @@ impl App {
|
||||
|
||||
state.process_pending_llm_request().await?;
|
||||
state.process_pending_tool_execution().await?;
|
||||
state.poll_controller_events()?;
|
||||
|
||||
loop {
|
||||
match session_rx.try_recv() {
|
||||
|
||||
165
crates/owlen-tui/src/app/mvu.rs
Normal file
165
crates/owlen-tui/src/app/mvu.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use owlen_core::{consent::ConsentScope, ui::InputMode};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppModel {
|
||||
pub composer: ComposerModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComposerModel {
|
||||
pub draft: String,
|
||||
pub pending_submit: bool,
|
||||
pub mode: InputMode,
|
||||
}
|
||||
|
||||
impl Default for ComposerModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
draft: String::new(),
|
||||
pending_submit: false,
|
||||
mode: InputMode::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
Composer(ComposerEvent),
|
||||
ToolPermission {
|
||||
request_id: Uuid,
|
||||
scope: ConsentScope,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ComposerEvent {
|
||||
DraftChanged { content: String },
|
||||
ModeChanged { mode: InputMode },
|
||||
Submit,
|
||||
SubmissionHandled { result: SubmissionOutcome },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SubmissionOutcome {
|
||||
MessageSent,
|
||||
CommandExecuted,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEffect {
|
||||
SetStatus(String),
|
||||
RequestSubmit,
|
||||
ResolveToolConsent {
|
||||
request_id: Uuid,
|
||||
scope: ConsentScope,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn update(model: &mut AppModel, event: AppEvent) -> Vec<AppEffect> {
|
||||
match event {
|
||||
AppEvent::Composer(event) => update_composer(&mut model.composer, event),
|
||||
AppEvent::ToolPermission { request_id, scope } => {
|
||||
vec![AppEffect::ResolveToolConsent { request_id, scope }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_composer(model: &mut ComposerModel, event: ComposerEvent) -> Vec<AppEffect> {
|
||||
match event {
|
||||
ComposerEvent::DraftChanged { content } => {
|
||||
model.draft = content;
|
||||
Vec::new()
|
||||
}
|
||||
ComposerEvent::ModeChanged { mode } => {
|
||||
model.mode = mode;
|
||||
Vec::new()
|
||||
}
|
||||
ComposerEvent::Submit => {
|
||||
if model.draft.trim().is_empty() {
|
||||
return vec![AppEffect::SetStatus(
|
||||
"Cannot send empty message".to_string(),
|
||||
)];
|
||||
}
|
||||
|
||||
model.pending_submit = true;
|
||||
vec![AppEffect::RequestSubmit]
|
||||
}
|
||||
ComposerEvent::SubmissionHandled { result } => {
|
||||
model.pending_submit = false;
|
||||
match result {
|
||||
SubmissionOutcome::MessageSent | SubmissionOutcome::CommandExecuted => {
|
||||
model.draft.clear();
|
||||
if model.mode == InputMode::Editing {
|
||||
model.mode = InputMode::Normal;
|
||||
}
|
||||
}
|
||||
SubmissionOutcome::Failed => {}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn submit_with_empty_draft_sets_error() {
|
||||
let mut model = AppModel::default();
|
||||
let effects = update(&mut model, AppEvent::Composer(ComposerEvent::Submit));
|
||||
|
||||
assert!(!model.composer.pending_submit);
|
||||
assert_eq!(effects.len(), 1);
|
||||
match &effects[0] {
|
||||
AppEffect::SetStatus(message) => {
|
||||
assert!(message.contains("Cannot send empty message"));
|
||||
}
|
||||
other => panic!("unexpected effect: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_with_content_requests_processing() {
|
||||
let mut model = AppModel::default();
|
||||
let _ = update(
|
||||
&mut model,
|
||||
AppEvent::Composer(ComposerEvent::DraftChanged {
|
||||
content: "hello world".into(),
|
||||
}),
|
||||
);
|
||||
|
||||
let effects = update(&mut model, AppEvent::Composer(ComposerEvent::Submit));
|
||||
|
||||
assert!(model.composer.pending_submit);
|
||||
assert_eq!(effects.len(), 1);
|
||||
matches!(effects[0], AppEffect::RequestSubmit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_success_clears_draft_and_mode() {
|
||||
let mut model = AppModel::default();
|
||||
let _ = update(
|
||||
&mut model,
|
||||
AppEvent::Composer(ComposerEvent::DraftChanged {
|
||||
content: "hello world".into(),
|
||||
}),
|
||||
);
|
||||
let _ = update(&mut model, AppEvent::Composer(ComposerEvent::Submit));
|
||||
assert!(model.composer.pending_submit);
|
||||
|
||||
let effects = update(
|
||||
&mut model,
|
||||
AppEvent::Composer(ComposerEvent::SubmissionHandled {
|
||||
result: SubmissionOutcome::MessageSent,
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(effects.is_empty());
|
||||
assert!(!model.composer.pending_submit);
|
||||
assert!(model.composer.draft.is_empty());
|
||||
assert_eq!(model.composer.mode, InputMode::Normal);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use owlen_core::session::SessionController;
|
||||
use owlen_core::session::{ControllerEvent, SessionController};
|
||||
use owlen_core::ui::{AppState, InputMode};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -16,11 +16,12 @@ pub struct CodeApp {
|
||||
impl CodeApp {
|
||||
pub async fn new(
|
||||
mut controller: SessionController,
|
||||
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
|
||||
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
||||
controller
|
||||
.conversation_mut()
|
||||
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
|
||||
let (inner, rx) = ChatApp::new(controller).await?;
|
||||
let (inner, rx) = ChatApp::new(controller, controller_event_rx).await?;
|
||||
Ok((Self { inner }, rx))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! Command catalog and lookup utilities for the command palette.
|
||||
pub mod registry;
|
||||
pub use registry::{AppCommand, CommandRegistry};
|
||||
|
||||
// Command catalog and lookup utilities for the command palette.
|
||||
|
||||
/// Metadata describing a single command keyword.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
||||
105
crates/owlen-tui/src/commands/registry.rs
Normal file
105
crates/owlen-tui/src/commands/registry.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use owlen_core::ui::FocusedPanel;
|
||||
|
||||
use crate::widgets::model_picker::FilterMode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum AppCommand {
|
||||
OpenModelPicker(Option<FilterMode>),
|
||||
OpenCommandPalette,
|
||||
CycleFocusForward,
|
||||
CycleFocusBackward,
|
||||
FocusPanel(FocusedPanel),
|
||||
ComposerSubmit,
|
||||
EnterCommandMode,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommandRegistry {
|
||||
commands: HashMap<String, AppCommand>,
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
pub fn new() -> Self {
|
||||
let mut commands = HashMap::new();
|
||||
|
||||
commands.insert(
|
||||
"model.open_all".to_string(),
|
||||
AppCommand::OpenModelPicker(None),
|
||||
);
|
||||
commands.insert(
|
||||
"model.open_local".to_string(),
|
||||
AppCommand::OpenModelPicker(Some(FilterMode::LocalOnly)),
|
||||
);
|
||||
commands.insert(
|
||||
"model.open_cloud".to_string(),
|
||||
AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly)),
|
||||
);
|
||||
commands.insert(
|
||||
"model.open_available".to_string(),
|
||||
AppCommand::OpenModelPicker(Some(FilterMode::Available)),
|
||||
);
|
||||
commands.insert("palette.open".to_string(), AppCommand::OpenCommandPalette);
|
||||
commands.insert("focus.next".to_string(), AppCommand::CycleFocusForward);
|
||||
commands.insert("focus.prev".to_string(), AppCommand::CycleFocusBackward);
|
||||
commands.insert(
|
||||
"focus.files".to_string(),
|
||||
AppCommand::FocusPanel(FocusedPanel::Files),
|
||||
);
|
||||
commands.insert(
|
||||
"focus.chat".to_string(),
|
||||
AppCommand::FocusPanel(FocusedPanel::Chat),
|
||||
);
|
||||
commands.insert(
|
||||
"focus.thinking".to_string(),
|
||||
AppCommand::FocusPanel(FocusedPanel::Thinking),
|
||||
);
|
||||
commands.insert(
|
||||
"focus.input".to_string(),
|
||||
AppCommand::FocusPanel(FocusedPanel::Input),
|
||||
);
|
||||
commands.insert(
|
||||
"focus.code".to_string(),
|
||||
AppCommand::FocusPanel(FocusedPanel::Code),
|
||||
);
|
||||
commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit);
|
||||
commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode);
|
||||
|
||||
Self { commands }
|
||||
}
|
||||
|
||||
pub fn resolve(&self, command: &str) -> Option<AppCommand> {
|
||||
self.commands.get(command).copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_known_command() {
|
||||
let registry = CommandRegistry::new();
|
||||
assert_eq!(
|
||||
registry.resolve("focus.next"),
|
||||
Some(AppCommand::CycleFocusForward)
|
||||
);
|
||||
assert_eq!(
|
||||
registry.resolve("model.open_cloud"),
|
||||
Some(AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_unknown_command() {
|
||||
let registry = CommandRegistry::new();
|
||||
assert_eq!(registry.resolve("does.not.exist"), None);
|
||||
}
|
||||
}
|
||||
308
crates/owlen-tui/src/state/keymap.rs
Normal file
308
crates/owlen-tui/src/state/keymap.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use log::warn;
|
||||
use owlen_core::{config::default_config_path, ui::InputMode};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::commands::registry::{AppCommand, CommandRegistry};
|
||||
|
||||
const DEFAULT_KEYMAP: &str = include_str!("../../keymap.toml");
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Keymap {
|
||||
bindings: HashMap<(InputMode, KeyPattern), AppCommand>,
|
||||
}
|
||||
|
||||
impl Keymap {
|
||||
pub fn load(custom_path: Option<&str>, registry: &CommandRegistry) -> Self {
|
||||
let mut content = None;
|
||||
|
||||
if let Some(path) = custom_path.and_then(expand_path) {
|
||||
if let Ok(text) = fs::read_to_string(&path) {
|
||||
content = Some(text);
|
||||
} else {
|
||||
warn!(
|
||||
"Failed to read keymap from {}. Falling back to defaults.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if content.is_none() {
|
||||
let default_path = default_config_keymap_path();
|
||||
if let Some(path) = default_path {
|
||||
if let Ok(text) = fs::read_to_string(&path) {
|
||||
content = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = content.unwrap_or_else(|| DEFAULT_KEYMAP.to_string());
|
||||
let parsed: KeymapConfig = toml::from_str(&data).unwrap_or_else(|err| {
|
||||
warn!("Failed to parse keymap: {err}. Using built-in defaults.");
|
||||
toml::from_str(DEFAULT_KEYMAP).expect("embedded keymap should parse successfully")
|
||||
});
|
||||
|
||||
let mut bindings = HashMap::new();
|
||||
|
||||
for entry in parsed.bindings {
|
||||
let mode = match parse_mode(&entry.mode) {
|
||||
Some(mode) => mode,
|
||||
None => {
|
||||
warn!("Unknown input mode '{}' in keymap binding", entry.mode);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let command = match registry.resolve(&entry.command) {
|
||||
Some(cmd) => cmd,
|
||||
None => {
|
||||
warn!("Unknown command '{}' in keymap binding", entry.command);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for key in entry.keys.into_iter() {
|
||||
match KeyPattern::from_str(&key) {
|
||||
Some(pattern) => {
|
||||
bindings.insert((mode, pattern), command);
|
||||
}
|
||||
None => warn!(
|
||||
"Unrecognised key specification '{}' for mode {}",
|
||||
key, entry.mode
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self { bindings }
|
||||
}
|
||||
|
||||
pub fn resolve(&self, mode: InputMode, event: &KeyEvent) -> Option<AppCommand> {
|
||||
let pattern = KeyPattern::from_event(event)?;
|
||||
self.bindings.get(&(mode, pattern)).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KeymapConfig {
|
||||
#[serde(default, rename = "binding")]
|
||||
bindings: Vec<KeyBindingConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KeyBindingConfig {
|
||||
mode: String,
|
||||
command: String,
|
||||
keys: KeyList,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum KeyList {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl KeyList {
|
||||
fn into_iter(self) -> Vec<String> {
|
||||
match self {
|
||||
KeyList::Single(key) => vec![key],
|
||||
KeyList::Multiple(keys) => keys,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct KeyPattern {
|
||||
code: KeyCodeKind,
|
||||
modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum KeyCodeKind {
|
||||
Char(char),
|
||||
Enter,
|
||||
Tab,
|
||||
BackTab,
|
||||
Backspace,
|
||||
Esc,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Home,
|
||||
End,
|
||||
F(u8),
|
||||
}
|
||||
|
||||
impl KeyPattern {
|
||||
fn from_event(event: &KeyEvent) -> Option<Self> {
|
||||
let code = match event.code {
|
||||
KeyCode::Char(c) => KeyCodeKind::Char(c),
|
||||
KeyCode::Enter => KeyCodeKind::Enter,
|
||||
KeyCode::Tab => KeyCodeKind::Tab,
|
||||
KeyCode::BackTab => KeyCodeKind::BackTab,
|
||||
KeyCode::Backspace => KeyCodeKind::Backspace,
|
||||
KeyCode::Esc => KeyCodeKind::Esc,
|
||||
KeyCode::Up => KeyCodeKind::Up,
|
||||
KeyCode::Down => KeyCodeKind::Down,
|
||||
KeyCode::Left => KeyCodeKind::Left,
|
||||
KeyCode::Right => KeyCodeKind::Right,
|
||||
KeyCode::PageUp => KeyCodeKind::PageUp,
|
||||
KeyCode::PageDown => KeyCodeKind::PageDown,
|
||||
KeyCode::Home => KeyCodeKind::Home,
|
||||
KeyCode::End => KeyCodeKind::End,
|
||||
KeyCode::F(n) => KeyCodeKind::F(n),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
code,
|
||||
modifiers: normalize_modifiers(event.modifiers),
|
||||
})
|
||||
}
|
||||
|
||||
fn from_str(spec: &str) -> Option<Self> {
|
||||
let tokens: Vec<&str> = spec
|
||||
.split('+')
|
||||
.map(|token| token.trim())
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect();
|
||||
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut modifiers = KeyModifiers::empty();
|
||||
let key_token = tokens.last().copied().unwrap();
|
||||
|
||||
for token in tokens[..tokens.len().saturating_sub(1)].iter() {
|
||||
match token.to_ascii_lowercase().as_str() {
|
||||
"ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL),
|
||||
"alt" | "option" => modifiers.insert(KeyModifiers::ALT),
|
||||
"shift" => modifiers.insert(KeyModifiers::SHIFT),
|
||||
other => warn!("Unknown modifier '{other}' in key binding '{spec}'"),
|
||||
}
|
||||
}
|
||||
|
||||
let code = parse_key_token(key_token, &mut modifiers)?;
|
||||
|
||||
Some(Self {
|
||||
code,
|
||||
modifiers: normalize_modifiers(modifiers),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key_token(token: &str, modifiers: &mut KeyModifiers) -> Option<KeyCodeKind> {
|
||||
let token_lower = token.to_ascii_lowercase();
|
||||
let code = match token_lower.as_str() {
|
||||
"enter" | "return" => KeyCodeKind::Enter,
|
||||
"tab" => {
|
||||
if modifiers.contains(KeyModifiers::SHIFT) {
|
||||
modifiers.remove(KeyModifiers::SHIFT);
|
||||
KeyCodeKind::BackTab
|
||||
} else {
|
||||
KeyCodeKind::Tab
|
||||
}
|
||||
}
|
||||
"backtab" => KeyCodeKind::BackTab,
|
||||
"backspace" | "bs" => KeyCodeKind::Backspace,
|
||||
"esc" | "escape" => KeyCodeKind::Esc,
|
||||
"up" => KeyCodeKind::Up,
|
||||
"down" => KeyCodeKind::Down,
|
||||
"left" => KeyCodeKind::Left,
|
||||
"right" => KeyCodeKind::Right,
|
||||
"pageup" | "page_up" | "pgup" => KeyCodeKind::PageUp,
|
||||
"pagedown" | "page_down" | "pgdn" => KeyCodeKind::PageDown,
|
||||
"home" => KeyCodeKind::Home,
|
||||
"end" => KeyCodeKind::End,
|
||||
token if token.starts_with('f') && token.len() > 1 => {
|
||||
let num = token[1..].parse::<u8>().ok()?;
|
||||
KeyCodeKind::F(num)
|
||||
}
|
||||
"space" => KeyCodeKind::Char(' '),
|
||||
"semicolon" => KeyCodeKind::Char(';'),
|
||||
"slash" => KeyCodeKind::Char('/'),
|
||||
_ => {
|
||||
let chars: Vec<char> = token.chars().collect();
|
||||
if chars.len() == 1 {
|
||||
KeyCodeKind::Char(chars[0])
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(code)
|
||||
}
|
||||
|
||||
fn parse_mode(mode: &str) -> Option<InputMode> {
|
||||
match mode.to_ascii_lowercase().as_str() {
|
||||
"normal" => Some(InputMode::Normal),
|
||||
"editing" => Some(InputMode::Editing),
|
||||
"command" => Some(InputMode::Command),
|
||||
"visual" => Some(InputMode::Visual),
|
||||
"provider_selection" | "provider" => Some(InputMode::ProviderSelection),
|
||||
"model_selection" | "model" => Some(InputMode::ModelSelection),
|
||||
"help" => Some(InputMode::Help),
|
||||
"session_browser" | "sessions" => Some(InputMode::SessionBrowser),
|
||||
"theme_browser" | "themes" => Some(InputMode::ThemeBrowser),
|
||||
"repo_search" | "search" => Some(InputMode::RepoSearch),
|
||||
"symbol_search" | "symbols" => Some(InputMode::SymbolSearch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_config_keymap_path() -> Option<PathBuf> {
|
||||
let config_path = default_config_path();
|
||||
let dir = config_path.parent()?;
|
||||
Some(dir.join("keymap.toml"))
|
||||
}
|
||||
|
||||
fn expand_path(path: &str) -> Option<PathBuf> {
|
||||
if path.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let expanded = shellexpand::tilde(path);
|
||||
let candidate = Path::new(expanded.as_ref()).to_path_buf();
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers {
|
||||
modifiers
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::widgets::model_picker::FilterMode;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_from_default_keymap() {
|
||||
let registry = CommandRegistry::new();
|
||||
assert!(registry.resolve("model.open_all").is_some());
|
||||
let parsed: KeymapConfig = toml::from_str(DEFAULT_KEYMAP).unwrap();
|
||||
assert!(!parsed.bindings.is_empty());
|
||||
let keymap = Keymap::load(None, ®istry);
|
||||
|
||||
let event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE);
|
||||
assert!(
|
||||
!keymap.bindings.is_empty(),
|
||||
"expected default keymap to provide bindings"
|
||||
);
|
||||
assert_eq!(
|
||||
keymap.resolve(InputMode::Normal, &event),
|
||||
Some(AppCommand::OpenModelPicker(None))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
mod command_palette;
|
||||
mod file_icons;
|
||||
mod file_tree;
|
||||
mod keymap;
|
||||
mod search;
|
||||
mod workspace;
|
||||
|
||||
@@ -16,6 +17,7 @@ pub use file_icons::{FileIconResolver, FileIconSet, IconDetection};
|
||||
pub use file_tree::{
|
||||
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
||||
};
|
||||
pub use keymap::Keymap;
|
||||
pub use search::{
|
||||
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
|
||||
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,
|
||||
|
||||
@@ -12,10 +12,13 @@ use ratatui::{
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::chat_app::{ChatApp, ModelAvailabilityState, ModelScope, ModelSelectorItemKind};
|
||||
use crate::chat_app::{
|
||||
ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo,
|
||||
ModelSelectorItemKind,
|
||||
};
|
||||
|
||||
/// Filtering modes for the model picker popup.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FilterMode {
|
||||
#[default]
|
||||
All,
|
||||
@@ -36,16 +39,21 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_width: u16 = 80;
|
||||
let min_width: u16 = 50;
|
||||
let mut width = area.width.min(max_width);
|
||||
if area.width >= min_width {
|
||||
width = width.max(min_width);
|
||||
}
|
||||
width = width.max(1);
|
||||
let search_query = app.model_search_query().trim().to_string();
|
||||
let search_active = !search_query.is_empty();
|
||||
|
||||
let mut height = (selector_items.len().clamp(1, 10) as u16) * 3 + 6;
|
||||
height = height.clamp(6, area.height);
|
||||
let max_width = area.width.min(90);
|
||||
let min_width = area.width.min(56);
|
||||
let width = area.width.min(max_width).max(min_width).max(1);
|
||||
|
||||
let visible_models = app.visible_model_count();
|
||||
let min_rows: usize = if search_active { 5 } else { 4 };
|
||||
let max_rows: usize = 12;
|
||||
let row_estimate = visible_models.max(min_rows).min(max_rows);
|
||||
let mut height = (row_estimate as u16) * 3 + 8;
|
||||
let min_height = area.height.clamp(8, 12);
|
||||
let max_height = area.height.min(32);
|
||||
height = height.clamp(min_height, max_height);
|
||||
|
||||
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||
let mut y = area.y + (area.height.saturating_sub(height)) / 3;
|
||||
@@ -84,15 +92,110 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
let highlight_symbol = " ";
|
||||
let highlight_width = UnicodeWidthStr::width(highlight_symbol);
|
||||
let max_line_width = inner.width.saturating_sub(highlight_width as u16).max(1) as usize;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(4), Constraint::Length(2)])
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let matches = app.visible_model_count();
|
||||
let search_prefix = Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let bracket_style = Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let caret_style = if search_active {
|
||||
Style::default()
|
||||
.fg(theme.selection_fg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM)
|
||||
};
|
||||
|
||||
let mut search_spans = Vec::new();
|
||||
search_spans.push(Span::styled("Search ▸ ", search_prefix));
|
||||
search_spans.push(Span::styled("[", bracket_style));
|
||||
search_spans.push(Span::styled(" ", bracket_style));
|
||||
|
||||
if search_active {
|
||||
search_spans.push(Span::styled(
|
||||
search_query.clone(),
|
||||
Style::default()
|
||||
.fg(theme.selection_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
search_spans.push(Span::styled(
|
||||
"Type to search…",
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
|
||||
search_spans.push(Span::styled(" ", bracket_style));
|
||||
search_spans.push(Span::styled("▎", caret_style));
|
||||
search_spans.push(Span::styled(" ", bracket_style));
|
||||
search_spans.push(Span::styled("]", bracket_style));
|
||||
search_spans.push(Span::raw(" "));
|
||||
let suffix_label = if search_active { "match" } else { "model" };
|
||||
search_spans.push(Span::styled(
|
||||
format!(
|
||||
"({} {}{})",
|
||||
matches,
|
||||
suffix_label,
|
||||
if matches == 1 { "" } else { "s" }
|
||||
),
|
||||
Style::default().fg(theme.placeholder),
|
||||
));
|
||||
|
||||
let search_line = Line::from(search_spans);
|
||||
|
||||
let instruction_line = if search_active {
|
||||
Line::from(vec![
|
||||
Span::styled("Backspace", Style::default().fg(theme.placeholder)),
|
||||
Span::raw(": delete "),
|
||||
Span::styled("Ctrl+U", Style::default().fg(theme.placeholder)),
|
||||
Span::raw(": clear "),
|
||||
Span::styled("Enter", Style::default().fg(theme.placeholder)),
|
||||
Span::raw(": select "),
|
||||
Span::styled("Esc", Style::default().fg(theme.placeholder)),
|
||||
Span::raw(": close"),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(theme.placeholder)),
|
||||
Span::raw(": select "),
|
||||
Span::styled("Space", Style::default().fg(theme.placeholder)),
|
||||
Span::raw(": toggle provider "),
|
||||
Span::styled("Esc", Style::default().fg(theme.placeholder)),
|
||||
Span::raw(": close"),
|
||||
])
|
||||
};
|
||||
|
||||
let search_paragraph = Paragraph::new(vec![search_line, instruction_line])
|
||||
.style(Style::default().bg(theme.background).fg(theme.text));
|
||||
frame.render_widget(search_paragraph, layout[0]);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.selection_fg)
|
||||
.bg(theme.selection_bg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let highlight_symbol = " ";
|
||||
let highlight_width = UnicodeWidthStr::width(highlight_symbol);
|
||||
let max_line_width = layout[1]
|
||||
.width
|
||||
.saturating_sub(highlight_width as u16)
|
||||
.max(1) as usize;
|
||||
|
||||
let active_model_id = app.selected_model();
|
||||
let annotated = app.annotated_models();
|
||||
|
||||
@@ -108,12 +211,19 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
let mut spans = Vec::new();
|
||||
spans.push(status_icon(*status, theme));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
provider.clone(),
|
||||
let header_spans = render_highlighted_text(
|
||||
provider,
|
||||
if search_active {
|
||||
app.provider_search_highlight(provider)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Style::default()
|
||||
.fg(theme.mode_command)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
highlight_style,
|
||||
);
|
||||
spans.extend(header_spans);
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(provider_type_badge(*provider_type, theme));
|
||||
spans.push(Span::raw(" "));
|
||||
@@ -145,6 +255,11 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
let badges = model_badge_icons(model);
|
||||
let detail = app.cached_model_detail(&model.id);
|
||||
let annotated_model = annotated.get(*model_index);
|
||||
let search_info = if search_active {
|
||||
app.model_search_info(*model_index)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (title, metadata) = build_model_selector_lines(
|
||||
theme,
|
||||
model,
|
||||
@@ -152,6 +267,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
&badges,
|
||||
detail,
|
||||
model.id == active_model_id,
|
||||
SearchRenderContext {
|
||||
info: search_info,
|
||||
highlight_style,
|
||||
},
|
||||
);
|
||||
lines.push(clip_line_to_width(title, max_line_width));
|
||||
if let Some(meta) = metadata {
|
||||
@@ -176,14 +295,9 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
.as_ref()
|
||||
.map(|msg| msg.as_str())
|
||||
.unwrap_or("(no models configured)");
|
||||
let line = clip_line_to_width(
|
||||
Line::from(vec![
|
||||
Span::styled(icon, style),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!(" {}", msg), style),
|
||||
]),
|
||||
max_line_width,
|
||||
);
|
||||
let mut spans = vec![Span::styled(icon, style), Span::raw(" ")];
|
||||
spans.push(Span::styled(format!(" {}", msg), style));
|
||||
let line = clip_line_to_width(Line::from(spans), max_line_width);
|
||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background)));
|
||||
}
|
||||
}
|
||||
@@ -199,16 +313,22 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||
.highlight_symbol(" ");
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(app.selected_model_item);
|
||||
frame.render_stateful_widget(list, layout[0], &mut state);
|
||||
state.select(app.selected_model_item());
|
||||
frame.render_stateful_widget(list, layout[1], &mut state);
|
||||
|
||||
let footer_text = if search_active {
|
||||
"Enter: select · Space: toggle provider · Backspace: delete · Ctrl+U: clear"
|
||||
} else {
|
||||
"Enter: select · Space: toggle provider · Type to search · Esc: cancel"
|
||||
};
|
||||
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
"Enter: select · Space: toggle provider · ←/→ collapse/expand · Esc: cancel",
|
||||
footer_text,
|
||||
Style::default().fg(theme.placeholder),
|
||||
)))
|
||||
.alignment(ratatui::layout::Alignment::Center)
|
||||
.style(Style::default().bg(theme.background).fg(theme.placeholder));
|
||||
frame.render_widget(footer, layout[1]);
|
||||
frame.render_widget(footer, layout[2]);
|
||||
}
|
||||
|
||||
fn status_icon(status: ProviderStatus, theme: &owlen_core::theme::Theme) -> Span<'static> {
|
||||
@@ -302,13 +422,72 @@ fn filter_badge(mode: FilterMode, theme: &owlen_core::theme::Theme) -> Span<'sta
|
||||
)
|
||||
}
|
||||
|
||||
fn build_model_selector_lines(
|
||||
fn render_highlighted_text(
|
||||
text: &str,
|
||||
highlight: Option<&HighlightMask>,
|
||||
normal_style: Style,
|
||||
highlight_style: Style,
|
||||
) -> Vec<Span<'static>> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
|
||||
let mask = highlight.map(|mask| mask.bits()).unwrap_or(&[]);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut buffer = String::new();
|
||||
let mut current_highlight = false;
|
||||
|
||||
for (idx, grapheme) in graphemes.iter().enumerate() {
|
||||
let mark = mask.get(idx).copied().unwrap_or(false);
|
||||
if idx == 0 {
|
||||
current_highlight = mark;
|
||||
}
|
||||
if mark != current_highlight {
|
||||
if !buffer.is_empty() {
|
||||
let style = if current_highlight {
|
||||
highlight_style
|
||||
} else {
|
||||
normal_style
|
||||
};
|
||||
spans.push(Span::styled(buffer.clone(), style));
|
||||
buffer.clear();
|
||||
}
|
||||
current_highlight = mark;
|
||||
}
|
||||
buffer.push_str(grapheme);
|
||||
}
|
||||
|
||||
if !buffer.is_empty() {
|
||||
let style = if current_highlight {
|
||||
highlight_style
|
||||
} else {
|
||||
normal_style
|
||||
};
|
||||
spans.push(Span::styled(buffer, style));
|
||||
}
|
||||
|
||||
if spans.is_empty() {
|
||||
spans.push(Span::styled(text.to_string(), normal_style));
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
struct SearchRenderContext<'a> {
|
||||
info: Option<&'a ModelSearchInfo>,
|
||||
highlight_style: Style,
|
||||
}
|
||||
|
||||
fn build_model_selector_lines<'a>(
|
||||
theme: &owlen_core::theme::Theme,
|
||||
model: &ModelInfo,
|
||||
annotated: Option<&AnnotatedModelInfo>,
|
||||
model: &'a ModelInfo,
|
||||
annotated: Option<&'a AnnotatedModelInfo>,
|
||||
badges: &[&'static str],
|
||||
detail: Option<&owlen_core::model::DetailedModelInfo>,
|
||||
detail: Option<&'a owlen_core::model::DetailedModelInfo>,
|
||||
is_current: bool,
|
||||
search: SearchRenderContext<'a>,
|
||||
) -> (Line<'static>, Option<Line<'static>>) {
|
||||
let provider_type = annotated
|
||||
.map(|info| info.model.provider.provider_type)
|
||||
@@ -329,19 +508,42 @@ fn build_model_selector_lines(
|
||||
spans.push(provider_type_badge(provider_type, theme));
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
let mut display_name = if model.name.trim().is_empty() {
|
||||
model.id.clone()
|
||||
} else {
|
||||
model.name.clone()
|
||||
};
|
||||
if !display_name.eq_ignore_ascii_case(&model.id) {
|
||||
display_name.push_str(&format!(" · {}", model.id));
|
||||
}
|
||||
let name_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD);
|
||||
let id_style = Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM);
|
||||
|
||||
spans.push(Span::styled(
|
||||
display_name,
|
||||
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let name_trimmed = model.name.trim();
|
||||
if !name_trimmed.is_empty() {
|
||||
let name_spans = render_highlighted_text(
|
||||
name_trimmed,
|
||||
search.info.and_then(|info| info.name.as_ref()),
|
||||
name_style,
|
||||
search.highlight_style,
|
||||
);
|
||||
spans.extend(name_spans);
|
||||
|
||||
if !model.id.eq_ignore_ascii_case(name_trimmed) {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("·", Style::default().fg(theme.placeholder)));
|
||||
spans.push(Span::raw(" "));
|
||||
let id_spans = render_highlighted_text(
|
||||
model.id.as_str(),
|
||||
search.info.and_then(|info| info.id.as_ref()),
|
||||
id_style,
|
||||
search.highlight_style,
|
||||
);
|
||||
spans.extend(id_spans);
|
||||
}
|
||||
} else {
|
||||
let id_spans = render_highlighted_text(
|
||||
model.id.as_str(),
|
||||
search.info.and_then(|info| info.id.as_ref()),
|
||||
name_style,
|
||||
search.highlight_style,
|
||||
);
|
||||
spans.extend(id_spans);
|
||||
}
|
||||
|
||||
if !badges.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
@@ -359,7 +561,7 @@ fn build_model_selector_lines(
|
||||
));
|
||||
}
|
||||
|
||||
let mut meta_parts: Vec<String> = Vec::new();
|
||||
let mut meta_tags: Vec<String> = Vec::new();
|
||||
let mut seen_meta: HashSet<String> = HashSet::new();
|
||||
let mut push_meta = |value: String| {
|
||||
let trimmed = value.trim();
|
||||
@@ -368,7 +570,7 @@ fn build_model_selector_lines(
|
||||
}
|
||||
let key = trimmed.to_ascii_lowercase();
|
||||
if seen_meta.insert(key) {
|
||||
meta_parts.push(trimmed.to_string());
|
||||
meta_tags.push(trimmed.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -437,22 +639,62 @@ fn build_model_selector_lines(
|
||||
push_meta(format!("max tokens {}", ctx));
|
||||
}
|
||||
|
||||
let mut description_segment: Option<(String, Option<HighlightMask>)> = None;
|
||||
if let Some(desc) = model.description.as_deref() {
|
||||
let trimmed = desc.trim();
|
||||
if !trimmed.is_empty() {
|
||||
meta_parts.push(ellipsize(trimmed, 80));
|
||||
let (display, retained, truncated) = ellipsize(trimmed, 80);
|
||||
let highlight = search
|
||||
.info
|
||||
.and_then(|info| info.description.as_ref())
|
||||
.filter(|mask| mask.is_marked())
|
||||
.map(|mask| {
|
||||
if truncated {
|
||||
mask.truncated(retained)
|
||||
} else {
|
||||
mask.clone()
|
||||
}
|
||||
});
|
||||
description_segment = Some((display, highlight));
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = if meta_parts.is_empty() {
|
||||
let metadata = if meta_tags.is_empty() && description_segment.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(Line::from(vec![Span::styled(
|
||||
format!(" {}", meta_parts.join(" • ")),
|
||||
Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM),
|
||||
)]))
|
||||
let meta_style = Style::default()
|
||||
.fg(theme.placeholder)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let mut segments: Vec<Span<'static>> = Vec::new();
|
||||
segments.push(Span::styled(" ", meta_style));
|
||||
|
||||
let mut first = true;
|
||||
for tag in meta_tags {
|
||||
if !first {
|
||||
segments.push(Span::styled(" • ", meta_style));
|
||||
}
|
||||
segments.push(Span::styled(tag, meta_style));
|
||||
first = false;
|
||||
}
|
||||
|
||||
if let Some((text, highlight)) = description_segment {
|
||||
if !first {
|
||||
segments.push(Span::styled(" • ", meta_style));
|
||||
}
|
||||
if let Some(mask) = highlight.as_ref() {
|
||||
let desc_spans = render_highlighted_text(
|
||||
text.as_str(),
|
||||
Some(mask),
|
||||
meta_style,
|
||||
search.highlight_style,
|
||||
);
|
||||
segments.extend(desc_spans);
|
||||
} else {
|
||||
segments.push(Span::styled(text, meta_style));
|
||||
}
|
||||
}
|
||||
|
||||
Some(Line::from(segments))
|
||||
};
|
||||
|
||||
(Line::from(spans), metadata)
|
||||
@@ -501,18 +743,19 @@ fn clip_line_to_width(line: Line<'_>, max_width: usize) -> Line<'static> {
|
||||
Line::from(clipped)
|
||||
}
|
||||
|
||||
fn ellipsize(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
fn ellipsize(text: &str, max_graphemes: usize) -> (String, usize, bool) {
|
||||
let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
|
||||
if graphemes.len() <= max_graphemes {
|
||||
return (text.to_string(), graphemes.len(), false);
|
||||
}
|
||||
|
||||
let target = max_chars.saturating_sub(1).max(1);
|
||||
let keep = max_graphemes.saturating_sub(1).max(1);
|
||||
let mut truncated = String::new();
|
||||
for ch in text.chars().take(target) {
|
||||
truncated.push(ch);
|
||||
for grapheme in graphemes.iter().take(keep) {
|
||||
truncated.push_str(grapheme);
|
||||
}
|
||||
truncated.push('…');
|
||||
truncated
|
||||
(truncated, keep, true)
|
||||
}
|
||||
|
||||
fn model_badge_icons(model: &ModelInfo) -> Vec<&'static str> {
|
||||
|
||||
164
crates/owlen-tui/tests/agent_flow_ui.rs
Normal file
164
crates/owlen-tui/tests/agent_flow_ui.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use std::{any::Any, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use futures_util::stream;
|
||||
use owlen_core::{
|
||||
Config, Mode, Provider,
|
||||
config::McpMode,
|
||||
session::SessionController,
|
||||
storage::StorageManager,
|
||||
types::{ChatResponse, Message, Role, ToolCall},
|
||||
ui::{NoOpUiController, UiController},
|
||||
};
|
||||
use owlen_tui::ChatApp;
|
||||
use owlen_tui::app::UiRuntime;
|
||||
use owlen_tui::events::Event;
|
||||
use tempfile::tempdir;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
struct StubProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StubProvider {
|
||||
fn name(&self) -> &str {
|
||||
"stub-provider"
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> owlen_core::Result<Vec<owlen_core::types::ModelInfo>> {
|
||||
Ok(vec![owlen_core::types::ModelInfo {
|
||||
id: "stub-model".into(),
|
||||
name: "Stub Model".into(),
|
||||
description: Some("Stub model for testing".into()),
|
||||
provider: self.name().into(),
|
||||
context_window: Some(4096),
|
||||
capabilities: vec!["chat".into()],
|
||||
supports_tools: true,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn send_prompt(
|
||||
&self,
|
||||
_request: owlen_core::types::ChatRequest,
|
||||
) -> owlen_core::Result<ChatResponse> {
|
||||
Ok(ChatResponse {
|
||||
message: Message::assistant("stub response".to_string()),
|
||||
usage: None,
|
||||
is_streaming: false,
|
||||
is_final: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream_prompt(
|
||||
&self,
|
||||
_request: owlen_core::types::ChatRequest,
|
||||
) -> owlen_core::Result<owlen_core::ChatStream> {
|
||||
Ok(Box::pin(stream::empty()))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> owlen_core::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &(dyn Any + Send + Sync) {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn denied_consent_appends_apology_message() {
|
||||
let temp_dir = tempdir().expect("temp dir");
|
||||
let storage = Arc::new(
|
||||
StorageManager::with_database_path(temp_dir.path().join("owlen-tui-tests.db"))
|
||||
.await
|
||||
.expect("storage"),
|
||||
);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.privacy.encrypt_local_data = false;
|
||||
config.general.default_model = Some("stub-model".into());
|
||||
config.mcp.mode = McpMode::LocalOnly;
|
||||
config
|
||||
.refresh_mcp_servers(None)
|
||||
.expect("refresh MCP servers");
|
||||
|
||||
let provider: Arc<dyn Provider> = Arc::new(StubProvider);
|
||||
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
||||
let (event_tx, controller_event_rx) = mpsc::unbounded_channel();
|
||||
|
||||
// Pre-populate a pending consent request before handing the controller to the TUI.
|
||||
let mut session = SessionController::new(
|
||||
Arc::clone(&provider),
|
||||
config,
|
||||
Arc::clone(&storage),
|
||||
Arc::clone(&ui),
|
||||
true,
|
||||
Some(event_tx.clone()),
|
||||
)
|
||||
.await
|
||||
.expect("session controller");
|
||||
|
||||
session
|
||||
.set_operating_mode(Mode::Code)
|
||||
.await
|
||||
.expect("code mode");
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "resources/delete".to_string(),
|
||||
arguments: serde_json::json!({"path": "/tmp/example.txt"}),
|
||||
};
|
||||
|
||||
let message_id = session
|
||||
.conversation_mut()
|
||||
.push_assistant_message("Preparing to modify files.");
|
||||
session
|
||||
.conversation_mut()
|
||||
.set_tool_calls_on_message(message_id, vec![tool_call])
|
||||
.expect("tool calls");
|
||||
|
||||
let advertised_calls = session
|
||||
.check_streaming_tool_calls(message_id)
|
||||
.expect("queued consent");
|
||||
assert_eq!(advertised_calls.len(), 1);
|
||||
|
||||
let (mut app, mut session_rx) = ChatApp::new(session, controller_event_rx)
|
||||
.await
|
||||
.expect("chat app");
|
||||
// Session events are not used in this test.
|
||||
session_rx.close();
|
||||
|
||||
// Process the controller event emitted by check_streaming_tool_calls.
|
||||
UiRuntime::poll_controller_events(&mut app).expect("poll controller events");
|
||||
assert!(app.has_pending_consent());
|
||||
|
||||
let consent_state = app
|
||||
.consent_dialog()
|
||||
.expect("consent dialog should be visible")
|
||||
.clone();
|
||||
assert_eq!(consent_state.tool_name, "resources/delete");
|
||||
|
||||
// Simulate the user pressing "4" to deny consent.
|
||||
let deny_key = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE);
|
||||
UiRuntime::handle_ui_event(&mut app, Event::Key(deny_key))
|
||||
.await
|
||||
.expect("handle deny key");
|
||||
|
||||
assert!(!app.has_pending_consent());
|
||||
assert!(
|
||||
app.status_message()
|
||||
.to_lowercase()
|
||||
.contains("consent denied")
|
||||
);
|
||||
|
||||
let conversation = app.conversation();
|
||||
let last_message = conversation.messages.last().expect("last message");
|
||||
assert_eq!(last_message.role, Role::Assistant);
|
||||
assert!(
|
||||
last_message
|
||||
.content
|
||||
.to_lowercase()
|
||||
.contains("consent was denied"),
|
||||
"assistant should acknowledge the denied consent"
|
||||
);
|
||||
}
|
||||
13
crates/providers/experimental/README.md
Normal file
13
crates/providers/experimental/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Experimental Providers
|
||||
|
||||
This directory collects non-workspace placeholder crates for potential
|
||||
third-party providers. The code under the following folders is not yet
|
||||
implemented and is kept out of the default Cargo workspace to avoid
|
||||
confusion:
|
||||
|
||||
- `openai`
|
||||
- `anthropic`
|
||||
- `gemini`
|
||||
|
||||
If you want to explore or contribute to these providers, start by reading
|
||||
the `README.md` inside each crate for the current status and ideas.
|
||||
@@ -37,9 +37,9 @@ A simplified diagram of how components interact:
|
||||
- `owlen-core`: Defines the `LlmProvider` abstraction, routing, configuration, session state, encryption, and the MCP client layer. This crate is UI-agnostic and must not depend on concrete providers, terminals, or blocking I/O.
|
||||
- `owlen-tui`: Hosts all terminal UI behaviour (event loop, rendering, input modes) while delegating business logic and provider access back to `owlen-core`.
|
||||
- `owlen-cli`: Small entry point that parses command-line options, resolves configuration, selects providers, and launches either the TUI or headless agent flows by calling into `owlen-core`.
|
||||
- `owlen-mcp-llm-server`: Runs concrete providers (e.g., Ollama) behind an MCP boundary, exposing them as `generate_text` tools. This crate owns provider-specific wiring and process sandboxing.
|
||||
- `owlen-mcp-server`: Generic MCP server for file operations and resource management.
|
||||
- `owlen-ollama`: Direct Ollama provider implementation (legacy, used only by MCP servers).
|
||||
- `owlen-mcp-llm-server`: Runs concrete providers (e.g., Ollama Local, Ollama Cloud) behind an MCP boundary, exposing them as `generate_text` tools. This crate owns provider-specific wiring and process sandboxing.
|
||||
- `owlen-mcp-server`: Generic MCP server for file operations, resource projection, and other non-LLM tools.
|
||||
- `owlen-providers`: Houses concrete provider adapters (today: Ollama local + cloud) that the MCP servers embed.
|
||||
|
||||
### Boundary Guidelines
|
||||
|
||||
@@ -47,6 +47,46 @@ A simplified diagram of how components interact:
|
||||
- **owlen-cli**: Only orchestrates startup/shutdown. Avoid adding business logic; when a new command needs behaviour, implement it in `owlen-core` or another library crate and invoke it from the CLI.
|
||||
- **owlen-mcp-llm-server**: The only crate that should directly talk to Ollama (or other provider processes). TUI/CLI code communicates with providers exclusively through MCP clients in `owlen-core`.
|
||||
|
||||
## Provider Boundaries & MCP Topology
|
||||
|
||||
Owlen’s runtime is intentionally layered so that user interfaces never couple to provider-specific code. The flow can be visualised as:
|
||||
|
||||
```
|
||||
[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::ollama::* │
|
||||
│ crates/mcp/server ──> filesystem & workspace tools │
|
||||
│ crates/mcp/prompt-server ─> template rendering helpers │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **ProviderManager (owlen-core)** keeps the registry of `ModelProvider` implementations, merges model catalogues, and caches health. Local Ollama and Cloud Ollama appear as separate providers whose metadata is merged for the UI.
|
||||
- **RemoteMcpClient (owlen-core)** is the default `ModelProvider`. It implements both the MCP client traits and the `ModelProvider` interface, allowing it to bridge chat streams back into the ProviderManager without exposing transport details.
|
||||
- **MCP servers (crates/mcp/\*)** are short-lived binaries with narrowly scoped responsibilities:
|
||||
- `crates/mcp/llm-server` wraps `owlen-providers::ollama` backends and exposes `generate_text` / `list_models`.
|
||||
- `crates/mcp/server` offers tool calls (file reads/writes, search).
|
||||
- `crates/mcp/prompt-server` renders prompt templates.
|
||||
- **owlen-providers** contains the actual provider adapters (Ollama local & cloud today). MCP servers embed these adapters directly; nothing else should reach into them.
|
||||
|
||||
### Health & Model Discovery Flow
|
||||
|
||||
1. Frontends call `ProviderManager::list_all_models()`. The manager fans out health checks to each registered provider (including the MCP client) and collates their models into a single list tagged with scope (`Local`, `Cloud`, etc.).
|
||||
2. The TUI model picker (`owlen-tui/src/widgets/model_picker.rs`) reads those annotated entries to drive filters like **Local**, **Cloud**, and **Available**.
|
||||
3. When the user kicks off a chat, the TUI emits a request that flows through `Session::send_message`, which delegates to `ProviderManager::generate`. The selected provider (usually `RemoteMcpClient`) streams chunks back across the MCP transport and the manager updates health status based on success or failure.
|
||||
4. Tool invocations travel the same transport: the MCP client sends tool calls to `crates/mcp/server`, and responses surface as consent prompts or streamed completions in the UI.
|
||||
|
||||
## MCP Architecture (Phase 10)
|
||||
|
||||
As of Phase 10, OWLEN uses a **MCP-only architecture** where all LLM interactions go through the Model Context Protocol:
|
||||
|
||||
70
docs/repo-map.md
Normal file
70
docs/repo-map.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Repo Map
|
||||
|
||||
> Generated by `scripts/gen-repo-map.sh`. Regenerate whenever the workspace layout changes.
|
||||
|
||||
```text
|
||||
.
|
||||
├── crates
|
||||
│ ├── mcp
|
||||
│ │ ├── client
|
||||
│ │ ├── code-server
|
||||
│ │ ├── llm-server
|
||||
│ │ ├── prompt-server
|
||||
│ │ └── server
|
||||
│ ├── owlen-cli
|
||||
│ │ ├── src
|
||||
│ │ ├── tests
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ └── README.md
|
||||
│ ├── owlen-core
|
||||
│ │ ├── examples
|
||||
│ │ ├── migrations
|
||||
│ │ ├── src
|
||||
│ │ ├── tests
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ └── README.md
|
||||
│ ├── owlen-markdown
|
||||
│ │ ├── src
|
||||
│ │ └── Cargo.toml
|
||||
│ ├── owlen-providers
|
||||
│ │ ├── src
|
||||
│ │ ├── tests
|
||||
│ │ └── Cargo.toml
|
||||
│ ├── owlen-tui
|
||||
│ │ ├── src
|
||||
│ │ ├── tests
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ └── README.md
|
||||
│ └── providers
|
||||
│ └── experimental
|
||||
├── docs
|
||||
│ ├── migrations
|
||||
│ ├── CHANGELOG_v1.0.md
|
||||
│ ├── adding-providers.md
|
||||
│ ├── architecture.md
|
||||
│ ├── configuration.md
|
||||
│ ├── faq.md
|
||||
│ ├── migration-guide.md
|
||||
│ ├── phase5-mode-system.md
|
||||
│ ├── platform-support.md
|
||||
│ ├── provider-implementation.md
|
||||
│ ├── testing.md
|
||||
│ └── troubleshooting.md
|
||||
├── examples
|
||||
├── scripts
|
||||
│ ├── check-windows.sh
|
||||
│ └── gen-repo-map.sh
|
||||
├── AGENTS.md
|
||||
├── CHANGELOG.md
|
||||
├── CODE_OF_CONDUCT.md
|
||||
├── CONTRIBUTING.md
|
||||
├── Cargo.lock
|
||||
├── Cargo.toml
|
||||
├── LICENSE
|
||||
├── PKGBUILD
|
||||
├── README.md
|
||||
├── SECURITY.md
|
||||
└── config.toml
|
||||
|
||||
29 directories, 32 files
|
||||
```
|
||||
31
scripts/gen-repo-map.sh
Executable file
31
scripts/gen-repo-map.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
OUTPUT_PATH="${1:-${REPO_ROOT}/docs/repo-map.md}"
|
||||
|
||||
if ! command -v tree >/dev/null 2>&1; then
|
||||
echo "error: the 'tree' command is required to regenerate the repo map. Install it (e.g., 'sudo pacman -S tree') and re-run this script." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXCLUDES='target|\\.git|\\.github|node_modules|dist|images|themes|dev|\\.venv'
|
||||
|
||||
TMP_FILE="$(mktemp)"
|
||||
trap 'rm -f "${TMP_FILE}"' EXIT
|
||||
|
||||
pushd "${REPO_ROOT}" >/dev/null
|
||||
tree -a -L 2 --dirsfirst --prune -I "${EXCLUDES}" > "${TMP_FILE}"
|
||||
popd >/dev/null
|
||||
|
||||
{
|
||||
printf '# Repo Map\n\n'
|
||||
printf '> Generated by `scripts/gen-repo-map.sh`. Regenerate when the layout changes.\n\n'
|
||||
printf '```text\n'
|
||||
cat "${TMP_FILE}"
|
||||
printf '```\n'
|
||||
} > "${OUTPUT_PATH}"
|
||||
|
||||
echo "Repo map written to ${OUTPUT_PATH}"
|
||||
57
scripts/release-notes.sh
Executable file
57
scripts/release-notes.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
CHANGELOG="${REPO_ROOT}/CHANGELOG.md"
|
||||
|
||||
TAG="${1:-}"
|
||||
OUTPUT="${2:-}"
|
||||
|
||||
if [[ -z "${TAG}" ]]; then
|
||||
echo "usage: $0 <tag> [output-file]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="${TAG#v}"
|
||||
TAG="${TAG#V}"
|
||||
|
||||
if [[ ! -f "${CHANGELOG}" ]]; then
|
||||
echo "error: CHANGELOG.md not found at ${CHANGELOG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NOTES=$(TAG="${TAG}" CHANGELOG_PATH="${CHANGELOG}" python - <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
changelog_path = Path(os.environ['CHANGELOG_PATH'])
|
||||
tag = os.environ['TAG']
|
||||
text = changelog_path.read_text(encoding='utf-8')
|
||||
pattern = re.compile(rf'^## \[{re.escape(tag)}\]\s*(?:-.*)?$', re.MULTILINE)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
sys.stderr.write(f"No changelog section found for tag {tag}.\n")
|
||||
sys.exit(1)
|
||||
start = match.end()
|
||||
rest = text[start:]
|
||||
next_heading = re.search(r'^## \[', rest, re.MULTILINE)
|
||||
section = rest[:next_heading.start()] if next_heading else rest
|
||||
lines = [line.rstrip() for line in section.strip().splitlines()]
|
||||
print('\n'.join(lines))
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ -z "${NOTES}" ]]; then
|
||||
echo "error: no content generated for tag ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${OUTPUT}" ]]; then
|
||||
printf '%s\n' "${NOTES}" > "${OUTPUT}"
|
||||
else
|
||||
printf '%s\n' "${NOTES}"
|
||||
fi
|
||||
9
xtask/Cargo.toml
Normal file
9
xtask/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
162
xtask/src/main.rs
Normal file
162
xtask/src/main.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about = "Owlen developer tasks", long_about = None)]
|
||||
struct Xtask {
|
||||
#[command(subcommand)]
|
||||
command: Task,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Task {
|
||||
/// Format the workspace (use --check to verify without writing).
|
||||
Fmt {
|
||||
#[arg(long, help = "Run rustfmt in check mode")]
|
||||
check: bool,
|
||||
},
|
||||
/// Run clippy with all warnings elevated to errors.
|
||||
Lint,
|
||||
/// Execute the full workspace test suite.
|
||||
Test,
|
||||
/// Run coverage via cargo-llvm-cov (requires the tool to be installed).
|
||||
Coverage,
|
||||
/// Launch the default Owlen CLI binary (owlen) with optional args.
|
||||
DevRun {
|
||||
#[arg(last = true, help = "Arguments forwarded to `owlen`")]
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// Composite release validation (fmt --check, clippy, test).
|
||||
ReleaseCheck,
|
||||
/// Regenerate docs/repo-map.md (accepts optional output path).
|
||||
GenRepoMap {
|
||||
#[arg(long, value_name = "PATH", help = "Override the repo map output path")]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Xtask::parse();
|
||||
|
||||
match cli.command {
|
||||
Task::Fmt { check } => fmt(check),
|
||||
Task::Lint => lint(),
|
||||
Task::Test => test(),
|
||||
Task::Coverage => coverage(),
|
||||
Task::DevRun { args } => dev_run(args),
|
||||
Task::ReleaseCheck => release_check(),
|
||||
Task::GenRepoMap { output } => gen_repo_map(output),
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt(check: bool) -> Result<()> {
|
||||
let mut args = vec!["fmt".to_string(), "--all".to_string()];
|
||||
if check {
|
||||
args.push("--".to_string());
|
||||
args.push("--check".to_string());
|
||||
}
|
||||
run_cargo(args)
|
||||
}
|
||||
|
||||
fn lint() -> Result<()> {
|
||||
run_cargo(vec![
|
||||
"clippy".into(),
|
||||
"--workspace".into(),
|
||||
"--all-features".into(),
|
||||
"--".into(),
|
||||
"-D".into(),
|
||||
"warnings".into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn test() -> Result<()> {
|
||||
run_cargo(vec![
|
||||
"test".into(),
|
||||
"--workspace".into(),
|
||||
"--all-features".into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn coverage() -> Result<()> {
|
||||
run_cargo(vec![
|
||||
"llvm-cov".into(),
|
||||
"--workspace".into(),
|
||||
"--all-features".into(),
|
||||
"--summary-only".into(),
|
||||
])
|
||||
.with_context(|| "install `cargo llvm-cov` to use the coverage task".to_string())
|
||||
}
|
||||
|
||||
fn dev_run(args: Vec<String>) -> Result<()> {
|
||||
let mut command_args = vec![
|
||||
"run".into(),
|
||||
"-p".into(),
|
||||
"owlen-cli".into(),
|
||||
"--bin".into(),
|
||||
"owlen".into(),
|
||||
];
|
||||
if !args.is_empty() {
|
||||
command_args.push("--".into());
|
||||
command_args.extend(args);
|
||||
}
|
||||
run_cargo(command_args)
|
||||
}
|
||||
|
||||
fn release_check() -> Result<()> {
|
||||
fmt(true)?;
|
||||
lint()?;
|
||||
test()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gen_repo_map(output: Option<PathBuf>) -> Result<()> {
|
||||
let script = workspace_root().join("scripts/gen-repo-map.sh");
|
||||
if !script.exists() {
|
||||
bail!("repo map script not found at {}", script.display());
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&script);
|
||||
cmd.current_dir(workspace_root());
|
||||
if let Some(path) = output {
|
||||
cmd.arg(path);
|
||||
}
|
||||
let status = cmd
|
||||
.status()
|
||||
.with_context(|| format!("failed to run {}", script.display()))?;
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"{} exited with status {}",
|
||||
script.display(),
|
||||
status.code().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_cargo(args: Vec<String>) -> Result<()> {
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(workspace_root());
|
||||
cmd.args(&args);
|
||||
|
||||
let status = cmd
|
||||
.status()
|
||||
.with_context(|| format!("failed to run cargo {}", args.join(" ")))?;
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"`cargo {}` exited with status {}",
|
||||
args.join(" "),
|
||||
status.code().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("xtask has a parent directory")
|
||||
.to_path_buf()
|
||||
}
|
||||
Reference in New Issue
Block a user