feat(tui): status surface & toast overhaul

This commit is contained in:
2025-10-25 21:55:52 +02:00
parent d7066d7d37
commit 03244e8d24
13 changed files with 709 additions and 123 deletions

View File

@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable `ui.layers` and `ui.animations` settings to tune glass elevation, neon intensity, and opt-in micro-animations. - 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.
- Inline guidance overlay adds a three-step onboarding tour, keymap-aware cheat sheets (F1 / `?`), and persists completion state via `ui.guidance`. - Inline guidance overlay adds a three-step onboarding tour, keymap-aware cheat sheets (F1 / `?`), and persists completion state via `ui.guidance`.
- Status surface renders a layered HUD with streaming/tool indicators, contextual gauges, and redesigned toast cards featuring icons, countdown timers, and a compact history log.
- 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.
- Model picker badges now inspect provider capabilities so vision/audio/thinking models surface the correct icons even when descriptions are sparse. - Model picker badges now inspect provider capabilities so vision/audio/thinking models surface the correct icons even when descriptions are sparse.

View File

@@ -62,7 +62,7 @@ use crate::state::{
SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task,
spawn_symbol_search_task, spawn_symbol_search_task,
}; };
use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::toast::{Toast, ToastHistoryEntry, ToastLevel, ToastManager};
use crate::ui::{format_token_short, format_tool_output}; 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};
@@ -2581,10 +2581,23 @@ impl ChatApp {
self.toasts.iter() self.toasts.iter()
} }
pub fn toast_history(&self) -> impl Iterator<Item = &ToastHistoryEntry> {
self.toasts.history()
}
pub fn push_toast(&mut self, level: ToastLevel, message: impl Into<String>) { pub fn push_toast(&mut self, level: ToastLevel, message: impl Into<String>) {
self.toasts.push(message, level); self.toasts.push(message, level);
} }
pub fn push_toast_with_hint(
&mut self,
level: ToastLevel,
message: impl Into<String>,
shortcut_hint: impl Into<String>,
) {
self.toasts.push_with_hint(message, level, shortcut_hint);
}
fn prune_toasts(&mut self) { fn prune_toasts(&mut self) {
self.toasts.retain_active(); self.toasts.retain_active();
} }
@@ -2606,7 +2619,7 @@ impl ChatApp {
let summary = format!("{}: {}", entry.target, entry.message); let summary = format!("{}: {}", entry.target, entry.message);
let clipped = Self::ellipsize(&summary, 120); let clipped = Self::ellipsize(&summary, 120);
self.push_toast(toast_level, clipped.clone()); self.push_toast_with_hint(toast_level, clipped.clone(), "F12 · Debug log");
latest_summary = Some((entry.level, clipped)); latest_summary = Some((entry.level, clipped));
} }
@@ -9503,7 +9516,7 @@ impl ChatApp {
} else { } else {
ToastLevel::Warning ToastLevel::Warning
}; };
self.push_toast(level, message); self.push_toast_with_hint(level, message, ":limits");
} }
} else if current == UsageBand::Normal && previous != UsageBand::Normal { } else if current == UsageBand::Normal && previous != UsageBand::Normal {
self.usage_thresholds.insert(key.clone(), UsageBand::Normal); self.usage_thresholds.insert(key.clone(), UsageBand::Normal);

View File

@@ -16,21 +16,74 @@ pub struct Toast {
pub level: ToastLevel, pub level: ToastLevel,
created: Instant, created: Instant,
duration: Duration, duration: Duration,
shortcut_hint: Option<String>,
} }
impl Toast { impl Toast {
fn new(message: String, level: ToastLevel, lifetime: Duration) -> Self { fn new(
message: String,
level: ToastLevel,
lifetime: Duration,
shortcut_hint: Option<String>,
) -> Self {
Self { Self {
message, message,
level, level,
created: Instant::now(), created: Instant::now(),
duration: lifetime, duration: lifetime,
shortcut_hint,
} }
} }
fn is_expired(&self, now: Instant) -> bool { fn is_expired(&self, now: Instant) -> bool {
now.duration_since(self.created) >= self.duration now.duration_since(self.created) >= self.duration
} }
pub fn shortcut(&self) -> Option<&str> {
self.shortcut_hint.as_deref()
}
pub fn elapsed_fraction(&self, now: Instant) -> f32 {
let elapsed = now.saturating_duration_since(self.created).as_secs_f32();
let total = self.duration.as_secs_f32().max(f32::MIN_POSITIVE);
(elapsed / total).clamp(0.0, 1.0)
}
pub fn remaining_fraction(&self, now: Instant) -> f32 {
1.0 - self.elapsed_fraction(now)
}
pub fn remaining_duration(&self, now: Instant) -> Duration {
let elapsed = now.saturating_duration_since(self.created);
if elapsed >= self.duration {
Duration::from_secs(0)
} else {
self.duration - elapsed
}
}
}
#[derive(Debug, Clone)]
pub struct ToastHistoryEntry {
pub message: String,
pub level: ToastLevel,
recorded: Instant,
pub shortcut_hint: Option<String>,
}
impl ToastHistoryEntry {
fn new(message: String, level: ToastLevel, shortcut_hint: Option<String>) -> Self {
Self {
message,
level,
recorded: Instant::now(),
shortcut_hint,
}
}
pub fn recorded(&self) -> Instant {
self.recorded
}
} }
/// Fixed-size toast queue with automatic expiration. /// Fixed-size toast queue with automatic expiration.
@@ -39,6 +92,8 @@ pub struct ToastManager {
items: VecDeque<Toast>, items: VecDeque<Toast>,
max_active: usize, max_active: usize,
lifetime: Duration, lifetime: Duration,
history: VecDeque<ToastHistoryEntry>,
history_limit: usize,
} }
impl Default for ToastManager { impl Default for ToastManager {
@@ -53,6 +108,8 @@ impl ToastManager {
items: VecDeque::new(), items: VecDeque::new(),
max_active: 3, max_active: 3,
lifetime: Duration::from_secs(3), lifetime: Duration::from_secs(3),
history: VecDeque::new(),
history_limit: 20,
} }
} }
@@ -62,11 +119,33 @@ impl ToastManager {
} }
pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) { pub fn push(&mut self, message: impl Into<String>, level: ToastLevel) {
let toast = Toast::new(message.into(), level, self.lifetime); self.push_internal(message.into(), level, None);
}
pub fn push_with_hint(
&mut self,
message: impl Into<String>,
level: ToastLevel,
shortcut_hint: impl Into<String>,
) {
self.push_internal(
message.into(),
level,
Some(shortcut_hint.into().trim().to_string()),
);
}
fn push_internal(&mut self, message: String, level: ToastLevel, shortcut_hint: Option<String>) {
let toast = Toast::new(message.clone(), level, self.lifetime, shortcut_hint.clone());
self.items.push_front(toast); self.items.push_front(toast);
while self.items.len() > self.max_active { while self.items.len() > self.max_active {
self.items.pop_back(); self.items.pop_back();
} }
self.history
.push_front(ToastHistoryEntry::new(message, level, shortcut_hint));
if self.history.len() > self.history_limit {
self.history.pop_back();
}
} }
pub fn retain_active(&mut self) { pub fn retain_active(&mut self) {
@@ -78,6 +157,10 @@ impl ToastManager {
self.items.iter() self.items.iter()
} }
pub fn history(&self) -> impl Iterator<Item = &ToastHistoryEntry> {
self.history.iter()
}
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.items.is_empty() self.items.is_empty()
} }
@@ -111,4 +194,16 @@ mod tests {
manager.retain_active(); manager.retain_active();
assert!(manager.is_empty()); assert!(manager.is_empty());
} }
#[test]
fn manager_tracks_history_and_hints() {
let mut manager = ToastManager::new();
manager.push_with_hint("saving project", ToastLevel::Info, "Ctrl+S");
manager.push("all good", ToastLevel::Success);
let latest = manager.history().next().expect("history entry");
assert_eq!(latest.level, ToastLevel::Success);
assert!(latest.shortcut_hint.is_none());
assert!(manager.history().nth(1).unwrap().shortcut_hint.is_some());
}
} }

