feat(cli): add ansi_basic theme fallback and offline provider shim for limited‑color terminals
- Detect terminal color support and automatically switch to the new `ansi_basic` theme when only 16‑color support is available. - Introduce `OfflineProvider` that supplies a placeholder model and friendly messages when no providers are reachable, keeping the TUI usable. - Add `CONFIG_SCHEMA_VERSION` (`1.1.0`) with schema migration logic and default handling in `Config`. - Update configuration saving to persist the schema version and ensure defaults. - Register the `ansi_basic` theme in `theme.rs`. - Extend `ChatApp` with `set_status_message` to display custom status lines. - Update documentation (architecture, Vim mode state machine) to reflect new behavior. - Add async‑trait and futures dependencies required for the offline provider implementation.
This commit is contained in:
@@ -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.
|
- 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.
|
- `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.
|
- 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
|
### Changed
|
||||||
- The main `README.md` has been updated to be more concise and link to the new documentation.
|
- 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.
|
- 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.
|
- Ollama provider error handling now distinguishes timeouts, missing models, and authentication failures.
|
||||||
- `owlen` warns when the active terminal likely lacks 256-color support.
|
- `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ owlen-core = { path = "../owlen-core" }
|
|||||||
owlen-tui = { path = "../owlen-tui", optional = true }
|
owlen-tui = { path = "../owlen-tui", optional = true }
|
||||||
owlen-ollama = { path = "../owlen-ollama" }
|
owlen-ollama = { path = "../owlen-ollama" }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|
||||||
# CLI framework
|
# CLI framework
|
||||||
clap = { workspace = true, features = ["derive"] }
|
clap = { workspace = true, features = ["derive"] }
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
//! OWLEN CLI - Chat TUI client
|
//! OWLEN CLI - Chat TUI client
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use owlen_core::config as core_config;
|
use owlen_core::config as core_config;
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
config::{Config, McpMode},
|
config::{Config, McpMode},
|
||||||
mcp::remote_client::RemoteMcpClient,
|
mcp::remote_client::RemoteMcpClient,
|
||||||
mode::Mode,
|
mode::Mode,
|
||||||
|
provider::ChatStream,
|
||||||
session::SessionController,
|
session::SessionController,
|
||||||
storage::StorageManager,
|
storage::StorageManager,
|
||||||
Provider,
|
types::{ChatRequest, ChatResponse, Message, ModelInfo},
|
||||||
|
Error, Provider,
|
||||||
};
|
};
|
||||||
use owlen_ollama::OllamaProvider;
|
use owlen_ollama::OllamaProvider;
|
||||||
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
||||||
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -24,6 +28,7 @@ use crossterm::{
|
|||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
|
use futures::stream;
|
||||||
use ratatui::{prelude::CrosstermBackend, Terminal};
|
use ratatui::{prelude::CrosstermBackend, Terminal};
|
||||||
|
|
||||||
/// Owlen - Terminal UI for LLM chat
|
/// Owlen - Terminal UI for LLM chat
|
||||||
@@ -209,23 +214,113 @@ fn run_config_doctor() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn warn_if_limited_terminal() {
|
const BASIC_THEME_NAME: &str = "ansi_basic";
|
||||||
const FALLBACK_TERM: &str = "unknown";
|
|
||||||
let term = std::env::var("TERM").unwrap_or_else(|_| FALLBACK_TERM.to_string());
|
#[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 colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
||||||
let term_lower = term.to_lowercase();
|
let term_lower = term.to_lowercase();
|
||||||
let color_lower = colorterm.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("truecolor")
|
||||||
|| color_lower.contains("24bit");
|
|| color_lower.contains("24bit")
|
||||||
|
|| color_lower.contains("fullcolor");
|
||||||
|
|
||||||
if !supports_256 {
|
if supports_extended {
|
||||||
eprintln!(
|
TerminalColorSupport::Full
|
||||||
"Warning: terminal '{}' may not fully support 256-color themes. \
|
} else {
|
||||||
Consider using a terminal with truecolor support for the best experience.",
|
TerminalColorSupport::Limited { term }
|
||||||
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 chat(&self, request: ChatRequest) -> Result<ChatResponse, Error> {
|
||||||
|
Ok(self.friendly_response(&request.model))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_stream(&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"
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,38 +336,66 @@ async fn main() -> Result<()> {
|
|||||||
// Set auto-consent for TUI mode to prevent blocking stdin reads
|
// Set auto-consent for TUI mode to prevent blocking stdin reads
|
||||||
std::env::set_var("OWLEN_AUTO_CONSENT", "1");
|
std::env::set_var("OWLEN_AUTO_CONSENT", "1");
|
||||||
|
|
||||||
warn_if_limited_terminal();
|
let color_support = detect_terminal_color_support();
|
||||||
|
|
||||||
let (tui_tx, _tui_rx) = mpsc::unbounded_channel::<TuiRequest>();
|
|
||||||
let tui_controller = Arc::new(TuiController::new(tui_tx));
|
|
||||||
|
|
||||||
// Load configuration (or fall back to defaults) for the session controller.
|
// Load configuration (or fall back to defaults) for the session controller.
|
||||||
let mut cfg = config::try_load_config().unwrap_or_default();
|
let mut cfg = config::try_load_config().unwrap_or_default();
|
||||||
// Disable encryption for CLI to avoid password prompts in this environment.
|
// Disable encryption for CLI to avoid password prompts in this environment.
|
||||||
cfg.privacy.encrypt_local_data = false;
|
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()?;
|
cfg.validate()?;
|
||||||
|
|
||||||
|
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)
|
// Create provider according to MCP configuration (supports legacy/local fallback)
|
||||||
let provider = build_provider(&cfg)?;
|
let provider = build_provider(&cfg)?;
|
||||||
|
let mut offline_notice: Option<String> = None;
|
||||||
if let Err(err) = provider.health_check().await {
|
let provider = match provider.health_check().await {
|
||||||
let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly)
|
Ok(_) => provider,
|
||||||
&& !cfg.mcp_servers.is_empty()
|
Err(err) => {
|
||||||
{
|
let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly)
|
||||||
"Ensure the configured MCP server is running and reachable."
|
&& !cfg.mcp_servers.is_empty()
|
||||||
} else {
|
{
|
||||||
"Ensure Ollama is running (`ollama serve`) and reachable at the configured base_url."
|
"Ensure the configured MCP server is running and reachable."
|
||||||
};
|
} else {
|
||||||
return Err(anyhow::anyhow!(format!(
|
"Ensure Ollama is running (`ollama serve`) and reachable at the configured base_url."
|
||||||
"Provider health check failed: {err}. {hint}"
|
};
|
||||||
)));
|
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 storage = Arc::new(StorageManager::new().await?);
|
let storage = Arc::new(StorageManager::new().await?);
|
||||||
let controller =
|
let controller =
|
||||||
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
||||||
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
||||||
app.initialize_models().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
|
// Set the initial mode
|
||||||
app.set_mode(initial_mode).await;
|
app.set_mode(initial_mode).await;
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ use std::time::Duration;
|
|||||||
/// Default location for the OWLEN configuration file
|
/// Default location for the OWLEN configuration file
|
||||||
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
|
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
|
/// Core configuration shared by all OWLEN clients
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
/// Schema version for on-disk configuration files
|
||||||
|
#[serde(default = "Config::default_schema_version")]
|
||||||
|
pub schema_version: String,
|
||||||
/// General application settings
|
/// General application settings
|
||||||
pub general: GeneralSettings,
|
pub general: GeneralSettings,
|
||||||
/// MCP (Multi-Client-Provider) settings
|
/// MCP (Multi-Client-Provider) settings
|
||||||
@@ -57,6 +63,7 @@ impl Default for Config {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
schema_version: Self::default_schema_version(),
|
||||||
general: GeneralSettings::default(),
|
general: GeneralSettings::default(),
|
||||||
mcp: McpSettings::default(),
|
mcp: McpSettings::default(),
|
||||||
providers,
|
providers,
|
||||||
@@ -97,6 +104,10 @@ impl McpServerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
fn default_schema_version() -> String {
|
||||||
|
CONFIG_SCHEMA_VERSION.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Load configuration from disk, falling back to defaults when missing
|
/// Load configuration from disk, falling back to defaults when missing
|
||||||
pub fn load(path: Option<&Path>) -> Result<Self> {
|
pub fn load(path: Option<&Path>) -> Result<Self> {
|
||||||
let path = match path {
|
let path = match path {
|
||||||
@@ -106,10 +117,27 @@ impl Config {
|
|||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let content = fs::read_to_string(&path)?;
|
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()))?;
|
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.ensure_defaults();
|
||||||
config.mcp.apply_backward_compat();
|
config.mcp.apply_backward_compat();
|
||||||
|
config.apply_schema_migrations(&previous_version);
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
} else {
|
} else {
|
||||||
@@ -130,8 +158,10 @@ impl Config {
|
|||||||
fs::create_dir_all(dir)?;
|
fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut snapshot = self.clone();
|
||||||
|
snapshot.schema_version = Config::default_schema_version();
|
||||||
let content =
|
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)?;
|
fs::write(path, content)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -171,6 +201,9 @@ impl Config {
|
|||||||
|
|
||||||
ensure_provider_config(self, "ollama");
|
ensure_provider_config(self, "ollama");
|
||||||
ensure_provider_config(self, "ollama-cloud");
|
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.
|
/// Validate configuration invariants and surface actionable error messages.
|
||||||
@@ -181,6 +214,17 @@ impl Config {
|
|||||||
Ok(())
|
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<()> {
|
fn validate_default_provider(&self) -> Result<()> {
|
||||||
if self.general.default_provider.trim().is_empty() {
|
if self.general.default_provider.trim().is_empty() {
|
||||||
return Err(crate::Error::Config(
|
return Err(crate::Error::Config(
|
||||||
|
|||||||
@@ -209,6 +209,10 @@ pub fn built_in_themes() -> HashMap<String, Theme> {
|
|||||||
"default_light",
|
"default_light",
|
||||||
include_str!("../../../themes/default_light.toml"),
|
include_str!("../../../themes/default_light.toml"),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"ansi_basic",
|
||||||
|
include_str!("../../../themes/ansi-basic.toml"),
|
||||||
|
),
|
||||||
("gruvbox", include_str!("../../../themes/gruvbox.toml")),
|
("gruvbox", include_str!("../../../themes/gruvbox.toml")),
|
||||||
("dracula", include_str!("../../../themes/dracula.toml")),
|
("dracula", include_str!("../../../themes/dracula.toml")),
|
||||||
("solarized", include_str!("../../../themes/solarized.toml")),
|
("solarized", include_str!("../../../themes/solarized.toml")),
|
||||||
|
|||||||
@@ -314,6 +314,11 @@ impl ChatApp {
|
|||||||
// Mode switching is handled by the SessionController's tool filtering
|
// Mode switching is handled by the SessionController's tool filtering
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Override the status line with a custom message.
|
||||||
|
pub fn set_status_message<S: Into<String>>(&mut self, status: S) {
|
||||||
|
self.status = status.into();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] {
|
pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] {
|
||||||
&self.model_selector_items
|
&self.model_selector_items
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ let config = McpServerConfig {
|
|||||||
let client = RemoteMcpClient::new_with_config(&config)?;
|
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
|
## Session Management
|
||||||
|
|
||||||
The session management system is responsible for tracking the state of a conversation. The two main structs are:
|
The session management system is responsible for tracking the state of a conversation. The two main structs are:
|
||||||
|
|||||||
24
themes/ansi-basic.toml
Normal file
24
themes/ansi-basic.toml
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user