feat(ui): add glass modals and theme preview

AC:\n- Theme, help, command, and model modals share the glass chrome.\n- Theme selector shows a live preview for the highlighted palette.\n- Updated docs and screenshots explain the refreshed cockpit.\n\nTests:\n- cargo test -p owlen-tui
This commit is contained in:
2025-10-24 02:54:19 +02:00
parent bbb94367e1
commit 3f6d7d56f6
10 changed files with 1397 additions and 365 deletions

View File

@@ -23,7 +23,7 @@ This project is currently in **alpha** and under active development. Core featur
![OWLEN TUI Layout](images/layout.png) ![OWLEN TUI Layout](images/layout.png)
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory. The refreshed chrome introduces a cockpit-style header with live gradient gauges for context and cloud usage, plus glassy panels that keep vim-inspired navigation easy to follow. See more screenshots in the [`images/`](images/) directory.
## Features ## Features
@@ -32,6 +32,7 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig
- **Advanced Text Editing**: Multi-line input, history, and clipboard support. - **Advanced Text Editing**: Multi-line input, history, and clipboard support.
- **Session Management**: Save, load, and manage conversations. - **Session Management**: Save, load, and manage conversations.
- **Code Side Panel**: Switch to code mode (`:mode code`) and open files inline with `:open <path>` for LLM-assisted coding. - **Code Side Panel**: Switch to code mode (`:mode code`) and open files inline with `:open <path>` for LLM-assisted coding.
- **Cockpit Header**: Gradient context and cloud usage bars with live quota bands and provider fallbacks.
- **Theming System**: 10 built-in themes and support for custom themes. - **Theming System**: 10 built-in themes and support for custom themes.
- **Modular Architecture**: Extensible provider system orchestrated by the new `ProviderManager`, ready for additional MCP-backed providers. - **Modular Architecture**: Extensible provider system orchestrated by the new `ProviderManager`, ready for additional MCP-backed providers.
- **Dual-Source Model Picker**: Merge local and cloud catalogues with real-time availability badges powered by the background health worker. - **Dual-Source Model Picker**: Merge local and cloud catalogues with real-time availability badges powered by the background health worker.

View File

