feat(tui): adaptive layout & polish refresh

This commit is contained in:
2025-10-25 18:52:37 +02:00
parent e89da02d49
commit 124db19e68
12 changed files with 944 additions and 124 deletions

View File

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

View File

@@ -1818,6 +1818,10 @@ pub struct UiSettings {
pub keymap_path: Option<String>,
#[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(),
}
}
}

View File

@@ -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<Rect>,
pub(crate) code_panel: Option<Rect>,
pub(crate) model_info_panel: Option<Rect>,
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<ContextUsage>,
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;

View File

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

View File

@@ -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<GaugeDescriptor> = 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();

View File

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

View File

@@ -80,8 +80,9 @@ fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
output
}
async fn build_chat_app<F>(configure: F) -> ChatApp
async fn build_chat_app<C, F>(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(':'),

View File

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

View File

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

View File

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

View File

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

View File

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