From c72b2ad76657fd1100159043e01c5840ff156b82 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Fri, 24 Oct 2025 08:46:31 -0700 Subject: [PATCH] adding messaging for stale rate limits + when no rate limits are cached (#5570) --- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/status/card.rs | 103 +++++++++++------ codex-rs/tui/src/status/rate_limits.rs | 12 ++ ...snapshot_shows_missing_limits_message.snap | 2 +- ...s_snapshot_shows_stale_limits_message.snap | 21 ++++ codex-rs/tui/src/status/tests.rs | 109 ++++++++++++++++-- 6 files changed, 207 insertions(+), 41 deletions(-) create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c090d212..75dad913 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1631,6 +1631,7 @@ impl ChatWidget { context_usage, &self.conversation_id, self.rate_limit_snapshot.as_ref(), + Local::now(), )); } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index a077a926..93335097 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -3,6 +3,8 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; +use chrono::DateTime; +use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; use codex_core::protocol::SandboxPolicy; @@ -25,6 +27,7 @@ use super::helpers::format_directory_display; use super::helpers::format_tokens_compact; use super::rate_limits::RateLimitSnapshotDisplay; use super::rate_limits::StatusRateLimitData; +use super::rate_limits::StatusRateLimitRow; use super::rate_limits::compose_rate_limit_data; use super::rate_limits::format_status_limit_summary; use super::rate_limits::render_status_limit_progress_bar; @@ -64,9 +67,17 @@ pub(crate) fn new_status_output( context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, + now: DateTime, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); - let card = StatusHistoryCell::new(config, total_usage, context_usage, session_id, rate_limits); + let card = StatusHistoryCell::new( + config, + total_usage, + context_usage, + session_id, + rate_limits, + now, + ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) } @@ -78,6 +89,7 @@ impl StatusHistoryCell { context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, + now: DateTime, ) -> Self { let config_entries = create_config_summary_entries(config); let (model_name, model_details) = compose_model_display(config, &config_entries); @@ -108,7 +120,7 @@ impl StatusHistoryCell { output: total_usage.output_tokens, context_window, }; - let rate_limits = compose_rate_limit_data(rate_limits); + let rate_limits = compose_rate_limit_data(rate_limits, now); Self { model_name, @@ -171,47 +183,66 @@ impl StatusHistoryCell { ]; } - let mut lines = Vec::with_capacity(rows_data.len() * 2); - - for row in rows_data { - let value_spans = vec![ - Span::from(render_status_limit_progress_bar(row.percent_used)), - Span::from(" "), - Span::from(format_status_limit_summary(row.percent_used)), - ]; - let base_spans = formatter.full_spans(row.label.as_str(), value_spans); - let base_line = Line::from(base_spans.clone()); - - if let Some(resets_at) = row.resets_at.as_ref() { - let resets_span = Span::from(format!("(resets {resets_at})")).dim(); - let mut inline_spans = base_spans.clone(); - inline_spans.push(Span::from(" ").dim()); - inline_spans.push(resets_span.clone()); - - if line_display_width(&Line::from(inline_spans.clone())) - <= available_inner_width - { - lines.push(Line::from(inline_spans)); - } else { - lines.push(base_line); - lines.push(formatter.continuation(vec![resets_span])); - } - } else { - lines.push(base_line); - } - } - + self.rate_limit_row_lines(rows_data, available_inner_width, formatter) + } + StatusRateLimitData::Stale(rows_data) => { + let mut lines = + self.rate_limit_row_lines(rows_data, available_inner_width, formatter); + lines.push(formatter.line( + "Warning", + vec![Span::from("limits may be stale - start new turn to refresh.").dim()], + )); lines } StatusRateLimitData::Missing => { vec![formatter.line( "Limits", - vec![Span::from("send a message to load usage data").dim()], + vec![ + Span::from("visit ").dim(), + "chatgpt.com/codex/settings/usage".cyan().underlined(), + ], )] } } } + fn rate_limit_row_lines( + &self, + rows: &[StatusRateLimitRow], + available_inner_width: usize, + formatter: &FieldFormatter, + ) -> Vec> { + let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); + + for row in rows { + let value_spans = vec![ + Span::from(render_status_limit_progress_bar(row.percent_used)), + Span::from(" "), + Span::from(format_status_limit_summary(row.percent_used)), + ]; + let base_spans = formatter.full_spans(row.label.as_str(), value_spans); + let base_line = Line::from(base_spans.clone()); + + if let Some(resets_at) = row.resets_at.as_ref() { + let resets_span = Span::from(format!("(resets {resets_at})")).dim(); + let mut inline_spans = base_spans.clone(); + inline_spans.push(Span::from(" ").dim()); + inline_spans.push(resets_span.clone()); + + if line_display_width(&Line::from(inline_spans.clone())) <= available_inner_width { + lines.push(Line::from(inline_spans)); + } else { + lines.push(base_line); + lines.push(formatter.continuation(vec![resets_span])); + } + } else { + lines.push(base_line); + } + } + + lines + } + fn collect_rate_limit_labels(&self, seen: &mut BTreeSet, labels: &mut Vec) { match &self.rate_limits { StatusRateLimitData::Available(rows) => { @@ -223,6 +254,12 @@ impl StatusHistoryCell { } } } + StatusRateLimitData::Stale(rows) => { + for row in rows { + push_label(labels, seen, row.label.as_str()); + } + push_label(labels, seen, "Warning"); + } StatusRateLimitData::Missing => push_label(labels, seen, "Limits"), } } diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index ea63f189..4c08cdd6 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -2,6 +2,7 @@ use crate::chatwidget::get_limits_duration; use super::helpers::format_reset_timestamp; use chrono::DateTime; +use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::Utc; use codex_core::protocol::RateLimitSnapshot; @@ -21,9 +22,12 @@ pub(crate) struct StatusRateLimitRow { #[derive(Debug, Clone)] pub(crate) enum StatusRateLimitData { Available(Vec), + Stale(Vec), Missing, } +pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15; + #[derive(Debug, Clone)] pub(crate) struct RateLimitWindowDisplay { pub used_percent: f64, @@ -49,6 +53,7 @@ impl RateLimitWindowDisplay { #[derive(Debug, Clone)] pub(crate) struct RateLimitSnapshotDisplay { + pub captured_at: DateTime, pub primary: Option, pub secondary: Option, } @@ -58,6 +63,7 @@ pub(crate) fn rate_limit_snapshot_display( captured_at: DateTime, ) -> RateLimitSnapshotDisplay { RateLimitSnapshotDisplay { + captured_at, primary: snapshot .primary .as_ref() @@ -71,6 +77,7 @@ pub(crate) fn rate_limit_snapshot_display( pub(crate) fn compose_rate_limit_data( snapshot: Option<&RateLimitSnapshotDisplay>, + now: DateTime, ) -> StatusRateLimitData { match snapshot { Some(snapshot) => { @@ -102,8 +109,13 @@ pub(crate) fn compose_rate_limit_data( }); } + let is_stale = now.signed_duration_since(snapshot.captured_at) + > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES); + if rows.is_empty() { StatusRateLimitData::Available(vec![]) + } else if is_stale { + StatusRateLimitData::Stale(rows) } else { StatusRateLimitData::Available(rows) } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 66bcfa3e..99970063 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -15,5 +15,5 @@ expression: sanitized │ │ │ Token usage: 750 total (500 input + 250 output) │ │ Context window: 100% left (750 used / 272K) │ -│ Limits: send a message to load usage data │ +│ Limits: visit chatgpt.com/codex/settings/usage │ ╰─────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap new file mode 100644 index 00000000..bd073352 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭─────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Model: gpt-5-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Approval: on-request │ +│ Sandbox: read-only │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.1K used / 272K) │ +│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ +│ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b8c945ee..4ab4a8ea 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -111,7 +111,14 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -152,7 +159,14 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -178,7 +192,12 @@ fn status_card_token_usage_excludes_cached_tokens() { total_tokens: 2_100, }; - let composite = new_status_output(&config, &usage, Some(&usage), &None, None); + let now = chrono::Local + .with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .expect("timestamp"); + + let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -219,7 +238,14 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); let mut rendered_lines = render_lines(&composite.display_lines(46)); if cfg!(windows) { for line in &mut rendered_lines { @@ -246,7 +272,12 @@ fn status_snapshot_shows_missing_limits_message() { total_tokens: 750, }; - let composite = new_status_output(&config, &usage, Some(&usage), &None, None); + let now = chrono::Local + .with_ymd_and_hms(2024, 2, 3, 4, 5, 6) + .single() + .expect("timestamp"); + + let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -282,7 +313,66 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + captured_at, + ); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[test] +fn status_snapshot_shows_stale_limits_message() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: Some(RateLimitWindow { + used_percent: 40.0, + window_minutes: Some(10_080), + resets_at: Some(reset_at_from(&captured_at, 1_800)), + }), + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let now = captured_at + ChronoDuration::minutes(20); + + let composite = new_status_output( + &config, + &usage, + Some(&usage), + &None, + Some(&rate_display), + now, + ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { for line in &mut rendered_lines { @@ -314,7 +404,12 @@ fn status_context_window_uses_last_usage() { total_tokens: 13_679, }; - let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None); + let now = chrono::Local + .with_ymd_and_hms(2024, 6, 1, 12, 0, 0) + .single() + .expect("timestamp"); + + let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None, now); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines .into_iter()