@@ -115,6 +115,7 @@ pub struct ContextUsage {
pub(crate) struct LayoutSnapshot { pub(crate) struct LayoutSnapshot {
pub(crate) frame: Rect, pub(crate) frame: Rect,
pub(crate) content: Rect, pub(crate) content: Rect,
pub(crate) header_panel: Option<Rect>,
pub(crate) file_panel: Option<Rect>, pub(crate) file_panel: Option<Rect>,
pub(crate) chat_panel: Option<Rect>, pub(crate) chat_panel: Option<Rect>,
pub(crate) thinking_panel: Option<Rect>, pub(crate) thinking_panel: Option<Rect>,
@@ -131,6 +132,7 @@ impl LayoutSnapshot {
Self { Self {
frame, frame,
content, content,
header_panel: None,
file_panel: None, file_panel: None,
chat_panel: None, chat_panel: None,
thinking_panel: None, thinking_panel: None,
@@ -155,6 +157,11 @@ impl LayoutSnapshot {
return Some(UiRegion::ModelInfo); return Some(UiRegion::ModelInfo);
} }
} }
if let Some(rect) = self.header_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Header);
}
}
if let Some(rect) = self.code_panel { if let Some(rect) = self.code_panel {
if Self::contains(rect, column, row) { if Self::contains(rect, column, row) {
return Some(UiRegion::Code); return Some(UiRegion::Code);
@@ -213,6 +220,7 @@ impl Default for LayoutSnapshot {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UiRegion { enum UiRegion {
Header,
Frame, Frame,
Content, Content,
FileTree, FileTree,
@@ -7992,7 +8000,8 @@ impl ChatApp {
| UiRegion::Status | UiRegion::Status
| UiRegion::Chat | UiRegion::Chat
| UiRegion::Content | UiRegion::Content
| UiRegion::Frame => { | UiRegion::Frame
| UiRegion::Header => {
if self.focus_panel(FocusedPanel::Chat) { if self.focus_panel(FocusedPanel::Chat) {
self.auto_scroll self.auto_scroll
.on_user_scroll(amount, self.viewport_height); .on_user_scroll(amount, self.viewport_height);
@@ -8036,7 +8045,8 @@ impl ChatApp {
| UiRegion::Status | UiRegion::Status
| UiRegion::Chat | UiRegion::Chat
| UiRegion::Content | UiRegion::Content
| UiRegion::Frame => { | UiRegion::Frame
| UiRegion::Header => {
self.focus_panel(FocusedPanel::Chat); self.focus_panel(FocusedPanel::Chat);
self.set_input_mode(InputMode::Normal); self.set_input_mode(InputMode::Normal);
} }

View File

@@ -0,0 +1,144 @@
use owlen_core::theme::Theme;
use ratatui::style::Color;
#[derive(Clone, Copy)]
pub struct GlassPalette {
pub active: Color,
pub inactive: Color,
pub highlight: Color,
pub track: Color,
pub label: Color,
pub shadow: Color,
pub context_stops: [Color; 3],
pub usage_stops: [Color; 3],
}
impl GlassPalette {
pub fn for_theme(theme: &Theme) -> Self {
let luminance = color_luminance(theme.background);
if luminance < 0.5 {
Self {
active: Color::Rgb(26, 28, 40),
inactive: Color::Rgb(18, 20, 30),
highlight: Color::Rgb(32, 35, 48),
track: Color::Rgb(35, 38, 50),
label: Color::Rgb(241, 245, 249),
shadow: Color::Rgb(8, 9, 16),
context_stops: [
Color::Rgb(56, 189, 248),
Color::Rgb(250, 204, 21),
Color::Rgb(248, 113, 113),
],
usage_stops: [
Color::Rgb(34, 211, 238),
Color::Rgb(250, 204, 21),
Color::Rgb(248, 113, 113),
],
}
} else {
Self {
active: Color::Rgb(242, 247, 255),
inactive: Color::Rgb(229, 235, 250),
highlight: Color::Rgb(224, 230, 248),
track: Color::Rgb(203, 210, 230),
label: Color::Rgb(31, 41, 55),
shadow: Color::Rgb(200, 205, 220),
context_stops: [
Color::Rgb(59, 130, 246),
Color::Rgb(234, 179, 8),
Color::Rgb(239, 68, 68),
],
usage_stops: [
Color::Rgb(20, 184, 166),
Color::Rgb(245, 158, 11),
Color::Rgb(239, 68, 68),
],
}
}
}
}
pub fn gradient_color(stops: &[Color; 3], t: f64) -> Color {
let clamped = t.clamp(0.0, 1.0);
let segments = stops.len() - 1;
let scaled = clamped * segments as f64;
let index = scaled.floor() as usize;
let frac = scaled - index as f64;
let start = stops[index.min(stops.len() - 1)];
let end = stops[(index + 1).min(stops.len() - 1)];
let (sr, sg, sb) = color_to_rgb(start);
let (er, eg, eb) = color_to_rgb(end);
let mix = |a: u8, b: u8| -> u8 { (a as f64 + (b as f64 - a as f64) * frac).round() as u8 };
Color::Rgb(mix(sr, er), mix(sg, eg), mix(sb, eb))
}
fn color_luminance(color: Color) -> f64 {
let (r, g, b) = color_to_rgb(color);
let r = r as f64 / 255.0;
let g = g as f64 / 255.0;
let b = b as f64 / 255.0;
0.2126 * r + 0.7152 * g + 0.0722 * b
}
fn color_to_rgb(color: Color) -> (u8, u8, u8) {
match color {
Color::Reset => (0, 0, 0),
Color::Black => (0, 0, 0),
Color::Red => (205, 49, 49),
Color::Green => (49, 205, 49),
Color::Yellow => (205, 198, 49),
Color::Blue => (49, 49, 205),
Color::Magenta => (205, 49, 205),
Color::Cyan => (49, 205, 205),
Color::Gray => (170, 170, 170),
Color::DarkGray => (100, 100, 100),
Color::LightRed => (255, 128, 128),
Color::LightGreen => (144, 238, 144),
Color::LightYellow => (255, 255, 170),
Color::LightBlue => (173, 216, 230),
Color::LightMagenta => (255, 182, 255),
Color::LightCyan => (175, 238, 238),
Color::White => (255, 255, 255),
Color::Rgb(r, g, b) => (r, g, b),
Color::Indexed(idx) => indexed_to_rgb(idx),
}
}
fn indexed_to_rgb(idx: u8) -> (u8, u8, u8) {
match idx {
0 => (0, 0, 0),
1 => (128, 0, 0),
2 => (0, 128, 0),
3 => (128, 128, 0),
4 => (0, 0, 128),
5 => (128, 0, 128),
6 => (0, 128, 128),
7 => (192, 192, 192),
8 => (128, 128, 128),
9 => (255, 0, 0),
10 => (0, 255, 0),
11 => (255, 255, 0),
12 => (92, 92, 255),
13 => (255, 0, 255),
14 => (0, 255, 255),
15 => (255, 255, 255),
16..=231 => {
let idx = idx - 16;
let r = idx / 36;
let g = (idx % 36) / 6;
let b = idx % 6;
let convert = |component: u8| {
if component == 0 {
0
} else {
component.saturating_mul(40).saturating_add(55)
}
};
(convert(r), convert(g), convert(b))
}
232..=255 => {
let shade = 8 + (idx - 232) * 10;
(shade, shade, shade)
}
}
}

View File

@@ -20,6 +20,7 @@ pub mod code_app;
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod events; pub mod events;
pub(crate) mod glass;
pub mod highlight; pub mod highlight;
pub mod model_info_panel; pub mod model_info_panel;
pub mod slash; pub mod slash;

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, block::Padding},
}; };
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@@ -16,6 +16,7 @@ use crate::chat_app::{
ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo, ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo,
ModelSelectorItemKind, ModelSelectorItemKind,
}; };
use crate::glass::GlassPalette;
/// Filtering modes for the model picker popup. /// Filtering modes for the model picker popup.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
@@ -29,6 +30,7 @@ pub enum FilterMode {
pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
let theme = app.theme(); let theme = app.theme();
let palette = GlassPalette::for_theme(theme);
let area = frame.area(); let area = frame.area();
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return; return;
@@ -62,17 +64,33 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
} }
let popup_area = Rect::new(x, y, width, height); let popup_area = Rect::new(x, y, width, height);
if popup_area.width > 2 && popup_area.height > 2 {
let shadow_area = Rect::new(
popup_area.x.saturating_add(1),
popup_area.y.saturating_add(1),
popup_area.width.saturating_sub(1),
popup_area.height.saturating_sub(1),
);
if shadow_area.width > 0 && shadow_area.height > 0 {
frame.render_widget(
Block::default().style(Style::default().bg(palette.shadow)),
shadow_area,
);
}
}
frame.render_widget(Clear, popup_area); frame.render_widget(Clear, popup_area);
let mut title_spans = vec![ let mut title_spans = vec![
Span::styled( Span::styled(
" Model Selector ", " Model Selector ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD), Style::default()
.fg(palette.label)
.add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(
format!("· Provider: {}", app.selected_provider), format!("· Provider: {}", app.selected_provider),
Style::default() Style::default()
.fg(theme.placeholder) .fg(palette.label)
.add_modifier(Modifier::DIM), .add_modifier(Modifier::DIM),
), ),
]; ];
@@ -83,9 +101,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
let block = Block::default() let block = Block::default()
.title(Line::from(title_spans)) .title(Line::from(title_spans))
.borders(Borders::ALL) .title_style(Style::default().fg(palette.label))
.border_style(Style::default().fg(theme.info)) .borders(Borders::NONE)
.style(Style::default().bg(theme.background).fg(theme.text)); .padding(Padding::new(2, 2, 1, 1))
.style(Style::default().bg(palette.active).fg(palette.label));
let inner = block.inner(popup_area); let inner = block.inner(popup_area);
frame.render_widget(block, popup_area); frame.render_widget(block, popup_area);
@@ -104,10 +123,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
let matches = app.visible_model_count(); let matches = app.visible_model_count();
let search_prefix = Style::default() let search_prefix = Style::default()
.fg(theme.placeholder) .fg(palette.label)
.add_modifier(Modifier::DIM); .add_modifier(Modifier::DIM);
let bracket_style = Style::default() let bracket_style = Style::default()
.fg(theme.placeholder) .fg(palette.label)
.add_modifier(Modifier::DIM); .add_modifier(Modifier::DIM);
let caret_style = if search_active { let caret_style = if search_active {
Style::default() Style::default()
@@ -115,7 +134,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else { } else {
Style::default() Style::default()
.fg(theme.placeholder) .fg(palette.label)
.add_modifier(Modifier::DIM) .add_modifier(Modifier::DIM)
}; };
@@ -135,8 +154,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
search_spans.push(Span::styled( search_spans.push(Span::styled(
"Type to search…", "Type to search…",
Style::default() Style::default()
.fg(theme.placeholder) .fg(palette.label)
.add_modifier(Modifier::DIM), .add_modifier(Modifier::DIM | Modifier::ITALIC),
)); ));
} }
@@ -153,35 +172,37 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
suffix_label, suffix_label,
if matches == 1 { "" } else { "s" } if matches == 1 { "" } else { "s" }
), ),
Style::default().fg(theme.placeholder), Style::default()
.fg(palette.label)
.add_modifier(Modifier::DIM),
)); ));
let search_line = Line::from(search_spans); let search_line = Line::from(search_spans);
let instruction_line = if search_active { let instruction_line = if search_active {
Line::from(vec![ Line::from(vec![
Span::styled("Backspace", Style::default().fg(theme.placeholder)), Span::styled("Backspace", Style::default().fg(palette.label)),
Span::raw(": delete "), Span::raw(": delete "),
Span::styled("Ctrl+U", Style::default().fg(theme.placeholder)), Span::styled("Ctrl+U", Style::default().fg(palette.label)),
Span::raw(": clear "), Span::raw(": clear "),
Span::styled("Enter", Style::default().fg(theme.placeholder)), Span::styled("Enter", Style::default().fg(palette.label)),
Span::raw(": select "), Span::raw(": select "),
Span::styled("Esc", Style::default().fg(theme.placeholder)), Span::styled("Esc", Style::default().fg(palette.label)),
Span::raw(": close"), Span::raw(": close"),
]) ])
} else { } else {
Line::from(vec![ Line::from(vec![
Span::styled("Enter", Style::default().fg(theme.placeholder)), Span::styled("Enter", Style::default().fg(palette.label)),
Span::raw(": select "), Span::raw(": select "),
Span::styled("Space", Style::default().fg(theme.placeholder)), Span::styled("Space", Style::default().fg(palette.label)),
Span::raw(": toggle provider "), Span::raw(": toggle provider "),
Span::styled("Esc", Style::default().fg(theme.placeholder)), Span::styled("Esc", Style::default().fg(palette.label)),
Span::raw(": close"), Span::raw(": close"),
]) ])
}; };
let search_paragraph = Paragraph::new(vec![search_line, instruction_line]) let search_paragraph = Paragraph::new(vec![search_line, instruction_line])
.style(Style::default().bg(theme.background).fg(theme.text)); .style(Style::default().bg(palette.highlight).fg(palette.label));
frame.render_widget(search_paragraph, layout[0]); frame.render_widget(search_paragraph, layout[0]);
let highlight_style = Style::default() let highlight_style = Style::default()
@@ -236,7 +257,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
)); ));
let line = clip_line_to_width(Line::from(spans), max_line_width); let line = clip_line_to_width(Line::from(spans), max_line_width);
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active)));
} }
ModelSelectorItemKind::Scope { label, status, .. } => { ModelSelectorItemKind::Scope { label, status, .. } => {
let (style, icon) = scope_status_style(*status, theme); let (style, icon) = scope_status_style(*status, theme);
@@ -248,7 +269,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
]), ]),
max_line_width, max_line_width,
); );
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active)));
} }
ModelSelectorItemKind::Model { model_index, .. } => { ModelSelectorItemKind::Model { model_index, .. } => {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
@@ -286,7 +307,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
max_line_width, max_line_width,
)); ));
} }
items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); items.push(ListItem::new(lines).style(Style::default().bg(palette.active)));
} }
ModelSelectorItemKind::Empty { ModelSelectorItemKind::Empty {
message, status, .. message, status, ..
@@ -299,7 +320,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
let mut spans = vec![Span::styled(icon, style), Span::raw(" ")]; let mut spans = vec![Span::styled(icon, style), Span::raw(" ")];
spans.push(Span::styled(format!(" {}", msg), style)); spans.push(Span::styled(format!(" {}", msg), style));
let line = clip_line_to_width(Line::from(spans), max_line_width); let line = clip_line_to_width(Line::from(spans), max_line_width);
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); items.push(ListItem::new(vec![line]).style(Style::default().bg(palette.active)));
} }
} }
} }
@@ -311,7 +332,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
.fg(theme.selection_fg) .fg(theme.selection_fg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
) )
.highlight_symbol(" "); .highlight_symbol(" ")
.style(Style::default().bg(palette.active).fg(palette.label));
let mut state = ListState::default(); let mut state = ListState::default();
state.select(app.selected_model_item()); state.select(app.selected_model_item());
@@ -325,10 +347,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
let footer = Paragraph::new(Line::from(Span::styled( let footer = Paragraph::new(Line::from(Span::styled(
footer_text, footer_text,
Style::default().fg(theme.placeholder), Style::default().fg(palette.label),
))) )))
.alignment(ratatui::layout::Alignment::Center) .alignment(ratatui::layout::Alignment::Center)
.style(Style::default().bg(theme.background).fg(theme.placeholder)); .style(Style::default().bg(palette.highlight).fg(palette.label));
frame.render_widget(footer, layout[2]); frame.render_widget(footer, layout[2]);
} }

