feat(tui): status surface & toast overhaul
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
lines.push(Line::from(vec![Span::styled(
|
||||||
|
"New replies waiting · End to catch up",
|
||||||
|
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)
|
.alignment(Alignment::Left)
|
||||||
.style(Style::default().bg(palette.highlight).fg(palette.label));
|
.style(Style::default().bg(palette.highlight).fg(palette.label))
|
||||||
frame.render_widget(left_paragraph, columns[0]);
|
.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 {
|
||||||
|
|||||||
@@ -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 "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -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 "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -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 · "
|
||||||
|
|||||||
@@ -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 "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -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 arti│WARN ⚠ 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 · "
|
||||||
|
|||||||
@@ -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 "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -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 "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -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 · "
|
||||||
|
|||||||
@@ -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 "
|
||||||
" "
|
" "
|
||||||
|
|||||||
Reference in New Issue
Block a user