diff --git a/README.md b/README.md index 050807e..ba6f14b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This project is currently in **alpha** and under active development. Core featur ![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 @@ -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. - **Session Management**: Save, load, and manage conversations. - **Code Side Panel**: Switch to code mode (`:mode code`) and open files inline with `:open ` 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. - **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. diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 7742e19..f90bd89 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -115,6 +115,7 @@ pub struct ContextUsage { pub(crate) struct LayoutSnapshot { pub(crate) frame: Rect, pub(crate) content: Rect, + pub(crate) header_panel: Option, pub(crate) file_panel: Option, pub(crate) chat_panel: Option, pub(crate) thinking_panel: Option, @@ -131,6 +132,7 @@ impl LayoutSnapshot { Self { frame, content, + header_panel: None, file_panel: None, chat_panel: None, thinking_panel: None, @@ -155,6 +157,11 @@ impl LayoutSnapshot { 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 Self::contains(rect, column, row) { return Some(UiRegion::Code); @@ -213,6 +220,7 @@ impl Default for LayoutSnapshot { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UiRegion { + Header, Frame, Content, FileTree, @@ -7992,7 +8000,8 @@ impl ChatApp { | UiRegion::Status | UiRegion::Chat | UiRegion::Content - | UiRegion::Frame => { + | UiRegion::Frame + | UiRegion::Header => { if self.focus_panel(FocusedPanel::Chat) { self.auto_scroll .on_user_scroll(amount, self.viewport_height); @@ -8036,7 +8045,8 @@ impl ChatApp { | UiRegion::Status | UiRegion::Chat | UiRegion::Content - | UiRegion::Frame => { + | UiRegion::Frame + | UiRegion::Header => { self.focus_panel(FocusedPanel::Chat); self.set_input_mode(InputMode::Normal); } diff --git a/crates/owlen-tui/src/glass.rs b/crates/owlen-tui/src/glass.rs new file mode 100644 index 0000000..9f23b5c --- /dev/null +++ b/crates/owlen-tui/src/glass.rs @@ -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) + } + } +} diff --git a/crates/owlen-tui/src/lib.rs b/crates/owlen-tui/src/lib.rs index e0ce4e8..4db986c 100644 --- a/crates/owlen-tui/src/lib.rs +++ b/crates/owlen-tui/src/lib.rs @@ -20,6 +20,7 @@ pub mod code_app; pub mod commands; pub mod config; pub mod events; +pub(crate) mod glass; pub mod highlight; pub mod model_info_panel; pub mod slash; diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 31bb77d..05c8667 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -4,6 +4,7 @@ use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use ratatui::widgets::block::Padding; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json; use std::collections::{HashMap, HashSet}; @@ -16,6 +17,7 @@ use crate::chat_app::{ ChatApp, ContextUsage, HELP_TAB_COUNT, LayoutSnapshot, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, }; +use crate::glass::{GlassPalette, gradient_color}; use crate::highlight; use crate::state::{ CodePane, EditorTab, FileFilterMode, FileNode, KeymapProfile, LayoutNode, PaletteGroup, PaneId, @@ -26,12 +28,125 @@ use crate::widgets::model_picker::render_model_picker; use owlen_core::theme::Theme; use owlen_core::types::Role; use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay}; -use owlen_core::usage::{UsageBand, UsageSnapshot, UsageWindow, WindowMetrics}; +use owlen_core::usage::{UsageBand, UsageSnapshot, UsageWindow}; use textwrap::wrap; const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProgressBand { + Normal, + Warning, + Critical, +} + +impl ProgressBand { + fn color(self, theme: &Theme) -> Color { + match self { + ProgressBand::Normal => theme.info, + ProgressBand::Warning => Color::Yellow, + ProgressBand::Critical => theme.error, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct GaugeDescriptor { + title: String, + detail: String, + percent_label: String, + ratio: f64, + band: ProgressBand, +} + +fn progress_band_color(band: ProgressBand, theme: &Theme) -> Color { + band.color(theme) +} + +fn context_progress_band(ratio: f64) -> ProgressBand { + if ratio >= 0.85 { + ProgressBand::Critical + } else if ratio >= 0.60 { + ProgressBand::Warning + } else { + ProgressBand::Normal + } +} + +fn usage_progress_band(band: UsageBand) -> ProgressBand { + match band { + UsageBand::Normal => ProgressBand::Normal, + UsageBand::Warning => ProgressBand::Warning, + UsageBand::Critical => ProgressBand::Critical, + } +} + +fn context_usage_descriptor(usage: ContextUsage) -> Option { + if usage.context_window == 0 { + return None; + } + + let window = usage.context_window as f64; + let ratio = (usage.prompt_tokens as f64 / window).clamp(0.0, 1.0); + let percent = (ratio * 100.0).round(); + let used = format_token_short(usage.prompt_tokens as u64); + let capacity = format_token_short(usage.context_window as u64); + Some(GaugeDescriptor { + title: "Context".to_string(), + detail: format!("{used} / {capacity}"), + percent_label: format!("{percent:.0}%"), + ratio, + band: context_progress_band(ratio), + }) +} + +fn usage_gauge_descriptor( + snapshot: &UsageSnapshot, + window: UsageWindow, +) -> Option { + let metrics = snapshot.window(window); + if metrics.total_tokens == 0 && metrics.quota_tokens.is_none() { + return None; + } + + let (title, shorthand) = match window { + UsageWindow::Hour => ("Cloud hour", "hr"), + UsageWindow::Week => ("Cloud week", "wk"), + }; + + let detail; + let percent_label; + let ratio; + + if let Some(quota) = metrics.quota_tokens { + if quota == 0 { + ratio = 0.0; + detail = "No quota".to_string(); + percent_label = "0%".to_string(); + } else { + ratio = (metrics.total_tokens as f64 / quota as f64).clamp(0.0, 1.0); + let used = format_token_short(metrics.total_tokens); + let quota_text = format_token_short(quota); + let percent = (ratio * 100.0).round(); + detail = format!("{used} / {quota_text}"); + percent_label = format!("{percent:.0}%"); + } + } else { + ratio = 0.0; + detail = format!("{} tokens", format_token_short(metrics.total_tokens)); + percent_label = "–".to_string(); + } + + Some(GaugeDescriptor { + title: format!("{title}"), + detail: format!("{detail} · {shorthand}"), + percent_label, + ratio, + band: usage_progress_band(metrics.band()), + }) +} + fn focus_beacon_span(is_active: bool, is_focused: bool, theme: &Theme) -> Span<'static> { if !is_active { return Span::styled(" ", Style::default().fg(theme.unfocused_beacon_fg)); @@ -188,69 +303,503 @@ mod focus_tests { #[cfg(test)] mod context_usage_tests { use super::*; - use ratatui::style::{Color, Modifier}; - - fn theme() -> Theme { - Theme::default() - } - #[test] - fn context_badge_formats_label_and_highlights() { - let theme = theme(); + fn context_descriptor_formats_values() { let usage = ContextUsage { prompt_tokens: 2600, completion_tokens: 0, - total_tokens: 2600, context_window: 8000, }; - let (label, style) = context_usage_badge(usage, &theme).expect("badge should render"); - assert_eq!(label, "Context 2.6k / 8k (33%)"); - assert_eq!(style.fg, Some(theme.info)); - assert!(style.add_modifier.contains(Modifier::BOLD)); + let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); + assert_eq!(descriptor.title, "Context"); + assert_eq!(descriptor.detail, "2.6k / 8k"); + assert_eq!(descriptor.percent_label, "33%"); + assert!((descriptor.ratio - 0.325).abs() < 1e-6); + assert_eq!(descriptor.band, ProgressBand::Normal); } #[test] - fn context_badge_warns_near_limits() { - let theme = theme(); + fn context_descriptor_warns_near_limits() { let usage = ContextUsage { prompt_tokens: 7000, completion_tokens: 0, - total_tokens: 7000, context_window: 10000, }; - let (_, style) = context_usage_badge(usage, &theme).expect("badge should render"); - assert_eq!(style.fg, Some(Color::Yellow)); + let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); + assert_eq!(descriptor.band, ProgressBand::Warning); + assert_eq!(descriptor.percent_label, "70%"); } #[test] - fn context_badge_flags_danger_zone() { - let theme = theme(); + fn context_descriptor_flags_danger_zone() { let usage = ContextUsage { prompt_tokens: 9000, completion_tokens: 0, - total_tokens: 9000, context_window: 10000, }; - let (_, style) = context_usage_badge(usage, &theme).expect("badge should render"); - assert_eq!(style.fg, Some(theme.error)); + let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); + assert_eq!(descriptor.band, ProgressBand::Critical); + assert_eq!(descriptor.percent_label, "90%"); } #[test] - fn context_badge_handles_zero_usage() { - let theme = theme(); + fn context_descriptor_handles_zero_usage() { let usage = ContextUsage { prompt_tokens: 0, completion_tokens: 0, - total_tokens: 0, context_window: 32000, }; - let (label, style) = context_usage_badge(usage, &theme).expect("badge should render"); - assert_eq!(label, "Context 0 / 32k (0%)"); - assert_eq!(style.fg, Some(theme.info)); + let descriptor = context_usage_descriptor(usage).expect("descriptor should render"); + assert_eq!(descriptor.detail, "0 / 32k"); + assert_eq!(descriptor.percent_label, "0%"); + assert_eq!(descriptor.band, ProgressBand::Normal); + } +} + +fn render_body_container(frame: &mut Frame<'_>, area: Rect, palette: &GlassPalette) -> Rect { + if area.width == 0 || area.height == 0 { + return area; + } + + if area.width > 2 && area.height > 2 { + let shadow_area = Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(1), + area.width.saturating_sub(1), + 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, area); + + let block = Block::default() + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(palette.active)); + + let inner = block.inner(area); + frame.render_widget(block, area); + inner +} + +fn render_chat_header( + frame: &mut Frame<'_>, + area: Rect, + app: &ChatApp, + palette: &GlassPalette, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + + frame.render_widget(Clear, area); + + let header_block = Block::default() + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 0)) + .style(Style::default().bg(palette.highlight)); + let highlight_area = header_block.inner(area); + frame.render_widget(header_block, area); + + if highlight_area.width == 0 || highlight_area.height == 0 { + return; + } + + let mut constraints = vec![Constraint::Length(2)]; + if highlight_area.height > 2 { + constraints.push(Constraint::Min(2)); + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(highlight_area); + + render_header_top(frame, rows[0], app, palette, theme); + + if rows.len() > 1 { + render_header_bars(frame, rows[1], app, palette, theme); + } +} + +fn render_header_top( + frame: &mut Frame<'_>, + area: Rect, + app: &ChatApp, + palette: &GlassPalette, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + let mut left_spans = Vec::new(); + left_spans.push(Span::styled( + format!(" 🦉 OWLEN v{APP_VERSION} "), + Style::default() + .fg(theme.focused_panel_border) + .add_modifier(Modifier::BOLD), + )); + + let mode_label = match app.get_mode() { + owlen_core::mode::Mode::Chat => "Chat", + owlen_core::mode::Mode::Code => "Code", + }; + left_spans.push(Span::styled( + format!("· Mode {mode_label} "), + Style::default().fg(palette.label), + )); + + let focus_label = match app.focused_panel() { + FocusedPanel::Files => "Files", + FocusedPanel::Chat => "Chat", + FocusedPanel::Thinking => "Thinking", + FocusedPanel::Input => "Input", + FocusedPanel::Code => "Code", + }; + left_spans.push(Span::styled( + format!("· Focus {focus_label}"), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::ITALIC), + )); + + if app.is_agent_running() { + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + "🤖 RUN", + Style::default() + .fg(theme.agent_badge_running_fg) + .bg(theme.agent_badge_running_bg) + .add_modifier(Modifier::BOLD), + )); + } else if app.is_agent_mode() { + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + "🤖 ARM", + Style::default() + .fg(theme.agent_badge_idle_fg) + .bg(theme.agent_badge_idle_bg) + .add_modifier(Modifier::BOLD), + )); + } + + let left_line = spans_within_width(left_spans, columns[0].width); + frame.render_widget( + Paragraph::new(left_line).style(Style::default().bg(palette.highlight).fg(palette.label)), + columns[0], + ); + + let mut right_spans = Vec::new(); + let provider_display = truncate_with_ellipsis(app.current_provider(), 18); + right_spans.push(Span::styled( + provider_display, + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + )); + + let model_label = app.active_model_label(); + if !model_label.is_empty() { + let model_display = truncate_with_ellipsis(&model_label, 24); + right_spans.push(Span::styled( + format!(" · {model_display}"), + Style::default().fg(palette.label), + )); + } + + if app.is_loading() || app.is_streaming() { + let spinner = app.get_loading_indicator(); + let spinner = if spinner.is_empty() { "…" } else { spinner }; + right_spans.push(Span::styled( + format!(" · {spinner} streaming"), + Style::default().fg(progress_band_color(ProgressBand::Normal, theme)), + )); + } + + let right_line = spans_within_width(right_spans, columns[1].width); + frame.render_widget( + Paragraph::new(right_line) + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .alignment(Alignment::Right), + columns[1], + ); +} + +fn render_header_bars( + frame: &mut Frame<'_>, + area: Rect, + app: &ChatApp, + palette: &GlassPalette, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + + if area.height < 2 { + render_context_column(frame, area, app, palette, theme); + return; + } + + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(area); + + render_context_column(frame, columns[0], app, palette, theme); + render_usage_column(frame, columns[1], app, palette, theme); +} + +fn render_context_column( + frame: &mut Frame<'_>, + area: Rect, + app: &ChatApp, + palette: &GlassPalette, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let descriptor = app + .context_usage_with_fallback() + .and_then(context_usage_descriptor); + + match descriptor { + Some(descriptor) => { + if area.height < 2 { + render_gauge_compact(frame, area, &descriptor, palette); + } else { + render_gauge( + frame, + area, + &descriptor, + &palette.context_stops, + palette, + theme, + ); + } + } + None => { + frame.render_widget( + Paragraph::new("Context metrics not available") + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .wrap(Wrap { trim: true }), + area, + ); + } + } +} + +fn render_usage_column( + frame: &mut Frame<'_>, + area: Rect, + app: &ChatApp, + palette: &GlassPalette, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let descriptors: Vec = app + .usage_snapshot() + .into_iter() + .flat_map(|snapshot| { + [ + usage_gauge_descriptor(snapshot, UsageWindow::Hour), + usage_gauge_descriptor(snapshot, UsageWindow::Week), + ] + }) + .flatten() + .collect(); + + if descriptors.is_empty() { + frame.render_widget( + Paragraph::new("Cloud usage pending") + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .wrap(Wrap { trim: true }), + area, + ); + return; + } + + let bottom = area.y.saturating_add(area.height); + let mut cursor_y = area.y; + + for descriptor in descriptors { + if cursor_y >= bottom { + break; + } + let remaining = bottom - cursor_y; + if remaining < 2 { + frame.render_widget( + Paragraph::new(format!("{} {}", descriptor.title, descriptor.percent_label)) + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .wrap(Wrap { trim: true }), + Rect::new(area.x, cursor_y, area.width, remaining), + ); + break; + } + + let gauge_area = Rect::new(area.x, cursor_y, area.width, 2); + render_gauge( + frame, + gauge_area, + &descriptor, + &palette.usage_stops, + palette, + theme, + ); + cursor_y = cursor_y.saturating_add(2); + } +} + +fn render_gauge( + frame: &mut Frame<'_>, + area: Rect, + descriptor: &GaugeDescriptor, + stops: &[Color; 3], + palette: &GlassPalette, + theme: &Theme, +) { + if area.height < 2 || area.width < 4 { + render_gauge_compact(frame, area, descriptor, palette); + return; + } + + let label_color = progress_band_color(descriptor.band, theme); + + let label_area = Rect::new(area.x, area.y, area.width, 1); + let bar_area = Rect::new(area.x, area.y + 1, area.width, 1); + + let label_line = spans_within_width( + vec![ + Span::styled( + descriptor.title.clone(), + Style::default() + .fg(label_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + descriptor.detail.clone(), + Style::default().fg(palette.label), + ), + Span::styled( + descriptor.percent_label.clone(), + Style::default() + .fg(label_color) + .add_modifier(Modifier::BOLD), + ), + ], + label_area.width, + ); + + frame.render_widget( + Paragraph::new(label_line).style(Style::default().bg(palette.highlight).fg(palette.label)), + label_area, + ); + + draw_gradient_bar( + frame, + bar_area, + descriptor.ratio, + stops, + palette, + &descriptor.percent_label, + ); +} + +fn render_gauge_compact( + frame: &mut Frame<'_>, + area: Rect, + descriptor: &GaugeDescriptor, + palette: &GlassPalette, +) { + if area.width == 0 || area.height == 0 { + return; + } + + frame.render_widget( + Paragraph::new(format!( + "{} · {} ({})", + descriptor.title, descriptor.detail, descriptor.percent_label + )) + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn draw_gradient_bar( + frame: &mut Frame<'_>, + area: Rect, + ratio: f64, + stops: &[Color; 3], + palette: &GlassPalette, + percent_label: &str, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let ratio = ratio.clamp(0.0, 1.0); + let width = area.width; + let mut filled_width = ((width as f64) * ratio).round() as u16; + filled_width = filled_width.min(width); + + { + let buffer = frame.buffer_mut(); + for offset in 0..width { + let x = area.x + offset; + let is_filled = offset < filled_width; + let color = if filled_width == 0 || !is_filled { + palette.track + } else if filled_width <= 1 { + gradient_color(stops, ratio) + } else { + let segment = offset.min(filled_width - 1); + let pos = segment as f64 / (filled_width - 1) as f64; + gradient_color(stops, pos) + }; + + buffer[(x, area.y)] + .set_symbol(" ") + .set_bg(color) + .set_fg(color); + } + } + + let buffer = frame.buffer_mut(); + let label_chars: Vec = percent_label.chars().collect(); + if !label_chars.is_empty() && label_chars.len() as u16 <= width { + let start = area.x + (width - label_chars.len() as u16) / 2; + for (idx, ch) in label_chars.iter().enumerate() { + let x = start + idx as u16; + if x >= area.x + width { + break; + } + buffer[(x, area.y)] + .set_symbol(&ch.to_string()) + .set_fg(palette.label); + } } } @@ -260,25 +809,53 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Set terminal background color let theme = app.theme().clone(); - let background_block = Block::default().style(Style::default().bg(theme.background)); + let palette = GlassPalette::for_theme(&theme); let frame_area = frame.area(); - frame.render_widget(background_block, frame_area); + frame.render_widget( + Block::default().style(Style::default().bg(theme.background)), + frame_area, + ); - let title_line = Line::from(vec![Span::styled( - format!(" 🦉 OWLEN v{} – AI Assistant ", APP_VERSION), - Style::default() - .fg(theme.focused_panel_border) - .add_modifier(Modifier::BOLD), - )]); - let main_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.unfocused_panel_border)) - .style(Style::default().bg(theme.background).fg(theme.text)) - .title(title_line); + let mut header_height = if frame_area.height >= 14 { 6 } else { 4 }; + if frame_area.height <= header_height { + header_height = frame_area.height.saturating_sub(1); + } + let header_height = header_height.max(3).min(frame_area.height); - let content_area = main_block.inner(frame_area); + let segments = if frame_area.height <= header_height { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(frame_area.height)]) + .split(frame_area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(header_height), Constraint::Min(1)]) + .split(frame_area) + }; + + if segments.is_empty() { + app.set_layout_snapshot(LayoutSnapshot::new(frame_area, frame_area)); + return; + } + + let (header_area, body_area) = if segments.len() == 1 { + (Rect::new(0, 0, 0, 0), segments[0]) + } else { + (segments[0], segments[1]) + }; + + if header_area.width > 0 && header_area.height > 0 { + render_chat_header(frame, header_area, app, &palette, &theme); + } + + let content_area = render_body_container(frame, body_area, &palette); let mut snapshot = LayoutSnapshot::new(frame_area, content_area); - frame.render_widget(main_block, frame_area); + snapshot.header_panel = if header_area.width > 0 && header_area.height > 0 { + Some(header_area) + } else { + None + }; if content_area.width == 0 || content_area.height == 0 { app.set_layout_snapshot(snapshot); @@ -1583,27 +2160,38 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { )); } - if let Some(usage) = app.context_usage_with_fallback() { - if let Some((label, style)) = context_usage_badge(usage, &theme) { - title_spans.push(Span::raw(" · ")); - title_spans.push(Span::styled(label, style)); - } - } - title_spans.push(Span::raw(" ")); title_spans.push(Span::styled( "PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus", panel_hint_style(has_focus, &theme), )); + let palette = GlassPalette::for_theme(&theme); let chat_block = Block::default() - .borders(Borders::ALL) - .border_style(panel_border_style(true, has_focus, &theme)) - .style(Style::default().bg(theme.background).fg(theme.text)) - .title(Line::from(title_spans)); + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style( + Style::default() + .bg(if has_focus { + palette.active + } else { + palette.inactive + }) + .fg(theme.text), + ) + .title(Line::from(title_spans)) + .title_style(Style::default().fg(theme.pane_header_active)); let paragraph = Paragraph::new(lines) - .style(Style::default().bg(theme.background).fg(theme.text)) + .style( + Style::default() + .bg(if has_focus { + palette.active + } else { + palette.inactive + }) + .fg(theme.text), + ) .block(chat_block) .scroll((scroll_position, 0)); @@ -1711,14 +2299,32 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { panel_hint_style(has_focus, &theme), )); + let palette = GlassPalette::for_theme(&theme); let paragraph = Paragraph::new(lines) - .style(Style::default().bg(theme.background)) + .style( + Style::default() + .bg(if has_focus { + palette.active + } else { + palette.inactive + }) + .fg(theme.placeholder), + ) .block( Block::default() .title(Line::from(title_spans)) - .borders(Borders::ALL) - .border_style(panel_border_style(true, has_focus, &theme)) - .style(Style::default().bg(theme.background).fg(theme.text)), + .title_style(Style::default().fg(theme.pane_header_active)) + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style( + Style::default() + .bg(if has_focus { + palette.active + } else { + palette.inactive + }) + .fg(theme.text), + ), ) .scroll((scroll_position, 0)) .wrap(Wrap { trim: false }); @@ -1917,14 +2523,32 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { panel_hint_style(has_focus, &theme), )); + let palette = GlassPalette::for_theme(&theme); let paragraph = Paragraph::new(lines) - .style(Style::default().bg(theme.background)) + .style( + Style::default() + .bg(if has_focus { + palette.active + } else { + palette.inactive + }) + .fg(theme.text), + ) .block( Block::default() .title(Line::from(title_spans)) - .borders(Borders::ALL) - .border_style(panel_border_style(true, has_focus, &theme)) - .style(Style::default().bg(theme.background).fg(theme.text)), + .title_style(Style::default().fg(theme.pane_header_active)) + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style( + Style::default() + .bg(if has_focus { + palette.active + } else { + palette.inactive + }) + .fg(theme.text), + ), ) .wrap(Wrap { trim: false }); @@ -1975,11 +2599,21 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { )); } + let palette = GlassPalette::for_theme(&theme); + let base_style = Style::default() + .bg(if has_focus { + palette.active + } else { + palette.inactive + }) + .fg(theme.text); + let input_block = Block::default() .title(Line::from(title_spans)) - .borders(Borders::ALL) - .border_style(panel_border_style(is_active, has_focus, &theme)) - .style(Style::default().bg(theme.background).fg(theme.text)); + .title_style(Style::default().fg(theme.pane_header_active)) + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(base_style); if matches!(app.mode(), InputMode::Editing) { // Use the textarea directly to preserve selection state @@ -1987,6 +2621,7 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let textarea = app.textarea_mut(); textarea.set_block(input_block.clone()); textarea.set_hard_tab_indent(false); + textarea.set_style(base_style); render_editable_textarea(frame, area, textarea, true, show_cursor, &theme); } else if matches!(app.mode(), InputMode::Visual) { // In visual mode, render textarea in read-only mode with selection @@ -1994,6 +2629,7 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let textarea = app.textarea_mut(); textarea.set_block(input_block.clone()); textarea.set_hard_tab_indent(false); + textarea.set_style(base_style); render_editable_textarea(frame, area, textarea, true, show_cursor, &theme); } else if matches!(app.mode(), InputMode::Command) { // In command mode, show the command buffer with : prefix @@ -2006,7 +2642,7 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { ))]; let paragraph = Paragraph::new(lines) - .style(Style::default().bg(theme.background)) + .style(base_style) .block(input_block) .wrap(Wrap { trim: false }); @@ -2027,7 +2663,7 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { }; let paragraph = Paragraph::new(lines) - .style(Style::default().bg(theme.background)) + .style(base_style) .block(input_block) .wrap(Wrap { trim: false }); @@ -2054,6 +2690,7 @@ fn system_status_message(app: &ChatApp) -> String { fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, message: &str) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); let color = if message.starts_with("Error:") { theme.error @@ -2074,16 +2711,17 @@ fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, messag }; let paragraph = Paragraph::new(text_lines) - .style(Style::default().bg(theme.background)) + .style(Style::default().bg(palette.highlight).fg(color)) .block( Block::default() .title(Span::styled( " System/Status ", Style::default().fg(theme.info).add_modifier(Modifier::BOLD), )) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.unfocused_panel_border)) - .style(Style::default().bg(theme.background).fg(theme.text)), + .title_style(Style::default().fg(theme.info)) + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(palette.highlight).fg(theme.text)), ) .wrap(Wrap { trim: false }); @@ -2092,6 +2730,7 @@ fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, messag fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); frame.render_widget(Clear, area); let title = Line::from(vec![ @@ -2110,10 +2749,11 @@ fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { ]); let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.focused_panel_border)) - .style(Style::default().bg(theme.background).fg(theme.text)) - .title(title); + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(palette.active).fg(theme.text)) + .title(title) + .title_style(Style::default().fg(theme.pane_header_active)); let inner = block.inner(area); frame.render_widget(block, area); @@ -2184,7 +2824,7 @@ fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let paragraph = Paragraph::new(lines) .wrap(Wrap { trim: true }) .alignment(Alignment::Left) - .style(Style::default().bg(theme.background)); + .style(Style::default().bg(palette.active).fg(theme.text)); frame.render_widget(paragraph, inner); } @@ -2222,7 +2862,7 @@ fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize where I: IntoIterator, { - let content_width = available_width.saturating_sub(2) as usize; // subtract block borders + let content_width = available_width.saturating_sub(4) as usize; // account for padded panel chrome let mut total = 0usize; let mut seen = false; @@ -2241,10 +2881,14 @@ where fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); + + frame.render_widget(Clear, area); + let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.unfocused_panel_border)) - .style(Style::default().bg(theme.status_background)); + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 0, 0)) + .style(Style::default().bg(palette.highlight)); let inner = block.inner(area); frame.render_widget(block, area); @@ -2343,7 +2987,7 @@ 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)); + .style(Style::default().bg(palette.highlight).fg(palette.label)); frame.render_widget(left_paragraph, columns[0]); let file_tree = app.file_tree(); @@ -2379,7 +3023,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let mid_paragraph = Paragraph::new(mid_parts.join(" · ")) .alignment(Alignment::Center) - .style(Style::default().bg(theme.status_background).fg(theme.text)); + .style(Style::default().bg(palette.highlight).fg(palette.label)); frame.render_widget(mid_paragraph, columns[1]); let provider = app.current_provider(); @@ -2388,7 +3032,9 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let model_display = truncate_with_ellipsis(&model_label, 24); let mut right_spans = vec![Span::styled( format!("{} ▸ {}", provider_display, model_display), - Style::default().fg(theme.text).add_modifier(Modifier::BOLD), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), )]; if app.is_loading() || app.is_streaming() { @@ -2396,7 +3042,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let spinner = if spinner.is_empty() { "…" } else { spinner }; right_spans.push(Span::styled( format!(" · {} streaming", spinner), - Style::default().fg(theme.info), + Style::default().fg(progress_band_color(ProgressBand::Normal, theme)), )); right_spans.push(Span::styled( " · p:Pause r:Resume s:Stop", @@ -2406,18 +3052,6 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { )); } - if let Some(usage) = app.context_usage_with_fallback() { - if let Some((label, style)) = context_usage_badge(usage, theme) { - right_spans.push(Span::styled(format!(" · {}", label), style)); - } - } - - if let Some(snapshot) = app.usage_snapshot() { - for span in usage_badge_spans(snapshot, theme) { - right_spans.push(span); - } - } - right_spans.push(Span::styled( " · LSP:✓", Style::default() @@ -2428,110 +3062,10 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let right_line = spans_within_width(right_spans, columns[2].width); let right_paragraph = Paragraph::new(right_line) .alignment(Alignment::Right) - .style(Style::default().bg(theme.status_background).fg(theme.text)); + .style(Style::default().bg(palette.highlight).fg(palette.label)); frame.render_widget(right_paragraph, columns[2]); } -fn context_usage_badge(usage: ContextUsage, theme: &Theme) -> Option<(String, Style)> { - let context_window = usage.context_window.max(1); - let ratio = usage.prompt_tokens as f64 / context_window as f64; - let percent = ((ratio * 100.0).round() as u32).min(100); - let used = format_token_short(usage.prompt_tokens as u64); - let window = format_token_short(context_window as u64); - let label = format!("Context {} / {} ({}%)", used, window, percent); - - let style = if ratio < 0.60 { - Style::default().fg(theme.info).add_modifier(Modifier::BOLD) - } else if ratio < 0.85 { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(theme.error) - .add_modifier(Modifier::BOLD) - }; - - Some((label, style)) -} - -fn usage_badge_spans(snapshot: &UsageSnapshot, theme: &Theme) -> Vec> { - let mut spans = Vec::new(); - let provider_label = ChatApp::provider_display_name(&snapshot.provider); - - let hour_metrics = snapshot.window(UsageWindow::Hour); - let week_metrics = snapshot.window(UsageWindow::Week); - - if hour_metrics.quota_tokens.is_none() - && hour_metrics.total_tokens == 0 - && week_metrics.quota_tokens.is_none() - && week_metrics.total_tokens == 0 - { - return spans; - } - - spans.push(Span::styled( - format!(" · {} usage", provider_label), - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM), - )); - - if let Some(span) = usage_window_span("hr", hour_metrics, theme) { - spans.push(span); - } - if let Some(span) = usage_window_span("wk", week_metrics, theme) { - spans.push(span); - } - - spans -} - -fn usage_window_span(label: &str, metrics: &WindowMetrics, theme: &Theme) -> Option> { - if metrics.quota_tokens.is_none() && metrics.total_tokens == 0 { - return None; - } - - let used = format_token_short(metrics.total_tokens); - let text = if let Some(quota) = metrics.quota_tokens { - if quota == 0 { - format!(" · {} {} tokens", label, used) - } else if let Some(percent_ratio) = metrics.percent_of_quota() { - let quota_text = format_token_short(quota); - let percent_text = format_percent_short((percent_ratio * 100.0).min(999.9)); - format!(" · {} {}/{} ({}%)", label, used, quota_text, percent_text) - } else { - let quota_text = format_token_short(quota); - format!(" · {} {}/{}", label, used, quota_text) - } - } else { - format!(" · {} {} tokens", label, used) - }; - - let style = match metrics.band() { - UsageBand::Normal => Style::default().fg(theme.info).add_modifier(Modifier::BOLD), - UsageBand::Warning => Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - UsageBand::Critical => Style::default() - .fg(theme.error) - .add_modifier(Modifier::BOLD), - }; - - Some(Span::styled(text, style)) -} - -fn format_percent_short(percent: f64) -> String { - let clamped = percent.max(0.0); - if clamped >= 100.0 || clamped == 0.0 { - format!("{clamped:.0}") - } else if clamped >= 10.0 { - format!("{clamped:.0}") - } else { - format!("{clamped:.1}") - } -} - pub(crate) fn format_token_short(value: u64) -> String { if value >= 1_000_000_000 { format_compact(value as f64 / 1_000_000_000.0, "B") @@ -3283,15 +3817,13 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) { fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); let config = app.config(); + if area.width == 0 || area.height == 0 { + return; + } - let block = Block::default() - .title("Privacy Settings") - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.unfocused_panel_border)) - .style(Style::default().bg(theme.background).fg(theme.text)); - let inner = block.inner(area); - frame.render_widget(block, area); + frame.render_widget(Clear, area); let remote_search_enabled = config.privacy.enable_remote_search && config.tools.web_search.enabled; @@ -3345,16 +3877,46 @@ fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let paragraph = Paragraph::new(lines) .wrap(Wrap { trim: true }) - .style(Style::default().bg(theme.background).fg(theme.text)); - frame.render_widget(paragraph, inner); + .style(Style::default().bg(palette.active).fg(palette.label)); + frame.render_widget(paragraph, area); } fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); let profile = app.current_keymap_profile(); let area = centered_rect(75, 70, frame.area()); + if area.width == 0 || area.height == 0 { + return; + } + + if area.width > 2 && area.height > 2 { + let shadow = Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(1), + area.width.saturating_sub(1), + area.height.saturating_sub(1), + ); + if shadow.width > 0 && shadow.height > 0 { + frame.render_widget( + Block::default().style(Style::default().bg(palette.shadow)), + shadow, + ); + } + } + frame.render_widget(Clear, area); + let container = Block::default() + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(palette.active).fg(palette.label)); + let inner = container.inner(area); + frame.render_widget(container, area); + if inner.width == 0 || inner.height == 0 { + return; + } + let tab_index = app.help_tab_index(); let tabs = [ "Navigation", @@ -3863,29 +4425,20 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Constraint::Min(0), // Content Constraint::Length(2), // Navigation hint ]) - .split(area); + .split(inner); // Render tabs - let tabs_block = Block::default() - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) - .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); + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .alignment(Alignment::Center); frame.render_widget(tabs_para, layout[0]); // Render content if tab_index == PRIVACY_TAB_INDEX { render_privacy_settings(frame, layout[1], app); } else { - let content_block = Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .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); + let content_para = + Paragraph::new(help_text).style(Style::default().bg(palette.active).fg(palette.label)); frame.render_widget(content_para, layout[1]); } @@ -3895,32 +4448,27 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Span::styled( "Tab/h/l", Style::default() - .fg(theme.focused_panel_border) + .fg(palette.label) .add_modifier(Modifier::BOLD), ), Span::raw(":Switch "), Span::styled( format!("1-{}", HELP_TAB_COUNT), Style::default() - .fg(theme.focused_panel_border) + .fg(palette.label) .add_modifier(Modifier::BOLD), ), Span::raw(":Jump "), Span::styled( "Esc", Style::default() - .fg(theme.focused_panel_border) + .fg(palette.label) .add_modifier(Modifier::BOLD), ), Span::raw(":Close "), ]); - let nav_block = Block::default() - .borders(Borders::ALL) - .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) + .style(Style::default().bg(palette.highlight).fg(palette.label)) .alignment(Alignment::Center); frame.render_widget(nav_para, layout[2]); } @@ -4065,13 +4613,14 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); let area = frame.area(); if area.width == 0 || area.height == 0 { return; } let themes = app.available_themes(); - let current_theme_name = &app.theme().name; + let current_theme_name = &theme.name; let max_width: u16 = 80; let min_width: u16 = 40; @@ -4084,8 +4633,8 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { width = width.max(1); let visible_rows = themes.len().clamp(1, 12) as u16; - let mut height = visible_rows.saturating_mul(2).saturating_add(6); - height = height.clamp(6, area.height); + let mut height = visible_rows.saturating_mul(2).saturating_add(8); + height = height.clamp(8, area.height); let x = area.x + (area.width.saturating_sub(width)) / 2; let mut y = area.y + (area.height.saturating_sub(height)) / 3; @@ -4094,29 +4643,47 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { } 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); - let title_line = Line::from(vec![ + let title_spans = vec![ Span::styled( " Theme Selector ", - Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), ), Span::styled( format!("· Current: {}", current_theme_name), Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM), ), - ]); + ]; - let block = Block::default() - .title(title_line) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.info)) - .style(Style::default().bg(theme.background).fg(theme.text)); + let container = Block::default() + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .title(Line::from(title_spans)) + .title_style(Style::default().fg(palette.label)) + .style(Style::default().bg(palette.active).fg(palette.label)); - let inner = block.inner(popup_area); - frame.render_widget(block, popup_area); + let inner = container.inner(popup_area); + frame.render_widget(container, popup_area); if inner.width == 0 || inner.height == 0 { return; } @@ -4125,21 +4692,122 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { let empty = Paragraph::new(Line::from(Span::styled( "No themes available · Press Esc to close", Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM | Modifier::ITALIC), ))) .alignment(Alignment::Center) - .style(Style::default().bg(theme.background).fg(theme.placeholder)); + .style(Style::default().bg(palette.active).fg(palette.label)); frame.render_widget(empty, inner); return; } + let all_themes = owlen_core::theme::load_all_themes(); + let built_in = owlen_core::theme::built_in_themes(); + let layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(4), Constraint::Length(2)]) + .constraints([Constraint::Min(6), Constraint::Length(2)]) .split(inner); - let built_in = owlen_core::theme::built_in_themes(); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(layout[0]); + + let left_sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(3)]) + .split(columns[0]); + + let search_query = app.model_search_query().trim().to_string(); + let search_active = !search_query.is_empty(); + let matches = app.visible_model_count(); + + let search_prefix = Style::default() + .fg(palette.label) + .add_modifier(Modifier::DIM); + let bracket_style = Style::default() + .fg(palette.label) + .add_modifier(Modifier::DIM); + let caret_style = if search_active { + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(palette.label) + .add_modifier(Modifier::DIM) + }; + + let mut search_spans = Vec::new(); + search_spans.push(Span::styled("Search ▸ ", search_prefix)); + search_spans.push(Span::styled("[", bracket_style)); + search_spans.push(Span::styled(" ", bracket_style)); + + if search_active { + search_spans.push(Span::styled( + search_query.clone(), + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD), + )); + } else { + search_spans.push(Span::styled( + "Type to search…", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::DIM | Modifier::ITALIC), + )); + } + + search_spans.push(Span::styled(" ", bracket_style)); + search_spans.push(Span::styled("▎", caret_style)); + search_spans.push(Span::styled(" ", bracket_style)); + search_spans.push(Span::styled("]", bracket_style)); + search_spans.push(Span::raw(" ")); + let suffix_label = if search_active { "match" } else { "model" }; + search_spans.push(Span::styled( + format!( + "({} {}{})", + matches, + suffix_label, + if matches == 1 { "" } else { "s" } + ), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::DIM), + )); + + let search_line = Line::from(search_spans); + + let instruction_line = if search_active { + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(palette.label)), + Span::raw(": delete "), + Span::styled("Ctrl+U", Style::default().fg(palette.label)), + Span::raw(": clear "), + Span::styled("Enter", Style::default().fg(palette.label)), + Span::raw(": select "), + Span::styled("Esc", Style::default().fg(palette.label)), + Span::raw(": close"), + ]) + } else { + Line::from(vec![ + Span::styled("Enter", Style::default().fg(palette.label)), + Span::raw(": select "), + Span::styled("Space", Style::default().fg(palette.label)), + Span::raw(": toggle provider "), + Span::styled("Esc", Style::default().fg(palette.label)), + Span::raw(": close"), + ]) + }; + + if left_sections.len() >= 1 { + let search_paragraph = Paragraph::new(vec![search_line, instruction_line]) + .style(Style::default().bg(palette.highlight).fg(palette.label)); + frame.render_widget(search_paragraph, left_sections[0]); + } + let mut items: Vec = Vec::with_capacity(themes.len()); for theme_name in themes.iter() { let is_current = theme_name == current_theme_name; @@ -4154,7 +4822,7 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { title.push_str(" ✓"); } - let mut title_style = Style::default().fg(theme.text); + let mut title_style = Style::default().fg(palette.label); if is_current { title_style = title_style .fg(theme.focused_panel_border) @@ -4162,7 +4830,7 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { } let metadata_style = Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM); let lines = vec![ @@ -4173,7 +4841,9 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { ]), ]; - items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); + items.push( + ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label)), + ); } let highlight_style = Style::default() @@ -4187,25 +4857,187 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { .min(themes.len().saturating_sub(1)); state.select(Some(selected_index)); - let list = List::new(items) - .highlight_style(highlight_style) - .highlight_symbol(" ") - .style(Style::default().bg(theme.background).fg(theme.text)); + if left_sections.len() >= 2 { + let list = List::new(items) + .highlight_style(highlight_style) + .highlight_symbol(" ") + .style(Style::default().bg(palette.active).fg(palette.label)); - frame.render_stateful_widget(list, layout[0], &mut state); + frame.render_stateful_widget(list, left_sections[1], &mut state); + } + + if columns.len() >= 2 { + let preview_area = columns[1]; + if preview_area.width > 0 && preview_area.height > 0 { + if let Some(selected_name) = themes.get(selected_index) { + let preview_theme = all_themes + .get(selected_name.as_str()) + .cloned() + .or_else(|| all_themes.get(current_theme_name).cloned()) + .unwrap_or_else(|| theme.clone()); + render_theme_preview( + frame, + preview_area, + &preview_theme, + preview_theme.name == theme.name, + ); + } + } + } let footer = Paragraph::new(Line::from(Span::styled( "↑/↓ or j/k: Navigate · Enter: Apply theme · g/G: Top/Bottom · Esc: Cancel", - Style::default().fg(theme.placeholder), + Style::default().fg(palette.label), ))) .alignment(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[1]); } +fn render_theme_preview(frame: &mut Frame<'_>, area: Rect, preview_theme: &Theme, is_active: bool) { + if area.width < 10 || area.height < 5 { + return; + } + + frame.render_widget(Clear, area); + let preview_palette = GlassPalette::for_theme(preview_theme); + let mut title = format!("Preview · {}", preview_theme.name); + if is_active { + title.push_str(" (active)"); + } + + let block = Block::default() + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .title(Line::from(Span::styled( + title, + Style::default() + .fg(preview_palette.label) + .add_modifier(Modifier::BOLD), + ))) + .style( + Style::default() + .bg(preview_palette.active) + .fg(preview_palette.label), + ); + + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![ + Span::styled( + "You", + Style::default() + .fg(preview_theme.user_message_role) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" » "), + Span::styled( + "Let's try this palette.", + Style::default().fg(preview_theme.text), + ), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Owlen", + Style::default() + .fg(preview_theme.assistant_message_role) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" » "), + Span::styled( + "Looks sharp and legible!", + Style::default().fg(preview_theme.text), + ), + ])); + lines.push(Line::raw("")); + + for (label, fg, bg) in [ + ( + "Focus border", + preview_theme.background, + preview_theme.focused_panel_border, + ), + ( + "Selection", + preview_theme.selection_fg, + preview_theme.selection_bg, + ), + ("Info", preview_theme.background, preview_theme.info), + ("Error", preview_theme.background, preview_theme.error), + ] { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default().bg(bg).fg(fg)), + Span::raw(" "), + Span::styled( + label.to_string(), + Style::default() + .fg(preview_palette.label) + .add_modifier(Modifier::DIM), + ), + ])); + } + + let gauge_line = Line::from(vec![ + Span::styled( + "Context ", + Style::default() + .fg(preview_theme.info) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "██████", + Style::default() + .bg(preview_theme.info) + .fg(preview_theme.background), + ), + Span::styled( + "──", + Style::default() + .fg(preview_palette.label) + .add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled( + "Usage ", + Style::default() + .fg(preview_theme.mode_help) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "████", + Style::default() + .bg(preview_theme.mode_help) + .fg(preview_theme.background), + ), + Span::styled( + "────", + Style::default() + .fg(preview_palette.label) + .add_modifier(Modifier::DIM), + ), + ]); + lines.push(Line::raw("")); + lines.push(gauge_line); + + let paragraph = Paragraph::new(lines) + .style( + Style::default() + .bg(preview_palette.active) + .fg(preview_palette.label), + ) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner); +} + fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); let suggestions = app.command_suggestions(); let buffer = app.command_buffer(); let area = frame.area(); @@ -4235,26 +5067,44 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { } let popup_area = Rect::new(x, y, width, height); + if popup_area.width > 2 && popup_area.height > 2 { + let shadow = 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.width > 0 && shadow.height > 0 { + frame.render_widget( + Block::default().style(Style::default().bg(palette.shadow)), + shadow, + ); + } + } + frame.render_widget(Clear, popup_area); let header = Line::from(vec![ Span::styled( " Command Palette ", - Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), ), Span::styled( "Ctrl+P", Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM), ), ]); let block = Block::default() .title(header) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.info)) - .style(Style::default().bg(theme.background).fg(theme.text)); + .title_style(Style::default().fg(palette.label)) + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(palette.active).fg(palette.label)); let inner = block.inner(popup_area); frame.render_widget(block, popup_area); @@ -4276,12 +5126,12 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { Span::styled( ":", Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::BOLD), ), Span::raw(buffer), ])) - .style(Style::default().bg(theme.background).fg(theme.text)); + .style(Style::default().bg(palette.highlight).fg(palette.label)); frame.render_widget(input, layout[0]); let selected_index = if suggestions.is_empty() { @@ -4297,11 +5147,11 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let placeholder = Paragraph::new(Line::from(Span::styled( "No matches — keep typing", Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::ITALIC), ))) .alignment(Alignment::Center) - .style(Style::default().bg(theme.background).fg(theme.placeholder)); + .style(Style::default().bg(palette.active).fg(palette.label)); frame.render_widget(placeholder, layout[1]); } else { let highlight = Style::default() @@ -4317,8 +5167,8 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { lines.push(Line::from(Span::styled( palette_group_label(suggestion.group), Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::BOLD), + .fg(palette.label) + .add_modifier(Modifier::BOLD | Modifier::DIM), ))); previous_group = Some(suggestion.group); } @@ -4330,7 +5180,9 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { } else { " " }, - Style::default().fg(theme.placeholder), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::DIM), ), Span::raw(" "), Span::styled( @@ -4344,12 +5196,13 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { lines.push(Line::from(Span::styled( format!(" {}", detail), Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM), ))); } - let item = ListItem::new(lines); + let item = + ListItem::new(lines).style(Style::default().bg(palette.active).fg(palette.label)); items.push(item); } @@ -4358,7 +5211,7 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let list = List::new(items) .highlight_style(highlight) - .style(Style::default().bg(theme.background).fg(theme.text)); + .style(Style::default().bg(palette.active).fg(palette.label)); frame.render_stateful_widget(list, layout[1], &mut list_state); } @@ -4372,10 +5225,10 @@ fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let footer = Paragraph::new(Line::from(Span::styled( detail_text, - Style::default().fg(theme.placeholder), + Style::default().fg(palette.label), ))) .alignment(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]); } diff --git a/crates/owlen-tui/src/widgets/model_picker.rs b/crates/owlen-tui/src/widgets/model_picker.rs index 1da2e9f..2350746 100644 --- a/crates/owlen-tui/src/widgets/model_picker.rs +++ b/crates/owlen-tui/src/widgets/model_picker.rs @@ -7,7 +7,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, 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_width::UnicodeWidthStr; @@ -16,6 +16,7 @@ use crate::chat_app::{ ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo, ModelSelectorItemKind, }; +use crate::glass::GlassPalette; /// Filtering modes for the model picker popup. #[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) { let theme = app.theme(); + let palette = GlassPalette::for_theme(theme); let area = frame.area(); if area.width == 0 || area.height == 0 { return; @@ -62,17 +64,33 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { } 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); let mut title_spans = vec![ Span::styled( " Model Selector ", - Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), ), Span::styled( format!("· Provider: {}", app.selected_provider), Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM), ), ]; @@ -83,9 +101,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { let block = Block::default() .title(Line::from(title_spans)) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.info)) - .style(Style::default().bg(theme.background).fg(theme.text)); + .title_style(Style::default().fg(palette.label)) + .borders(Borders::NONE) + .padding(Padding::new(2, 2, 1, 1)) + .style(Style::default().bg(palette.active).fg(palette.label)); let inner = block.inner(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 search_prefix = Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM); let bracket_style = Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM); let caret_style = if search_active { Style::default() @@ -115,7 +134,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { .add_modifier(Modifier::BOLD) } else { Style::default() - .fg(theme.placeholder) + .fg(palette.label) .add_modifier(Modifier::DIM) }; @@ -135,8 +154,8 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { search_spans.push(Span::styled( "Type to search…", Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM), + .fg(palette.label) + .add_modifier(Modifier::DIM | Modifier::ITALIC), )); } @@ -153,35 +172,37 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { suffix_label, 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 instruction_line = if search_active { Line::from(vec![ - Span::styled("Backspace", Style::default().fg(theme.placeholder)), + Span::styled("Backspace", Style::default().fg(palette.label)), 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::styled("Enter", Style::default().fg(theme.placeholder)), + Span::styled("Enter", Style::default().fg(palette.label)), Span::raw(": select "), - Span::styled("Esc", Style::default().fg(theme.placeholder)), + Span::styled("Esc", Style::default().fg(palette.label)), Span::raw(": close"), ]) } else { Line::from(vec![ - Span::styled("Enter", Style::default().fg(theme.placeholder)), + Span::styled("Enter", Style::default().fg(palette.label)), Span::raw(": select "), - Span::styled("Space", Style::default().fg(theme.placeholder)), + Span::styled("Space", Style::default().fg(palette.label)), Span::raw(": toggle provider "), - Span::styled("Esc", Style::default().fg(theme.placeholder)), + Span::styled("Esc", Style::default().fg(palette.label)), Span::raw(": close"), ]) }; 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]); 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); - 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, .. } => { 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, ); - 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, .. } => { let mut lines: Vec> = Vec::new(); @@ -286,7 +307,7 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { 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 { 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(" ")]; spans.push(Span::styled(format!(" {}", msg), style)); 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) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(" "); + .highlight_symbol(" ") + .style(Style::default().bg(palette.active).fg(palette.label)); let mut state = ListState::default(); 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( footer_text, - Style::default().fg(theme.placeholder), + Style::default().fg(palette.label), ))) .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]); } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b82ff86..918b801 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. - **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. diff --git a/images/layout.png b/images/layout.png index 2100e6e..4f3166a 100644 Binary files a/images/layout.png and b/images/layout.png differ diff --git a/themes/default_dark.toml b/themes/default_dark.toml index 90308df..0a63f83 100644 --- a/themes/default_dark.toml +++ b/themes/default_dark.toml @@ -1,30 +1,30 @@ name = "default_dark" -text = "white" -background = "black" -focused_panel_border = "lightmagenta" -unfocused_panel_border = "#5f1487" -user_message_role = "lightblue" -assistant_message_role = "yellow" -tool_output = "gray" -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" -code_block_background = "#191919" -code_block_border = "lightmagenta" -code_block_text = "white" -code_block_keyword = "yellow" -code_block_string = "lightgreen" -code_block_comment = "gray" -placeholder = "darkgray" -error = "red" -info = "lightgreen" +text = "#e2e8f0" +background = "#020617" +focused_panel_border = "#7dd3fc" +unfocused_panel_border = "#1e293b" +user_message_role = "#38bdf8" +assistant_message_role = "#fbbf24" +tool_output = "#94a3b8" +thinking_panel_title = "#a855f7" +command_bar_background = "#0f172a" +status_background = "#111827" +mode_normal = "#38bdf8" +mode_editing = "#34d399" +mode_model_selection = "#fbbf24" +mode_provider_selection = "#22d3ee" +mode_help = "#a855f7" +mode_visual = "#f472b6" +mode_command = "#facc15" +selection_bg = "#1d4ed8" +selection_fg = "#f8fafc" +cursor = "#f472b6" +code_block_background = "#111827" +code_block_border = "#2563eb" +code_block_text = "#e2e8f0" +code_block_keyword = "#fbbf24" +code_block_string = "#34d399" +code_block_comment = "#64748b" +placeholder = "#64748b" +error = "#f87171" +info = "#38bdf8" diff --git a/themes/default_light.toml b/themes/default_light.toml index eaba091..d9408b4 100644 --- a/themes/default_light.toml +++ b/themes/default_light.toml @@ -1,30 +1,30 @@ name = "default_light" -text = "black" -background = "white" -focused_panel_border = "#4a90e2" -unfocused_panel_border = "#dddddd" -user_message_role = "#0055a4" -assistant_message_role = "#8e44ad" -tool_output = "gray" -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" -code_block_background = "#f5f5f5" -code_block_border = "#009688" -code_block_text = "black" -code_block_keyword = "#b58900" -code_block_string = "#388e3c" -code_block_comment = "#90a4ae" -placeholder = "gray" -error = "#c0392b" -info = "green" +text = "#0f172a" +background = "#f8fafc" +focused_panel_border = "#2563eb" +unfocused_panel_border = "#c7d2fe" +user_message_role = "#2563eb" +assistant_message_role = "#9333ea" +tool_output = "#64748b" +thinking_panel_title = "#7c3aed" +command_bar_background = "#e2e8f0" +status_background = "#e0e7ff" +mode_normal = "#2563eb" +mode_editing = "#0ea5e9" +mode_model_selection = "#facc15" +mode_provider_selection = "#0ea5e9" +mode_help = "#7c3aed" +mode_visual = "#7c3aed" +mode_command = "#f97316" +selection_bg = "#bfdbfe" +selection_fg = "#0f172a" +cursor = "#f97316" +code_block_background = "#e2e8f0" +code_block_border = "#2563eb" +code_block_text = "#0f172a" +code_block_keyword = "#b45309" +code_block_string = "#15803d" +code_block_comment = "#94a3b8" +placeholder = "#64748b" +error = "#dc2626" +info = "#2563eb"