View File

@@ -121,5 +121,6 @@ If you are experiencing performance issues, you can try the following:
- **Reduce context size:** A smaller context size will result in faster responses from the LLM. - **Reduce context size:** A smaller context size will result in faster responses from the LLM.
- **Use a less resource-intensive model:** Some models are faster but less capable than others. - **Use a less resource-intensive model:** Some models are faster but less capable than others.
- **Watch the header gauges:** The cockpit header now shows live context usage and cloud quota bands—if either bar turns amber or red, trim the prompt or switch providers before retrying.
If you are still having trouble, please [open an issue](https://github.com/Owlibou/owlen/issues) on our GitHub repository. If you are still having trouble, please [open an issue](https://github.com/Owlibou/owlen/issues) on our GitHub repository.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,30 +1,30 @@
name = "default_dark" name = "default_dark"
text = "white" text = "#e2e8f0"
background = "black" background = "#020617"
focused_panel_border = "lightmagenta" focused_panel_border = "#7dd3fc"
unfocused_panel_border = "#5f1487" unfocused_panel_border = "#1e293b"
user_message_role = "lightblue" user_message_role = "#38bdf8"
assistant_message_role = "yellow" assistant_message_role = "#fbbf24"
tool_output = "gray" tool_output = "#94a3b8"
thinking_panel_title = "lightmagenta" thinking_panel_title = "#a855f7"
command_bar_background = "black" command_bar_background = "#0f172a"
status_background = "black" status_background = "#111827"
mode_normal = "lightblue" mode_normal = "#38bdf8"
mode_editing = "lightgreen" mode_editing = "#34d399"
mode_model_selection = "lightyellow" mode_model_selection = "#fbbf24"
mode_provider_selection = "lightcyan" mode_provider_selection = "#22d3ee"
mode_help = "lightmagenta" mode_help = "#a855f7"
mode_visual = "magenta" mode_visual = "#f472b6"
mode_command = "yellow" mode_command = "#facc15"
selection_bg = "lightblue" selection_bg = "#1d4ed8"
selection_fg = "black" selection_fg = "#f8fafc"
cursor = "magenta" cursor = "#f472b6"
code_block_background = "#191919" code_block_background = "#111827"
code_block_border = "lightmagenta" code_block_border = "#2563eb"
code_block_text = "white" code_block_text = "#e2e8f0"
code_block_keyword = "yellow" code_block_keyword = "#fbbf24"
code_block_string = "lightgreen" code_block_string = "#34d399"
code_block_comment = "gray" code_block_comment = "#64748b"
placeholder = "darkgray" placeholder = "#64748b"
error = "red" error = "#f87171"
info = "lightgreen" info = "#38bdf8"

View File

@@ -1,30 +1,30 @@
name = "default_light" name = "default_light"
text = "black" text = "#0f172a"
background = "white" background = "#f8fafc"
focused_panel_border = "#4a90e2" focused_panel_border = "#2563eb"
unfocused_panel_border = "#dddddd" unfocused_panel_border = "#c7d2fe"
user_message_role = "#0055a4" user_message_role = "#2563eb"
assistant_message_role = "#8e44ad" assistant_message_role = "#9333ea"
tool_output = "gray" tool_output = "#64748b"
thinking_panel_title = "#8e44ad" thinking_panel_title = "#7c3aed"
command_bar_background = "white" command_bar_background = "#e2e8f0"
status_background = "white" status_background = "#e0e7ff"
mode_normal = "#0055a4" mode_normal = "#2563eb"
mode_editing = "#2e8b57" mode_editing = "#0ea5e9"
mode_model_selection = "#b58900" mode_model_selection = "#facc15"
mode_provider_selection = "#008b8b" mode_provider_selection = "#0ea5e9"
mode_help = "#8e44ad" mode_help = "#7c3aed"
mode_visual = "#8e44ad" mode_visual = "#7c3aed"
mode_command = "#b58900" mode_command = "#f97316"
selection_bg = "#a4c8f0" selection_bg = "#bfdbfe"
selection_fg = "black" selection_fg = "#0f172a"
cursor = "#d95f02" cursor = "#f97316"
code_block_background = "#f5f5f5" code_block_background = "#e2e8f0"
code_block_border = "#009688" code_block_border = "#2563eb"
code_block_text = "black" code_block_text = "#0f172a"
code_block_keyword = "#b58900" code_block_keyword = "#b45309"
code_block_string = "#388e3c" code_block_string = "#15803d"
code_block_comment = "#90a4ae" code_block_comment = "#94a3b8"
placeholder = "gray" placeholder = "#64748b"
error = "#c0392b" error = "#dc2626"
info = "green" info = "#2563eb"