diff --git a/CHANGELOG.md b/CHANGELOG.md index d97f9a4..ceb9d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys). - Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`. - Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in. +- Adaptive TUI layout with responsive 80/120-column breakpoints, refreshed glass/neon theming, and animated focus rings for pane transitions. +- Configurable `ui.layers` and `ui.animations` settings to tune glass elevation, neon intensity, and opt-in micro-animations. - Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching. - Cloud usage tracker persists hourly/weekly token totals, adds a `:limits` command, shows live header badges, and raises toast warnings at 80 %/95 % of the configured quotas. - Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions. diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 552b6c6..c0f9970 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -1818,6 +1818,10 @@ pub struct UiSettings { pub keymap_path: Option, #[serde(default)] pub accessibility: AccessibilitySettings, + #[serde(default)] + pub layers: LayerSettings, + #[serde(default)] + pub animations: AnimationSettings, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1847,6 +1851,101 @@ impl Default for AccessibilitySettings { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LayerSettings { + #[serde(default = "LayerSettings::default_shadow_elevation")] + pub shadow_elevation: u8, + #[serde(default = "LayerSettings::default_glass_tint")] + pub glass_tint: f32, + #[serde(default = "LayerSettings::default_neon_intensity")] + pub neon_intensity: u8, + #[serde(default = "LayerSettings::default_focus_ring")] + pub focus_ring: bool, +} + +impl LayerSettings { + const fn default_shadow_elevation() -> u8 { + 2 + } + + const fn default_neon_intensity() -> u8 { + 60 + } + + const fn default_focus_ring() -> bool { + true + } + + const fn default_glass_tint() -> f32 { + 0.82 + } + + pub fn shadow_depth(&self) -> u8 { + self.shadow_elevation.min(3) + } + + pub fn neon_factor(&self) -> f64 { + (self.neon_intensity as f64).clamp(0.0, 100.0) / 100.0 + } + + pub fn glass_tint_factor(&self) -> f64 { + self.glass_tint.clamp(0.0, 1.0) as f64 + } +} + +impl Default for LayerSettings { + fn default() -> Self { + Self { + shadow_elevation: Self::default_shadow_elevation(), + glass_tint: Self::default_glass_tint(), + neon_intensity: Self::default_neon_intensity(), + focus_ring: Self::default_focus_ring(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnimationSettings { + #[serde(default = "AnimationSettings::default_micro")] + pub micro: bool, + #[serde(default = "AnimationSettings::default_gauge_smoothing")] + pub gauge_smoothing: f32, + #[serde(default = "AnimationSettings::default_pane_decay")] + pub pane_decay: f32, +} + +impl AnimationSettings { + const fn default_micro() -> bool { + true + } + + const fn default_gauge_smoothing() -> f32 { + 0.24 + } + + const fn default_pane_decay() -> f32 { + 0.68 + } + + pub fn gauge_smoothing_factor(&self) -> f64 { + self.gauge_smoothing.clamp(0.05, 1.0) as f64 + } + + pub fn pane_decay_factor(&self) -> f64 { + self.pane_decay.clamp(0.2, 0.95) as f64 + } +} + +impl Default for AnimationSettings { + fn default() -> Self { + Self { + micro: Self::default_micro(), + gauge_smoothing: Self::default_gauge_smoothing(), + pane_decay: Self::default_pane_decay(), + } + } +} + /// Preference for which symbol set to render in the terminal UI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] @@ -1994,6 +2093,8 @@ impl Default for UiSettings { keymap_leader: Self::default_keymap_leader(), keymap_path: None, accessibility: AccessibilitySettings::default(), + layers: LayerSettings::default(), + animations: AnimationSettings::default(), } } } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index d7d303f..128f8a2 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -66,8 +66,9 @@ use crate::ui::{format_token_short, format_tool_output}; use crate::widgets::model_picker::FilterMode; use crate::{commands, highlight}; use owlen_core::config::{ - LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_API_KEY_ENV, - OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, + AnimationSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, + LayerSettings, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, + OLLAMA_MODE_KEY, }; use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID}; // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly @@ -115,6 +116,161 @@ pub struct ContextUsage { pub context_window: u32, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum AdaptiveLayout { + Compact, + Cozy, + #[default] + Spacious, +} + +impl AdaptiveLayout { + pub fn from_width(width: u16) -> Self { + if width <= 80 { + AdaptiveLayout::Compact + } else if width >= 120 { + AdaptiveLayout::Spacious + } else { + AdaptiveLayout::Cozy + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum GaugeKey { + Context, + UsageHour, + UsageWeek, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PanePulse { + Stage, + FilePanel, + CodePanel, + ModelPanel, + DebugPanel, +} + +#[derive(Debug, Default, Clone, Copy)] +struct AnimatedGauge { + current: f64, +} + +impl AnimatedGauge { + fn snap(&mut self, value: f64) { + self.current = value.clamp(0.0, 1.0); + } + + fn sample(&mut self, target: f64, smoothing: f64) -> f64 { + let clamped_target = target.clamp(0.0, 1.0); + if (self.current - clamped_target).abs() < 0.0005 { + self.current = clamped_target; + return self.current; + } + let factor = smoothing.clamp(0.01, 1.0); + self.current += (clamped_target - self.current) * factor; + if (self.current - clamped_target).abs() < 0.0005 { + self.current = clamped_target; + } + self.current + } +} + +#[derive(Debug, Default)] +struct GaugeAnimations { + context: AnimatedGauge, + usage_hour: AnimatedGauge, + usage_week: AnimatedGauge, +} + +impl GaugeAnimations { + fn snap(&mut self, key: GaugeKey, value: f64) { + match key { + GaugeKey::Context => self.context.snap(value), + GaugeKey::UsageHour => self.usage_hour.snap(value), + GaugeKey::UsageWeek => self.usage_week.snap(value), + } + } + + fn sample(&mut self, key: GaugeKey, target: f64, smoothing: f64) -> f64 { + match key { + GaugeKey::Context => self.context.sample(target, smoothing), + GaugeKey::UsageHour => self.usage_hour.sample(target, smoothing), + GaugeKey::UsageWeek => self.usage_week.sample(target, smoothing), + } + } +} + +#[derive(Debug, Default, Clone, Copy)] +struct AnimatedPulse { + value: f64, +} + +impl AnimatedPulse { + fn trigger(&mut self) { + self.value = 1.0; + } + + fn clear(&mut self) { + self.value = 0.0; + } + + fn sample(&mut self, decay: f64) -> f64 { + let current = self.value.clamp(0.0, 1.0); + if current == 0.0 { + return 0.0; + } + let decay = decay.clamp(0.1, 0.99); + self.value *= decay; + if self.value < 0.01 { + self.value = 0.0; + } + current + } +} + +#[derive(Debug, Default)] +struct PaneAnimations { + stage: AnimatedPulse, + file: AnimatedPulse, + code: AnimatedPulse, + model: AnimatedPulse, + debug: AnimatedPulse, +} + +impl PaneAnimations { + fn trigger(&mut self, pulse: PanePulse) { + match pulse { + PanePulse::Stage => self.stage.trigger(), + PanePulse::FilePanel => self.file.trigger(), + PanePulse::CodePanel => self.code.trigger(), + PanePulse::ModelPanel => self.model.trigger(), + PanePulse::DebugPanel => self.debug.trigger(), + } + } + + fn sample(&mut self, pulse: PanePulse, decay: f64) -> f64 { + match pulse { + PanePulse::Stage => self.stage.sample(decay), + PanePulse::FilePanel => self.file.sample(decay), + PanePulse::CodePanel => self.code.sample(decay), + PanePulse::ModelPanel => self.model.sample(decay), + PanePulse::DebugPanel => self.debug.sample(decay), + } + } + + fn clear(&mut self, pulse: PanePulse) { + match pulse { + PanePulse::Stage => self.stage.clear(), + PanePulse::FilePanel => self.file.clear(), + PanePulse::CodePanel => self.code.clear(), + PanePulse::ModelPanel => self.model.clear(), + PanePulse::DebugPanel => self.debug.clear(), + } + } +} + #[derive(Clone, Copy, Debug)] pub(crate) struct LayoutSnapshot { pub(crate) frame: Rect, @@ -129,6 +285,7 @@ pub(crate) struct LayoutSnapshot { pub(crate) status_panel: Option, pub(crate) code_panel: Option, pub(crate) model_info_panel: Option, + pub(crate) layout_mode: AdaptiveLayout, } impl LayoutSnapshot { @@ -146,6 +303,7 @@ impl LayoutSnapshot { status_panel: None, code_panel: None, model_info_panel: None, + layout_mode: AdaptiveLayout::default(), } } @@ -634,6 +792,11 @@ pub struct ChatApp { usage_thresholds: HashMap<(String, UsageWindow), UsageBand>, context_usage: Option, last_layout: LayoutSnapshot, + layer_settings: LayerSettings, + animation_settings: AnimationSettings, + active_layout: AdaptiveLayout, + gauge_animations: GaugeAnimations, + pane_animations: PaneAnimations, /// Simple execution budget: maximum number of tool calls allowed per session. _execution_budget: usize, /// Agent mode enabled @@ -797,6 +960,8 @@ impl ChatApp { let keymap_profile = config_guard.ui.keymap_profile.clone(); let keymap_leader_raw = config_guard.ui.keymap_leader.clone(); let accessibility = config_guard.ui.accessibility.clone(); + let layer_settings = config_guard.ui.layers.clone(); + let animation_settings = config_guard.ui.animations.clone(); drop(config_guard); let keymap_overrides = KeymapOverrides::new(keymap_leader_raw); let keymap = { @@ -944,6 +1109,11 @@ impl ChatApp { base_theme_name, accessibility_high_contrast, accessibility_reduced_chrome, + layer_settings, + animation_settings, + active_layout: AdaptiveLayout::default(), + gauge_animations: GaugeAnimations::default(), + pane_animations: PaneAnimations::default(), }; app.mvu_model.composer.mode = InputMode::Normal; @@ -1608,6 +1778,9 @@ impl ChatApp { } pub fn set_file_panel_collapsed(&mut self, collapsed: bool) { + if self.file_panel_collapsed != collapsed { + self.trigger_pane_pulse(PanePulse::FilePanel); + } self.file_panel_collapsed = collapsed; } @@ -1630,7 +1803,7 @@ impl ChatApp { return; } if self.file_panel_collapsed { - self.file_panel_collapsed = false; + self.set_file_panel_collapsed(false); self.focused_panel = FocusedPanel::Files; self.ensure_focus_valid(); } @@ -1638,7 +1811,7 @@ impl ChatApp { pub fn collapse_file_panel(&mut self) { if !self.file_panel_collapsed { - self.file_panel_collapsed = true; + self.set_file_panel_collapsed(true); if matches!(self.focused_panel, FocusedPanel::Files) { self.focused_panel = FocusedPanel::Chat; } @@ -1823,6 +1996,9 @@ impl ChatApp { } pub fn set_model_info_visible(&mut self, visible: bool) { + if self.show_model_info != visible { + self.trigger_pane_pulse(PanePulse::ModelPanel); + } self.show_model_info = visible; if !visible { self.model_info_panel.reset_scroll(); @@ -2117,6 +2293,64 @@ impl ChatApp { self.accessibility_high_contrast || self.accessibility_reduced_chrome } + pub fn layer_settings(&self) -> &LayerSettings { + &self.layer_settings + } + + pub fn animation_settings(&self) -> &AnimationSettings { + &self.animation_settings + } + + pub fn micro_animations_enabled(&self) -> bool { + self.animation_settings.micro + && !self.accessibility_reduced_chrome + && !self.accessibility_high_contrast + } + + pub fn layout_mode(&self) -> AdaptiveLayout { + self.active_layout + } + + pub fn update_layout_mode(&mut self, layout: AdaptiveLayout) { + if self.active_layout != layout { + if self.micro_animations_enabled() { + self.pane_animations.trigger(PanePulse::Stage); + } + self.active_layout = layout; + } + } + + pub fn pane_glow(&mut self, pulse: PanePulse) -> f64 { + if !self.micro_animations_enabled() { + self.pane_animations.clear(pulse); + return 0.0; + } + self.pane_animations + .sample(pulse, self.animation_settings.pane_decay_factor()) + } + + pub fn animated_gauge_ratio(&mut self, key: GaugeKey, target: f64) -> f64 { + if !self.micro_animations_enabled() { + self.gauge_animations.snap(key, target); + return target.clamp(0.0, 1.0); + } + self.gauge_animations.sample( + key, + target, + self.animation_settings.gauge_smoothing_factor(), + ) + } + + pub fn reset_gauge(&mut self, key: GaugeKey) { + self.gauge_animations.snap(key, 0.0); + } + + fn trigger_pane_pulse(&mut self, pulse: PanePulse) { + if self.micro_animations_enabled() { + self.pane_animations.trigger(pulse); + } + } + pub fn accessibility_status(&self) -> String { Self::accessibility_summary( self.accessibility_high_contrast, @@ -2144,6 +2378,18 @@ impl ChatApp { } pub(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) { + if self.micro_animations_enabled() { + let previous = self.last_layout; + if previous.file_panel.is_some() != snapshot.file_panel.is_some() { + self.trigger_pane_pulse(PanePulse::FilePanel); + } + if previous.code_panel.is_some() != snapshot.code_panel.is_some() { + self.trigger_pane_pulse(PanePulse::CodePanel); + } + if previous.model_info_panel.is_some() != snapshot.model_info_panel.is_some() { + self.trigger_pane_pulse(PanePulse::ModelPanel); + } + } self.last_layout = snapshot; } @@ -2205,6 +2451,7 @@ impl ChatApp { pub fn toggle_debug_log_panel(&mut self) { let now_visible = self.debug_log.toggle_visible(); + self.trigger_pane_pulse(PanePulse::DebugPanel); if now_visible { self.status = "Debug log open — F12 to hide".to_string(); self.error = None; diff --git a/crates/owlen-tui/src/glass.rs b/crates/owlen-tui/src/glass.rs index d758be1..ecd64d8 100644 --- a/crates/owlen-tui/src/glass.rs +++ b/crates/owlen-tui/src/glass.rs @@ -1,4 +1,4 @@ -use owlen_core::theme::Theme; +use owlen_core::{config::LayerSettings, theme::Theme}; use ratatui::style::{Color, palette::tailwind}; #[derive(Clone, Copy)] @@ -11,14 +11,23 @@ pub struct GlassPalette { pub shadow: Color, pub context_stops: [Color; 3], pub usage_stops: [Color; 3], + pub frosted: Color, + pub frost_edge: Color, + pub neon_accent: Color, + pub neon_glow: Color, + pub focus_ring: Color, } impl GlassPalette { - pub fn for_theme(theme: &Theme) -> Self { - Self::for_theme_with_mode(theme, false) + pub fn for_theme(theme: &Theme, layers: &LayerSettings) -> Self { + Self::for_theme_with_mode(theme, false, layers) } - pub fn for_theme_with_mode(theme: &Theme, reduced_chrome: bool) -> Self { + pub fn for_theme_with_mode( + theme: &Theme, + reduced_chrome: bool, + layers: &LayerSettings, + ) -> Self { if reduced_chrome { let base = theme.background; let label = theme.text; @@ -34,20 +43,48 @@ impl GlassPalette { shadow: base, context_stops: [context_color, context_color, context_color], usage_stops: [usage_color, usage_color, usage_color], + frosted: base, + frost_edge: base, + neon_accent: theme.info, + neon_glow: theme.info, + focus_ring: theme.focused_panel_border, }; } let luminance = color_luminance(theme.background); + let neon_factor = layers.neon_factor(); + let glass_tint = layers.glass_tint_factor(); + let focus_enabled = layers.focus_ring; if luminance < 0.5 { + let frosted = blend_color(tailwind::SLATE.c900, theme.background, glass_tint * 0.65); + let frost_edge = blend_color(frosted, tailwind::SLATE.c700, 0.25); + let inactive = blend_color(frosted, tailwind::SLATE.c800, 0.55); + let highlight = blend_color(frosted, tailwind::SLATE.c700, 0.35); + let track = blend_color(frosted, tailwind::SLATE.c600, 0.25); + let neon_seed = tailwind::SKY.c400; + let neon_accent = blend_color(neon_seed, theme.info, neon_factor); + let neon_glow = blend_color(neon_accent, Color::White, 0.18); + let focus_ring = if focus_enabled { + blend_color(neon_accent, theme.focused_panel_border, 0.45) + } else { + blend_color(frosted, theme.unfocused_panel_border, 0.15) + }; + let shadow = match layers.shadow_depth() { + 0 => blend_color(theme.background, tailwind::SLATE.c800, 0.15), + 1 => tailwind::SLATE.c900, + 2 => tailwind::SLATE.c950, + _ => Color::Rgb(2, 4, 12), + }; + Self { - active: tailwind::SLATE.c900, - inactive: tailwind::SLATE.c800, - highlight: tailwind::SLATE.c800, - track: tailwind::SLATE.c700, + active: frosted, + inactive, + highlight, + track, label: tailwind::SLATE.c100, - shadow: tailwind::SLATE.c950, + shadow, context_stops: [ - tailwind::SKY.c400, + blend_color(neon_seed, tailwind::AMBER.c300, 0.3), tailwind::AMBER.c300, tailwind::ROSE.c400, ], @@ -56,15 +93,40 @@ impl GlassPalette { tailwind::AMBER.c300, tailwind::ROSE.c400, ], + frosted, + frost_edge, + neon_accent, + neon_glow, + focus_ring, } } else { + let frosted = blend_color(tailwind::ZINC.c100, theme.background, glass_tint * 0.75); + let frost_edge = blend_color(frosted, tailwind::ZINC.c200, 0.4); + let inactive = blend_color(frosted, tailwind::ZINC.c200, 0.65); + let highlight = blend_color(frosted, tailwind::ZINC.c200, 0.35); + let track = blend_color(frosted, tailwind::ZINC.c300, 0.45); + let neon_seed = tailwind::BLUE.c500; + let neon_accent = blend_color(neon_seed, theme.info, neon_factor); + let neon_glow = blend_color(neon_accent, Color::White, 0.22); + let focus_ring = if focus_enabled { + blend_color(neon_accent, theme.focused_panel_border, 0.35) + } else { + blend_color(frosted, theme.unfocused_panel_border, 0.1) + }; + let shadow = match layers.shadow_depth() { + 0 => blend_color(theme.background, tailwind::ZINC.c200, 0.12), + 1 => tailwind::ZINC.c300, + 2 => tailwind::ZINC.c400, + _ => Color::Rgb(210, 210, 210), + }; + Self { - active: tailwind::ZINC.c100, - inactive: tailwind::ZINC.c200, - highlight: tailwind::ZINC.c200, - track: tailwind::ZINC.c300, + active: frosted, + inactive, + highlight, + track, label: tailwind::SLATE.c700, - shadow: tailwind::ZINC.c300, + shadow, context_stops: [ tailwind::BLUE.c500, tailwind::AMBER.c400, @@ -75,6 +137,11 @@ impl GlassPalette { tailwind::AMBER.c400, tailwind::ROSE.c500, ], + frosted, + frost_edge, + neon_accent, + neon_glow, + focus_ring, } } } @@ -94,6 +161,16 @@ pub fn gradient_color(stops: &[Color; 3], t: f64) -> Color { Color::Rgb(mix(sr, er), mix(sg, eg), mix(sb, eb)) } +pub fn blend_color(a: Color, b: Color, t: f64) -> Color { + let clamped = t.clamp(0.0, 1.0); + let (ar, ag, ab) = color_to_rgb(a); + let (br, bg, bb) = color_to_rgb(b); + let mix = |start: u8, end: u8| -> u8 { + (start as f64 + (end as f64 - start as f64) * clamped).round() as u8 + }; + Color::Rgb(mix(ar, br), mix(ag, bg), mix(ab, bb)) +} + fn color_luminance(color: Color) -> f64 { let (r, g, b) = color_to_rgb(color); let r = r as f64 / 255.0; diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 04f28b5..0ce728e 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -14,10 +14,10 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ - ChatApp, ContextUsage, HELP_TAB_COUNT, LayoutSnapshot, MIN_MESSAGE_CARD_WIDTH, - MessageRenderContext, + AdaptiveLayout, ChatApp, ContextUsage, GaugeKey, HELP_TAB_COUNT, LayoutSnapshot, + MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, PanePulse, }; -use crate::glass::{GlassPalette, gradient_color}; +use crate::glass::{GlassPalette, blend_color, gradient_color}; use crate::highlight; use crate::state::{ CodePane, EditorTab, FileFilterMode, FileNode, KeymapProfile, LayoutNode, PaletteGroup, PaneId, @@ -25,10 +25,10 @@ use crate::state::{ }; use crate::toast::{Toast, ToastLevel}; 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}; +use owlen_core::{config::LayerSettings, theme::Theme}; use textwrap::wrap; const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; @@ -364,22 +364,36 @@ fn render_body_container( frame: &mut Frame<'_>, area: Rect, palette: &GlassPalette, + layers: &LayerSettings, reduced_chrome: bool, + layout_mode: AdaptiveLayout, ) -> Rect { if area.width == 0 || area.height == 0 { return area; } - if !reduced_chrome && 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 { + let shadow_depth = if reduced_chrome { + 0 + } else { + layers.shadow_depth() + }; + + if shadow_depth > 0 && area.width > 2 && area.height > 2 { + for step in 1..=shadow_depth { + let offset = step as u16; + let shadow_area = Rect::new( + area.x.saturating_add(offset), + area.y.saturating_add(offset), + area.width.saturating_sub(offset), + area.height.saturating_sub(offset), + ); + if shadow_area.width == 0 || shadow_area.height == 0 { + continue; + } + let blend = (step as f64) / (shadow_depth as f64 + 1.5); + let shadow_color = blend_color(palette.shadow, palette.frosted, blend); frame.render_widget( - Block::default().style(Style::default().bg(palette.shadow)), + Block::default().style(Style::default().bg(shadow_color)), shadow_area, ); } @@ -390,23 +404,148 @@ fn render_body_container( let padding = if reduced_chrome { Padding::new(1, 1, 0, 0) } else { - Padding::new(2, 2, 1, 1) + let (pad_lr, pad_top, pad_bottom): (u16, u16, u16) = match layout_mode { + AdaptiveLayout::Compact => (1, 0, 0), + AdaptiveLayout::Cozy => (2, 1, 1), + AdaptiveLayout::Spacious => (3, 1, 2), + }; + Padding::new(pad_lr, pad_lr, pad_top, pad_bottom) + }; + + let block_background = if reduced_chrome { + palette.active + } else { + match layout_mode { + AdaptiveLayout::Compact => blend_color(palette.frosted, palette.highlight, 0.25), + AdaptiveLayout::Cozy => palette.frosted, + AdaptiveLayout::Spacious => blend_color(palette.frosted, palette.frost_edge, 0.35), + } }; let block = Block::default() .borders(Borders::NONE) .padding(padding) - .style(Style::default().bg(palette.active)); + .style(Style::default().bg(block_background)); let inner = block.inner(area); frame.render_widget(block, area); + + if layers.focus_ring && !reduced_chrome && area.width > 1 && area.height > 1 { + let ring_mix = match layout_mode { + AdaptiveLayout::Compact => 0.25, + AdaptiveLayout::Cozy => 0.45, + AdaptiveLayout::Spacious => 0.6, + }; + let ring_color = blend_color(palette.focus_ring, palette.neon_glow, ring_mix); + draw_focus_ring(frame, area, ring_color); + } inner } +fn draw_focus_ring(frame: &mut Frame<'_>, area: Rect, color: Color) { + if area.width == 0 || area.height == 0 { + return; + } + let buffer = frame.buffer_mut(); + let x0 = area.x; + let y0 = area.y; + let x1 = area.x + area.width.saturating_sub(1); + let y1 = area.y + area.height.saturating_sub(1); + + for x in x0..=x1 { + let cell_top = &mut buffer[(x, y0)]; + cell_top.set_bg(color); + if cell_top.symbol() == " " { + cell_top.set_fg(color); + } + if y1 != y0 { + let cell_bottom = &mut buffer[(x, y1)]; + cell_bottom.set_bg(color); + if cell_bottom.symbol() == " " { + cell_bottom.set_fg(color); + } + } + } + + for y in y0..=y1 { + let cell_left = &mut buffer[(x0, y)]; + cell_left.set_bg(color); + if cell_left.symbol() == " " { + cell_left.set_fg(color); + } + if x1 != x0 { + let cell_right = &mut buffer[(x1, y)]; + cell_right.set_bg(color); + if cell_right.symbol() == " " { + cell_right.set_fg(color); + } + } + } +} + +fn render_surface_glow( + frame: &mut Frame<'_>, + area: Rect, + palette: &GlassPalette, + intensity: f64, + layout_mode: AdaptiveLayout, +) { + if intensity <= 0.0 || area.width < 2 || area.height < 2 { + return; + } + let clamped = intensity.clamp(0.0, 1.0); + let mix = match layout_mode { + AdaptiveLayout::Compact => 0.35, + AdaptiveLayout::Cozy => 0.55, + AdaptiveLayout::Spacious => 0.7, + }; + let glow_color = blend_color(palette.frosted, palette.neon_glow, clamped * mix); + let buffer = frame.buffer_mut(); + let x0 = area.x; + let x1 = area.x + area.width.saturating_sub(1); + let y_top = area.y; + let y_second = if area.height > 2 { y_top + 1 } else { y_top }; + + for x in x0..=x1 { + buffer[(x, y_top)].set_bg(glow_color); + if y_second != y_top { + let secondary = blend_color(glow_color, palette.frosted, 0.5); + buffer[(x, y_second)].set_bg(secondary); + } + } +} + +fn apply_panel_glow(frame: &mut Frame<'_>, area: Rect, palette: &GlassPalette, intensity: f64) { + if intensity <= 0.0 || area.width == 0 || area.height == 0 { + return; + } + let clamped = intensity.clamp(0.0, 1.0); + let accent = blend_color(palette.frost_edge, palette.neon_accent, clamped); + let edge = blend_color(accent, palette.frosted, 0.5); + let buffer = frame.buffer_mut(); + let x0 = area.x; + let y0 = area.y; + let x1 = area.x + area.width.saturating_sub(1); + let y1 = area.y + area.height.saturating_sub(1); + + for x in x0..=x1 { + buffer[(x, y0)].set_bg(accent); + if y1 != y0 { + buffer[(x, y1)].set_bg(edge); + } + } + for y in y0..=y1 { + buffer[(x0, y)].set_bg(accent); + if x1 != x0 { + buffer[(x1, y)].set_bg(edge); + } + } +} + fn render_chat_header( frame: &mut Frame<'_>, area: Rect, - app: &ChatApp, + app: &mut ChatApp, palette: &GlassPalette, theme: &Theme, ) { @@ -570,7 +709,7 @@ fn render_header_top( fn render_header_bars( frame: &mut Frame<'_>, area: Rect, - app: &ChatApp, + app: &mut ChatApp, palette: &GlassPalette, theme: &Theme, ) { @@ -596,8 +735,14 @@ fn render_header_bars( .flex(Flex::SpaceBetween) .split(legend_split.0); - render_context_column(frame, columns[0], app, palette, theme); - render_usage_column(frame, columns[1], app, palette, theme); + { + let column = columns[0]; + render_context_column(frame, column, app, palette, theme); + } + { + let column = columns[1]; + render_usage_column(frame, column, app, palette, theme); + } if let Some(legend_area) = legend_split.1 { render_accessibility_legend(frame, legend_area, app, palette); @@ -607,7 +752,7 @@ fn render_header_bars( fn render_context_column( frame: &mut Frame<'_>, area: Rect, - app: &ChatApp, + app: &mut ChatApp, palette: &GlassPalette, theme: &Theme, ) { @@ -621,6 +766,7 @@ fn render_context_column( match descriptor { Some(descriptor) => { + let display_ratio = app.animated_gauge_ratio(GaugeKey::Context, descriptor.ratio); if area.height < 2 { render_gauge_compact(frame, area, &descriptor, palette); } else { @@ -628,6 +774,7 @@ fn render_context_column( frame, area, &descriptor, + display_ratio, &palette.context_stops, palette, theme, @@ -635,6 +782,7 @@ fn render_context_column( } } None => { + app.reset_gauge(GaugeKey::Context); frame.render_widget( Paragraph::new("Context metrics not available") .style(Style::default().bg(palette.highlight).fg(palette.label)) @@ -648,7 +796,7 @@ fn render_context_column( fn render_usage_column( frame: &mut Frame<'_>, area: Rect, - app: &ChatApp, + app: &mut ChatApp, palette: &GlassPalette, theme: &Theme, ) { @@ -656,17 +804,27 @@ fn render_usage_column( 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(); + let mut descriptors: Vec<(GaugeKey, GaugeDescriptor)> = Vec::new(); + let mut hour_present = false; + let mut week_present = false; + + if let Some(snapshot) = app.usage_snapshot() { + if let Some(descriptor) = usage_gauge_descriptor(snapshot, UsageWindow::Hour) { + hour_present = true; + descriptors.push((GaugeKey::UsageHour, descriptor)); + } + if let Some(descriptor) = usage_gauge_descriptor(snapshot, UsageWindow::Week) { + week_present = true; + descriptors.push((GaugeKey::UsageWeek, descriptor)); + } + } + + if !hour_present { + app.reset_gauge(GaugeKey::UsageHour); + } + if !week_present { + app.reset_gauge(GaugeKey::UsageWeek); + } if descriptors.is_empty() { frame.render_widget( @@ -681,7 +839,7 @@ fn render_usage_column( let bottom = area.y.saturating_add(area.height); let mut cursor_y = area.y; - for descriptor in descriptors { + for (key, descriptor) in descriptors { if cursor_y >= bottom { break; } @@ -697,10 +855,12 @@ fn render_usage_column( } let gauge_area = Rect::new(area.x, cursor_y, area.width, 2); + let display_ratio = app.animated_gauge_ratio(key, descriptor.ratio); render_gauge( frame, gauge_area, &descriptor, + display_ratio, &palette.usage_stops, palette, theme, @@ -735,6 +895,7 @@ fn render_gauge( frame: &mut Frame<'_>, area: Rect, descriptor: &GaugeDescriptor, + display_ratio: f64, stops: &[Color; 3], palette: &GlassPalette, theme: &Theme, @@ -779,7 +940,7 @@ fn render_gauge( draw_gradient_bar( frame, bar_area, - descriptor.ratio, + display_ratio, stops, palette, &descriptor.percent_label, @@ -868,14 +1029,33 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { // Set terminal background color let theme = app.theme().clone(); - let palette = GlassPalette::for_theme_with_mode(&theme, app.is_reduced_chrome()); + let palette = + GlassPalette::for_theme_with_mode(&theme, app.is_reduced_chrome(), app.layer_settings()); let frame_area = frame.area(); frame.render_widget( Block::default().style(Style::default().bg(theme.background)), frame_area, ); - let mut header_height = if frame_area.height >= 14 { 6 } else { 4 }; + let layout_hint = AdaptiveLayout::from_width(frame_area.width); + + let mut header_height = match layout_hint { + AdaptiveLayout::Compact => 4, + AdaptiveLayout::Cozy => { + if frame_area.height >= 14 { + 5 + } else { + 4 + } + } + AdaptiveLayout::Spacious => { + if frame_area.height >= 14 { + 6 + } else { + 4 + } + } + }; if frame_area.height <= header_height { header_height = frame_area.height.saturating_sub(1); } @@ -906,8 +1086,21 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { render_chat_header(frame, header_area, app, &palette, &theme); } - let content_area = render_body_container(frame, body_area, &palette, app.is_reduced_chrome()); + let content_area = render_body_container( + frame, + body_area, + &palette, + app.layer_settings(), + app.is_reduced_chrome(), + layout_hint, + ); + let final_layout_mode = AdaptiveLayout::from_width(content_area.width); + app.update_layout_mode(final_layout_mode); + let stage_glow = app.pane_glow(PanePulse::Stage); + render_surface_glow(frame, content_area, &palette, stage_glow, final_layout_mode); + let mut snapshot = LayoutSnapshot::new(frame_area, content_area); + snapshot.layout_mode = final_layout_mode; snapshot.header_panel = if header_area.width > 0 && header_area.height > 0 { Some(header_area) } else { @@ -919,12 +1112,19 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { return; } + let file_panel_glow = app.pane_glow(PanePulse::FilePanel); + let code_panel_glow = app.pane_glow(PanePulse::CodePanel); + let model_panel_glow = app.pane_glow(PanePulse::ModelPanel); + let debug_panel_glow = app.pane_glow(PanePulse::DebugPanel); + if !app.is_code_mode() && !app.is_file_panel_collapsed() { app.set_file_panel_collapsed(true); } - let show_file_panel = - app.is_code_mode() && !app.is_file_panel_collapsed() && content_area.width >= 40; + let show_file_panel = app.is_code_mode() + && !app.is_file_panel_collapsed() + && !matches!(final_layout_mode, AdaptiveLayout::Compact) + && content_area.width >= 40; let (file_area, main_area) = if !show_file_panel { (None, content_area) @@ -938,10 +1138,29 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { }; let (chat_area, code_area) = if app.should_show_code_view() { - let segments = Layout::horizontal([Constraint::Percentage(65), Constraint::Percentage(35)]) - .flex(Flex::Start) - .split(main_area); - (segments[0], Some(segments[1])) + match final_layout_mode { + AdaptiveLayout::Spacious => { + let segments = + Layout::horizontal([Constraint::Percentage(65), Constraint::Percentage(35)]) + .flex(Flex::Start) + .split(main_area); + (segments[0], Some(segments[1])) + } + _ => { + let (chat_pct, code_pct) = if matches!(final_layout_mode, AdaptiveLayout::Compact) { + (55, 45) + } else { + (62, 38) + }; + let segments = Layout::vertical([ + Constraint::Percentage(chat_pct), + Constraint::Percentage(code_pct), + ]) + .flex(Flex::Start) + .split(main_area); + (segments[0], Some(segments[1])) + } + } } else { (main_area, None) }; @@ -949,6 +1168,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { if let Some(file_area) = file_area { snapshot.file_panel = Some(file_area); render_file_tree(frame, file_area, app); + if file_panel_glow > 0.0 { + apply_panel_glow(frame, file_area, &palette, file_panel_glow); + } } // Calculate dynamic input height based on textarea content @@ -1080,6 +1302,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { let viewport_height = area.height.saturating_sub(2) as usize; app.set_model_info_viewport_height(viewport_height); app.model_info_panel_mut().render(frame, area, &theme); + if model_panel_glow > 0.0 { + apply_panel_glow(frame, area, &palette, model_panel_glow); + } } else { snapshot.model_info_panel = None; } @@ -1087,6 +1312,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { if let Some(area) = code_area { snapshot.code_panel = Some(area); render_code_workspace(frame, area, app); + if code_panel_glow > 0.0 { + apply_panel_glow(frame, area, &palette, code_panel_glow); + } } else { snapshot.code_panel = None; } @@ -1102,6 +1330,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { .saturating_add(content_area.height.saturating_sub(panel_height)); let log_area = Rect::new(content_area.x, y, content_area.width, panel_height); render_debug_log_panel(frame, log_area, app); + if debug_panel_glow > 0.0 { + apply_panel_glow(frame, log_area, &palette, debug_panel_glow); + } } } @@ -2222,7 +2453,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { )); let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(&theme, reduced); + let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); let chat_block = Block::default() .borders(Borders::NONE) .padding(if reduced { @@ -2362,7 +2593,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { panel_hint_style(has_focus, &theme), )); - let palette = GlassPalette::for_theme_with_mode(&theme, reduced); + let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); let paragraph = Paragraph::new(lines) .style( Style::default() @@ -2593,7 +2824,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { panel_hint_style(has_focus, &theme), )); - let palette = GlassPalette::for_theme_with_mode(&theme, reduced); + let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); let paragraph = Paragraph::new(lines) .style( Style::default() @@ -2674,7 +2905,7 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(&theme, reduced); + let palette = GlassPalette::for_theme_with_mode(&theme, reduced, app.layer_settings()); let base_style = Style::default() .bg(if has_focus { palette.active @@ -2770,7 +3001,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 reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); let color = if message.starts_with("Error:") { theme.error @@ -2815,7 +3046,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 reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); frame.render_widget(Clear, area); let title = Line::from(vec![ @@ -2971,7 +3202,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); frame.render_widget(Clear, area); @@ -3912,7 +4143,7 @@ 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 reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); let config = app.config(); if area.width == 0 || area.height == 0 { return; @@ -3979,7 +4210,7 @@ fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); let profile = app.current_keymap_profile(); let leader = app.keymap_leader(); let area = centered_rect(75, 70, frame.area()); @@ -4847,7 +5078,7 @@ fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); let area = frame.area(); if area.width == 0 || area.height == 0 { return; @@ -5122,6 +5353,7 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { preview_area, &preview_theme, preview_theme.name == theme.name, + app.layer_settings(), ); } } @@ -5137,13 +5369,19 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) { frame.render_widget(footer, layout[1]); } -fn render_theme_preview(frame: &mut Frame<'_>, area: Rect, preview_theme: &Theme, is_active: bool) { +fn render_theme_preview( + frame: &mut Frame<'_>, + area: Rect, + preview_theme: &Theme, + is_active: bool, + layers: &LayerSettings, +) { if area.width < 10 || area.height < 5 { return; } frame.render_widget(Clear, area); - let preview_palette = GlassPalette::for_theme(preview_theme); + let preview_palette = GlassPalette::for_theme(preview_theme, layers); let mut title = format!("Preview · {}", preview_theme.name); if is_active { title.push_str(" (active)"); @@ -5280,7 +5518,7 @@ fn render_theme_preview(frame: &mut Frame<'_>, area: Rect, preview_theme: &Theme fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); let suggestions = app.command_suggestions(); let buffer = app.command_buffer(); let area = frame.area(); diff --git a/crates/owlen-tui/src/widgets/model_picker.rs b/crates/owlen-tui/src/widgets/model_picker.rs index 69355b3..89a3a3d 100644 --- a/crates/owlen-tui/src/widgets/model_picker.rs +++ b/crates/owlen-tui/src/widgets/model_picker.rs @@ -31,7 +31,7 @@ pub enum FilterMode { pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced); + let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); let area = frame.area(); if area.width == 0 || area.height == 0 { return; diff --git a/crates/owlen-tui/tests/chat_snapshots.rs b/crates/owlen-tui/tests/chat_snapshots.rs index cfd95bc..95ef880 100644 --- a/crates/owlen-tui/tests/chat_snapshots.rs +++ b/crates/owlen-tui/tests/chat_snapshots.rs @@ -80,8 +80,9 @@ fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { output } -async fn build_chat_app(configure: F) -> ChatApp +async fn build_chat_app(configure_config: C, configure_session: F) -> ChatApp where + C: FnOnce(&mut Config), F: FnOnce(&mut SessionController), { let temp_dir = tempdir().expect("temp dir"); @@ -92,6 +93,7 @@ where let storage = Arc::new(storage); let mut config = Config::default(); + configure_config(&mut config); config.general.default_model = Some("stub-model".into()); config.general.enable_streaming = true; config.privacy.encrypt_local_data = false; @@ -118,7 +120,7 @@ where .await .expect("chat mode"); - configure(&mut session); + configure_session(&mut session); let (app, mut session_rx) = ChatApp::new(session, controller_event_rx) .await @@ -142,48 +144,63 @@ fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String { #[tokio::test(flavor = "multi_thread")] async fn render_chat_idle_snapshot() { - let mut app = build_chat_app(|_| {}).await; + let mut app_80 = build_chat_app(|_| {}, |_| {}).await; + with_settings!({ snapshot_suffix => "80x35" }, { + let snapshot = render_snapshot(&mut app_80, 80, 35); + assert_snapshot!("chat_idle_snapshot", snapshot); + }); + let mut app_100 = build_chat_app(|_| {}, |_| {}).await; with_settings!({ snapshot_suffix => "100x35" }, { - let snapshot = render_snapshot(&mut app, 100, 35); + let snapshot = render_snapshot(&mut app_100, 100, 35); + assert_snapshot!("chat_idle_snapshot", snapshot); + }); + + let mut app_140 = build_chat_app(|_| {}, |_| {}).await; + with_settings!({ snapshot_suffix => "140x35" }, { + let snapshot = render_snapshot(&mut app_140, 140, 35); assert_snapshot!("chat_idle_snapshot", snapshot); }); } #[tokio::test(flavor = "multi_thread")] async fn render_chat_tool_call_snapshot() { - let mut app = build_chat_app(|session| { - let conversation = session.conversation_mut(); - conversation.push_user_message("What happened in the Rust ecosystem today?"); + let mut app = build_chat_app( + |_| {}, + |session| { + let conversation = session.conversation_mut(); + conversation.push_user_message("What happened in the Rust ecosystem today?"); - let stream_id = conversation.start_streaming_response(); - conversation - .set_stream_placeholder(stream_id, "Consulting the knowledge base…") - .expect("placeholder"); + let stream_id = conversation.start_streaming_response(); + conversation + .set_stream_placeholder(stream_id, "Consulting the knowledge base…") + .expect("placeholder"); - let tool_call = ToolCall { - id: "call-search-1".into(), - name: "web_search".into(), - arguments: serde_json::json!({ "query": "Rust language news" }), - }; - conversation - .set_tool_calls_on_message(stream_id, vec![tool_call.clone()]) - .expect("tool call metadata"); - conversation - .append_stream_chunk(stream_id, "Found multiple articles…", false) - .expect("stream chunk"); + let tool_call = ToolCall { + id: "call-search-1".into(), + name: "web_search".into(), + arguments: serde_json::json!({ "query": "Rust language news" }), + }; + conversation + .set_tool_calls_on_message(stream_id, vec![tool_call.clone()]) + .expect("tool call metadata"); + conversation + .append_stream_chunk(stream_id, "Found multiple articles…", false) + .expect("stream chunk"); - let tool_message = Message::tool( - tool_call.id.clone(), - "Rust 1.85 released with generics cleanups and faster async compilation.".to_string(), - ); - conversation.push_message(tool_message); + let tool_message = Message::tool( + tool_call.id.clone(), + "Rust 1.85 released with generics cleanups and faster async compilation." + .to_string(), + ); + conversation.push_message(tool_message); - let assistant_summary = Message::assistant( - "Summarising the latest Rust release and the async runtime updates.".into(), - ); - conversation.push_message(assistant_summary); - }) + let assistant_summary = Message::assistant( + "Summarising the latest Rust release and the async runtime updates.".into(), + ); + conversation.push_message(assistant_summary); + }, + ) .await; // Surface quota toast to exercise header/status rendering. @@ -200,9 +217,25 @@ async fn render_chat_tool_call_snapshot() { }); } +#[tokio::test(flavor = "multi_thread")] +async fn render_chat_idle_no_animation_snapshot() { + let mut app = build_chat_app( + |cfg| { + cfg.ui.animations.micro = false; + }, + |_| {}, + ) + .await; + + with_settings!({ snapshot_suffix => "no-anim-100x35" }, { + let snapshot = render_snapshot(&mut app, 100, 35); + assert_snapshot!("chat_idle_snapshot_no_anim", snapshot); + }); +} + #[tokio::test(flavor = "multi_thread")] async fn render_command_palette_focus_snapshot() { - let mut app = build_chat_app(|_| {}).await; + let mut app = build_chat_app(|_| {}, |_| {}).await; app.handle_event(Event::Key(KeyEvent::new( KeyCode::Char(':'), diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap index c94fabf..0b26e75 100644 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@100x35.snap @@ -1,5 +1,6 @@ --- source: crates/owlen-tui/tests/chat_snapshots.rs +assertion_line: 156 expression: snapshot --- " " @@ -8,7 +9,6 @@ expression: snapshot " Context metrics not available Cloud usage pending " " " " " -" " " ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " " " " No messages yet. Press 'i' to start typing. " @@ -27,6 +27,7 @@ expression: snapshot " " " " " " +" " " Input Press i to start typing · Ctrl+5 focus " " " " " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@140x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@140x35.snap new file mode 100644 index 0000000..e01db8f --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@140x35.snap @@ -0,0 +1,40 @@ +--- +source: crates/owlen-tui/tests/chat_snapshots.rs +assertion_line: 162 +expression: snapshot +--- +" " +" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " +" " +" Context metrics not available Cloud usage pending " +" " +" " +" " +" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " +" " +" No messages yet. Press 'i' to start typing. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Input Press i to start typing · Ctrl+5 focus " +" " +" " +" System/Status " +" " +" " +" NORMAL │ CHAT │ INPUT · Ctrl owlen-tui · 1:1 · UTF-8 LF · Plain Text ollama_local ▸ stub-model · LSP:✓ " +" " +" " +" " +" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@80x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@80x35.snap new file mode 100644 index 0000000..4f99c8a --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot@80x35.snap @@ -0,0 +1,40 @@ +--- +source: crates/owlen-tui/tests/chat_snapshots.rs +assertion_line: 150 +expression: snapshot +--- +" " +" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " +" Context metrics not available Cloud usage pending " +" " +" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " +" " +" No messages yet. Press 'i' to start typing. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Input Press i to start typing · Ctrl+5 focus " +" " +" " +" System/Status " +" " +" " +" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model " +" " +" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot_no_anim@no-anim-100x35.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot_no_anim@no-anim-100x35.snap new file mode 100644 index 0000000..736e006 --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_idle_snapshot_no_anim@no-anim-100x35.snap @@ -0,0 +1,40 @@ +--- +source: crates/owlen-tui/tests/chat_snapshots.rs +assertion_line: 232 +expression: snapshot +--- +" " +" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " +" " +" Context metrics not available Cloud usage pending " +" " +" " +" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " +" " +" No messages yet. Press 'i' to start typing. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Input Press i to start typing · Ctrl+5 focus " +" " +" " +" System/Status " +" " +" " +" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ " +" " +" " +" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap index be7f3c2..52c105b 100644 --- a/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__chat_tool_call_snapshot@80x24.snap @@ -1,28 +1,29 @@ --- source: crates/owlen-tui/tests/chat_snapshots.rs +assertion_line: 216 expression: snapshot --- " " " 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model " -" " " Context metrics not available Cloud usage pending " " " +" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " +" ┌──────────────────────────────────────────────┐ " +" │ Found multiple articles│ WARN Cloud usage is at 82% of the hourly │ " +" └──────────────────────────└──────────────────────────────────────────────┘ " +" ┌ 🔧 Tool [Result: call-search-1] ──────────────────────────────────────┐ " +" │ Rust 1.85 released with generics cleanups and faster async compila │ " +" │ tion. │ " +" └────────────────────────────────────────────────────────────────────────┘ " +" ┌ 🤖 Assistant ──────────────────────────────────────────────────────────┐ " +" │ Summarising the latest Rust release and the async runtime updates. │ " +" " +" Input Press i to start typing · Ctrl+5 focus " " " " " -" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus " -" ┌──────────────────────────────────────────────┐ " -" │ lation. │ WARN Cloud usage is at 82% of the hourly │ " -" └────────────────────────└──────────────────────────────────────────────┘ " -" ┌ 🤖 Assistant ────────────────────────────────────────────────────────┐ " -" │ Summarising the latest Rust release and the async runtime update │ " -" │ s. │ " -" " -" Input Press i to start typing · Ctrl+5 focus " +" System/Status " " " " " -" System/Status " -" " -" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-mode " -" " +" NORMAL │ CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model " " " " "