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).
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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>,
|
pub keymap_path: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub accessibility: AccessibilitySettings,
|
pub accessibility: AccessibilitySettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layers: LayerSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub animations: AnimationSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[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.
|
/// Preference for which symbol set to render in the terminal UI.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -1994,6 +2093,8 @@ impl Default for UiSettings {
|
|||||||
keymap_leader: Self::default_keymap_leader(),
|
keymap_leader: Self::default_keymap_leader(),
|
||||||
keymap_path: None,
|
keymap_path: None,
|
||||||
accessibility: AccessibilitySettings::default(),
|
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::widgets::model_picker::FilterMode;
|
||||||
use crate::{commands, highlight};
|
use crate::{commands, highlight};
|
||||||
use owlen_core::config::{
|
use owlen_core::config::{
|
||||||
LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_API_KEY_ENV,
|
AnimationSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV,
|
||||||
OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
|
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};
|
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
|
||||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||||
@@ -115,6 +116,161 @@ pub struct ContextUsage {
|
|||||||
pub context_window: u32,
|
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)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub(crate) struct LayoutSnapshot {
|
pub(crate) struct LayoutSnapshot {
|
||||||
pub(crate) frame: Rect,
|
pub(crate) frame: Rect,
|
||||||
@@ -129,6 +285,7 @@ pub(crate) struct LayoutSnapshot {
|
|||||||
pub(crate) status_panel: Option<Rect>,
|
pub(crate) status_panel: Option<Rect>,
|
||||||
pub(crate) code_panel: Option<Rect>,
|
pub(crate) code_panel: Option<Rect>,
|
||||||
pub(crate) model_info_panel: Option<Rect>,
|
pub(crate) model_info_panel: Option<Rect>,
|
||||||
|
pub(crate) layout_mode: AdaptiveLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LayoutSnapshot {
|
impl LayoutSnapshot {
|
||||||
@@ -146,6 +303,7 @@ impl LayoutSnapshot {
|
|||||||
status_panel: None,
|
status_panel: None,
|
||||||
code_panel: None,
|
code_panel: None,
|
||||||
model_info_panel: None,
|
model_info_panel: None,
|
||||||
|
layout_mode: AdaptiveLayout::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +792,11 @@ pub struct ChatApp {
|
|||||||
usage_thresholds: HashMap<(String, UsageWindow), UsageBand>,
|
usage_thresholds: HashMap<(String, UsageWindow), UsageBand>,
|
||||||
context_usage: Option<ContextUsage>,
|
context_usage: Option<ContextUsage>,
|
||||||
last_layout: LayoutSnapshot,
|
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.
|
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||||
_execution_budget: usize,
|
_execution_budget: usize,
|
||||||
/// Agent mode enabled
|
/// Agent mode enabled
|
||||||
@@ -797,6 +960,8 @@ impl ChatApp {
|
|||||||
let keymap_profile = config_guard.ui.keymap_profile.clone();
|
let keymap_profile = config_guard.ui.keymap_profile.clone();
|
||||||
let keymap_leader_raw = config_guard.ui.keymap_leader.clone();
|
let keymap_leader_raw = config_guard.ui.keymap_leader.clone();
|
||||||
let accessibility = config_guard.ui.accessibility.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);
|
drop(config_guard);
|
||||||
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
|
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
|
||||||
let keymap = {
|
let keymap = {
|
||||||
@@ -944,6 +1109,11 @@ impl ChatApp {
|
|||||||
base_theme_name,
|
base_theme_name,
|
||||||
accessibility_high_contrast,
|
accessibility_high_contrast,
|
||||||
accessibility_reduced_chrome,
|
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;
|
app.mvu_model.composer.mode = InputMode::Normal;
|
||||||
@@ -1608,6 +1778,9 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_file_panel_collapsed(&mut self, collapsed: bool) {
|
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;
|
self.file_panel_collapsed = collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1630,7 +1803,7 @@ impl ChatApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.file_panel_collapsed {
|
if self.file_panel_collapsed {
|
||||||
self.file_panel_collapsed = false;
|
self.set_file_panel_collapsed(false);
|
||||||
self.focused_panel = FocusedPanel::Files;
|
self.focused_panel = FocusedPanel::Files;
|
||||||
self.ensure_focus_valid();
|
self.ensure_focus_valid();
|
||||||
}
|
}
|
||||||
@@ -1638,7 +1811,7 @@ impl ChatApp {
|
|||||||
|
|
||||||
pub fn collapse_file_panel(&mut self) {
|
pub fn collapse_file_panel(&mut self) {
|
||||||
if !self.file_panel_collapsed {
|
if !self.file_panel_collapsed {
|
||||||
self.file_panel_collapsed = true;
|
self.set_file_panel_collapsed(true);
|
||||||
if matches!(self.focused_panel, FocusedPanel::Files) {
|
if matches!(self.focused_panel, FocusedPanel::Files) {
|
||||||
self.focused_panel = FocusedPanel::Chat;
|
self.focused_panel = FocusedPanel::Chat;
|
||||||
}
|
}
|
||||||
@@ -1823,6 +1996,9 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_model_info_visible(&mut self, visible: bool) {
|
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;
|
self.show_model_info = visible;
|
||||||
if !visible {
|
if !visible {
|
||||||
self.model_info_panel.reset_scroll();
|
self.model_info_panel.reset_scroll();
|
||||||
@@ -2117,6 +2293,64 @@ impl ChatApp {
|
|||||||
self.accessibility_high_contrast || self.accessibility_reduced_chrome
|
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 {
|
pub fn accessibility_status(&self) -> String {
|
||||||
Self::accessibility_summary(
|
Self::accessibility_summary(
|
||||||
self.accessibility_high_contrast,
|
self.accessibility_high_contrast,
|
||||||
@@ -2144,6 +2378,18 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) {
|
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;
|
self.last_layout = snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2205,6 +2451,7 @@ impl ChatApp {
|
|||||||
|
|
||||||
pub fn toggle_debug_log_panel(&mut self) {
|
pub fn toggle_debug_log_panel(&mut self) {
|
||||||
let now_visible = self.debug_log.toggle_visible();
|
let now_visible = self.debug_log.toggle_visible();
|
||||||
|
self.trigger_pane_pulse(PanePulse::DebugPanel);
|
||||||
if now_visible {
|
if now_visible {
|
||||||
self.status = "Debug log open — F12 to hide".to_string();
|
self.status = "Debug log open — F12 to hide".to_string();
|
||||||
self.error = None;
|
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};
|
use ratatui::style::{Color, palette::tailwind};
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -11,14 +11,23 @@ pub struct GlassPalette {
|
|||||||
pub shadow: Color,
|
pub shadow: Color,
|
||||||
pub context_stops: [Color; 3],
|
pub context_stops: [Color; 3],
|
||||||
pub usage_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 {
|
impl GlassPalette {
|
||||||
pub fn for_theme(theme: &Theme) -> Self {
|
pub fn for_theme(theme: &Theme, layers: &LayerSettings) -> Self {
|
||||||
Self::for_theme_with_mode(theme, false)
|
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 {
|
if reduced_chrome {
|
||||||
let base = theme.background;
|
let base = theme.background;
|
||||||
let label = theme.text;
|
let label = theme.text;
|
||||||
@@ -34,20 +43,48 @@ impl GlassPalette {
|
|||||||
shadow: base,
|
shadow: base,
|
||||||
context_stops: [context_color, context_color, context_color],
|
context_stops: [context_color, context_color, context_color],
|
||||||
usage_stops: [usage_color, usage_color, usage_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 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 {
|
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 {
|
Self {
|
||||||
active: tailwind::SLATE.c900,
|
active: frosted,
|
||||||
inactive: tailwind::SLATE.c800,
|
inactive,
|
||||||
highlight: tailwind::SLATE.c800,
|
highlight,
|
||||||
track: tailwind::SLATE.c700,
|
track,
|
||||||
label: tailwind::SLATE.c100,
|
label: tailwind::SLATE.c100,
|
||||||
shadow: tailwind::SLATE.c950,
|
shadow,
|
||||||
context_stops: [
|
context_stops: [
|
||||||
tailwind::SKY.c400,
|
blend_color(neon_seed, tailwind::AMBER.c300, 0.3),
|
||||||
tailwind::AMBER.c300,
|
tailwind::AMBER.c300,
|
||||||
tailwind::ROSE.c400,
|
tailwind::ROSE.c400,
|
||||||
],
|
],
|
||||||
@@ -56,15 +93,40 @@ impl GlassPalette {
|
|||||||
tailwind::AMBER.c300,
|
tailwind::AMBER.c300,
|
||||||
tailwind::ROSE.c400,
|
tailwind::ROSE.c400,
|
||||||
],
|
],
|
||||||
|
frosted,
|
||||||
|
frost_edge,
|
||||||
|
neon_accent,
|
||||||
|
neon_glow,
|
||||||
|
focus_ring,
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
Self {
|
||||||
active: tailwind::ZINC.c100,
|
active: frosted,
|
||||||
inactive: tailwind::ZINC.c200,
|
inactive,
|
||||||
highlight: tailwind::ZINC.c200,
|
highlight,
|
||||||
track: tailwind::ZINC.c300,
|
track,
|
||||||
label: tailwind::SLATE.c700,
|
label: tailwind::SLATE.c700,
|
||||||
shadow: tailwind::ZINC.c300,
|
shadow,
|
||||||
context_stops: [
|
context_stops: [
|
||||||
tailwind::BLUE.c500,
|
tailwind::BLUE.c500,
|
||||||
tailwind::AMBER.c400,
|
tailwind::AMBER.c400,
|
||||||
@@ -75,6 +137,11 @@ impl GlassPalette {
|
|||||||
tailwind::AMBER.c400,
|
tailwind::AMBER.c400,
|
||||||
tailwind::ROSE.c500,
|
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))
|
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 {
|
fn color_luminance(color: Color) -> f64 {
|
||||||
let (r, g, b) = color_to_rgb(color);
|
let (r, g, b) = color_to_rgb(color);
|
||||||
let r = r as f64 / 255.0;
|
let r = r as f64 / 255.0;
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::chat_app::{
|
use crate::chat_app::{
|
||||||
ChatApp, ContextUsage, HELP_TAB_COUNT, LayoutSnapshot, MIN_MESSAGE_CARD_WIDTH,
|
AdaptiveLayout, ChatApp, ContextUsage, GaugeKey, HELP_TAB_COUNT, LayoutSnapshot,
|
||||||
MessageRenderContext,
|
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::highlight;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
CodePane, EditorTab, FileFilterMode, FileNode, KeymapProfile, LayoutNode, PaletteGroup, PaneId,
|
CodePane, EditorTab, FileFilterMode, FileNode, KeymapProfile, LayoutNode, PaletteGroup, PaneId,
|
||||||
@@ -25,10 +25,10 @@ use crate::state::{
|
|||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel};
|
use crate::toast::{Toast, ToastLevel};
|
||||||
use crate::widgets::model_picker::render_model_picker;
|
use crate::widgets::model_picker::render_model_picker;
|
||||||
use owlen_core::theme::Theme;
|
|
||||||
use owlen_core::types::Role;
|
use owlen_core::types::Role;
|
||||||
use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay};
|
use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay};
|
||||||
use owlen_core::usage::{UsageBand, UsageSnapshot, UsageWindow};
|
use owlen_core::usage::{UsageBand, UsageSnapshot, UsageWindow};
|
||||||
|
use owlen_core::{config::LayerSettings, theme::Theme};
|
||||||
use textwrap::wrap;
|
use textwrap::wrap;
|
||||||
|
|
||||||
const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1;
|
const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1;
|
||||||
@@ -364,22 +364,36 @@ fn render_body_container(
|
|||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
palette: &GlassPalette,
|
palette: &GlassPalette,
|
||||||
|
layers: &LayerSettings,
|
||||||
reduced_chrome: bool,
|
reduced_chrome: bool,
|
||||||
|
layout_mode: AdaptiveLayout,
|
||||||
) -> Rect {
|
) -> Rect {
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return area;
|
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(
|
let shadow_area = Rect::new(
|
||||||
area.x.saturating_add(1),
|
area.x.saturating_add(offset),
|
||||||
area.y.saturating_add(1),
|
area.y.saturating_add(offset),
|
||||||
area.width.saturating_sub(1),
|
area.width.saturating_sub(offset),
|
||||||
area.height.saturating_sub(1),
|
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(
|
frame.render_widget(
|
||||||
Block::default().style(Style::default().bg(palette.shadow)),
|
Block::default().style(Style::default().bg(shadow_color)),
|
||||||
shadow_area,
|
shadow_area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -390,23 +404,148 @@ fn render_body_container(
|
|||||||
let padding = if reduced_chrome {
|
let padding = if reduced_chrome {
|
||||||
Padding::new(1, 1, 0, 0)
|
Padding::new(1, 1, 0, 0)
|
||||||
} else {
|
} 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()
|
let block = Block::default()
|
||||||
.borders(Borders::NONE)
|
.borders(Borders::NONE)
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.style(Style::default().bg(palette.active));
|
.style(Style::default().bg(block_background));
|
||||||
|
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, 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
|
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(
|
fn render_chat_header(
|
||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
app: &ChatApp,
|
app: &mut ChatApp,
|
||||||
palette: &GlassPalette,
|
palette: &GlassPalette,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
) {
|
) {
|
||||||
@@ -570,7 +709,7 @@ fn render_header_top(
|
|||||||
fn render_header_bars(
|
fn render_header_bars(
|
||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
app: &ChatApp,
|
app: &mut ChatApp,
|
||||||
palette: &GlassPalette,
|
palette: &GlassPalette,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
) {
|
) {
|
||||||
@@ -596,8 +735,14 @@ fn render_header_bars(
|
|||||||
.flex(Flex::SpaceBetween)
|
.flex(Flex::SpaceBetween)
|
||||||
.split(legend_split.0);
|
.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 {
|
if let Some(legend_area) = legend_split.1 {
|
||||||
render_accessibility_legend(frame, legend_area, app, palette);
|
render_accessibility_legend(frame, legend_area, app, palette);
|
||||||
@@ -607,7 +752,7 @@ fn render_header_bars(
|
|||||||
fn render_context_column(
|
fn render_context_column(
|
||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
app: &ChatApp,
|
app: &mut ChatApp,
|
||||||
palette: &GlassPalette,
|
palette: &GlassPalette,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
) {
|
) {
|
||||||
@@ -621,6 +766,7 @@ fn render_context_column(
|
|||||||
|
|
||||||
match descriptor {
|
match descriptor {
|
||||||
Some(descriptor) => {
|
Some(descriptor) => {
|
||||||
|
let display_ratio = app.animated_gauge_ratio(GaugeKey::Context, descriptor.ratio);
|
||||||
if area.height < 2 {
|
if area.height < 2 {
|
||||||
render_gauge_compact(frame, area, &descriptor, palette);
|
render_gauge_compact(frame, area, &descriptor, palette);
|
||||||
} else {
|
} else {
|
||||||
@@ -628,6 +774,7 @@ fn render_context_column(
|
|||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
&descriptor,
|
&descriptor,
|
||||||
|
display_ratio,
|
||||||
&palette.context_stops,
|
&palette.context_stops,
|
||||||
palette,
|
palette,
|
||||||
theme,
|
theme,
|
||||||
@@ -635,6 +782,7 @@ fn render_context_column(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
app.reset_gauge(GaugeKey::Context);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("Context metrics not available")
|
Paragraph::new("Context metrics not available")
|
||||||
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
||||||
@@ -648,7 +796,7 @@ fn render_context_column(
|
|||||||
fn render_usage_column(
|
fn render_usage_column(
|
||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
app: &ChatApp,
|
app: &mut ChatApp,
|
||||||
palette: &GlassPalette,
|
palette: &GlassPalette,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
) {
|
) {
|
||||||
@@ -656,17 +804,27 @@ fn render_usage_column(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let descriptors: Vec<GaugeDescriptor> = app
|
let mut descriptors: Vec<(GaugeKey, GaugeDescriptor)> = Vec::new();
|
||||||
.usage_snapshot()
|
let mut hour_present = false;
|
||||||
.into_iter()
|
let mut week_present = false;
|
||||||
.flat_map(|snapshot| {
|
|
||||||
[
|
if let Some(snapshot) = app.usage_snapshot() {
|
||||||
usage_gauge_descriptor(snapshot, UsageWindow::Hour),
|
if let Some(descriptor) = usage_gauge_descriptor(snapshot, UsageWindow::Hour) {
|
||||||
usage_gauge_descriptor(snapshot, UsageWindow::Week),
|
hour_present = true;
|
||||||
]
|
descriptors.push((GaugeKey::UsageHour, descriptor));
|
||||||
})
|
}
|
||||||
.flatten()
|
if let Some(descriptor) = usage_gauge_descriptor(snapshot, UsageWindow::Week) {
|
||||||
.collect();
|
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() {
|
if descriptors.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -681,7 +839,7 @@ fn render_usage_column(
|
|||||||
let bottom = area.y.saturating_add(area.height);
|
let bottom = area.y.saturating_add(area.height);
|
||||||
let mut cursor_y = area.y;
|
let mut cursor_y = area.y;
|
||||||
|
|
||||||
for descriptor in descriptors {
|
for (key, descriptor) in descriptors {
|
||||||
if cursor_y >= bottom {
|
if cursor_y >= bottom {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -697,10 +855,12 @@ fn render_usage_column(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let gauge_area = Rect::new(area.x, cursor_y, area.width, 2);
|
let gauge_area = Rect::new(area.x, cursor_y, area.width, 2);
|
||||||
|
let display_ratio = app.animated_gauge_ratio(key, descriptor.ratio);
|
||||||
render_gauge(
|
render_gauge(
|
||||||
frame,
|
frame,
|
||||||
gauge_area,
|
gauge_area,
|
||||||
&descriptor,
|
&descriptor,
|
||||||
|
display_ratio,
|
||||||
&palette.usage_stops,
|
&palette.usage_stops,
|
||||||
palette,
|
palette,
|
||||||
theme,
|
theme,
|
||||||
@@ -735,6 +895,7 @@ fn render_gauge(
|
|||||||
frame: &mut Frame<'_>,
|
frame: &mut Frame<'_>,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
descriptor: &GaugeDescriptor,
|
descriptor: &GaugeDescriptor,
|
||||||
|
display_ratio: f64,
|
||||||
stops: &[Color; 3],
|
stops: &[Color; 3],
|
||||||
palette: &GlassPalette,
|
palette: &GlassPalette,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
@@ -779,7 +940,7 @@ fn render_gauge(
|
|||||||
draw_gradient_bar(
|
draw_gradient_bar(
|
||||||
frame,
|
frame,
|
||||||
bar_area,
|
bar_area,
|
||||||
descriptor.ratio,
|
display_ratio,
|
||||||
stops,
|
stops,
|
||||||
palette,
|
palette,
|
||||||
&descriptor.percent_label,
|
&descriptor.percent_label,
|
||||||
@@ -868,14 +1029,33 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
|
|
||||||
// Set terminal background color
|
// Set terminal background color
|
||||||
let theme = app.theme().clone();
|
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();
|
let frame_area = frame.area();
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Block::default().style(Style::default().bg(theme.background)),
|
Block::default().style(Style::default().bg(theme.background)),
|
||||||
frame_area,
|
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 {
|
if frame_area.height <= header_height {
|
||||||
header_height = frame_area.height.saturating_sub(1);
|
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);
|
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);
|
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 {
|
snapshot.header_panel = if header_area.width > 0 && header_area.height > 0 {
|
||||||
Some(header_area)
|
Some(header_area)
|
||||||
} else {
|
} else {
|
||||||
@@ -919,12 +1112,19 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
return;
|
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() {
|
if !app.is_code_mode() && !app.is_file_panel_collapsed() {
|
||||||
app.set_file_panel_collapsed(true);
|
app.set_file_panel_collapsed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let show_file_panel =
|
let show_file_panel = app.is_code_mode()
|
||||||
app.is_code_mode() && !app.is_file_panel_collapsed() && content_area.width >= 40;
|
&& !app.is_file_panel_collapsed()
|
||||||
|
&& !matches!(final_layout_mode, AdaptiveLayout::Compact)
|
||||||
|
&& content_area.width >= 40;
|
||||||
|
|
||||||
let (file_area, main_area) = if !show_file_panel {
|
let (file_area, main_area) = if !show_file_panel {
|
||||||
(None, content_area)
|
(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 (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)
|
.flex(Flex::Start)
|
||||||
.split(main_area);
|
.split(main_area);
|
||||||
(segments[0], Some(segments[1]))
|
(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 {
|
} else {
|
||||||
(main_area, None)
|
(main_area, None)
|
||||||
};
|
};
|
||||||
@@ -949,6 +1168,9 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
if let Some(file_area) = file_area {
|
if let Some(file_area) = file_area {
|
||||||
snapshot.file_panel = Some(file_area);
|
snapshot.file_panel = Some(file_area);
|
||||||
render_file_tree(frame, file_area, app);
|
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
|
// 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;
|
let viewport_height = area.height.saturating_sub(2) as usize;
|
||||||
app.set_model_info_viewport_height(viewport_height);
|
app.set_model_info_viewport_height(viewport_height);
|
||||||
app.model_info_panel_mut().render(frame, area, &theme);
|
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 {
|
} else {
|
||||||
snapshot.model_info_panel = None;
|
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 {
|
if let Some(area) = code_area {
|
||||||
snapshot.code_panel = Some(area);
|
snapshot.code_panel = Some(area);
|
||||||
render_code_workspace(frame, area, app);
|
render_code_workspace(frame, area, app);
|
||||||
|
if code_panel_glow > 0.0 {
|
||||||
|
apply_panel_glow(frame, area, &palette, code_panel_glow);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
snapshot.code_panel = None;
|
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));
|
.saturating_add(content_area.height.saturating_sub(panel_height));
|
||||||
let log_area = Rect::new(content_area.x, y, content_area.width, panel_height);
|
let log_area = Rect::new(content_area.x, y, content_area.width, panel_height);
|
||||||
render_debug_log_panel(frame, log_area, app);
|
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 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()
|
let chat_block = Block::default()
|
||||||
.borders(Borders::NONE)
|
.borders(Borders::NONE)
|
||||||
.padding(if reduced {
|
.padding(if reduced {
|
||||||
@@ -2362,7 +2593,7 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
panel_hint_style(has_focus, &theme),
|
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)
|
let paragraph = Paragraph::new(lines)
|
||||||
.style(
|
.style(
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -2593,7 +2824,7 @@ fn render_agent_actions(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
panel_hint_style(has_focus, &theme),
|
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)
|
let paragraph = Paragraph::new(lines)
|
||||||
.style(
|
.style(
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -2674,7 +2905,7 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let reduced = app.is_reduced_chrome();
|
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()
|
let base_style = Style::default()
|
||||||
.bg(if has_focus {
|
.bg(if has_focus {
|
||||||
palette.active
|
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) {
|
fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, message: &str) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let reduced = app.is_reduced_chrome();
|
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:") {
|
let color = if message.starts_with("Error:") {
|
||||||
theme.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) {
|
fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let reduced = app.is_reduced_chrome();
|
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);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
let title = Line::from(vec![
|
let title = Line::from(vec![
|
||||||
@@ -2971,7 +3202,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
|
|
||||||
let reduced = app.is_reduced_chrome();
|
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);
|
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) {
|
fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let reduced = app.is_reduced_chrome();
|
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();
|
let config = app.config();
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -3979,7 +4210,7 @@ fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let reduced = app.is_reduced_chrome();
|
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 profile = app.current_keymap_profile();
|
||||||
let leader = app.keymap_leader();
|
let leader = app.keymap_leader();
|
||||||
let area = centered_rect(75, 70, frame.area());
|
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) {
|
fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let reduced = app.is_reduced_chrome();
|
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();
|
let area = frame.area();
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -5122,6 +5353,7 @@ fn render_theme_browser(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
preview_area,
|
preview_area,
|
||||||
&preview_theme,
|
&preview_theme,
|
||||||
preview_theme.name == theme.name,
|
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]);
|
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 {
|
if area.width < 10 || area.height < 5 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
frame.render_widget(Clear, area);
|
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);
|
let mut title = format!("Preview · {}", preview_theme.name);
|
||||||
if is_active {
|
if is_active {
|
||||||
title.push_str(" (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) {
|
fn render_command_suggestions(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let reduced = app.is_reduced_chrome();
|
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 suggestions = app.command_suggestions();
|
||||||
let buffer = app.command_buffer();
|
let buffer = app.command_buffer();
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pub enum FilterMode {
|
|||||||
pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let reduced = app.is_reduced_chrome();
|
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();
|
let area = frame.area();
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
|||||||
output
|
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
|
where
|
||||||
|
C: FnOnce(&mut Config),
|
||||||
F: FnOnce(&mut SessionController),
|
F: FnOnce(&mut SessionController),
|
||||||
{
|
{
|
||||||
let temp_dir = tempdir().expect("temp dir");
|
let temp_dir = tempdir().expect("temp dir");
|
||||||
@@ -92,6 +93,7 @@ where
|
|||||||
let storage = Arc::new(storage);
|
let storage = Arc::new(storage);
|
||||||
|
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
|
configure_config(&mut config);
|
||||||
config.general.default_model = Some("stub-model".into());
|
config.general.default_model = Some("stub-model".into());
|
||||||
config.general.enable_streaming = true;
|
config.general.enable_streaming = true;
|
||||||
config.privacy.encrypt_local_data = false;
|
config.privacy.encrypt_local_data = false;
|
||||||
@@ -118,7 +120,7 @@ where
|
|||||||
.await
|
.await
|
||||||
.expect("chat mode");
|
.expect("chat mode");
|
||||||
|
|
||||||
configure(&mut session);
|
configure_session(&mut session);
|
||||||
|
|
||||||
let (app, mut session_rx) = ChatApp::new(session, controller_event_rx)
|
let (app, mut session_rx) = ChatApp::new(session, controller_event_rx)
|
||||||
.await
|
.await
|
||||||
@@ -142,17 +144,30 @@ fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn render_chat_idle_snapshot() {
|
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" }, {
|
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);
|
assert_snapshot!("chat_idle_snapshot", snapshot);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn render_chat_tool_call_snapshot() {
|
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();
|
let conversation = session.conversation_mut();
|
||||||
conversation.push_user_message("What happened in the Rust ecosystem today?");
|
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(
|
let tool_message = Message::tool(
|
||||||
tool_call.id.clone(),
|
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);
|
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(),
|
"Summarising the latest Rust release and the async runtime updates.".into(),
|
||||||
);
|
);
|
||||||
conversation.push_message(assistant_summary);
|
conversation.push_message(assistant_summary);
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Surface quota toast to exercise header/status rendering.
|
// 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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn render_command_palette_focus_snapshot() {
|
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(
|
app.handle_event(Event::Key(KeyEvent::new(
|
||||||
KeyCode::Char(':'),
|
KeyCode::Char(':'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: crates/owlen-tui/tests/chat_snapshots.rs
|
source: crates/owlen-tui/tests/chat_snapshots.rs
|
||||||
|
assertion_line: 156
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
@@ -8,7 +9,6 @@ expression: snapshot
|
|||||||
" Context metrics not available Cloud usage pending "
|
" Context metrics not available Cloud usage pending "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
|
||||||
" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus "
|
" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 focus "
|
||||||
" "
|
" "
|
||||||
" No messages yet. Press 'i' to start typing. "
|
" No messages yet. Press 'i' to start typing. "
|
||||||
@@ -27,6 +27,7 @@ expression: snapshot
|
|||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
" "
|
||||||
" Input Press i to start typing · Ctrl+5 focus "
|
" 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
|
source: crates/owlen-tui/tests/chat_snapshots.rs
|
||||||
|
assertion_line: 216
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model "
|
" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model "
|
||||||
" "
|
|
||||||
" Context metrics not available Cloud usage pending "
|
" Context metrics not available Cloud usage pending "
|
||||||
" "
|
" "
|
||||||
" "
|
|
||||||
" "
|
|
||||||
" ▌ Chat · stub-model PgUp/PgDn scroll · g/G jump · s save · Ctrl+2 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 │ "
|
" │ Found multiple articles│ WARN Cloud usage is at 82% of the hourly │ "
|
||||||
" └────────────────────────└──────────────────────────────────────────────┘ "
|
" └──────────────────────────└──────────────────────────────────────────────┘ "
|
||||||
" ┌ 🤖 Assistant ────────────────────────────────────────────────────────┐ "
|
" ┌ 🔧 Tool [Result: call-search-1] ──────────────────────────────────────┐ "
|
||||||
" │ Summarising the latest Rust release and the async runtime update │ "
|
" │ Rust 1.85 released with generics cleanups and faster async compila │ "
|
||||||
" │ s. │ "
|
" │ tion. │ "
|
||||||
|
" └────────────────────────────────────────────────────────────────────────┘ "
|
||||||
|
" ┌ 🤖 Assistant ──────────────────────────────────────────────────────────┐ "
|
||||||
|
" │ Summarising the latest Rust release and the async runtime updates. │ "
|
||||||
" "
|
" "
|
||||||
" Input Press i to start typing · Ctrl+5 focus "
|
" 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 "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
|||||||
Reference in New Issue
Block a user