From e26b81403381bf0841b8fe62201c950870011097 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Thu, 2 Jul 2026 20:11:02 +0900 Subject: [PATCH] feat: show header context tokens and provider rate limits --- crates/goat-tui/src/app/mod.rs | 74 +++++++++++++++++++++++++++++++++- crates/goat-tui/src/view.rs | 67 ++++++++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/crates/goat-tui/src/app/mod.rs b/crates/goat-tui/src/app/mod.rs index e78f4e2..ba45554 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -1024,6 +1024,19 @@ impl App { let pct = (used as f64 / f64::from(window) * 100.0).min(100.0) as f32; Some((pct, used, window)) } + + pub(crate) fn rate_limit_indicator(&self) -> Option> { + let model = self.model.as_ref()?; + let key = (model.provider.clone(), model.account.clone()); + let (snapshot, _) = self.usage.rate_limits.get(&key)?; + (!snapshot.windows.is_empty()).then(|| { + snapshot + .windows + .iter() + .map(|window| (window.label.clone(), window.used_percent)) + .collect() + }) + } } fn slash_command_name(raw: &str) -> Option<&str> { @@ -1182,7 +1195,10 @@ fn prepare_input_event(ev: CtEvent, tx: &tokio::sync::mpsc::Sender) -> #[cfg(test)] mod tests { use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; - use goat_protocol::{AccountChoice, Event as EngineEvent, ModelEntry, ModelTarget, Op, TaskId}; + use goat_protocol::{ + AccountChoice, Event as EngineEvent, ModelEntry, ModelTarget, Op, RateLimitSnapshot, + RateWindow, TaskId, Usage, + }; use super::{App, Overlay}; use crate::theme::Theme; @@ -2127,9 +2143,63 @@ mod tests { assert!(app.pending.ask.is_none()); } + #[test] + fn ctx_and_rate_limit_indicators_use_active_model() { + let mut app = App::new(Theme::dark()); + app.model = Some(ModelTarget { + provider: "anthropic".to_owned(), + model: "sonnet".to_owned(), + account: "default".to_owned(), + effort: None, + }); + app.on_engine(EngineEvent::Usage { + id: TaskId(1), + provider: "anthropic".to_owned(), + account: "default".to_owned(), + usage: Usage { + input_tokens: 40_000, + output_tokens: 5_000, + cache_read_tokens: 0, + cache_write_tokens: 0, + }, + context_window: Some(128_000), + compaction_threshold: None, + }); + app.on_engine(EngineEvent::RateLimits { + provider: "anthropic".to_owned(), + account: "default".to_owned(), + snapshot: RateLimitSnapshot { + windows: vec![ + RateWindow { + label: "5h".to_owned(), + used_percent: 42.0, + resets_at: None, + }, + RateWindow { + label: "weekly".to_owned(), + used_percent: 18.0, + resets_at: None, + }, + ], + representative: Some("5h".to_owned()), + }, + cached_at: 0, + }); + + let (pct, used, window) = app.ctx_indicator().expect("ctx"); + assert_eq!(used, 45_000); + assert_eq!(window, 128_000); + assert!((pct - 35.15625).abs() < f32::EPSILON); + + let rates = app.rate_limit_indicator().expect("rates"); + assert_eq!( + rates, + vec![("5h".to_owned(), 42.0), ("weekly".to_owned(), 18.0),] + ); + } + #[test] fn usage_attributes_to_event_model_not_current() { - use goat_protocol::Usage; let mut app = App::new(Theme::dark()); app.model = Some(ModelTarget { provider: "anthropic".to_owned(), diff --git a/crates/goat-tui/src/view.rs b/crates/goat-tui/src/view.rs index 0298233..9eb22c4 100644 --- a/crates/goat-tui/src/view.rs +++ b/crates/goat-tui/src/view.rs @@ -8,7 +8,7 @@ use unicode_width::UnicodeWidthStr; use crate::{ app::{App, Overlay}, - layout::{LIST_MAX, PAD_X, SCROLL_GUTTER}, + layout::{LIST_MAX, PAD_X, SCROLL_GUTTER, format_tokens}, overlay, symbols, theme::Theme, }; @@ -318,9 +318,37 @@ fn model_label(app: &App) -> Option { )) } +#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +fn format_ctx_status(used: u64, window: u32) -> (String, f32) { + let pct = if window == 0 { + 0.0 + } else { + (used as f64 / f64::from(window) * 100.0).min(100.0) as f32 + }; + let label = format!( + "ctx {}/{}", + format_tokens(used), + format_tokens(u64::from(window)) + ); + (label, pct) +} + +fn format_rate_status(windows: &[(String, f32)]) -> Vec<(String, f32)> { + windows + .iter() + .map(|(label, pct)| (format!("{label} {pct:.0}%"), *pct)) + .collect() +} + fn ctx_label(app: &App) -> Option<(String, f32)> { app.ctx_indicator() - .map(|(pct, _, _)| (format!("ctx {pct:.0}%"), pct)) + .map(|(_, used, window)| format_ctx_status(used, window)) +} + +fn rate_labels(app: &App) -> Vec<(String, f32)> { + app.rate_limit_indicator() + .map(|windows| format_rate_status(&windows)) + .unwrap_or_default() } pub(crate) fn window_label(window_count: usize) -> Option { @@ -336,15 +364,20 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App, theme: Theme) { let model = model_label(app); let ctx = ctx_label(app); + let rates = rate_labels(app); let windows = window_label(app.window_count); let model_w = model.as_ref().map_or(0, |label| label.width()); let ctx_w = ctx.as_ref().map_or(0, |(label, _)| label.width()); + let rates_w = rates + .iter() + .map(|(label, _)| 2 + label.width()) + .sum::(); let windows_w = windows.as_ref().map_or(0, |label| label.width()); let status_gap = (usize::from(model.is_some()) + usize::from(ctx.is_some()) + usize::from(windows.is_some())) * 2; - let status_w = model_w + ctx_w + windows_w + status_gap; + let status_w = model_w + ctx_w + rates_w + windows_w + status_gap; let cwd = fit_cwd(app.cwd(), inner_w.saturating_sub(status_w)); let mut spans: Vec = vec![Span::styled(cwd.clone(), theme.muted())]; @@ -365,6 +398,10 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App, theme: Theme) { spans.push(Span::raw(" ")); spans.push(Span::styled(label, theme.meter(pct))); } + for (label, pct) in rates { + spans.push(Span::raw(" ")); + spans.push(Span::styled(label, theme.meter(pct))); + } frame.render_widget(Paragraph::new(Line::from(spans)), row); } @@ -413,7 +450,7 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App, theme: Theme) { mod tests { use goat_protocol::{Effort, ModelTarget}; - use super::model_status_label; + use super::{format_ctx_status, format_rate_status, model_status_label}; fn target(effort: Option) -> ModelTarget { ModelTarget { @@ -459,4 +496,26 @@ mod tests { assert_eq!(super::window_label(2), Some("\u{29c9} 2".to_owned())); assert_eq!(super::window_label(5), Some("\u{29c9} 5".to_owned())); } + + #[test] + fn format_ctx_status_uses_token_fraction() { + let (label, pct) = format_ctx_status(45_000, 128_000); + assert_eq!(label, "ctx 45k/128k"); + assert!((pct - 35.15625).abs() < f32::EPSILON); + } + + #[test] + fn format_rate_status_maps_window_labels() { + let windows = vec![("5h".to_owned(), 42.0), ("weekly".to_owned(), 18.0)]; + let labels = format_rate_status(&windows); + assert_eq!( + labels, + vec![("5h 42%".to_owned(), 42.0), ("weekly 18%".to_owned(), 18.0)] + ); + } + + #[test] + fn format_rate_status_empty() { + assert!(format_rate_status(&[]).is_empty()); + } }