diff --git a/Cargo.toml b/Cargo.toml index 5770571..5f9cdfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ exclude = [] [workspace.package] -version = "0.1.8" +version = "0.1.9" edition = "2021" authors = ["Owlibou"] license = "AGPL-3.0" diff --git a/PKGBUILD b/PKGBUILD index 7dd0d77..127a54a 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: vikingowl pkgname=owlen -pkgver=0.1.8 +pkgver=0.1.9 pkgrel=1 pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features" arch=('x86_64') @@ -40,5 +40,11 @@ package() { # Install documentation install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" + + # Install built-in themes for reference + install -Dm644 themes/README.md "$pkgdir/usr/share/$pkgname/themes/README.md" + for theme in themes/*.toml; do + install -Dm644 "$theme" "$pkgdir/usr/share/$pkgname/themes/$(basename $theme)" + done } diff --git a/README.md b/README.md index 9887769..7ff9b22 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ > Terminal-native assistant for running local language models with a comfortable TUI. ![Status](https://img.shields.io/badge/status-alpha-yellow) -![Version](https://img.shields.io/badge/version-0.1.8-blue) +![Version](https://img.shields.io/badge/version-0.1.9-blue) ![Rust](https://img.shields.io/badge/made_with-Rust-ffc832?logo=rust&logoColor=white) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue) ## Alpha Status -- This project is currently in **alpha** (v0.1.8) and under active development. +- This project is currently in **alpha** (v0.1.9) and under active development. - Core features are functional but expect occasional bugs and missing polish. - Breaking changes may occur between releases as we refine the API. - Feedback, bug reports, and contributions are very welcome! @@ -48,6 +48,7 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig - **Session Management** - Start new conversations, clear history, browse saved sessions - **Thinking Mode Support** - Dedicated panel for extended reasoning content - **Bracketed Paste** - Safe paste handling for multi-line content +- **Theming System** - 10 built-in themes plus custom theme support ### Code Client (`owlen-code`) [Experimental] - All chat client features @@ -140,14 +141,17 @@ cargo build --release --bin owlen-code --features code-client - `Enter` - Send message and return to normal mode - `Ctrl-J` / `Shift-Enter` - Insert newline - `Ctrl-↑/↓` - Navigate input history +- `Ctrl-A` / `Ctrl-E` - Jump to start/end of line +- `Ctrl-W` / `Ctrl-B` - Word movement +- `Ctrl-R` - Redo - Paste events handled automatically **Visual Mode**: - `j/k/h/l` - Extend selection - `w/b/e` - Word-based selection - `y` - Yank (copy) selection -- `d` - Cut selection (Input panel only) -- `Esc` - Cancel selection +- `d` / `Delete` - Cut selection (Input panel only) +- `Esc` / `v` - Cancel selection **Command Mode**: - `Tab` - Autocomplete selected command suggestion @@ -160,8 +164,17 @@ cargo build --release --bin owlen-code --features code-client - `:save [name]` / `:w [name]` - Save current conversation - `:load` / `:open` - Browse and load saved sessions - `:sessions` / `:ls` - List saved sessions +- `:theme ` - Switch theme (saved to config) +- `:themes` - Browse themes in interactive modal +- `:reload` - Reload configuration and themes - *Commands show real-time suggestions as you type* +**Theme Browser** (accessed via `:themes`): +- `j` / `k` / `↑` / `↓` - Navigate themes +- `Enter` - Apply selected theme +- `g` / `G` - Jump to top/bottom +- `Esc` / `q` - Close browser + **Session Browser** (accessed via `:load` or `:sessions`): - `j` / `k` / `↑` / `↓` - Navigate sessions - `Enter` - Load selected session @@ -210,15 +223,63 @@ generate_descriptions = true # AI-generated summaries for saved sessions Configuration is automatically saved when you change models or providers. +### Theming + +OWLEN includes 10 built-in themes that are embedded in the binary. You can also create custom themes. + +**Built-in themes:** +- `default_dark` (default) - High-contrast dark theme +- `default_light` - Clean light theme +- `gruvbox` - Retro warm color scheme +- `dracula` - Vibrant purple and cyan +- `solarized` - Precision colors for readability +- `midnight-ocean` - Deep blue oceanic theme +- `rose-pine` - Soho vibes with muted pastels +- `monokai` - Classic code editor theme +- `material-dark` - Google's Material Design dark variant +- `material-light` - Google's Material Design light variant + +**Commands:** +- `:theme ` - Switch theme instantly (automatically saved to config) +- `:themes` - Browse and select themes in an interactive modal +- `:reload` - Reload configuration and themes + +**Setting default theme:** +```toml +[ui] +theme = "gruvbox" # or any built-in/custom theme name +``` + +**Creating custom themes:** + +Create a `.toml` file in `~/.config/owlen/themes/`: + +```toml +# ~/.config/owlen/themes/my-theme.toml +name = "my-theme" +text = "#ffffff" +background = "#000000" +focused_panel_border = "#ff00ff" +unfocused_panel_border = "#800080" +user_message_role = "#00ffff" +assistant_message_role = "#ffff00" +# ... see themes/README.md for full schema +``` + +**Colors** can be hex RGB (`#rrggbb`) or named colors (`red`, `blue`, `lightgreen`, etc.). See `themes/README.md` for the complete list of supported color names. + +For reference theme files and detailed documentation, see the `themes/` directory in the repository or `/usr/share/owlen/themes/` after installation. + ## Repository Layout ``` owlen/ ├── crates/ -│ ├── owlen-core/ # Core types, session management, shared UI components +│ ├── owlen-core/ # Core types, session management, theming, shared UI components │ ├── owlen-ollama/ # Ollama provider implementation │ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering) │ └── owlen-cli/ # Binary entry points (owlen, owlen-code) +├── themes/ # Built-in theme definitions (embedded in binary) ├── LICENSE # AGPL-3.0 License ├── Cargo.toml # Workspace configuration └── README.md @@ -270,9 +331,9 @@ cargo fmt - [x] Bracketed paste support - [x] Command autocompletion with Tab completion - [x] Session persistence (save/load conversations) +- [x] Theming system with 9 built-in themes and custom theme support ### In Progress -- [ ] Theming options and color customization - [ ] Enhanced configuration UX (in-app settings) - [ ] Conversation export (Markdown, JSON, plain text) @@ -322,4 +383,4 @@ Built with: --- -**Status**: Alpha v0.1.8 | **License**: AGPL-3.0 | **Made with Rust** 🦀 \ No newline at end of file +**Status**: Alpha v0.1.9 | **License**: AGPL-3.0 | **Made with Rust** 🦀 \ No newline at end of file diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index a1684c8..4eced3c 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -24,6 +24,7 @@ async-trait = "0.1.73" toml = "0.8.0" shellexpand = "3.1.0" dirs = "5.0" +ratatui = { workspace = true } [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index fe71e42..1d15aa9 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -202,7 +202,7 @@ pub struct UiSettings { impl UiSettings { fn default_theme() -> String { - "default".to_string() + "default_dark".to_string() } fn default_word_wrap() -> bool { @@ -388,7 +388,9 @@ mod tests { #[cfg(target_os = "macos")] { // macOS should use ~/Library/Application Support - assert!(path.to_string_lossy().contains("Library/Application Support")); + assert!(path + .to_string_lossy() + .contains("Library/Application Support")); } println!("Config conversation path: {}", path.display()); diff --git a/crates/owlen-core/src/conversation.rs b/crates/owlen-core/src/conversation.rs index 7888ff2..8f80806 100644 --- a/crates/owlen-core/src/conversation.rs +++ b/crates/owlen-core/src/conversation.rs @@ -277,20 +277,26 @@ impl ConversationManager { &self, storage: &StorageManager, name: Option, - description: Option + description: Option, ) -> Result { storage.save_conversation_with_description(&self.active, name, description) } /// Load a conversation from disk and make it active - pub fn load_from_disk(&mut self, storage: &StorageManager, path: impl AsRef) -> Result<()> { + pub fn load_from_disk( + &mut self, + storage: &StorageManager, + path: impl AsRef, + ) -> Result<()> { let conversation = storage.load_conversation(path)?; self.load(conversation); Ok(()) } /// List all saved sessions - pub fn list_saved_sessions(storage: &StorageManager) -> Result> { + pub fn list_saved_sessions( + storage: &StorageManager, + ) -> Result> { storage.list_sessions() } } diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs index a4f3779..c4ecc7f 100644 --- a/crates/owlen-core/src/lib.rs +++ b/crates/owlen-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod provider; pub mod router; pub mod session; pub mod storage; +pub mod theme; pub mod types; pub mod ui; pub mod wrap_cursor; @@ -24,6 +25,7 @@ pub use model::*; pub use provider::*; pub use router::*; pub use session::*; +pub use theme::*; /// Result type used throughout the OWLEN ecosystem pub type Result = std::result::Result; diff --git a/crates/owlen-core/src/session.rs b/crates/owlen-core/src/session.rs index c2d7916..aa9e919 100644 --- a/crates/owlen-core/src/session.rs +++ b/crates/owlen-core/src/session.rs @@ -231,7 +231,15 @@ impl SessionController { if conv.messages.len() == 1 { let first_msg = &conv.messages[0]; let preview = first_msg.content.chars().take(50).collect::(); - return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })); + return Ok(format!( + "{}{}", + preview, + if first_msg.content.len() > 50 { + "..." + } else { + "" + } + )); } // Build a summary prompt from the first few and last few messages @@ -240,7 +248,8 @@ impl SessionController { // Add system message to guide the description summary_messages.push(crate::types::Message::system( "Summarize this conversation in 1-2 short sentences (max 100 characters). \ - Focus on the main topic or question being discussed. Be concise and descriptive.".to_string() + Focus on the main topic or question being discussed. Be concise and descriptive." + .to_string(), )); // Include first message @@ -283,7 +292,15 @@ impl SessionController { if description.is_empty() { let first_msg = &conv.messages[0]; let preview = first_msg.content.chars().take(50).collect::(); - return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })); + return Ok(format!( + "{}{}", + preview, + if first_msg.content.len() > 50 { + "..." + } else { + "" + } + )); } // Truncate if too long @@ -298,7 +315,15 @@ impl SessionController { // Fallback to simple description if AI generation fails let first_msg = &conv.messages[0]; let preview = first_msg.content.chars().take(50).collect::(); - Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })) + Ok(format!( + "{}{}", + preview, + if first_msg.content.len() > 50 { + "..." + } else { + "" + } + )) } } } diff --git a/crates/owlen-core/src/storage.rs b/crates/owlen-core/src/storage.rs index 113573e..6e411ab 100644 --- a/crates/owlen-core/src/storage.rs +++ b/crates/owlen-core/src/storage.rs @@ -45,10 +45,7 @@ impl StorageManager { // Ensure the directory exists if !sessions_dir.exists() { fs::create_dir_all(&sessions_dir).map_err(|e| { - Error::Storage(format!( - "Failed to create sessions directory: {}", - e - )) + Error::Storage(format!("Failed to create sessions directory: {}", e)) })?; } @@ -66,7 +63,11 @@ impl StorageManager { } /// Save a conversation to disk - pub fn save_conversation(&self, conversation: &Conversation, name: Option) -> Result { + pub fn save_conversation( + &self, + conversation: &Conversation, + name: Option, + ) -> Result { self.save_conversation_with_description(conversation, name, None) } @@ -75,7 +76,7 @@ impl StorageManager { &self, conversation: &Conversation, name: Option, - description: Option + description: Option, ) -> Result { let filename = if let Some(ref session_name) = name { // Use provided name, sanitized @@ -101,26 +102,22 @@ impl StorageManager { save_conv.description = description; } - let json = serde_json::to_string_pretty(&save_conv).map_err(|e| { - Error::Storage(format!("Failed to serialize conversation: {}", e)) - })?; + let json = serde_json::to_string_pretty(&save_conv) + .map_err(|e| Error::Storage(format!("Failed to serialize conversation: {}", e)))?; - fs::write(&path, json).map_err(|e| { - Error::Storage(format!("Failed to write session file: {}", e)) - })?; + fs::write(&path, json) + .map_err(|e| Error::Storage(format!("Failed to write session file: {}", e)))?; Ok(path) } /// Load a conversation from disk pub fn load_conversation(&self, path: impl AsRef) -> Result { - let content = fs::read_to_string(path.as_ref()).map_err(|e| { - Error::Storage(format!("Failed to read session file: {}", e)) - })?; + let content = fs::read_to_string(path.as_ref()) + .map_err(|e| Error::Storage(format!("Failed to read session file: {}", e)))?; - let conversation: Conversation = serde_json::from_str(&content).map_err(|e| { - Error::Storage(format!("Failed to parse session file: {}", e)) - })?; + let conversation: Conversation = serde_json::from_str(&content) + .map_err(|e| Error::Storage(format!("Failed to parse session file: {}", e)))?; Ok(conversation) } @@ -129,14 +126,12 @@ impl StorageManager { pub fn list_sessions(&self) -> Result> { let mut sessions = Vec::new(); - let entries = fs::read_dir(&self.sessions_dir).map_err(|e| { - Error::Storage(format!("Failed to read sessions directory: {}", e)) - })?; + let entries = fs::read_dir(&self.sessions_dir) + .map_err(|e| Error::Storage(format!("Failed to read sessions directory: {}", e)))?; for entry in entries { - let entry = entry.map_err(|e| { - Error::Storage(format!("Failed to read directory entry: {}", e)) - })?; + let entry = entry + .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?; let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("json") { @@ -172,9 +167,8 @@ impl StorageManager { /// Delete a saved session pub fn delete_session(&self, path: impl AsRef) -> Result<()> { - fs::remove_file(path.as_ref()).map_err(|e| { - Error::Storage(format!("Failed to delete session file: {}", e)) - }) + fs::remove_file(path.as_ref()) + .map_err(|e| Error::Storage(format!("Failed to delete session file: {}", e))) } /// Get the sessions directory path @@ -237,7 +231,9 @@ mod tests { #[cfg(target_os = "macos")] { // macOS should use ~/Library/Application Support - assert!(path.to_string_lossy().contains("Library/Application Support")); + assert!(path + .to_string_lossy() + .contains("Library/Application Support")); } println!("Default sessions directory: {}", path.display()); @@ -257,10 +253,13 @@ mod tests { let mut conv = Conversation::new("test-model".to_string()); conv.messages.push(Message::user("Hello".to_string())); - conv.messages.push(Message::assistant("Hi there!".to_string())); + conv.messages + .push(Message::assistant("Hi there!".to_string())); // Save conversation - let path = storage.save_conversation(&conv, Some("test_session".to_string())).unwrap(); + let path = storage + .save_conversation(&conv, Some("test_session".to_string())) + .unwrap(); assert!(path.exists()); // Load conversation @@ -280,7 +279,9 @@ mod tests { for i in 0..3 { let mut conv = Conversation::new("test-model".to_string()); conv.messages.push(Message::user(format!("Message {}", i))); - storage.save_conversation(&conv, Some(format!("session_{}", i))).unwrap(); + storage + .save_conversation(&conv, Some(format!("session_{}", i))) + .unwrap(); } // List sessions diff --git a/crates/owlen-core/src/theme.rs b/crates/owlen-core/src/theme.rs new file mode 100644 index 0000000..aa9bd1f --- /dev/null +++ b/crates/owlen-core/src/theme.rs @@ -0,0 +1,645 @@ +//! Theming system for OWLEN TUI +//! +//! Provides customizable color schemes for all UI components. + +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// A complete theme definition for OWLEN TUI +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Theme { + /// Name of the theme + pub name: String, + + /// Default text color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub text: Color, + + /// Default background color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub background: Color, + + /// Border color for focused panels + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub focused_panel_border: Color, + + /// Border color for unfocused panels + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub unfocused_panel_border: Color, + + /// Color for user message role indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub user_message_role: Color, + + /// Color for assistant message role indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub assistant_message_role: Color, + + /// Color for thinking panel title + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub thinking_panel_title: Color, + + /// Background color for command bar + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub command_bar_background: Color, + + /// Status line background color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub status_background: Color, + + /// Color for Normal mode indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub mode_normal: Color, + + /// Color for Editing mode indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub mode_editing: Color, + + /// Color for Model Selection mode indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub mode_model_selection: Color, + + /// Color for Provider Selection mode indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub mode_provider_selection: Color, + + /// Color for Help mode indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub mode_help: Color, + + /// Color for Visual mode indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub mode_visual: Color, + + /// Color for Command mode indicator + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub mode_command: Color, + + /// Selection/highlight background color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub selection_bg: Color, + + /// Selection/highlight foreground color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub selection_fg: Color, + + /// Cursor indicator color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub cursor: Color, + + /// Placeholder text color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub placeholder: Color, + + /// Warning/error message color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub error: Color, + + /// Success/info message color + #[serde(deserialize_with = "deserialize_color")] + #[serde(serialize_with = "serialize_color")] + pub info: Color, +} + +impl Default for Theme { + fn default() -> Self { + default_dark() + } +} + +/// Get the default themes directory path +pub fn default_themes_dir() -> PathBuf { + let config_dir = PathBuf::from(shellexpand::tilde(crate::config::DEFAULT_CONFIG_PATH).as_ref()) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from("~/.config/owlen")); + + config_dir.join("themes") +} + +/// Load all available themes (built-in + custom) +pub fn load_all_themes() -> HashMap { + let mut themes = HashMap::new(); + + // Load built-in themes + for (name, theme) in built_in_themes() { + themes.insert(name, theme); + } + + // Load custom themes from disk + let themes_dir = default_themes_dir(); + if let Ok(entries) = fs::read_dir(&themes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("toml") { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + match load_theme_from_file(&path) { + Ok(theme) => { + themes.insert(name.clone(), theme); + } + Err(e) => { + eprintln!("Warning: Failed to load custom theme '{}': {}", name, e); + } + } + } + } + } + + themes +} + +/// Load a theme from a TOML file +pub fn load_theme_from_file(path: &Path) -> Result { + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read theme file: {}", e))?; + + toml::from_str(&content).map_err(|e| format!("Failed to parse theme file: {}", e)) +} + +/// Get a theme by name (built-in or custom) +pub fn get_theme(name: &str) -> Option { + load_all_themes().get(name).cloned() +} + +/// Get all built-in themes (embedded in the binary) +pub fn built_in_themes() -> HashMap { + let mut themes = HashMap::new(); + + // Load embedded theme files + let embedded_themes = [ + ( + "default_dark", + include_str!("../../../themes/default_dark.toml"), + ), + ( + "default_light", + include_str!("../../../themes/default_light.toml"), + ), + ("gruvbox", include_str!("../../../themes/gruvbox.toml")), + ("dracula", include_str!("../../../themes/dracula.toml")), + ("solarized", include_str!("../../../themes/solarized.toml")), + ( + "midnight-ocean", + include_str!("../../../themes/midnight-ocean.toml"), + ), + ("rose-pine", include_str!("../../../themes/rose-pine.toml")), + ("monokai", include_str!("../../../themes/monokai.toml")), + ( + "material-dark", + include_str!("../../../themes/material-dark.toml"), + ), + ( + "material-light", + include_str!("../../../themes/material-light.toml"), + ), + ]; + + for (name, content) in embedded_themes { + match toml::from_str::(content) { + Ok(theme) => { + themes.insert(name.to_string(), theme); + } + Err(e) => { + eprintln!("Warning: Failed to parse built-in theme '{}': {}", name, e); + // Fallback to hardcoded version if parsing fails + if let Some(fallback) = get_fallback_theme(name) { + themes.insert(name.to_string(), fallback); + } + } + } + } + + themes +} + +/// Get fallback hardcoded theme (used if embedded TOML fails to parse) +fn get_fallback_theme(name: &str) -> Option { + match name { + "default_dark" => Some(default_dark()), + "default_light" => Some(default_light()), + "gruvbox" => Some(gruvbox()), + "dracula" => Some(dracula()), + "solarized" => Some(solarized()), + "midnight-ocean" => Some(midnight_ocean()), + "rose-pine" => Some(rose_pine()), + "monokai" => Some(monokai()), + "material-dark" => Some(material_dark()), + "material-light" => Some(material_light()), + _ => None, + } +} + +/// Default dark theme +fn default_dark() -> Theme { + Theme { + name: "default_dark".to_string(), + text: Color::White, + background: Color::Black, + focused_panel_border: Color::LightMagenta, + unfocused_panel_border: Color::Rgb(95, 20, 135), + user_message_role: Color::LightBlue, + assistant_message_role: Color::Yellow, + thinking_panel_title: Color::LightMagenta, + command_bar_background: Color::Black, + status_background: Color::Black, + mode_normal: Color::LightBlue, + mode_editing: Color::LightGreen, + mode_model_selection: Color::LightYellow, + mode_provider_selection: Color::LightCyan, + mode_help: Color::LightMagenta, + mode_visual: Color::Magenta, + mode_command: Color::Yellow, + selection_bg: Color::LightBlue, + selection_fg: Color::Black, + cursor: Color::Magenta, + placeholder: Color::DarkGray, + error: Color::Red, + info: Color::LightGreen, + } +} + +/// Default light theme +fn default_light() -> Theme { + Theme { + name: "default_light".to_string(), + text: Color::Black, + background: Color::White, + focused_panel_border: Color::Rgb(74, 144, 226), + unfocused_panel_border: Color::Rgb(221, 221, 221), + user_message_role: Color::Rgb(0, 85, 164), + assistant_message_role: Color::Rgb(142, 68, 173), + thinking_panel_title: Color::Rgb(142, 68, 173), + command_bar_background: Color::White, + status_background: Color::White, + mode_normal: Color::Rgb(0, 85, 164), + mode_editing: Color::Rgb(46, 139, 87), + mode_model_selection: Color::Rgb(181, 137, 0), + mode_provider_selection: Color::Rgb(0, 139, 139), + mode_help: Color::Rgb(142, 68, 173), + mode_visual: Color::Rgb(142, 68, 173), + mode_command: Color::Rgb(181, 137, 0), + selection_bg: Color::Rgb(164, 200, 240), + selection_fg: Color::Black, + cursor: Color::Rgb(217, 95, 2), + placeholder: Color::Gray, + error: Color::Rgb(192, 57, 43), + info: Color::Green, + } +} + +/// Gruvbox theme +fn gruvbox() -> Theme { + Theme { + name: "gruvbox".to_string(), + text: Color::Rgb(235, 219, 178), // #ebdbb2 + background: Color::Rgb(40, 40, 40), // #282828 + focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange) + unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64 + user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green) + assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue) + thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple) + command_bar_background: Color::Rgb(60, 56, 54), // #3c3836 + status_background: Color::Rgb(60, 56, 54), + mode_normal: Color::Rgb(131, 165, 152), // blue + mode_editing: Color::Rgb(184, 187, 38), // green + mode_model_selection: Color::Rgb(250, 189, 47), // yellow + mode_provider_selection: Color::Rgb(142, 192, 124), // aqua + mode_help: Color::Rgb(211, 134, 155), // purple + mode_visual: Color::Rgb(254, 128, 25), // orange + mode_command: Color::Rgb(250, 189, 47), // yellow + selection_bg: Color::Rgb(80, 73, 69), + selection_fg: Color::Rgb(235, 219, 178), + cursor: Color::Rgb(254, 128, 25), + placeholder: Color::Rgb(102, 92, 84), + error: Color::Rgb(251, 73, 52), // #fb4934 + info: Color::Rgb(184, 187, 38), + } +} + +/// Dracula theme +fn dracula() -> Theme { + Theme { + name: "dracula".to_string(), + text: Color::Rgb(248, 248, 242), // #f8f8f2 + background: Color::Rgb(40, 42, 54), // #282a36 + focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink) + unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a + user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan) + assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink) + thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple) + command_bar_background: Color::Rgb(68, 71, 90), + status_background: Color::Rgb(68, 71, 90), + mode_normal: Color::Rgb(139, 233, 253), + mode_editing: Color::Rgb(80, 250, 123), // #50fa7b (green) + mode_model_selection: Color::Rgb(241, 250, 140), // #f1fa8c (yellow) + mode_provider_selection: Color::Rgb(139, 233, 253), + mode_help: Color::Rgb(189, 147, 249), + mode_visual: Color::Rgb(255, 121, 198), + mode_command: Color::Rgb(241, 250, 140), + selection_bg: Color::Rgb(68, 71, 90), + selection_fg: Color::Rgb(248, 248, 242), + cursor: Color::Rgb(255, 121, 198), + placeholder: Color::Rgb(98, 114, 164), + error: Color::Rgb(255, 85, 85), // #ff5555 + info: Color::Rgb(80, 250, 123), + } +} + +/// Solarized Dark theme +fn solarized() -> Theme { + Theme { + name: "solarized".to_string(), + text: Color::Rgb(131, 148, 150), // #839496 (base0) + background: Color::Rgb(0, 43, 54), // #002b36 (base03) + focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue) + unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02) + user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan) + assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange) + thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet) + command_bar_background: Color::Rgb(7, 54, 66), + status_background: Color::Rgb(7, 54, 66), + mode_normal: Color::Rgb(38, 139, 210), // blue + mode_editing: Color::Rgb(133, 153, 0), // #859900 (green) + mode_model_selection: Color::Rgb(181, 137, 0), // #b58900 (yellow) + mode_provider_selection: Color::Rgb(42, 161, 152), // cyan + mode_help: Color::Rgb(108, 113, 196), // violet + mode_visual: Color::Rgb(211, 54, 130), // #d33682 (magenta) + mode_command: Color::Rgb(181, 137, 0), // yellow + selection_bg: Color::Rgb(7, 54, 66), + selection_fg: Color::Rgb(147, 161, 161), + cursor: Color::Rgb(211, 54, 130), + placeholder: Color::Rgb(88, 110, 117), + error: Color::Rgb(220, 50, 47), // #dc322f (red) + info: Color::Rgb(133, 153, 0), + } +} + +/// Midnight Ocean theme +fn midnight_ocean() -> Theme { + Theme { + name: "midnight-ocean".to_string(), + text: Color::Rgb(192, 202, 245), + background: Color::Rgb(13, 17, 23), + focused_panel_border: Color::Rgb(88, 166, 255), + unfocused_panel_border: Color::Rgb(48, 54, 61), + user_message_role: Color::Rgb(121, 192, 255), + assistant_message_role: Color::Rgb(137, 221, 255), + thinking_panel_title: Color::Rgb(158, 206, 106), + command_bar_background: Color::Rgb(22, 27, 34), + status_background: Color::Rgb(22, 27, 34), + mode_normal: Color::Rgb(121, 192, 255), + mode_editing: Color::Rgb(158, 206, 106), + mode_model_selection: Color::Rgb(255, 212, 59), + mode_provider_selection: Color::Rgb(137, 221, 255), + mode_help: Color::Rgb(255, 115, 157), + mode_visual: Color::Rgb(246, 140, 245), + mode_command: Color::Rgb(255, 212, 59), + selection_bg: Color::Rgb(56, 139, 253), + selection_fg: Color::Rgb(13, 17, 23), + cursor: Color::Rgb(246, 140, 245), + placeholder: Color::Rgb(110, 118, 129), + error: Color::Rgb(248, 81, 73), + info: Color::Rgb(158, 206, 106), + } +} + +/// Rose Pine theme +fn rose_pine() -> Theme { + Theme { + name: "rose-pine".to_string(), + text: Color::Rgb(224, 222, 244), // #e0def4 + background: Color::Rgb(25, 23, 36), // #191724 + focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love) + unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a + user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam) + assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light) + thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris) + command_bar_background: Color::Rgb(38, 35, 58), + status_background: Color::Rgb(38, 35, 58), + mode_normal: Color::Rgb(156, 207, 216), + mode_editing: Color::Rgb(235, 188, 186), // #ebbcba (rose) + mode_model_selection: Color::Rgb(246, 193, 119), + mode_provider_selection: Color::Rgb(49, 116, 143), + mode_help: Color::Rgb(196, 167, 231), + mode_visual: Color::Rgb(235, 111, 146), + mode_command: Color::Rgb(246, 193, 119), + selection_bg: Color::Rgb(64, 61, 82), + selection_fg: Color::Rgb(224, 222, 244), + cursor: Color::Rgb(235, 111, 146), + placeholder: Color::Rgb(110, 106, 134), + error: Color::Rgb(235, 111, 146), + info: Color::Rgb(156, 207, 216), + } +} + +/// Monokai theme +fn monokai() -> Theme { + Theme { + name: "monokai".to_string(), + text: Color::Rgb(248, 248, 242), // #f8f8f2 + background: Color::Rgb(39, 40, 34), // #272822 + focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink) + unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e + user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan) + assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple) + thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow) + command_bar_background: Color::Rgb(39, 40, 34), + status_background: Color::Rgb(39, 40, 34), + mode_normal: Color::Rgb(102, 217, 239), + mode_editing: Color::Rgb(166, 226, 46), // #a6e22e (green) + mode_model_selection: Color::Rgb(230, 219, 116), + mode_provider_selection: Color::Rgb(102, 217, 239), + mode_help: Color::Rgb(174, 129, 255), + mode_visual: Color::Rgb(249, 38, 114), + mode_command: Color::Rgb(230, 219, 116), + selection_bg: Color::Rgb(117, 113, 94), + selection_fg: Color::Rgb(248, 248, 242), + cursor: Color::Rgb(249, 38, 114), + placeholder: Color::Rgb(117, 113, 94), + error: Color::Rgb(249, 38, 114), + info: Color::Rgb(166, 226, 46), + } +} + +/// Material Dark theme +fn material_dark() -> Theme { + Theme { + name: "material-dark".to_string(), + text: Color::Rgb(238, 255, 255), // #eeffff + background: Color::Rgb(38, 50, 56), // #263238 + focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan) + unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a + user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue) + assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple) + thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow) + command_bar_background: Color::Rgb(33, 43, 48), + status_background: Color::Rgb(33, 43, 48), + mode_normal: Color::Rgb(130, 170, 255), + mode_editing: Color::Rgb(195, 232, 141), // #c3e88d (green) + mode_model_selection: Color::Rgb(255, 203, 107), + mode_provider_selection: Color::Rgb(128, 203, 196), + mode_help: Color::Rgb(199, 146, 234), + mode_visual: Color::Rgb(240, 113, 120), // #f07178 (red) + mode_command: Color::Rgb(255, 203, 107), + selection_bg: Color::Rgb(84, 110, 122), + selection_fg: Color::Rgb(238, 255, 255), + cursor: Color::Rgb(255, 204, 0), + placeholder: Color::Rgb(84, 110, 122), + error: Color::Rgb(240, 113, 120), + info: Color::Rgb(195, 232, 141), + } +} + +/// Material Light theme +fn material_light() -> Theme { + Theme { + name: "material-light".to_string(), + text: Color::Rgb(33, 33, 33), + background: Color::Rgb(236, 239, 241), + focused_panel_border: Color::Rgb(0, 150, 136), + unfocused_panel_border: Color::Rgb(176, 190, 197), + user_message_role: Color::Rgb(68, 138, 255), + assistant_message_role: Color::Rgb(124, 77, 255), + thinking_panel_title: Color::Rgb(245, 124, 0), + command_bar_background: Color::Rgb(255, 255, 255), + status_background: Color::Rgb(255, 255, 255), + mode_normal: Color::Rgb(68, 138, 255), + mode_editing: Color::Rgb(56, 142, 60), + mode_model_selection: Color::Rgb(245, 124, 0), + mode_provider_selection: Color::Rgb(0, 150, 136), + mode_help: Color::Rgb(124, 77, 255), + mode_visual: Color::Rgb(211, 47, 47), + mode_command: Color::Rgb(245, 124, 0), + selection_bg: Color::Rgb(176, 190, 197), + selection_fg: Color::Rgb(33, 33, 33), + cursor: Color::Rgb(194, 24, 91), + placeholder: Color::Rgb(144, 164, 174), + error: Color::Rgb(211, 47, 47), + info: Color::Rgb(56, 142, 60), + } +} + +// Helper functions for color serialization/deserialization + +fn deserialize_color<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + parse_color(&s).map_err(serde::de::Error::custom) +} + +fn serialize_color(color: &Color, serializer: S) -> Result +where + S: serde::Serializer, +{ + let s = color_to_string(color); + serializer.serialize_str(&s) +} + +fn parse_color(s: &str) -> Result { + if let Some(hex) = s.strip_prefix('#') { + if hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16) + .map_err(|_| format!("Invalid hex color: {}", s))?; + let g = u8::from_str_radix(&hex[2..4], 16) + .map_err(|_| format!("Invalid hex color: {}", s))?; + let b = u8::from_str_radix(&hex[4..6], 16) + .map_err(|_| format!("Invalid hex color: {}", s))?; + return Ok(Color::Rgb(r, g, b)); + } + } + + // Try named colors + match s.to_lowercase().as_str() { + "black" => Ok(Color::Black), + "red" => Ok(Color::Red), + "green" => Ok(Color::Green), + "yellow" => Ok(Color::Yellow), + "blue" => Ok(Color::Blue), + "magenta" => Ok(Color::Magenta), + "cyan" => Ok(Color::Cyan), + "gray" | "grey" => Ok(Color::Gray), + "darkgray" | "darkgrey" => Ok(Color::DarkGray), + "lightred" => Ok(Color::LightRed), + "lightgreen" => Ok(Color::LightGreen), + "lightyellow" => Ok(Color::LightYellow), + "lightblue" => Ok(Color::LightBlue), + "lightmagenta" => Ok(Color::LightMagenta), + "lightcyan" => Ok(Color::LightCyan), + "white" => Ok(Color::White), + _ => Err(format!("Unknown color: {}", s)), + } +} + +fn color_to_string(color: &Color) -> String { + match color { + Color::Black => "black".to_string(), + Color::Red => "red".to_string(), + Color::Green => "green".to_string(), + Color::Yellow => "yellow".to_string(), + Color::Blue => "blue".to_string(), + Color::Magenta => "magenta".to_string(), + Color::Cyan => "cyan".to_string(), + Color::Gray => "gray".to_string(), + Color::DarkGray => "darkgray".to_string(), + Color::LightRed => "lightred".to_string(), + Color::LightGreen => "lightgreen".to_string(), + Color::LightYellow => "lightyellow".to_string(), + Color::LightBlue => "lightblue".to_string(), + Color::LightMagenta => "lightmagenta".to_string(), + Color::LightCyan => "lightcyan".to_string(), + Color::White => "white".to_string(), + Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b), + _ => "#ffffff".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_color_parsing() { + assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0)))); + assert!(matches!(parse_color("red"), Ok(Color::Red))); + assert!(matches!(parse_color("lightblue"), Ok(Color::LightBlue))); + } + + #[test] + fn test_built_in_themes() { + let themes = built_in_themes(); + assert!(themes.contains_key("default_dark")); + assert!(themes.contains_key("gruvbox")); + assert!(themes.contains_key("dracula")); + } +} diff --git a/crates/owlen-core/src/ui.rs b/crates/owlen-core/src/ui.rs index a7f257f..b61c86b 100644 --- a/crates/owlen-core/src/ui.rs +++ b/crates/owlen-core/src/ui.rs @@ -23,6 +23,7 @@ pub enum InputMode { Visual, Command, SessionBrowser, + ThemeBrowser, } impl fmt::Display for InputMode { @@ -36,6 +37,7 @@ impl fmt::Display for InputMode { InputMode::Visual => "Visual", InputMode::Command => "Command", InputMode::SessionBrowser => "Sessions", + InputMode::ThemeBrowser => "Themes", }; f.write_str(label) } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 92c5e5b..8640d5b 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -2,6 +2,7 @@ use anyhow::Result; use owlen_core::{ session::{SessionController, SessionOutcome}, storage::{SessionMeta, StorageManager}, + theme::Theme, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, ui::{AppState, AutoScroll, FocusedPanel, InputMode}, }; @@ -61,8 +62,10 @@ pub struct ChatApp { storage: StorageManager, // Storage manager for session persistence saved_sessions: Vec, // Cached list of saved sessions selected_session_index: usize, // Index of selected session in browser - save_name_buffer: String, // Buffer for entering save name help_tab_index: usize, // Currently selected help tab (0-4) + theme: Theme, // Current theme + available_themes: Vec, // Cached list of theme names + selected_theme_index: usize, // Index of selected theme in browser } impl ChatApp { @@ -77,6 +80,13 @@ impl ChatApp { .expect("Failed to create fallback storage") }); + // Load theme based on config + let theme_name = &controller.config().ui.theme; + let theme = owlen_core::theme::get_theme(theme_name).unwrap_or_else(|| { + eprintln!("Warning: Theme '{}' not found, using default", theme_name); + Theme::default() + }); + let app = Self { controller, mode: InputMode::Normal, @@ -112,8 +122,10 @@ impl ChatApp { storage, saved_sessions: Vec::new(), selected_session_index: 0, - save_name_buffer: String::new(), help_tab_index: 0, + theme, + available_themes: Vec::new(), + selected_theme_index: 0, }; (app, session_rx) @@ -238,6 +250,9 @@ impl ChatApp { ("m", "Alias for model"), ("new", "Start a new conversation"), ("n", "Alias for new"), + ("theme", "Switch theme"), + ("themes", "List available themes"), + ("reload", "Reload configuration and themes"), ] } @@ -312,6 +327,39 @@ impl ChatApp { self.help_tab_index } + pub fn available_themes(&self) -> &[String] { + &self.available_themes + } + + pub fn selected_theme_index(&self) -> usize { + self.selected_theme_index + } + + pub fn theme(&self) -> &Theme { + &self.theme + } + + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme; + } + + pub fn switch_theme(&mut self, theme_name: &str) -> Result<()> { + if let Some(theme) = owlen_core::theme::get_theme(theme_name) { + self.theme = theme; + // Save theme to config + self.controller.config_mut().ui.theme = theme_name.to_string(); + if let Err(err) = config::save_config(self.controller.config()) { + self.error = Some(format!("Failed to save theme config: {}", err)); + } else { + self.status = format!("Switched to theme: {}", theme_name); + } + Ok(()) + } else { + self.error = Some(format!("Theme '{}' not found", theme_name)); + Err(anyhow::anyhow!("Theme '{}' not found", theme_name)) + } + } + pub fn cycle_focus_forward(&mut self) { self.focused_panel = match self.focused_panel { FocusedPanel::Chat => { @@ -1086,9 +1134,7 @@ impl ChatApp { (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => { // Navigate up in suggestions if !self.command_suggestions.is_empty() { - self.selected_suggestion = self - .selected_suggestion - .saturating_sub(1); + self.selected_suggestion = self.selected_suggestion.saturating_sub(1); } } (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => { @@ -1122,18 +1168,30 @@ impl ChatApp { }; // Generate description if enabled in config - let description = if self.controller.config().storage.generate_descriptions { - self.status = "Generating description...".to_string(); - match self.controller.generate_conversation_description().await { - Ok(desc) => Some(desc), - Err(_) => None, - } - } else { - None - }; + let description = + if self.controller.config().storage.generate_descriptions { + self.status = "Generating description...".to_string(); + match self + .controller + .generate_conversation_description() + .await + { + Ok(desc) => Some(desc), + Err(_) => None, + } + } else { + None + }; // Save the conversation with description - match self.controller.conversation_mut().save_active_with_description(&self.storage, name.clone(), description) { + match self + .controller + .conversation_mut() + .save_active_with_description( + &self.storage, + name.clone(), + description, + ) { Ok(path) => { self.status = format!("Session saved: {}", path.display()); self.error = None; @@ -1155,7 +1213,8 @@ impl ChatApp { return Ok(AppState::Running); } Err(e) => { - self.error = Some(format!("Failed to list sessions: {}", e)); + self.error = + Some(format!("Failed to list sessions: {}", e)); } } } @@ -1171,7 +1230,8 @@ impl ChatApp { return Ok(AppState::Running); } Err(e) => { - self.error = Some(format!("Failed to list sessions: {}", e)); + self.error = + Some(format!("Failed to list sessions: {}", e)); } } } @@ -1192,6 +1252,70 @@ impl ChatApp { self.controller.start_new_conversation(None, None); self.status = "Started new conversation".to_string(); } + "theme" => { + if args.is_empty() { + self.error = Some("Usage: :theme ".to_string()); + } else { + let theme_name = args.join(" "); + match self.switch_theme(&theme_name) { + Ok(_) => { + // Success message already set by switch_theme + } + Err(_) => { + // Error message already set by switch_theme + } + } + } + } + "themes" => { + // Load all themes and enter browser mode + let themes = owlen_core::theme::load_all_themes(); + let mut theme_list: Vec = themes.keys().cloned().collect(); + theme_list.sort(); + + self.available_themes = theme_list; + + // Set selected index to current theme + let current_theme = &self.theme.name; + self.selected_theme_index = self + .available_themes + .iter() + .position(|name| name == current_theme) + .unwrap_or(0); + + self.mode = InputMode::ThemeBrowser; + self.command_buffer.clear(); + self.command_suggestions.clear(); + return Ok(AppState::Running); + } + "reload" => { + // Reload config + match owlen_core::config::Config::load(None) { + Ok(new_config) => { + // Update controller config + *self.controller.config_mut() = new_config.clone(); + + // Reload theme based on updated config + let theme_name = &new_config.ui.theme; + if let Some(new_theme) = + owlen_core::theme::get_theme(theme_name) + { + self.theme = new_theme; + self.status = format!( + "Configuration and theme reloaded (theme: {})", + theme_name + ); + } else { + self.status = "Configuration reloaded, but theme not found. Using current theme.".to_string(); + } + self.error = None; + } + Err(e) => { + self.error = + Some(format!("Failed to reload config: {}", e)); + } + } + } _ => { self.error = Some(format!("Unknown command: {}", cmd)); } @@ -1290,7 +1414,7 @@ impl ChatApp { } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { // Next tab - if self.help_tab_index < 4 { + if self.help_tab_index < 5 { self.help_tab_index += 1; } } @@ -1305,6 +1429,7 @@ impl ChatApp { KeyCode::Char('3') => self.help_tab_index = 2, KeyCode::Char('4') => self.help_tab_index = 3, KeyCode::Char('5') => self.help_tab_index = 4, + KeyCode::Char('6') => self.help_tab_index = 5, _ => {} }, InputMode::SessionBrowser => match key.code { @@ -1313,10 +1438,18 @@ impl ChatApp { } KeyCode::Enter => { // Load selected session - if let Some(session) = self.saved_sessions.get(self.selected_session_index) { - match self.controller.conversation_mut().load_from_disk(&self.storage, &session.path) { + if let Some(session) = self.saved_sessions.get(self.selected_session_index) + { + match self + .controller + .conversation_mut() + .load_from_disk(&self.storage, &session.path) + { Ok(_) => { - self.status = format!("Loaded session: {}", session.name.as_deref().unwrap_or("Unnamed")); + self.status = format!( + "Loaded session: {}", + session.name.as_deref().unwrap_or("Unnamed") + ); self.error = None; // Update thinking panel self.update_thinking_from_last_message(); @@ -1340,11 +1473,14 @@ impl ChatApp { } KeyCode::Char('d') => { // Delete selected session - if let Some(session) = self.saved_sessions.get(self.selected_session_index) { + if let Some(session) = self.saved_sessions.get(self.selected_session_index) + { match self.storage.delete_session(&session.path) { Ok(_) => { self.saved_sessions.remove(self.selected_session_index); - if self.selected_session_index >= self.saved_sessions.len() && !self.saved_sessions.is_empty() { + if self.selected_session_index >= self.saved_sessions.len() + && !self.saved_sessions.is_empty() + { self.selected_session_index = self.saved_sessions.len() - 1; } self.status = "Session deleted".to_string(); @@ -1357,6 +1493,48 @@ impl ChatApp { } _ => {} }, + InputMode::ThemeBrowser => match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.mode = InputMode::Normal; + } + KeyCode::Enter => { + // Apply selected theme + if let Some(theme_name) = self + .available_themes + .get(self.selected_theme_index) + .cloned() + { + match self.switch_theme(&theme_name) { + Ok(_) => { + // Success message already set by switch_theme + } + Err(_) => { + // Error message already set by switch_theme + } + } + } + self.mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if self.selected_theme_index > 0 { + self.selected_theme_index -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.selected_theme_index + 1 < self.available_themes.len() { + self.selected_theme_index += 1; + } + } + KeyCode::Home | KeyCode::Char('g') => { + self.selected_theme_index = 0; + } + KeyCode::End | KeyCode::Char('G') => { + if !self.available_themes.is_empty() { + self.selected_theme_index = self.available_themes.len() - 1; + } + } + _ => {} + }, }, _ => {} } diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index ee40988..103f706 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -15,6 +15,11 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Update thinking content from last message app.update_thinking_from_last_message(); + // Set terminal background color + let theme = app.theme().clone(); + let background_block = Block::default().style(Style::default().bg(theme.background)); + frame.render_widget(background_block, frame.area()); + // Calculate dynamic input height based on textarea content let available_width = frame.area().width; let input_height = if matches!(app.mode(), InputMode::Editing) { @@ -82,6 +87,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { InputMode::ModelSelection => render_model_selector(frame, app), InputMode::Help => render_help(frame, app), InputMode::SessionBrowser => render_session_browser(frame, app), + InputMode::ThemeBrowser => render_theme_browser(frame, app), InputMode::Command => render_command_suggestions(frame, app), _ => {} } @@ -410,36 +416,41 @@ fn wrap_line_segments(line: &str, width: usize) -> Vec { } fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { + let theme = app.theme(); let title_span = Span::styled( " 🦉 OWLEN - AI Assistant ", Style::default() - .fg(Color::LightMagenta) + .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), ); let model_span = Span::styled( format!("Model: {}", app.selected_model()), - Style::default().fg(Color::LightBlue), + Style::default().fg(theme.user_message_role), ); let header_block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))) + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)) .title(Line::from(vec![title_span])); let inner_area = header_block.inner(area); let header_text = vec![Line::from(""), Line::from(format!(" {model_span} "))]; - let paragraph = Paragraph::new(header_text).alignment(Alignment::Left); + let paragraph = Paragraph::new(header_text) + .style(Style::default().bg(theme.background).fg(theme.text)) + .alignment(Alignment::Left); frame.render_widget(header_block, area); frame.render_widget(paragraph, inner_area); } -fn apply_visual_selection( - lines: Vec, +fn apply_visual_selection<'a>( + lines: Vec>, selection: Option<((usize, usize), (usize, usize))>, -) -> Vec { + theme: &owlen_core::theme::Theme, +) -> Vec> { if let Some(((start_row, start_col), (end_row, end_col))) = selection { // Normalize selection (ensure start is before end) let ((start_r, start_c), (end_r, end_c)) = @@ -476,14 +487,22 @@ fn apply_visual_selection( let mut spans = Vec::new(); if start_byte > 0 { - spans.push(Span::raw(line_text[..start_byte].to_string())); + spans.push(Span::styled( + line_text[..start_byte].to_string(), + Style::default().fg(theme.text), + )); } spans.push(Span::styled( line_text[start_byte..end_byte].to_string(), - Style::default().bg(Color::LightBlue).fg(Color::Black), + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg), )); if end_byte < line_text.len() { - spans.push(Span::raw(line_text[end_byte..].to_string())); + spans.push(Span::styled( + line_text[end_byte..].to_string(), + Style::default().fg(theme.text), + )); } Line::from(spans) } else if idx == start_r { @@ -493,11 +512,16 @@ fn apply_visual_selection( let mut spans = Vec::new(); if start_byte > 0 { - spans.push(Span::raw(line_text[..start_byte].to_string())); + spans.push(Span::styled( + line_text[..start_byte].to_string(), + Style::default().fg(theme.text), + )); } spans.push(Span::styled( line_text[start_byte..].to_string(), - Style::default().bg(Color::LightBlue).fg(Color::Black), + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg), )); Line::from(spans) } else if idx == end_r { @@ -508,10 +532,15 @@ fn apply_visual_selection( let mut spans = Vec::new(); spans.push(Span::styled( line_text[..end_byte].to_string(), - Style::default().bg(Color::LightBlue).fg(Color::Black), + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg), )); if end_byte < line_text.len() { - spans.push(Span::raw(line_text[end_byte..].to_string())); + spans.push(Span::styled( + line_text[end_byte..].to_string(), + Style::default().fg(theme.text), + )); } Line::from(spans) } else { @@ -522,7 +551,7 @@ fn apply_visual_selection( .map(|span| { Span::styled( span.content, - span.style.bg(Color::LightBlue).fg(Color::Black), + span.style.bg(theme.selection_bg).fg(theme.selection_fg), ) }) .collect(); @@ -550,6 +579,8 @@ fn char_to_byte_index(s: &str, char_idx: usize) -> usize { } fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { + let theme = app.theme().clone(); + // Calculate viewport dimensions for autoscroll calculations let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders let content_width = area.width.saturating_sub(4).max(20); @@ -596,7 +627,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Role name line let mut role_line_spans = vec![ Span::raw(emoji), - Span::styled(name, role_color(role).add_modifier(Modifier::BOLD)), + Span::styled(name, role_color(role, &theme).add_modifier(Modifier::BOLD)), ]; // Add loading indicator if applicable @@ -607,7 +638,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { { role_line_spans.push(Span::styled( format!(" {}", app.get_loading_indicator()), - Style::default().fg(Color::Yellow), + Style::default().fg(theme.info), )); } @@ -629,7 +660,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { for (i, seg) in chunks.into_iter().enumerate() { let mut spans = vec![Span::raw(format!("{indent}{}", seg))]; if i == chunks_len - 1 && is_streaming { - spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta))); + spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor))); } lines.push(Line::from(spans)); } @@ -641,7 +672,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { for (i, seg) in chunks.into_iter().enumerate() { let mut spans = vec![Span::raw(seg.into_owned())]; if i == chunks_len - 1 && is_streaming { - spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta))); + spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor))); } lines.push(Line::from(spans)); } @@ -667,12 +698,12 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { Span::styled( "Assistant:", Style::default() - .fg(Color::LightMagenta) + .fg(theme.assistant_message_role) .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {}", app.get_loading_indicator()), - Style::default().fg(Color::Yellow), + Style::default().fg(theme.info), ), ]; lines.push(Line::from(loading_spans)); @@ -686,7 +717,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat) { if let Some(selection) = app.visual_selection() { - lines = apply_visual_selection(lines, Some(selection)); + lines = apply_visual_selection(lines, Some(selection), &theme); } } @@ -699,16 +730,18 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Highlight border if this panel is focused let border_color = if matches!(app.focused_panel(), FocusedPanel::Chat) { - Color::LightMagenta + theme.focused_panel_border } else { - Color::Rgb(95, 20, 135) + theme.unfocused_panel_border }; let paragraph = Paragraph::new(lines) + .style(Style::default().bg(theme.background).fg(theme.text)) .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)), + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(theme.background).fg(theme.text)), ) .scroll((scroll_position, 0)); @@ -744,6 +777,8 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { + let theme = app.theme().clone(); + if let Some(thinking) = app.current_thinking().cloned() { let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders let content_width = area.width.saturating_sub(4); @@ -758,7 +793,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { Line::from(Span::styled( seg.into_owned(), Style::default() - .fg(Color::DarkGray) + .fg(theme.placeholder) .add_modifier(Modifier::ITALIC), )) }) @@ -769,7 +804,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { && matches!(app.focused_panel(), FocusedPanel::Thinking) { if let Some(selection) = app.visual_selection() { - lines = apply_visual_selection(lines, Some(selection)); + lines = apply_visual_selection(lines, Some(selection), &theme); } } @@ -782,22 +817,24 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Highlight border if this panel is focused let border_color = if matches!(app.focused_panel(), FocusedPanel::Thinking) { - Color::LightMagenta + theme.focused_panel_border } else { - Color::DarkGray + theme.unfocused_panel_border }; let paragraph = Paragraph::new(lines) + .style(Style::default().bg(theme.background)) .block( Block::default() .title(Span::styled( " 💭 Thinking ", Style::default() - .fg(Color::DarkGray) + .fg(theme.thinking_panel_title) .add_modifier(Modifier::ITALIC), )) .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)), + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(theme.background).fg(theme.text)), ) .scroll((scroll_position, 0)) .wrap(Wrap { trim: false }); @@ -834,6 +871,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { + let theme = app.theme(); let title = match app.mode() { InputMode::Editing => " Input (Enter=send · Ctrl+J=newline · Esc=exit input mode) ", InputMode::Visual => " Visual Mode (y=yank · d=cut · Esc=cancel) ", @@ -843,20 +881,21 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Highlight border if this panel is focused let border_color = if matches!(app.focused_panel(), FocusedPanel::Input) { - Color::LightMagenta + theme.focused_panel_border } else { - Color::Rgb(95, 20, 135) + theme.unfocused_panel_border }; let input_block = Block::default() .title(Span::styled( title, Style::default() - .fg(Color::LightMagenta) + .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(theme.background).fg(theme.text)); if matches!(app.mode(), InputMode::Editing) { // Use the textarea directly to preserve selection state @@ -876,11 +915,12 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let lines = vec![Line::from(Span::styled( command_text, Style::default() - .fg(Color::Yellow) + .fg(theme.mode_command) .add_modifier(Modifier::BOLD), ))]; let paragraph = Paragraph::new(lines) + .style(Style::default().bg(theme.background)) .block(input_block) .wrap(Wrap { trim: false }); @@ -889,12 +929,19 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // In non-editing mode, show the current input buffer content as read-only let input_text = app.input_buffer().text(); let lines: Vec = if input_text.is_empty() { - vec![Line::from("Press 'i' to start typing")] + vec![Line::from(Span::styled( + "Press 'i' to start typing", + Style::default().fg(theme.placeholder), + ))] } else { - input_text.lines().map(Line::from).collect() + input_text + .lines() + .map(|l| Line::from(Span::styled(l, Style::default().fg(theme.text)))) + .collect() }; let paragraph = Paragraph::new(lines) + .style(Style::default().bg(theme.background)) .block(input_block) .wrap(Wrap { trim: false }); @@ -937,15 +984,17 @@ where } fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { + let theme = app.theme(); let (mode_text, mode_bg_color) = match app.mode() { - InputMode::Normal => (" NORMAL", Color::LightBlue), - InputMode::Editing => (" INPUT", Color::LightGreen), - InputMode::ModelSelection => (" MODEL", Color::LightYellow), - InputMode::ProviderSelection => (" PROVIDER", Color::LightCyan), - InputMode::Help => (" HELP", Color::LightMagenta), - InputMode::Visual => (" VISUAL", Color::Magenta), - InputMode::Command => (" COMMAND", Color::Yellow), - InputMode::SessionBrowser => (" SESSIONS", Color::Yellow), + InputMode::Normal => (" NORMAL", theme.mode_normal), + InputMode::Editing => (" INPUT", theme.mode_editing), + InputMode::ModelSelection => (" MODEL", theme.mode_model_selection), + InputMode::ProviderSelection => (" PROVIDER", theme.mode_provider_selection), + InputMode::Help => (" HELP", theme.mode_help), + InputMode::Visual => (" VISUAL", theme.mode_visual), + InputMode::Command => (" COMMAND", theme.mode_command), + InputMode::SessionBrowser => (" SESSIONS", theme.mode_command), + InputMode::ThemeBrowser => (" THEMES", theme.mode_help), }; let status_message = if let Some(error) = app.error_message() { @@ -960,16 +1009,19 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { Span::styled( format!(" {} ", mode_text), Style::default() - .fg(Color::Black) + .fg(theme.background) .bg(mode_bg_color) .add_modifier(Modifier::BOLD), ), - Span::raw(format!(" | {} ", status_message)), + Span::styled( + format!(" | {} ", status_message), + Style::default().fg(theme.text), + ), ]; let right_spans = vec![ - Span::raw(" Help: "), - Span::styled(help_text, Style::default().fg(Color::LightBlue)), + Span::styled(" Help: ", Style::default().fg(theme.text)), + Span::styled(help_text, Style::default().fg(theme.info)), ]; let layout = Layout::default() @@ -979,18 +1031,22 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let left_paragraph = Paragraph::new(Line::from(left_spans)) .alignment(Alignment::Left) + .style(Style::default().bg(theme.status_background).fg(theme.text)) .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.status_background).fg(theme.text)), ); let right_paragraph = Paragraph::new(Line::from(right_spans)) .alignment(Alignment::Right) + .style(Style::default().bg(theme.status_background).fg(theme.text)) .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.status_background).fg(theme.text)), ); frame.render_widget(left_paragraph, layout[0]); @@ -998,6 +1054,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { } fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) { + let theme = app.theme(); let area = centered_rect(60, 60, frame.area()); frame.render_widget(Clear, area); @@ -1008,7 +1065,7 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) { ListItem::new(Span::styled( provider.to_string(), Style::default() - .fg(Color::LightBlue) + .fg(theme.user_message_role) .add_modifier(Modifier::BOLD), )) }) @@ -1020,15 +1077,16 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) { .title(Span::styled( "Select Provider", Style::default() - .fg(Color::LightMagenta) + .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)), ) .highlight_style( Style::default() - .fg(Color::Magenta) + .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), ) .highlight_symbol("▶ "); @@ -1039,6 +1097,7 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) { } fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { + let theme = app.theme(); let area = centered_rect(60, 60, frame.area()); frame.render_widget(Clear, area); @@ -1054,7 +1113,7 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { ListItem::new(Span::styled( label, Style::default() - .fg(Color::LightBlue) + .fg(theme.user_message_role) .add_modifier(Modifier::BOLD), )) }) @@ -1066,15 +1125,15 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { .title(Span::styled( format!("Select Model ({})", app.selected_provider), Style::default() - .fg(Color::LightMagenta) + .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), + .style(Style::default().bg(theme.background).fg(theme.text)), ) .highlight_style( Style::default() - .fg(Color::Magenta) + .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), ) .highlight_symbol("▶ "); @@ -1085,11 +1144,12 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { } fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { + let theme = app.theme(); let area = centered_rect(75, 70, frame.area()); frame.render_widget(Clear, area); let tab_index = app.help_tab_index(); - let tabs = vec!["Navigation", "Editing", "Visual", "Commands", "Sessions"]; + let tabs = vec!["Navigation", "Editing", "Visual", "Commands", "Sessions", "Browsers"]; // Build tab line let mut tab_spans = Vec::new(); @@ -1098,14 +1158,14 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { tab_spans.push(Span::styled( format!(" {} ", tab_name), Style::default() - .fg(Color::Black) - .bg(Color::LightMagenta) + .fg(theme.selection_fg) + .bg(theme.selection_bg) .add_modifier(Modifier::BOLD), )); } else { tab_spans.push(Span::styled( format!(" {} ", tab_name), - Style::default().fg(Color::Gray), + Style::default().fg(theme.placeholder), )); } if i < tabs.len() - 1 { @@ -1114,18 +1174,21 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { } let help_text = match tab_index { - 0 => vec![ // Navigation + 0 => vec![ + // Navigation Line::from(""), - Line::from(vec![ - Span::styled("PANEL NAVIGATION", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "PANEL NAVIGATION", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" Tab → cycle panels forward"), Line::from(" Shift+Tab → cycle panels backward"), Line::from(" (Panels: Chat, Thinking, Input)"), Line::from(""), - Line::from(vec![ - Span::styled("CURSOR MOVEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "CURSOR MOVEMENT", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" h/← l/→ → move left/right by character"), Line::from(" j/↓ k/↑ → move down/up by line"), Line::from(" w → forward to next word start"), @@ -1137,18 +1200,21 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" gg → jump to top"), Line::from(" G → jump to bottom"), Line::from(""), - Line::from(vec![ - Span::styled("SCROLLING", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "SCROLLING", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" Ctrl+d/u → scroll half page down/up"), Line::from(" Ctrl+f/b → scroll full page down/up"), Line::from(" PageUp/Down → scroll full page"), ], - 1 => vec![ // Editing + 1 => vec![ + // Editing Line::from(""), - Line::from(vec![ - Span::styled("ENTERING INSERT MODE", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "ENTERING INSERT MODE", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" i / Enter → enter insert mode at cursor"), Line::from(" a → append after cursor"), Line::from(" A → append at end of line"), @@ -1156,9 +1222,10 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" o → insert line below and enter insert mode"), Line::from(" O → insert line above and enter insert mode"), Line::from(""), - Line::from(vec![ - Span::styled("INSERT MODE KEYS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "INSERT MODE KEYS", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" Enter → send message"), Line::from(" Ctrl+J → insert newline (multiline message)"), Line::from(" Ctrl+↑/↓ → navigate input history"), @@ -1166,24 +1233,29 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" Ctrl+E → jump to end of line"), Line::from(" Ctrl+W → word forward"), Line::from(" Ctrl+B → word backward"), + Line::from(" Ctrl+R → redo"), Line::from(" Esc → return to normal mode"), Line::from(""), - Line::from(vec![ - Span::styled("NORMAL MODE OPERATIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "NORMAL MODE OPERATIONS", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" dd → clear input buffer"), Line::from(" p → paste from clipboard to input"), ], - 2 => vec![ // Visual + 2 => vec![ + // Visual Line::from(""), - Line::from(vec![ - Span::styled("VISUAL MODE", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "VISUAL MODE", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" v → enter visual mode at cursor"), Line::from(""), - Line::from(vec![ - Span::styled("SELECTION MOVEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "SELECTION MOVEMENT", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" h/j/k/l → extend selection left/down/up/right"), Line::from(" w → extend to next word start"), Line::from(" e → extend to word end"), @@ -1192,65 +1264,76 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" ^ → extend to first non-blank"), Line::from(" $ → extend to line end"), Line::from(""), - Line::from(vec![ - Span::styled("VISUAL MODE OPERATIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) - ]), + Line::from(vec![Span::styled( + "VISUAL MODE OPERATIONS", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), Line::from(" y → yank (copy) selection to clipboard"), - Line::from(" d → cut selection (Input panel only)"), + Line::from(" d / Delete → cut selection (Input panel only)"), Line::from(" v / Esc → exit visual mode"), Line::from(""), - Line::from(vec![ - Span::styled("NOTES", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) - ]), + Line::from(vec![Span::styled( + "NOTES", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.user_message_role), + )]), Line::from(" • Visual mode works across all panels (Chat, Thinking, Input)"), Line::from(" • Yanked text is available for paste with 'p' in normal mode"), ], - 3 => vec![ // Commands + 3 => vec![ + // Commands Line::from(""), Line::from(vec![ - Span::styled("COMMAND MODE", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) + Span::styled("COMMAND MODE", Style::default().add_modifier(Modifier::BOLD).fg(theme.info)) ]), Line::from(" Press ':' to enter command mode, then type one of:"), Line::from(""), Line::from(vec![ - Span::styled("HELP & NAVIGATION", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) + Span::styled("KEYBINDINGS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) + ]), + Line::from(" Enter → execute command"), + Line::from(" Esc → exit command mode"), + Line::from(" Tab → autocomplete suggestion"), + Line::from(" ↑/↓ → navigate suggestions"), + Line::from(" Backspace → delete character"), + Line::from(""), + Line::from(vec![ + Span::styled("GENERAL", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) ]), Line::from(" :h, :help → show this help"), Line::from(" :q, :quit → quit application"), + Line::from(" :reload → reload configuration and themes"), Line::from(""), Line::from(vec![ - Span::styled("MODEL MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) - ]), - Line::from(" :m, :model → open model selector"), - Line::from(""), - Line::from(vec![ - Span::styled("CONVERSATION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) + Span::styled("CONVERSATION", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) ]), Line::from(" :n, :new → start new conversation"), Line::from(" :c, :clear → clear current conversation"), Line::from(""), Line::from(vec![ - Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) + Span::styled("MODEL & THEME", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) + ]), + Line::from(" :m, :model → open model selector"), + Line::from(" :themes → open theme selector"), + Line::from(" :theme → switch to a specific theme"), + Line::from(""), + Line::from(vec![ + Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) ]), Line::from(" :save [name] → save current session (with optional name)"), Line::from(" :w [name] → alias for :save"), Line::from(" :load, :o, :open → browse and load saved sessions"), Line::from(" :sessions, :ls → browse saved sessions"), - Line::from(""), - Line::from(vec![ - Span::styled("QUICK SHORTCUTS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) - ]), - Line::from(" q (normal mode) → quit without :"), - Line::from(" Ctrl+C → quit immediately"), ], 4 => vec![ // Sessions Line::from(""), Line::from(vec![ - Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)) + Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(theme.info)) ]), Line::from(""), Line::from(vec![ - Span::styled("SAVING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) + Span::styled("SAVING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) ]), Line::from(" :save → save with auto-generated name"), Line::from(" :save my-session → save with custom name"), @@ -1258,13 +1341,13 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" • Sessions stored in platform-specific directories"), Line::from(""), Line::from(vec![ - Span::styled("LOADING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) + Span::styled("LOADING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) ]), Line::from(" :load, :o, :open → browse and select session"), Line::from(" :sessions, :ls → browse saved sessions"), Line::from(""), Line::from(vec![ - Span::styled("SESSION BROWSER KEYS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) + Span::styled("SESSION BROWSER KEYS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) ]), Line::from(" j/k or ↑/↓ → navigate sessions"), Line::from(" Enter → load selected session"), @@ -1272,19 +1355,38 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" Esc → close browser"), Line::from(""), Line::from(vec![ - Span::styled("STORAGE LOCATIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)) + Span::styled("STORAGE LOCATIONS", Style::default().add_modifier(Modifier::BOLD).fg(theme.user_message_role)) ]), Line::from(" Linux → ~/.local/share/owlen/sessions"), Line::from(" Windows → %APPDATA%\\owlen\\sessions"), Line::from(" macOS → ~/Library/Application Support/owlen/sessions"), Line::from(""), Line::from(vec![ - Span::styled("CONTEXT PRESERVATION", Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)) + Span::styled("CONTEXT PRESERVATION", Style::default().add_modifier(Modifier::BOLD).fg(theme.assistant_message_role)) ]), Line::from(" • Full conversation history is preserved when saving"), Line::from(" • All context is restored when loading a session"), Line::from(" • Continue conversations seamlessly across restarts"), ], + 5 => vec![ // Browsers + Line::from(""), + Line::from(vec![ + Span::styled("PROVIDER & MODEL BROWSERS", Style::default().add_modifier(Modifier::BOLD).fg(theme.info)) + ]), + Line::from(" Enter → select item"), + Line::from(" Esc → close browser"), + Line::from(" ↑/↓ or j/k → navigate items"), + Line::from(""), + Line::from(vec![ + Span::styled("THEME BROWSER", Style::default().add_modifier(Modifier::BOLD).fg(theme.info)) + ]), + Line::from(" Enter → apply theme"), + Line::from(" Esc / q → close browser"), + Line::from(" ↑/↓ or j/k → navigate themes"), + Line::from(" g / Home → jump to top"), + Line::from(" G / End → jump to bottom"), + ], + _ => vec![], }; @@ -1301,37 +1403,56 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { // Render tabs let tabs_block = Block::default() .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))); - let tabs_para = Paragraph::new(Line::from(tab_spans)).block(tabs_block); + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)); + let tabs_para = Paragraph::new(Line::from(tab_spans)) + .style(Style::default().bg(theme.background)) + .block(tabs_block); frame.render_widget(tabs_para, layout[0]); // Render content let content_block = Block::default() .borders(Borders::LEFT | Borders::RIGHT) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))); - let content_para = Paragraph::new(help_text).block(content_block); + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)); + let content_para = Paragraph::new(help_text) + .style(Style::default().bg(theme.background).fg(theme.text)) + .block(content_block); frame.render_widget(content_para, layout[1]); // Render navigation hint let nav_hint = Line::from(vec![ Span::raw(" "), - Span::styled("Tab/h/l", Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD)), + Span::styled( + "Tab/h/l", + Style::default() + .fg(theme.focused_panel_border) + .add_modifier(Modifier::BOLD), + ), Span::raw(":Switch "), - Span::styled("1-5", Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD)), + Span::styled("1-6", Style::default().fg(theme.focused_panel_border).add_modifier(Modifier::BOLD)), Span::raw(":Jump "), - Span::styled("Esc/q", Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD)), + Span::styled( + "Esc/q", + Style::default() + .fg(theme.focused_panel_border) + .add_modifier(Modifier::BOLD), + ), Span::raw(":Close "), ]); let nav_block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(95, 20, 135))); + .border_style(Style::default().fg(theme.unfocused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)); let nav_para = Paragraph::new(nav_hint) + .style(Style::default().bg(theme.background)) .block(nav_block) .alignment(Alignment::Center); frame.render_widget(nav_para, layout[2]); } fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { + let theme = app.theme(); let area = centered_rect(70, 70, frame.area()); frame.render_widget(Clear, area); @@ -1348,14 +1469,15 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { ]; let paragraph = Paragraph::new(text) + .style(Style::default().bg(theme.background).fg(theme.text)) .block( Block::default() .title(Span::styled( " Saved Sessions ", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)), + .style(Style::default().bg(theme.background).fg(theme.text)), ) .alignment(Alignment::Center); @@ -1367,12 +1489,10 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { .iter() .enumerate() .map(|(idx, session)| { - let name = session - .name - .as_deref() - .unwrap_or("Unnamed session"); + let name = session.name.as_deref().unwrap_or("Unnamed session"); - let created = session.created_at + let created = session + .created_at .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); @@ -1391,41 +1511,47 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { let info = format!( "{} messages · {} · {}", - session.message_count, - session.model, - age_str + session.message_count, session.model, age_str ); let is_selected = idx == app.selected_session_index(); let style = if is_selected { Style::default() - .fg(Color::Black) - .bg(Color::Yellow) + .fg(theme.selection_fg) + .bg(theme.selection_bg) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(theme.text) }; let info_style = if is_selected { - Style::default().fg(Color::Black).bg(Color::Yellow) + Style::default() + .fg(theme.selection_fg) + .bg(theme.selection_bg) } else { - Style::default().fg(Color::Gray) + Style::default().fg(theme.placeholder) }; let desc_style = if is_selected { - Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::ITALIC) + Style::default() + .fg(theme.selection_fg) + .bg(theme.selection_bg) + .add_modifier(Modifier::ITALIC) } else { - Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC) + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::ITALIC) }; - let mut lines = vec![ - Line::from(Span::styled(name, style)), - ]; + let mut lines = vec![Line::from(Span::styled(name, style))]; // Add description if available and not empty if let Some(description) = &session.description { if !description.is_empty() { - lines.push(Line::from(Span::styled(format!(" \"{}\"", description), desc_style))); + lines.push(Line::from(Span::styled( + format!(" \"{}\"", description), + desc_style, + ))); } } @@ -1440,10 +1566,11 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { Block::default() .title(Span::styled( format!(" Saved Sessions ({}) ", sessions.len()), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)), + .border_style(Style::default().fg(theme.info)) + .style(Style::default().bg(theme.background).fg(theme.text)), ); let footer = Paragraph::new(vec![ @@ -1451,14 +1578,133 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { Line::from("↑/↓ or j/k: Navigate · Enter: Load · d: Delete · Esc: Cancel"), ]) .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); + .style(Style::default().fg(theme.placeholder).bg(theme.background)); let layout = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(5), - Constraint::Length(3), - ]) + .constraints([Constraint::Min(5), Constraint::Length(3)]) + .split(area); + + frame.render_widget(list, layout[0]); + frame.render_widget(footer, layout[1]); +} + +fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { + let theme = app.theme(); + let area = centered_rect(60, 70, frame.area()); + frame.render_widget(Clear, area); + + let themes = app.available_themes(); + let current_theme_name = &app.theme().name; + + if themes.is_empty() { + let text = vec![ + Line::from(""), + Line::from("No themes available."), + Line::from(""), + Line::from("Press Esc to close."), + ]; + + let paragraph = Paragraph::new(text) + .style(Style::default().bg(theme.background)) + .block( + Block::default() + .title(Span::styled( + " Themes ", + Style::default() + .fg(theme.mode_help) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.mode_help)) + .style(Style::default().bg(theme.background).fg(theme.text)), + ) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, area); + return; + } + + // Get theme metadata to show built-in vs custom + let all_themes = owlen_core::theme::load_all_themes(); + let built_in = owlen_core::theme::built_in_themes(); + + let items: Vec = themes + .iter() + .enumerate() + .map(|(idx, theme_name)| { + let is_current = theme_name == current_theme_name; + let is_selected = idx == app.selected_theme_index(); + let is_built_in = built_in.contains_key(theme_name); + + // Build display name + let mut display = theme_name.clone(); + if is_current { + display.push_str(" ✓"); + } + + let type_indicator = if is_built_in { "built-in" } else { "custom" }; + + let name_style = if is_selected { + Style::default() + .fg(theme.selection_fg) + .bg(theme.selection_bg) + .add_modifier(Modifier::BOLD) + } else if is_current { + Style::default() + .fg(theme.focused_panel_border) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + + let info_style = if is_selected { + Style::default() + .fg(theme.selection_fg) + .bg(theme.selection_bg) + } else { + Style::default().fg(theme.placeholder) + }; + + // Try to get theme description or show type + let info_text = if all_themes.contains_key(theme_name) { + format!(" {} · {}", type_indicator, theme_name) + } else { + format!(" {}", type_indicator) + }; + + let lines = vec![ + Line::from(Span::styled(display, name_style)), + Line::from(Span::styled(info_text, info_style)), + ]; + + ListItem::new(lines) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .title(Span::styled( + format!(" Themes ({}) ", themes.len()), + Style::default() + .fg(theme.mode_help) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.mode_help)) + .style(Style::default().bg(theme.background).fg(theme.text)), + ); + + let footer = Paragraph::new(vec![ + Line::from(""), + Line::from("↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc/q: Cancel"), + ]) + .alignment(Alignment::Center) + .style(Style::default().fg(theme.placeholder).bg(theme.background)); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(3)]) .split(area); frame.render_widget(list, layout[0]); @@ -1466,6 +1712,7 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { } fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { + let theme = app.theme(); let suggestions = app.command_suggestions(); // Only show suggestions if there are any @@ -1495,29 +1742,27 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let is_selected = idx == app.selected_suggestion(); let style = if is_selected { Style::default() - .fg(Color::Black) - .bg(Color::Yellow) + .fg(theme.selection_fg) + .bg(theme.selection_bg) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(theme.text) }; ListItem::new(Span::styled(cmd.to_string(), style)) }) .collect(); - let list = List::new(items) - .block( - Block::default() - .title(Span::styled( - " Commands (Tab to complete) ", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)), - ); + let list = List::new(items).block( + Block::default() + .title(Span::styled( + " Commands (Tab to complete) ", + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.info)) + .style(Style::default().bg(theme.background).fg(theme.text)), + ); frame.render_widget(list, popup_area); } @@ -1548,10 +1793,10 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { .split(vertical[1])[1] } -fn role_color(role: &Role) -> Style { +fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style { match role { - Role::User => Style::default().fg(Color::LightBlue), - Role::Assistant => Style::default().fg(Color::LightMagenta), - Role::System => Style::default().fg(Color::Cyan), + Role::User => Style::default().fg(theme.user_message_role), + Role::Assistant => Style::default().fg(theme.assistant_message_role), + Role::System => Style::default().fg(theme.info), } } diff --git a/themes/README.md b/themes/README.md new file mode 100644 index 0000000..6714960 --- /dev/null +++ b/themes/README.md @@ -0,0 +1,89 @@ +# OWLEN Built-in Themes + +This directory contains the built-in themes that are embedded into the OWLEN binary. + +## Available Themes + +- **default_dark** - High-contrast dark theme (default) +- **default_light** - Clean light theme +- **gruvbox** - Popular retro color scheme with warm tones +- **dracula** - Dark theme with vibrant purple and cyan colors +- **solarized** - Precision colors for optimal readability +- **midnight-ocean** - Deep blue oceanic theme +- **rose-pine** - Soho vibes with muted pastels +- **monokai** - Classic code editor theme +- **material-dark** - Google's Material Design dark variant +- **material-light** - Google's Material Design light variant + +## Theme File Format + +Each theme is defined in TOML format with the following structure: + +```toml +name = "theme-name" + +# Text colors +text = "#ffffff" # Main text color +placeholder = "#808080" # Placeholder/muted text + +# Background colors +background = "#000000" # Main background +command_bar_background = "#111111" +status_background = "#111111" + +# Border colors +focused_panel_border = "#ff00ff" # Active panel border +unfocused_panel_border = "#800080" # Inactive panel border + +# Message role colors +user_message_role = "#00ffff" # User messages +assistant_message_role = "#ffff00" # Assistant messages +thinking_panel_title = "#ff00ff" # Thinking panel title + +# Mode indicator colors (status bar) +mode_normal = "#00ffff" +mode_editing = "#00ff00" +mode_model_selection = "#ffff00" +mode_provider_selection = "#00ffff" +mode_help = "#ff00ff" +mode_visual = "#ff0080" +mode_command = "#ffff00" + +# Selection and cursor +selection_bg = "#0000ff" # Selection background +selection_fg = "#ffffff" # Selection foreground +cursor = "#ff0080" # Cursor color + +# Status colors +error = "#ff0000" # Error messages +info = "#00ff00" # Info/success messages +``` + +## Color Format + +Colors can be specified in two formats: + +1. **Hex RGB**: `#rrggbb` (e.g., `#ff0000` for red, `#ff8800` for orange) +2. **Named colors** (case-insensitive): + - **Basic**: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` + - **Gray variants**: `gray`, `grey`, `darkgray`, `darkgrey` + - **Light variants**: `lightred`, `lightgreen`, `lightyellow`, `lightblue`, `lightmagenta`, `lightcyan` + +**Note**: For colors not in the named list (like orange, purple, brown), use hex RGB format. + +OWLEN will display an error message on startup if a custom theme has invalid colors. + +## Creating Custom Themes + +To create your own theme: + +1. Copy one of these files to `~/.config/owlen/themes/` +2. Rename and modify the colors +3. Set `theme = "your-theme-name"` in `~/.config/owlen/config.toml` +4. Or use `:theme your-theme-name` in OWLEN to switch + +## Embedding in Binary + +These theme files are embedded into the OWLEN binary at compile time using Rust's `include_str!()` macro. This ensures they're always available, even if the files are deleted from disk. + +Custom themes placed in `~/.config/owlen/themes/` will override built-in themes with the same name. diff --git a/themes/default_dark.toml b/themes/default_dark.toml new file mode 100644 index 0000000..cae394c --- /dev/null +++ b/themes/default_dark.toml @@ -0,0 +1,23 @@ +name = "default_dark" +text = "white" +background = "black" +focused_panel_border = "lightmagenta" +unfocused_panel_border = "#5f1487" +user_message_role = "lightblue" +assistant_message_role = "yellow" +thinking_panel_title = "lightmagenta" +command_bar_background = "black" +status_background = "black" +mode_normal = "lightblue" +mode_editing = "lightgreen" +mode_model_selection = "lightyellow" +mode_provider_selection = "lightcyan" +mode_help = "lightmagenta" +mode_visual = "magenta" +mode_command = "yellow" +selection_bg = "lightblue" +selection_fg = "black" +cursor = "magenta" +placeholder = "darkgray" +error = "red" +info = "lightgreen" diff --git a/themes/default_light.toml b/themes/default_light.toml new file mode 100644 index 0000000..15e85a8 --- /dev/null +++ b/themes/default_light.toml @@ -0,0 +1,23 @@ +name = "default_light" +text = "black" +background = "white" +focused_panel_border = "#4a90e2" +unfocused_panel_border = "#dddddd" +user_message_role = "#0055a4" +assistant_message_role = "#8e44ad" +thinking_panel_title = "#8e44ad" +command_bar_background = "white" +status_background = "white" +mode_normal = "#0055a4" +mode_editing = "#2e8b57" +mode_model_selection = "#b58900" +mode_provider_selection = "#008b8b" +mode_help = "#8e44ad" +mode_visual = "#8e44ad" +mode_command = "#b58900" +selection_bg = "#a4c8f0" +selection_fg = "black" +cursor = "#d95f02" +placeholder = "gray" +error = "#c0392b" +info = "green" \ No newline at end of file diff --git a/themes/dracula.toml b/themes/dracula.toml new file mode 100644 index 0000000..45faeb2 --- /dev/null +++ b/themes/dracula.toml @@ -0,0 +1,23 @@ +name = "dracula" +text = "#f8f8f2" +background = "#282a36" +focused_panel_border = "#ff79c6" +unfocused_panel_border = "#44475a" +user_message_role = "#8be9fd" +assistant_message_role = "#ff79c6" +thinking_panel_title = "#bd93f9" +command_bar_background = "#44475a" +status_background = "#44475a" +mode_normal = "#8be9fd" +mode_editing = "#50fa7b" +mode_model_selection = "#f1fa8c" +mode_provider_selection = "#8be9fd" +mode_help = "#bd93f9" +mode_visual = "#ff79c6" +mode_command = "#f1fa8c" +selection_bg = "#44475a" +selection_fg = "#f8f8f2" +cursor = "#ff79c6" +placeholder = "#6272a4" +error = "#ff5555" +info = "#50fa7b" diff --git a/themes/gruvbox.toml b/themes/gruvbox.toml new file mode 100644 index 0000000..35ba865 --- /dev/null +++ b/themes/gruvbox.toml @@ -0,0 +1,23 @@ +name = "gruvbox" +text = "#ebdbb2" +background = "#282828" +focused_panel_border = "#fe8019" +unfocused_panel_border = "#7c6f64" +user_message_role = "#b8bb26" +assistant_message_role = "#83a598" +thinking_panel_title = "#d3869b" +command_bar_background = "#3c3836" +status_background = "#3c3836" +mode_normal = "#83a598" +mode_editing = "#b8bb26" +mode_model_selection = "#fabd2f" +mode_provider_selection = "#8ec07c" +mode_help = "#d3869b" +mode_visual = "#fe8019" +mode_command = "#fabd2f" +selection_bg = "#504945" +selection_fg = "#ebdbb2" +cursor = "#fe8019" +placeholder = "#665c54" +error = "#fb4934" +info = "#b8bb26" diff --git a/themes/material-dark.toml b/themes/material-dark.toml new file mode 100644 index 0000000..0c859be --- /dev/null +++ b/themes/material-dark.toml @@ -0,0 +1,23 @@ +name = "material-dark" +text = "#eeffff" +background = "#263238" +focused_panel_border = "#80cbc4" +unfocused_panel_border = "#546e7a" +user_message_role = "#82aaff" +assistant_message_role = "#c792ea" +thinking_panel_title = "#ffcb6b" +command_bar_background = "#212b30" +status_background = "#212b30" +mode_normal = "#82aaff" +mode_editing = "#c3e88d" +mode_model_selection = "#ffcb6b" +mode_provider_selection = "#80cbc4" +mode_help = "#c792ea" +mode_visual = "#f07178" +mode_command = "#ffcb6b" +selection_bg = "#546e7a" +selection_fg = "#eeffff" +cursor = "#ffcc00" +placeholder = "#546e7a" +error = "#f07178" +info = "#c3e88d" diff --git a/themes/material-light.toml b/themes/material-light.toml new file mode 100644 index 0000000..6cbf974 --- /dev/null +++ b/themes/material-light.toml @@ -0,0 +1,23 @@ +name = "material-light" +text = "#212121" +background = "#eceff1" +focused_panel_border = "#009688" +unfocused_panel_border = "#b0bec5" +user_message_role = "#448aff" +assistant_message_role = "#7c4dff" +thinking_panel_title = "#f57c00" +command_bar_background = "#ffffff" +status_background = "#ffffff" +mode_normal = "#448aff" +mode_editing = "#388e3c" +mode_model_selection = "#f57c00" +mode_provider_selection = "#009688" +mode_help = "#7c4dff" +mode_visual = "#d32f2f" +mode_command = "#f57c00" +selection_bg = "#b0bec5" +selection_fg = "#212121" +cursor = "#c2185b" +placeholder = "#90a4ae" +error = "#d32f2f" +info = "#388e3c" \ No newline at end of file diff --git a/themes/midnight-ocean.toml b/themes/midnight-ocean.toml new file mode 100644 index 0000000..c23297f --- /dev/null +++ b/themes/midnight-ocean.toml @@ -0,0 +1,23 @@ +name = "midnight-ocean" +text = "#c0caf5" +background = "#0d1117" +focused_panel_border = "#58a6ff" +unfocused_panel_border = "#30363d" +user_message_role = "#79c0ff" +assistant_message_role = "#89ddff" +thinking_panel_title = "#9ece6a" +command_bar_background = "#161b22" +status_background = "#161b22" +mode_normal = "#79c0ff" +mode_editing = "#9ece6a" +mode_model_selection = "#ffd43b" +mode_provider_selection = "#89ddff" +mode_help = "#ff739d" +mode_visual = "#f68cf5" +mode_command = "#ffd43b" +selection_bg = "#388bfd" +selection_fg = "#0d1117" +cursor = "#f68cf5" +placeholder = "#6e7681" +error = "#f85149" +info = "#9ece6a" diff --git a/themes/monokai.toml b/themes/monokai.toml new file mode 100644 index 0000000..bd83cf5 --- /dev/null +++ b/themes/monokai.toml @@ -0,0 +1,23 @@ +name = "monokai" +text = "#f8f8f2" +background = "#272822" +focused_panel_border = "#f92672" +unfocused_panel_border = "#75715e" +user_message_role = "#66d9ef" +assistant_message_role = "#ae81ff" +thinking_panel_title = "#e6db74" +command_bar_background = "#272822" +status_background = "#272822" +mode_normal = "#66d9ef" +mode_editing = "#a6e22e" +mode_model_selection = "#e6db74" +mode_provider_selection = "#66d9ef" +mode_help = "#ae81ff" +mode_visual = "#f92672" +mode_command = "#e6db74" +selection_bg = "#75715e" +selection_fg = "#f8f8f2" +cursor = "#f92672" +placeholder = "#75715e" +error = "#f92672" +info = "#a6e22e" diff --git a/themes/rose-pine.toml b/themes/rose-pine.toml new file mode 100644 index 0000000..a8400b4 --- /dev/null +++ b/themes/rose-pine.toml @@ -0,0 +1,23 @@ +name = "rose-pine" +text = "#e0def4" +background = "#191724" +focused_panel_border = "#eb6f92" +unfocused_panel_border = "#26233a" +user_message_role = "#31748f" +assistant_message_role = "#9ccfd8" +thinking_panel_title = "#c4a7e7" +command_bar_background = "#26233a" +status_background = "#26233a" +mode_normal = "#9ccfd8" +mode_editing = "#ebbcba" +mode_model_selection = "#f6c177" +mode_provider_selection = "#31748f" +mode_help = "#c4a7e7" +mode_visual = "#eb6f92" +mode_command = "#f6c177" +selection_bg = "#403d52" +selection_fg = "#e0def4" +cursor = "#eb6f92" +placeholder = "#6e6a86" +error = "#eb6f92" +info = "#9ccfd8" diff --git a/themes/solarized.toml b/themes/solarized.toml new file mode 100644 index 0000000..ef40542 --- /dev/null +++ b/themes/solarized.toml @@ -0,0 +1,23 @@ +name = "solarized" +text = "#839496" +background = "#002b36" +focused_panel_border = "#268bd2" +unfocused_panel_border = "#073642" +user_message_role = "#2aa198" +assistant_message_role = "#cb4b16" +thinking_panel_title = "#6c71c4" +command_bar_background = "#073642" +status_background = "#073642" +mode_normal = "#268bd2" +mode_editing = "#859900" +mode_model_selection = "#b58900" +mode_provider_selection = "#2aa198" +mode_help = "#6c71c4" +mode_visual = "#d33682" +mode_command = "#b58900" +selection_bg = "#073642" +selection_fg = "#93a1a1" +cursor = "#d33682" +placeholder = "#586e75" +error = "#dc322f" +info = "#859900"