Merge pull request 'Add built-in theme support with various pre-defined themes' (#28) from theming into main

Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
2025-10-03 07:48:18 +02:00
24 changed files with 1740 additions and 247 deletions

View File

@@ -9,7 +9,7 @@ members = [
exclude = [] exclude = []
[workspace.package] [workspace.package]
version = "0.1.8" version = "0.1.9"
edition = "2021" edition = "2021"
authors = ["Owlibou"] authors = ["Owlibou"]
license = "AGPL-3.0" license = "AGPL-3.0"

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev> # Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlen pkgname=owlen
pkgver=0.1.8 pkgver=0.1.9
pkgrel=1 pkgrel=1
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features" pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
arch=('x86_64') arch=('x86_64')
@@ -40,5 +40,11 @@ package() {
# Install documentation # Install documentation
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" 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
} }

View File

@@ -3,13 +3,13 @@
> Terminal-native assistant for running local language models with a comfortable TUI. > Terminal-native assistant for running local language models with a comfortable TUI.
![Status](https://img.shields.io/badge/status-alpha-yellow) ![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) ![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) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue)
## Alpha Status ## 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. - Core features are functional but expect occasional bugs and missing polish.
- Breaking changes may occur between releases as we refine the API. - Breaking changes may occur between releases as we refine the API.
- Feedback, bug reports, and contributions are very welcome! - 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 - **Session Management** - Start new conversations, clear history, browse saved sessions
- **Thinking Mode Support** - Dedicated panel for extended reasoning content - **Thinking Mode Support** - Dedicated panel for extended reasoning content
- **Bracketed Paste** - Safe paste handling for multi-line 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] ### Code Client (`owlen-code`) [Experimental]
- All chat client features - 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 - `Enter` - Send message and return to normal mode
- `Ctrl-J` / `Shift-Enter` - Insert newline - `Ctrl-J` / `Shift-Enter` - Insert newline
- `Ctrl-↑/↓` - Navigate input history - `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 - Paste events handled automatically
**Visual Mode**: **Visual Mode**:
- `j/k/h/l` - Extend selection - `j/k/h/l` - Extend selection
- `w/b/e` - Word-based selection - `w/b/e` - Word-based selection
- `y` - Yank (copy) selection - `y` - Yank (copy) selection
- `d` - Cut selection (Input panel only) - `d` / `Delete` - Cut selection (Input panel only)
- `Esc` - Cancel selection - `Esc` / `v` - Cancel selection
**Command Mode**: **Command Mode**:
- `Tab` - Autocomplete selected command suggestion - `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 - `:save [name]` / `:w [name]` - Save current conversation
- `:load` / `:open` - Browse and load saved sessions - `:load` / `:open` - Browse and load saved sessions
- `:sessions` / `:ls` - List saved sessions - `:sessions` / `:ls` - List saved sessions
- `:theme <name>` - Switch theme (saved to config)
- `:themes` - Browse themes in interactive modal
- `:reload` - Reload configuration and themes
- *Commands show real-time suggestions as you type* - *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`): **Session Browser** (accessed via `:load` or `:sessions`):
- `j` / `k` / `↑` / `↓` - Navigate sessions - `j` / `k` / `↑` / `↓` - Navigate sessions
- `Enter` - Load selected session - `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. 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 <name>` - 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 ## Repository Layout
``` ```
owlen/ owlen/
├── crates/ ├── 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-ollama/ # Ollama provider implementation
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering) │ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code) │ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
├── themes/ # Built-in theme definitions (embedded in binary)
├── LICENSE # AGPL-3.0 License ├── LICENSE # AGPL-3.0 License
├── Cargo.toml # Workspace configuration ├── Cargo.toml # Workspace configuration
└── README.md └── README.md
@@ -270,9 +331,9 @@ cargo fmt
- [x] Bracketed paste support - [x] Bracketed paste support
- [x] Command autocompletion with Tab completion - [x] Command autocompletion with Tab completion
- [x] Session persistence (save/load conversations) - [x] Session persistence (save/load conversations)
- [x] Theming system with 9 built-in themes and custom theme support
### In Progress ### In Progress
- [ ] Theming options and color customization
- [ ] Enhanced configuration UX (in-app settings) - [ ] Enhanced configuration UX (in-app settings)
- [ ] Conversation export (Markdown, JSON, plain text) - [ ] Conversation export (Markdown, JSON, plain text)
@@ -322,4 +383,4 @@ Built with:
--- ---
**Status**: Alpha v0.1.8 | **License**: AGPL-3.0 | **Made with Rust** 🦀 **Status**: Alpha v0.1.9 | **License**: AGPL-3.0 | **Made with Rust** 🦀

View File

@@ -24,6 +24,7 @@ async-trait = "0.1.73"
toml = "0.8.0" toml = "0.8.0"
shellexpand = "3.1.0" shellexpand = "3.1.0"
dirs = "5.0" dirs = "5.0"
ratatui = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }

View File

@@ -202,7 +202,7 @@ pub struct UiSettings {
impl UiSettings { impl UiSettings {
fn default_theme() -> String { fn default_theme() -> String {
"default".to_string() "default_dark".to_string()
} }
fn default_word_wrap() -> bool { fn default_word_wrap() -> bool {
@@ -388,7 +388,9 @@ mod tests {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// macOS should use ~/Library/Application Support // 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()); println!("Config conversation path: {}", path.display());

View File

@@ -277,20 +277,26 @@ impl ConversationManager {
&self, &self,
storage: &StorageManager, storage: &StorageManager,
name: Option<String>, name: Option<String>,
description: Option<String> description: Option<String>,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
storage.save_conversation_with_description(&self.active, name, description) storage.save_conversation_with_description(&self.active, name, description)
} }
/// Load a conversation from disk and make it active /// Load a conversation from disk and make it active
pub fn load_from_disk(&mut self, storage: &StorageManager, path: impl AsRef<Path>) -> Result<()> { pub fn load_from_disk(
&mut self,
storage: &StorageManager,
path: impl AsRef<Path>,
) -> Result<()> {
let conversation = storage.load_conversation(path)?; let conversation = storage.load_conversation(path)?;
self.load(conversation); self.load(conversation);
Ok(()) Ok(())
} }
/// List all saved sessions /// List all saved sessions
pub fn list_saved_sessions(storage: &StorageManager) -> Result<Vec<crate::storage::SessionMeta>> { pub fn list_saved_sessions(
storage: &StorageManager,
) -> Result<Vec<crate::storage::SessionMeta>> {
storage.list_sessions() storage.list_sessions()
} }
} }

View File

@@ -12,6 +12,7 @@ pub mod provider;
pub mod router; pub mod router;
pub mod session; pub mod session;
pub mod storage; pub mod storage;
pub mod theme;
pub mod types; pub mod types;
pub mod ui; pub mod ui;
pub mod wrap_cursor; pub mod wrap_cursor;
@@ -24,6 +25,7 @@ pub use model::*;
pub use provider::*; pub use provider::*;
pub use router::*; pub use router::*;
pub use session::*; pub use session::*;
pub use theme::*;
/// Result type used throughout the OWLEN ecosystem /// Result type used throughout the OWLEN ecosystem
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -231,7 +231,15 @@ impl SessionController {
if conv.messages.len() == 1 { if conv.messages.len() == 1 {
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
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 // 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 // Add system message to guide the description
summary_messages.push(crate::types::Message::system( summary_messages.push(crate::types::Message::system(
"Summarize this conversation in 1-2 short sentences (max 100 characters). \ "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 // Include first message
@@ -283,7 +292,15 @@ impl SessionController {
if description.is_empty() { if description.is_empty() {
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
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 // Truncate if too long
@@ -298,7 +315,15 @@ impl SessionController {
// Fallback to simple description if AI generation fails // Fallback to simple description if AI generation fails
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })) Ok(format!(
"{}{}",
preview,
if first_msg.content.len() > 50 {
"..."
} else {
""
}
))
} }
} }
} }

View File

@@ -45,10 +45,7 @@ impl StorageManager {
// Ensure the directory exists // Ensure the directory exists
if !sessions_dir.exists() { if !sessions_dir.exists() {
fs::create_dir_all(&sessions_dir).map_err(|e| { fs::create_dir_all(&sessions_dir).map_err(|e| {
Error::Storage(format!( Error::Storage(format!("Failed to create sessions directory: {}", e))
"Failed to create sessions directory: {}",
e
))
})?; })?;
} }
@@ -66,7 +63,11 @@ impl StorageManager {
} }
/// Save a conversation to disk /// Save a conversation to disk
pub fn save_conversation(&self, conversation: &Conversation, name: Option<String>) -> Result<PathBuf> { pub fn save_conversation(
&self,
conversation: &Conversation,
name: Option<String>,
) -> Result<PathBuf> {
self.save_conversation_with_description(conversation, name, None) self.save_conversation_with_description(conversation, name, None)
} }
@@ -75,7 +76,7 @@ impl StorageManager {
&self, &self,
conversation: &Conversation, conversation: &Conversation,
name: Option<String>, name: Option<String>,
description: Option<String> description: Option<String>,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
let filename = if let Some(ref session_name) = name { let filename = if let Some(ref session_name) = name {
// Use provided name, sanitized // Use provided name, sanitized
@@ -101,26 +102,22 @@ impl StorageManager {
save_conv.description = description; save_conv.description = description;
} }
let json = serde_json::to_string_pretty(&save_conv).map_err(|e| { let json = serde_json::to_string_pretty(&save_conv)
Error::Storage(format!("Failed to serialize conversation: {}", e)) .map_err(|e| Error::Storage(format!("Failed to serialize conversation: {}", e)))?;
})?;
fs::write(&path, json).map_err(|e| { fs::write(&path, json)
Error::Storage(format!("Failed to write session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to write session file: {}", e)))?;
})?;
Ok(path) Ok(path)
} }
/// Load a conversation from disk /// Load a conversation from disk
pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> { pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| { let content = fs::read_to_string(path.as_ref())
Error::Storage(format!("Failed to read session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to read session file: {}", e)))?;
})?;
let conversation: Conversation = serde_json::from_str(&content).map_err(|e| { let conversation: Conversation = serde_json::from_str(&content)
Error::Storage(format!("Failed to parse session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to parse session file: {}", e)))?;
})?;
Ok(conversation) Ok(conversation)
} }
@@ -129,14 +126,12 @@ impl StorageManager {
pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> { pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let mut sessions = Vec::new(); let mut sessions = Vec::new();
let entries = fs::read_dir(&self.sessions_dir).map_err(|e| { let entries = fs::read_dir(&self.sessions_dir)
Error::Storage(format!("Failed to read sessions directory: {}", e)) .map_err(|e| Error::Storage(format!("Failed to read sessions directory: {}", e)))?;
})?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|e| { let entry = entry
Error::Storage(format!("Failed to read directory entry: {}", e)) .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
})?;
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") { if path.extension().and_then(|s| s.to_str()) != Some("json") {
@@ -172,9 +167,8 @@ impl StorageManager {
/// Delete a saved session /// Delete a saved session
pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> { pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> {
fs::remove_file(path.as_ref()).map_err(|e| { fs::remove_file(path.as_ref())
Error::Storage(format!("Failed to delete session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to delete session file: {}", e)))
})
} }
/// Get the sessions directory path /// Get the sessions directory path
@@ -237,7 +231,9 @@ mod tests {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// macOS should use ~/Library/Application Support // 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()); println!("Default sessions directory: {}", path.display());
@@ -257,10 +253,13 @@ mod tests {
let mut conv = Conversation::new("test-model".to_string()); let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user("Hello".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 // 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()); assert!(path.exists());
// Load conversation // Load conversation
@@ -280,7 +279,9 @@ mod tests {
for i in 0..3 { for i in 0..3 {
let mut conv = Conversation::new("test-model".to_string()); let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user(format!("Message {}", i))); 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 // List sessions

View File

@@ -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<String, Theme> {
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<Theme, String> {
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<Theme> {
load_all_themes().get(name).cloned()
}
/// Get all built-in themes (embedded in the binary)
pub fn built_in_themes() -> HashMap<String, Theme> {
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::<Theme>(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<Theme> {
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<Color, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_color(&s).map_err(serde::de::Error::custom)
}
fn serialize_color<S>(color: &Color, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = color_to_string(color);
serializer.serialize_str(&s)
}
fn parse_color(s: &str) -> Result<Color, String> {
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"));
}
}

View File

@@ -23,6 +23,7 @@ pub enum InputMode {
Visual, Visual,
Command, Command,
SessionBrowser, SessionBrowser,
ThemeBrowser,
} }
impl fmt::Display for InputMode { impl fmt::Display for InputMode {
@@ -36,6 +37,7 @@ impl fmt::Display for InputMode {
InputMode::Visual => "Visual", InputMode::Visual => "Visual",
InputMode::Command => "Command", InputMode::Command => "Command",
InputMode::SessionBrowser => "Sessions", InputMode::SessionBrowser => "Sessions",
InputMode::ThemeBrowser => "Themes",
}; };
f.write_str(label) f.write_str(label)
} }

View File

@@ -2,6 +2,7 @@ use anyhow::Result;
use owlen_core::{ use owlen_core::{
session::{SessionController, SessionOutcome}, session::{SessionController, SessionOutcome},
storage::{SessionMeta, StorageManager}, storage::{SessionMeta, StorageManager},
theme::Theme,
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role}, types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
ui::{AppState, AutoScroll, FocusedPanel, InputMode}, ui::{AppState, AutoScroll, FocusedPanel, InputMode},
}; };
@@ -61,8 +62,10 @@ pub struct ChatApp {
storage: StorageManager, // Storage manager for session persistence storage: StorageManager, // Storage manager for session persistence
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
selected_session_index: usize, // Index of selected session in browser 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) help_tab_index: usize, // Currently selected help tab (0-4)
theme: Theme, // Current theme
available_themes: Vec<String>, // Cached list of theme names
selected_theme_index: usize, // Index of selected theme in browser
} }
impl ChatApp { impl ChatApp {
@@ -77,6 +80,13 @@ impl ChatApp {
.expect("Failed to create fallback storage") .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 { let app = Self {
controller, controller,
mode: InputMode::Normal, mode: InputMode::Normal,
@@ -112,8 +122,10 @@ impl ChatApp {
storage, storage,
saved_sessions: Vec::new(), saved_sessions: Vec::new(),
selected_session_index: 0, selected_session_index: 0,
save_name_buffer: String::new(),
help_tab_index: 0, help_tab_index: 0,
theme,
available_themes: Vec::new(),
selected_theme_index: 0,
}; };
(app, session_rx) (app, session_rx)
@@ -238,6 +250,9 @@ impl ChatApp {
("m", "Alias for model"), ("m", "Alias for model"),
("new", "Start a new conversation"), ("new", "Start a new conversation"),
("n", "Alias for new"), ("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 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) { pub fn cycle_focus_forward(&mut self) {
self.focused_panel = match self.focused_panel { self.focused_panel = match self.focused_panel {
FocusedPanel::Chat => { FocusedPanel::Chat => {
@@ -1086,9 +1134,7 @@ impl ChatApp {
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => { (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
// Navigate up in suggestions // Navigate up in suggestions
if !self.command_suggestions.is_empty() { if !self.command_suggestions.is_empty() {
self.selected_suggestion = self self.selected_suggestion = self.selected_suggestion.saturating_sub(1);
.selected_suggestion
.saturating_sub(1);
} }
} }
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => { (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
@@ -1122,9 +1168,14 @@ impl ChatApp {
}; };
// Generate description if enabled in config // Generate description if enabled in config
let description = if self.controller.config().storage.generate_descriptions { let description =
if self.controller.config().storage.generate_descriptions {
self.status = "Generating description...".to_string(); self.status = "Generating description...".to_string();
match self.controller.generate_conversation_description().await { match self
.controller
.generate_conversation_description()
.await
{
Ok(desc) => Some(desc), Ok(desc) => Some(desc),
Err(_) => None, Err(_) => None,
} }
@@ -1133,7 +1184,14 @@ impl ChatApp {
}; };
// Save the conversation with description // 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) => { Ok(path) => {
self.status = format!("Session saved: {}", path.display()); self.status = format!("Session saved: {}", path.display());
self.error = None; self.error = None;
@@ -1155,7 +1213,8 @@ impl ChatApp {
return Ok(AppState::Running); return Ok(AppState::Running);
} }
Err(e) => { 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); return Ok(AppState::Running);
} }
Err(e) => { 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.controller.start_new_conversation(None, None);
self.status = "Started new conversation".to_string(); self.status = "Started new conversation".to_string();
} }
"theme" => {
if args.is_empty() {
self.error = Some("Usage: :theme <name>".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<String> = 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)); self.error = Some(format!("Unknown command: {}", cmd));
} }
@@ -1290,7 +1414,7 @@ impl ChatApp {
} }
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
// Next tab // Next tab
if self.help_tab_index < 4 { if self.help_tab_index < 5 {
self.help_tab_index += 1; self.help_tab_index += 1;
} }
} }
@@ -1305,6 +1429,7 @@ impl ChatApp {
KeyCode::Char('3') => self.help_tab_index = 2, KeyCode::Char('3') => self.help_tab_index = 2,
KeyCode::Char('4') => self.help_tab_index = 3, KeyCode::Char('4') => self.help_tab_index = 3,
KeyCode::Char('5') => self.help_tab_index = 4, KeyCode::Char('5') => self.help_tab_index = 4,
KeyCode::Char('6') => self.help_tab_index = 5,
_ => {} _ => {}
}, },
InputMode::SessionBrowser => match key.code { InputMode::SessionBrowser => match key.code {
@@ -1313,10 +1438,18 @@ impl ChatApp {
} }
KeyCode::Enter => { KeyCode::Enter => {
// Load selected session // Load 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.controller.conversation_mut().load_from_disk(&self.storage, &session.path) { {
match self
.controller
.conversation_mut()
.load_from_disk(&self.storage, &session.path)
{
Ok(_) => { 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; self.error = None;
// Update thinking panel // Update thinking panel
self.update_thinking_from_last_message(); self.update_thinking_from_last_message();
@@ -1340,11 +1473,14 @@ impl ChatApp {
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
// Delete selected session // 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) { match self.storage.delete_session(&session.path) {
Ok(_) => { Ok(_) => {
self.saved_sessions.remove(self.selected_session_index); 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.selected_session_index = self.saved_sessions.len() - 1;
} }
self.status = "Session deleted".to_string(); 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;
}
}
_ => {}
},
}, },
_ => {} _ => {}
} }

File diff suppressed because it is too large Load Diff

89
themes/README.md Normal file
View File

@@ -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.

23
themes/default_dark.toml Normal file
View File

@@ -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"

23
themes/default_light.toml Normal file
View File

@@ -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"

23
themes/dracula.toml Normal file
View File

@@ -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"

23
themes/gruvbox.toml Normal file
View File

@@ -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"

23
themes/material-dark.toml Normal file
View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

23
themes/monokai.toml Normal file
View File

@@ -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"

23
themes/rose-pine.toml Normal file
View File

@@ -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"

23
themes/solarized.toml Normal file
View File

@@ -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"