feat(tui): adaptive layout & polish refresh
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_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(1),
|
||||
area.y.saturating_add(1),
|
||||
area.width.saturating_sub(1),
|
||||
area.height.saturating_sub(1),
|
||||
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 {
|
||||
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)])
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,17 +144,30 @@ 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 mut app = build_chat_app(
|
||||
|_| {},
|
||||
|session| {
|
||||
let conversation = session.conversation_mut();
|
||||
conversation.push_user_message("What happened in the Rust ecosystem today?");
|
||||
|
||||
@@ -175,7 +190,8 @@ async fn render_chat_tool_call_snapshot() {
|
||||
|
||||
let tool_message = Message::tool(
|
||||
tool_call.id.clone(),
|
||||
"Rust 1.85 released with generics cleanups and faster async compilation.".to_string(),
|
||||
"Rust 1.85 released with generics cleanups and faster async compilation."
|
||||
.to_string(),
|
||||
);
|
||||
conversation.push_message(tool_message);
|
||||
|
||||
@@ -183,7 +199,8 @@ async fn render_chat_tool_call_snapshot() {
|
||||
"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(':'),
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -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:✓ "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
@@ -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:✓ "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -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 "
|
||||
" ┌──────────────────────────────────────────────┐ "
|
||||
" │ lation. │ WARN Cloud usage is at 82% of the hourly │ "
|
||||
" └────────────────────────└──────────────────────────────────────────────┘ "
|
||||
" ┌ 🤖 Assistant ────────────────────────────────────────────────────────┐ "
|
||||
" │ Summarising the latest Rust release and the async runtime update │ "
|
||||
" │ s. │ "
|
||||
" │ 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 "
|
||||
" "
|
||||
" "
|
||||
" 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 "
|
||||
" "
|
||||
" "
|
||||
|
||||
Reference in New Issue
Block a user