diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c5077..32baea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Startup provider health check with actionable hints when Ollama or remote MCP servers are unavailable. - `dev/check-windows.sh` helper script for on-demand Windows cross-checks. - Global F1 keybinding for the in-app help overlay and a clearer status hint on launch. +- Automatic fallback to the new `ansi_basic` theme when the active terminal only advertises 16-color support. +- Offline provider shim that keeps the TUI usable while primary providers are unreachable and communicates recovery steps inline. ### Changed - The main `README.md` has been updated to be more concise and link to the new documentation. @@ -25,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configuration loading performs structural validation and fails fast on missing default providers or invalid MCP definitions. - Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures. - `owlen` warns when the active terminal likely lacks 256-color support. +- `config.toml` now carries a schema version (`1.1.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures. --- diff --git a/crates/owlen-cli/Cargo.toml b/crates/owlen-cli/Cargo.toml index 4e815af..128e6cf 100644 --- a/crates/owlen-cli/Cargo.toml +++ b/crates/owlen-cli/Cargo.toml @@ -28,6 +28,8 @@ owlen-core = { path = "../owlen-core" } owlen-tui = { path = "../owlen-tui", optional = true } owlen-ollama = { path = "../owlen-ollama" } log = { workspace = true } +async-trait = { workspace = true } +futures = { workspace = true } # CLI framework clap = { workspace = true, features = ["derive"] } diff --git a/crates/owlen-cli/src/main.rs b/crates/owlen-cli/src/main.rs index f919d1b..445ff18 100644 --- a/crates/owlen-cli/src/main.rs +++ b/crates/owlen-cli/src/main.rs @@ -1,19 +1,23 @@ //! OWLEN CLI - Chat TUI client -use anyhow::Result; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; use clap::{Parser, Subcommand}; use owlen_core::config as core_config; use owlen_core::{ config::{Config, McpMode}, mcp::remote_client::RemoteMcpClient, mode::Mode, + provider::ChatStream, session::SessionController, storage::StorageManager, - Provider, + types::{ChatRequest, ChatResponse, Message, ModelInfo}, + Error, Provider, }; use owlen_ollama::OllamaProvider; use owlen_tui::tui_controller::{TuiController, TuiRequest}; use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent}; +use std::borrow::Cow; use std::io; use std::sync::Arc; use tokio::sync::mpsc; @@ -24,6 +28,7 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use futures::stream; use ratatui::{prelude::CrosstermBackend, Terminal}; /// Owlen - Terminal UI for LLM chat @@ -209,23 +214,113 @@ fn run_config_doctor() -> Result<()> { Ok(()) } -fn warn_if_limited_terminal() { - const FALLBACK_TERM: &str = "unknown"; - let term = std::env::var("TERM").unwrap_or_else(|_| FALLBACK_TERM.to_string()); +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_256 = term_lower.contains("256color") + let supports_extended = term_lower.contains("256color") || color_lower.contains("truecolor") - || color_lower.contains("24bit"); + || color_lower.contains("24bit") + || color_lower.contains("fullcolor"); - if !supports_256 { - eprintln!( - "Warning: terminal '{}' may not fully support 256-color themes. \ - Consider using a terminal with truecolor support for the best experience.", - term + if supports_extended { + TerminalColorSupport::Full + } else { + TerminalColorSupport::Limited { term } + } +} + +fn apply_terminal_theme(cfg: &mut Config, support: &TerminalColorSupport) -> Option { + 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, 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 chat(&self, request: ChatRequest) -> Result { + Ok(self.friendly_response(&request.model)) + } + + async fn chat_stream(&self, request: ChatRequest) -> Result { + 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" + ))) } } @@ -241,38 +336,66 @@ async fn main() -> Result<()> { // Set auto-consent for TUI mode to prevent blocking stdin reads std::env::set_var("OWLEN_AUTO_CONSENT", "1"); - warn_if_limited_terminal(); - - let (tui_tx, _tui_rx) = mpsc::unbounded_channel::(); - let tui_controller = Arc::new(TuiController::new(tui_tx)); - + 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(); // Disable encryption for CLI to avoid password prompts in this environment. cfg.privacy.encrypt_local_data = false; + 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 (tui_tx, _tui_rx) = mpsc::unbounded_channel::(); + let tui_controller = Arc::new(TuiController::new(tui_tx)); + // Create provider according to MCP configuration (supports legacy/local fallback) let provider = build_provider(&cfg)?; - - if let Err(err) = provider.health_check().await { - let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly) - && !cfg.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." - }; - return Err(anyhow::anyhow!(format!( - "Provider health check failed: {err}. {hint}" - ))); - } + let mut offline_notice: Option = None; + let provider = match provider.health_check().await { + Ok(_) => provider, + Err(err) => { + let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly) + && !cfg.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 + } + }; let storage = Arc::new(StorageManager::new().await?); let controller = SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?; 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; diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 9dc6583..3146a63 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -10,9 +10,15 @@ use std::time::Duration; /// Default location for the OWLEN configuration file pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; +/// Current schema version written to `config.toml`. +pub const CONFIG_SCHEMA_VERSION: &str = "1.1.0"; + /// Core configuration shared by all OWLEN clients #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { + /// Schema version for on-disk configuration files + #[serde(default = "Config::default_schema_version")] + pub schema_version: String, /// General application settings pub general: GeneralSettings, /// MCP (Multi-Client-Provider) settings @@ -57,6 +63,7 @@ impl Default for Config { ); Self { + schema_version: Self::default_schema_version(), general: GeneralSettings::default(), mcp: McpSettings::default(), providers, @@ -97,6 +104,10 @@ impl McpServerConfig { } impl Config { + fn default_schema_version() -> String { + CONFIG_SCHEMA_VERSION.to_string() + } + /// Load configuration from disk, falling back to defaults when missing pub fn load(path: Option<&Path>) -> Result { let path = match path { @@ -106,10 +117,27 @@ impl Config { if path.exists() { let content = fs::read_to_string(&path)?; - let mut config: Config = + let parsed: toml::Value = toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))?; + let previous_version = parsed + .get("schema_version") + .and_then(|value| value.as_str()) + .unwrap_or("0.0.0") + .to_string(); + if let Some(agent_table) = parsed.get("agent").and_then(|value| value.as_table()) { + if agent_table.contains_key("max_tool_calls") { + log::warn!( + "Configuration option agent.max_tool_calls is deprecated and ignored. \ + The agent now uses agent.max_iterations." + ); + } + } + let mut config: Config = parsed + .try_into() + .map_err(|e: toml::de::Error| crate::Error::Config(e.to_string()))?; config.ensure_defaults(); config.mcp.apply_backward_compat(); + config.apply_schema_migrations(&previous_version); config.validate()?; Ok(config) } else { @@ -130,8 +158,10 @@ impl Config { fs::create_dir_all(dir)?; } + let mut snapshot = self.clone(); + snapshot.schema_version = Config::default_schema_version(); let content = - toml::to_string_pretty(self).map_err(|e| crate::Error::Config(e.to_string()))?; + toml::to_string_pretty(&snapshot).map_err(|e| crate::Error::Config(e.to_string()))?; fs::write(path, content)?; Ok(()) } @@ -171,6 +201,9 @@ impl Config { ensure_provider_config(self, "ollama"); ensure_provider_config(self, "ollama-cloud"); + if self.schema_version.is_empty() { + self.schema_version = Self::default_schema_version(); + } } /// Validate configuration invariants and surface actionable error messages. @@ -181,6 +214,17 @@ impl Config { Ok(()) } + fn apply_schema_migrations(&mut self, previous_version: &str) { + if previous_version != CONFIG_SCHEMA_VERSION { + log::info!( + "Upgrading configuration schema from '{}' to '{}'", + previous_version, + CONFIG_SCHEMA_VERSION + ); + } + self.schema_version = CONFIG_SCHEMA_VERSION.to_string(); + } + fn validate_default_provider(&self) -> Result<()> { if self.general.default_provider.trim().is_empty() { return Err(crate::Error::Config( diff --git a/crates/owlen-core/src/theme.rs b/crates/owlen-core/src/theme.rs index 0ec51ee..eac1b15 100644 --- a/crates/owlen-core/src/theme.rs +++ b/crates/owlen-core/src/theme.rs @@ -209,6 +209,10 @@ pub fn built_in_themes() -> HashMap { "default_light", include_str!("../../../themes/default_light.toml"), ), + ( + "ansi_basic", + include_str!("../../../themes/ansi-basic.toml"), + ), ("gruvbox", include_str!("../../../themes/gruvbox.toml")), ("dracula", include_str!("../../../themes/dracula.toml")), ("solarized", include_str!("../../../themes/solarized.toml")), diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index b2d728e..14fb871 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -314,6 +314,11 @@ impl ChatApp { // Mode switching is handled by the SessionController's tool filtering } + /// Override the status line with a custom message. + pub fn set_status_message>(&mut self, status: S) { + self.status = status.into(); + } + pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] { &self.model_selector_items } diff --git a/docs/architecture.md b/docs/architecture.md index 2b9573b..567ed62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,6 +86,18 @@ let config = McpServerConfig { let client = RemoteMcpClient::new_with_config(&config)?; ``` +## Vim Mode State Machine + +The TUI follows a Vim-inspired modal workflow. Maintaining the transitions keeps keyboard handling predictable: + +- **Normal → Insert**: triggered by keys such as `i`, `a`, or `o`; pressing `Esc` returns to Normal. +- **Normal → Visual**: `v` enters visual selection; `Esc` or completing a selection returns to Normal. +- **Normal → Command**: `:` opens command mode; executing a command or cancelling with `Esc` returns to Normal. +- **Normal → Auxiliary modes**: `?` (help), `:provider`, `:model`, and similar commands open transient overlays that always exit back to Normal once dismissed. +- **Insert/Visual/Command → Normal**: pressing `Esc` always restores the neutral state. + +The status line shows the active mode (for example, “Normal mode • Press F1 for help”), which doubles as a quick regression check during manual testing. + ## Session Management The session management system is responsible for tracking the state of a conversation. The two main structs are: diff --git a/themes/ansi-basic.toml b/themes/ansi-basic.toml new file mode 100644 index 0000000..b2870a7 --- /dev/null +++ b/themes/ansi-basic.toml @@ -0,0 +1,24 @@ +name = "ansi_basic" +text = "white" +background = "black" +focused_panel_border = "cyan" +unfocused_panel_border = "darkgray" +user_message_role = "cyan" +assistant_message_role = "yellow" +tool_output = "white" +thinking_panel_title = "magenta" +command_bar_background = "black" +status_background = "black" +mode_normal = "green" +mode_editing = "yellow" +mode_model_selection = "cyan" +mode_provider_selection = "magenta" +mode_help = "white" +mode_visual = "blue" +mode_command = "yellow" +selection_bg = "blue" +selection_fg = "white" +cursor = "white" +placeholder = "darkgray" +error = "red" +info = "green"