View File

@@ -9,6 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragra
use serde_json; use serde_json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::time::{Duration, Instant};
use tui_textarea::TextArea; use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@@ -63,6 +64,56 @@ fn progress_band_color(band: ProgressBand, theme: &Theme) -> Color {
band.color(theme) band.color(theme)
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusLevel {
Info,
Success,
Warning,
Error,
}
fn status_level_colors(level: StatusLevel, theme: &Theme) -> (Style, Style) {
match level {
StatusLevel::Info => (
Style::default()
.bg(theme.info)
.fg(theme.background)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.info),
),
StatusLevel::Success => (
Style::default()
.bg(theme.agent_badge_idle_bg)
.fg(theme.background)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.agent_badge_idle_bg),
),
StatusLevel::Warning => (
Style::default()
.bg(theme.agent_action)
.fg(theme.background)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.agent_action),
),
StatusLevel::Error => (
Style::default()
.bg(theme.error)
.fg(theme.background)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.error),
),
}
}
fn status_level_icon(level: StatusLevel) -> &'static str {
match level {
StatusLevel::Info => "",
StatusLevel::Success => "",
StatusLevel::Warning => "",
StatusLevel::Error => "",
}
}
fn context_progress_band(ratio: f64) -> ProgressBand { fn context_progress_band(ratio: f64) -> ProgressBand {
if ratio >= 0.85 { if ratio >= 0.85 {
ProgressBand::Critical ProgressBand::Critical
@@ -1362,24 +1413,13 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
} }
let theme = app.theme(); let theme = app.theme();
let now = Instant::now();
let available_width = usize::from(full_area.width.saturating_sub(2)); let available_width = usize::from(full_area.width.saturating_sub(2));
if available_width == 0 { if available_width == 0 {
return; return;
} }
let max_text_width = toasts let width = available_width.clamp(24, 52) as u16;
.iter()
.map(|toast| UnicodeWidthStr::width(toast.message.as_str()))
.max()
.unwrap_or(0);
let mut width = max_text_width.saturating_add(6); // padding + badge
width = width.clamp(14, available_width);
width = width.min(48);
if width == 0 {
return;
}
let width = width as u16;
let offset_x = full_area let offset_x = full_area
.x .x
@@ -1389,8 +1429,10 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
for toast in toasts { for toast in toasts {
let (label, badge_style, border_style) = toast_palette(toast.level, theme); let (label, badge_style, border_style) = toast_palette(toast.level, theme);
let accent_color = toast_level_color(toast.level, theme);
let icon = toast_icon(toast.level);
let badge_text = format!(" {} ", label); let badge_text = format!(" {} ", label);
let indent_width = UnicodeWidthStr::width(badge_text.as_str()) + 1; let indent_width = UnicodeWidthStr::width(badge_text.as_str()) + 2;
let indent = " ".repeat(indent_width); let indent = " ".repeat(indent_width);
let content_width = width.saturating_sub(4).max(1) as usize; let content_width = width.saturating_sub(4).max(1) as usize;
@@ -1404,22 +1446,48 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
.collect() .collect()
}; };
let text_style = Style::default().fg(theme.text); let mut paragraph_lines = Vec::new();
let mut paragraph_lines = Vec::with_capacity(lines.len());
if let Some((first, rest)) = lines.split_first() { if let Some((first, rest)) = lines.split_first() {
paragraph_lines.push(Line::from(vec![ paragraph_lines.push(Line::from(vec![
Span::styled(badge_text.clone(), badge_style), Span::styled(badge_text.clone(), badge_style),
Span::raw(" "), Span::styled(
Span::styled(first.clone(), text_style), format!(" {icon} "),
Style::default()
.fg(accent_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(first.clone(), Style::default().fg(theme.text)),
])); ]));
for line in rest { for line in rest {
paragraph_lines.push(Line::from(vec![ paragraph_lines.push(Line::from(vec![
Span::raw(indent.clone()), Span::raw(indent.clone()),
Span::styled(line.clone(), text_style), Span::styled(line.clone(), Style::default().fg(theme.text)),
])); ]));
} }
} }
if let Some(hint) = toast.shortcut().filter(|hint| !hint.is_empty()) {
paragraph_lines.push(Line::from(vec![
Span::raw(indent.clone()),
Span::styled(
hint.to_string(),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
),
]));
}
let bar_width = content_width.clamp(6, 24);
let remaining_fraction = toast.remaining_fraction(now) as f64;
let bar = unicode_progress_bar(bar_width, remaining_fraction);
let remaining_secs = toast.remaining_duration(now).as_secs().min(99);
paragraph_lines.push(Line::from(vec![
Span::raw(indent.clone()),
Span::styled(bar, Style::default().fg(accent_color)),
Span::raw(format!(" {:>2}s", remaining_secs)),
]));
let height = (paragraph_lines.len() as u16).saturating_add(2); let height = (paragraph_lines.len() as u16).saturating_add(2);
if offset_y.saturating_add(height) > frame_bottom { if offset_y.saturating_add(height) > frame_bottom {
break; break;
@@ -1434,7 +1502,7 @@ fn render_toasts(frame: &mut Frame<'_>, app: &ChatApp, full_area: Rect) {
let paragraph = Paragraph::new(paragraph_lines) let paragraph = Paragraph::new(paragraph_lines)
.block(block) .block(block)
.alignment(Alignment::Left) .alignment(Alignment::Left)
.wrap(Wrap { trim: false }); .wrap(Wrap { trim: true });
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
offset_y = offset_y.saturating_add(height + 1); offset_y = offset_y.saturating_add(height + 1);
@@ -3197,11 +3265,11 @@ where
if !seen { 1 } else { total.max(1) } if !seen { 1 } else { total.max(1) }
} }
fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { fn render_status(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme(); let theme = app.theme().clone();
let layer_settings = app.layer_settings().clone();
let reduced = app.is_reduced_chrome(); let reduced = app.is_reduced_chrome();
let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); let palette = GlassPalette::for_theme_with_mode(&theme, reduced, &layer_settings);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
@@ -3223,12 +3291,35 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let columns = Layout::default() let columns = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([ .constraints([
Constraint::Length(32), Constraint::Length(30),
Constraint::Min(24), Constraint::Min(36),
Constraint::Length(48), Constraint::Length(42),
]) ])
.split(inner); .split(inner);
if columns.len() < 3 {
render_status_center(frame, inner, app, &palette, &theme);
return;
}
render_status_center(frame, columns[1], app, &palette, &theme);
let app_ref: &ChatApp = app;
render_status_left(frame, columns[0], app_ref, &palette, &theme);
render_status_right(frame, columns[2], app_ref, &palette, &theme);
}
fn render_status_left(
frame: &mut Frame<'_>,
area: Rect,
app: &ChatApp,
palette: &GlassPalette,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let (mode_label, mode_color) = match app.mode() { let (mode_label, mode_color) = match app.mode() {
InputMode::Normal => ("NORMAL", theme.mode_normal), InputMode::Normal => ("NORMAL", theme.mode_normal),
InputMode::Editing => ("INSERT", theme.mode_editing), InputMode::Editing => ("INSERT", theme.mode_editing),
@@ -3268,14 +3359,10 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
FocusedPanel::Code => ("CODE", "Ctrl+3"), FocusedPanel::Code => ("CODE", "Ctrl+3"),
}; };
let mut left_spans = vec![ let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(format!(" {} ", mode_label), mode_badge_style), Span::styled(format!(" {} ", mode_label), mode_badge_style),
Span::styled( Span::raw(" "),
"",
Style::default()
.fg(theme.unfocused_panel_border)
.add_modifier(Modifier::DIM),
),
Span::styled( Span::styled(
format!(" {} ", op_label), format!(" {} ", op_label),
Style::default() Style::default()
@@ -3283,36 +3370,153 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.fg(op_fg) .fg(op_fg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" "),
Span::styled( Span::styled(
format!("{} · {}", focus_label, focus_hint), format!("Focus {focus_label} · {focus_hint}"),
Style::default() Style::default()
.fg(theme.pane_header_active) .fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD | Modifier::ITALIC), .add_modifier(Modifier::BOLD | Modifier::ITALIC),
), ),
]; ]));
if app.is_agent_running() { if app.is_agent_running() {
left_spans.push(Span::styled( lines.push(Line::from(vec![Span::styled(
" 🤖 RUN", "Agent running · Esc stops",
Style::default() Style::default()
.fg(theme.agent_badge_running_fg) .fg(theme.agent_badge_running_bg)
.bg(theme.agent_badge_running_bg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); )]));
} else if app.is_agent_mode() { } else if app.is_agent_mode() {
left_spans.push(Span::styled( lines.push(Line::from(vec![Span::styled(
" 🤖 ARM", "Agent armed · Alt+A toggle",
Style::default() Style::default()
.fg(theme.agent_badge_idle_fg) .fg(theme.agent_badge_idle_bg)
.bg(theme.agent_badge_idle_bg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); )]));
} }
let left_paragraph = Paragraph::new(Line::from(left_spans)) if app.has_new_message_alert() {
.alignment(Alignment::Left) lines.push(Line::from(vec![Span::styled(
.style(Style::default().bg(palette.highlight).fg(palette.label)); "New replies waiting · End to catch up",
frame.render_widget(left_paragraph, columns[0]); Style::default()
.fg(theme.agent_action)
.add_modifier(Modifier::BOLD),
)]));
}
lines.push(Line::from(vec![
Span::styled(
"F1",
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Help ", Style::default().fg(theme.placeholder)),
Span::styled(
"?",
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Guidance ", Style::default().fg(theme.placeholder)),
Span::styled(
"F12",
Style::default()
.fg(theme.selection_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" Debug log", Style::default().fg(theme.placeholder)),
]));
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(palette.highlight).fg(palette.label))
.wrap(Wrap { trim: true }),
area,
);
}
fn render_status_center(
frame: &mut Frame<'_>,
area: Rect,
app: &mut ChatApp,
palette: &GlassPalette,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let (level, primary, detail, auxiliary) = compute_status_message(app);
let (badge_style, _) = status_level_colors(level, theme);
let icon = status_level_icon(level);
let mut constraints = vec![Constraint::Length(2)];
if detail.is_some() {
constraints.push(Constraint::Length(1));
}
if auxiliary.is_some() {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(3));
constraints.push(Constraint::Min(0));
let regions = Layout::vertical(constraints).split(area);
let mut index = 0;
let primary_line = Line::from(vec![
Span::styled(format!(" {} ", icon), badge_style),
Span::raw(" "),
Span::styled(
primary,
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(
Paragraph::new(primary_line)
.alignment(Alignment::Left)
.style(Style::default().bg(palette.highlight).fg(palette.label))
.wrap(Wrap { trim: true }),
regions[index],
);
index += 1;
if let Some(detail_text) = detail {
frame.render_widget(
Paragraph::new(detail_text)
.style(Style::default().bg(palette.highlight).fg(theme.placeholder))
.wrap(Wrap { trim: true }),
regions[index],
);
index += 1;
}
if let Some(aux_text) = auxiliary {
frame.render_widget(
Paragraph::new(aux_text)
.style(Style::default().bg(palette.highlight).fg(palette.label))
.wrap(Wrap { trim: true }),
regions[index],
);
index += 1;
}
if index < regions.len() {
render_status_progress(frame, regions[index], app, palette, theme);
}
}
fn render_status_right(
frame: &mut Frame<'_>,
area: Rect,
app: &ChatApp,
palette: &GlassPalette,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let file_tree = app.file_tree(); let file_tree = app.file_tree();
let repo_label = if let Some(branch) = file_tree.git_branch() { let repo_label = if let Some(branch) = file_tree.git_branch() {
@@ -3334,60 +3538,338 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
}; };
let position_label = status_cursor_position(app); let position_label = status_cursor_position(app);
let encoding_label = "UTF-8 LF";
let language_label = language_label_for_path(current_path.as_deref()); let language_label = language_label_for_path(current_path.as_deref());
let encoding_label = "UTF-8";
let mut mid_parts = vec![repo_label];
if let Some(path) = current_path.as_ref() {
mid_parts.push(path.clone());
}
mid_parts.push(position_label);
mid_parts.push(encoding_label.to_string());
mid_parts.push(language_label.to_string());
let mid_paragraph = Paragraph::new(mid_parts.join(" · "))
.alignment(Alignment::Center)
.style(Style::default().bg(palette.highlight).fg(palette.label));
frame.render_widget(mid_paragraph, columns[1]);
let provider = app.current_provider(); let provider = app.current_provider();
let provider_display = truncate_with_ellipsis(provider, 16); let provider_display = truncate_with_ellipsis(provider, 16);
let model_label = app.active_model_label(); let model_label = app.active_model_label();
let model_display = truncate_with_ellipsis(&model_label, 24); let model_display = truncate_with_ellipsis(&model_label, 24);
let mut right_spans = vec![Span::styled(
let mut lines = Vec::new();
lines.push(Line::from(vec![Span::styled(
repo_label,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::BOLD),
)]));
if let Some(path) = current_path.as_ref() {
lines.push(Line::from(vec![Span::styled(
truncate_with_ellipsis(path, area.width.saturating_sub(4) as usize),
Style::default().fg(theme.placeholder),
)]));
}
lines.push(Line::from(vec![Span::styled(
format!(
"{} · {} · {}",
position_label, language_label, encoding_label
),
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
)]));
lines.push(Line::from(""));
let mut provider_spans = vec![Span::styled(
format!("{}{}", provider_display, model_display), format!("{}{}", provider_display, model_display),
Style::default() Style::default()
.fg(palette.label) .fg(palette.label)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
if app.is_streaming() {
if app.is_loading() || app.is_streaming() {
let spinner = app.get_loading_indicator(); let spinner = app.get_loading_indicator();
let spinner = if spinner.is_empty() { "" } else { spinner }; let spinner = if spinner.is_empty() { "" } else { spinner };
right_spans.push(Span::styled( provider_spans.push(Span::styled(
format!(" · {} streaming", spinner), format!(" · {} streaming", spinner),
Style::default().fg(progress_band_color(ProgressBand::Normal, theme)), Style::default().fg(toast_level_color(ToastLevel::Info, theme)),
));
right_spans.push(Span::styled(
" · p:Pause r:Resume s:Stop",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
)); ));
} }
provider_spans.push(Span::styled(
right_spans.push(Span::styled(
" · LSP:✓", " · LSP:✓",
Style::default() Style::default()
.fg(theme.placeholder) .fg(theme.placeholder)
.add_modifier(Modifier::DIM), .add_modifier(Modifier::DIM),
)); ));
lines.push(Line::from(provider_spans));
let right_line = spans_within_width(right_spans, columns[2].width); if app.is_loading() && !app.is_streaming() {
let right_paragraph = Paragraph::new(right_line) let spinner = app.get_loading_indicator();
.alignment(Alignment::Right) if !spinner.is_empty() {
.style(Style::default().bg(palette.highlight).fg(palette.label)); lines.push(Line::from(vec![Span::styled(
frame.render_widget(right_paragraph, columns[2]); format!("Loading {spinner} · Esc cancels"),
Style::default().fg(theme.info),
)]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Toast history",
Style::default()
.fg(theme.pane_header_active)
.add_modifier(Modifier::BOLD),
)]));
let history: Vec<_> = app.toast_history().take(3).cloned().collect();
let message_width = area.width.saturating_sub(10).max(12) as usize;
if history.is_empty() {
lines.push(Line::from(vec![Span::styled(
"No recent toasts",
Style::default().fg(theme.placeholder),
)]));
} else {
for entry in history {
let color = toast_level_color(entry.level, theme);
let icon = toast_icon(entry.level);
let age = format_elapsed_short(entry.recorded().elapsed());
let message = truncate_to_width(&entry.message, message_width);
let mut spans = vec![
Span::styled(format!("{icon} "), Style::default().fg(color)),
Span::styled(message, Style::default().fg(theme.text)),
Span::raw(" "),
Span::styled(
age,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::DIM),
),
];
if let Some(hint) = entry.shortcut_hint.clone() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
hint,
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
));
}
lines.push(Line::from(spans));
}
}
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(palette.highlight).fg(palette.label))
.wrap(Wrap { trim: true }),
area,
);
}
fn compute_status_message(app: &ChatApp) -> (StatusLevel, String, Option<String>, Option<String>) {
let status_text = app.status.trim();
let system_text = app.system_status().trim();
if let Some(error) = app.error.as_ref().filter(|err| !err.trim().is_empty()) {
let detail = if status_text.is_empty() {
None
} else {
Some(status_text.to_string())
};
let aux = if system_text.is_empty() {
None
} else {
Some(system_text.to_string())
};
return (StatusLevel::Error, error.trim().to_string(), detail, aux);
}
let mut level = StatusLevel::Info;
let primary = if status_text.is_empty() {
default_status_message(app)
} else {
let text = status_text.to_string();
let lower = text.to_lowercase();
if lower.contains("success") || lower.contains("saved") || lower.contains("ready") {
level = StatusLevel::Success;
} else if lower.contains("warn") || lower.contains("slow") || lower.contains("limit") {
level = StatusLevel::Warning;
}
text
};
let detail = if system_text.is_empty() {
None
} else {
Some(system_text.to_string())
};
(level, primary, detail, None)
}
fn default_status_message(app: &ChatApp) -> String {
let mode = app.mode().to_string();
format!("Ready · {} mode", mode)
}
fn render_status_progress(
frame: &mut Frame<'_>,
area: Rect,
app: &mut ChatApp,
palette: &GlassPalette,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let bar_width = area.width.saturating_sub(18).max(8) as usize;
let mut lines: Vec<Line> = Vec::new();
if let Some(descriptor) = app
.context_usage_with_fallback()
.and_then(context_usage_descriptor)
{
let ratio = app.animated_gauge_ratio(GaugeKey::Context, descriptor.ratio);
let bar = unicode_progress_bar(bar_width, ratio);
lines.push(Line::from(vec![
Span::styled(
"Context ",
Style::default().fg(theme.info).add_modifier(Modifier::BOLD),
),
Span::styled(bar, Style::default().fg(theme.info)),
Span::raw(format!(
" {} ({})",
descriptor.percent_label, descriptor.detail
)),
]));
} else {
app.reset_gauge(GaugeKey::Context);
}
if let Some(snapshot) = app.usage_snapshot().cloned() {
if let Some(descriptor) = usage_gauge_descriptor(&snapshot, UsageWindow::Hour) {
let ratio = app.animated_gauge_ratio(GaugeKey::UsageHour, descriptor.ratio);
let bar = unicode_progress_bar(bar_width, ratio);
lines.push(Line::from(vec![
Span::styled(
"Cloud hr ",
Style::default()
.fg(theme.agent_action)
.add_modifier(Modifier::BOLD),
),
Span::styled(bar, Style::default().fg(theme.agent_action)),
Span::raw(format!(" {}", descriptor.detail)),
]));
}
if let Some(descriptor) = usage_gauge_descriptor(&snapshot, UsageWindow::Week) {
let ratio = app.animated_gauge_ratio(GaugeKey::UsageWeek, descriptor.ratio);
let bar = unicode_progress_bar(bar_width, ratio);
lines.push(Line::from(vec![
Span::styled(
"Cloud wk ",
Style::default()
.fg(theme.agent_badge_idle_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(bar, Style::default().fg(theme.agent_badge_idle_bg)),
Span::raw(format!(" {}", descriptor.detail)),
]));
}
} else {
app.reset_gauge(GaugeKey::UsageHour);
app.reset_gauge(GaugeKey::UsageWeek);
}
if app.is_streaming() {
let spinner = app.get_loading_indicator();
let spinner = if spinner.is_empty() { "" } else { spinner };
lines.push(Line::from(vec![
Span::styled(
format!("Streaming {spinner} "),
Style::default().fg(theme.info),
),
Span::styled(
"p Pause · r Resume · s Stop",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
),
]));
} else if app.is_loading() {
let spinner = app.get_loading_indicator();
if !spinner.is_empty() {
lines.push(Line::from(vec![
Span::styled(
format!("Loading {spinner} "),
Style::default().fg(theme.info),
),
Span::styled(
"Esc cancels",
Style::default()
.fg(theme.placeholder)
.add_modifier(Modifier::ITALIC),
),
]));
}
}
if lines.is_empty() {
lines.push(Line::from(vec![Span::styled(
"Usage metrics pending · run :limits",
Style::default().fg(theme.placeholder),
)]));
}
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(palette.highlight).fg(palette.label))
.wrap(Wrap { trim: true }),
area,
);
}
fn unicode_progress_bar(width: usize, ratio: f64) -> String {
if width == 0 {
return String::new();
}
let width = width.clamp(1, 40);
let clamped = ratio.clamp(0.0, 1.0);
let filled = (clamped * width as f64).round() as usize;
let mut bar = String::with_capacity(width);
for index in 0..width {
if index < filled {
bar.push('█');
} else {
bar.push('░');
}
}
bar
}
fn format_elapsed_short(duration: Duration) -> String {
let secs = duration.as_secs();
if secs == 0 {
"<1s".to_string()
} else if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h", secs / 3600)
}
}
fn toast_level_color(level: ToastLevel, theme: &Theme) -> Color {
match level {
ToastLevel::Info => theme.info,
ToastLevel::Success => theme.agent_badge_idle_bg,
ToastLevel::Warning => theme.agent_action,
ToastLevel::Error => theme.error,
}
}
fn toast_icon(level: ToastLevel) -> &'static str {
match level {
ToastLevel::Info => "",
ToastLevel::Success => "",
ToastLevel::Warning => "",
ToastLevel::Error => "",
}
} }
pub(crate) fn format_token_short(value: u64) -> String { pub(crate) fn format_token_short(value: u64) -> String {

View File

@@ -1,6 +1,5 @@
--- ---
source: crates/owlen-tui/tests/chat_snapshots.rs source: crates/owlen-tui/tests/chat_snapshots.rs
assertion_line: 156
expression: snapshot expression: snapshot
--- ---
" " " "
@@ -34,7 +33,7 @@ expression: snapshot
" System/Status " " System/Status "
" " " "
" " " "
" NORMAL CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ " " NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui "
" " " Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
" " " "

View File

@@ -1,6 +1,5 @@
--- ---
source: crates/owlen-tui/tests/chat_snapshots.rs source: crates/owlen-tui/tests/chat_snapshots.rs
assertion_line: 162
expression: snapshot expression: snapshot
--- ---
" " " "
@@ -33,8 +32,8 @@ expression: snapshot
" System/Status " " System/Status "
" " " "
" " " "
" NORMAL CHAT INPUT · Ctrl owlen-tui · 1:1 · UTF-8 LF · Plain Text ollama_local ▸ stub-model · LSP:✓ " " NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui "
" " " Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
" " " "
" " " "

View File

@@ -1,6 +1,5 @@
--- ---
source: crates/owlen-tui/tests/chat_snapshots.rs source: crates/owlen-tui/tests/chat_snapshots.rs
assertion_line: 150
expression: snapshot expression: snapshot
--- ---
" " " "
@@ -35,6 +34,6 @@ expression: snapshot
" System/Status " " System/Status "
" " " "
" " " "
" NORMAL CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model " " NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tu "
" " " Ctrl+5 Icons: Nerd (auto) i "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · "

View File

@@ -1,6 +1,5 @@
--- ---
source: crates/owlen-tui/tests/chat_snapshots.rs source: crates/owlen-tui/tests/chat_snapshots.rs
assertion_line: 232
expression: snapshot expression: snapshot
--- ---
" " " "
@@ -34,7 +33,7 @@ expression: snapshot
" System/Status " " System/Status "
" " " "
" " " "
" NORMAL CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ " " NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tui "
" " " Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
" " " "

View File

@@ -1,6 +1,5 @@
--- ---
source: crates/owlen-tui/tests/chat_snapshots.rs source: crates/owlen-tui/tests/chat_snapshots.rs
assertion_line: 216
expression: snapshot expression: snapshot
--- ---
" " " "
@@ -8,10 +7,10 @@ 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 "
" ┌──────────────────────────────────────────────┐ " " ┌──────────────────────────────────────────────────┐ "
" │ Found multiple articles│ WARN Cloud usage is at 82% of the hourly │ " " │ Found multiple artiWARN Cloud usage is at 82% of the hourly quota.│ "
" └──────────────────────────└──────────────────────────────────────────────┘ " " └──────────────────────│████████████████████████ 2s │ "
" ┌ 🔧 Tool [Result: call-search-1] ────────────────────────────────────── " " ┌ 🔧 Tool [Result: cal└────────────────────────────────────────────────── "
" │ Rust 1.85 released with generics cleanups and faster async compila │ " " │ Rust 1.85 released with generics cleanups and faster async compila │ "
" │ tion. │ " " │ tion. │ "
" └────────────────────────────────────────────────────────────────────────┘ " " └────────────────────────────────────────────────────────────────────────┘ "
@@ -24,6 +23,6 @@ expression: snapshot
" System/Status " " System/Status "
" " " "
" " " "
" NORMAL CHAT │ INPUowlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model " " NORMAL CHAT Focus INPUT · ⓘ Normal mode • Press F1 for help owlen-tu "
" " " Ctrl+5 Icons: Nerd (auto) i "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · "

View File

@@ -22,7 +22,7 @@ expression: snapshot
" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close " " System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close "
" " " "
" " " "
" HELP CHAT INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ " " HELP CHAT Focus INPUT · ⓘ Owlen cheat sheet owlen-tui "
" " " Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
" " " "

View File

@@ -22,7 +22,7 @@ expression: snapshot
" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close " " System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close "
" " " "
" " " "
" HELP CHAT INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ " " HELP CHAT Focus INPUT · ⓘ Owlen cheat sheet owlen-tui "
" " " Ctrl+5 Icons: Nerd (auto) 1:1 · Plain Text · UTF-8 "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
" " " "

View File

@@ -23,6 +23,6 @@ expression: snapshot
" " " "
" Normal F1/? | " " Normal F1/? | "
" " " "
" HELP CHAT INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model " " HELP CHAT Focus INPUT · ⓘ Welcome to Owlen! Press F1 for owlen-tu "
" " " Ctrl+5 Normal ▸ h/j/k/l • Insert ▸ i,a • i "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits 1:1 · "

View File

@@ -22,7 +22,7 @@ expression: snapshot
" System/Sta Enter/→ Next Shift+Tab/← Back Esc Skip " " System/Sta Enter/→ Next Shift+Tab/← Back Esc Skip "
" " " "
" " " "
" HELP CHAT INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ " " HELP CHAT Focus INPUT · ⓘ Owlen onboarding · Step 2 of 3 owlen-tui "
" " " Ctrl+5 Normal ▸ h/j/k/l • Insert ▸ i,a • 1:1 · Plain Text · UTF-8 "
" " " F1 Help ? Guidance F12 DebugUsage metrics pending · run :limits "
" " " "