diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 23b9fc7638..bc9fe173e0 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -27,6 +27,11 @@ pub(super) struct RateLimitWarningState { pub(super) primary_index: usize, } +pub(super) struct RateLimitWarningContext<'a> { + pub(super) observed_at: &'a chrono::DateTime, + pub(super) profile: Option<&'a str>, +} + impl RateLimitWarningState { pub(super) fn take_warnings( &mut self, @@ -34,6 +39,7 @@ impl RateLimitWarningState { secondary_window_minutes: Option, primary_used_percent: Option, primary_window_minutes: Option, + context: &RateLimitWarningContext<'_>, ) -> Vec { let reached_secondary_cap = matches!(secondary_used_percent, Some(percent) if percent == 100.0); @@ -45,37 +51,39 @@ impl RateLimitWarningState { let mut warnings = Vec::new(); if let Some(secondary_used_percent) = secondary_used_percent { - let mut highest_secondary: Option = None; + let mut crossed_threshold = false; while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] { - highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); + crossed_threshold = true; self.secondary_index += 1; } - if let Some(threshold) = highest_secondary { + if crossed_threshold { let limit_label = limit_label_for_window(secondary_window_minutes, /*is_secondary*/ true); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + warnings.push(rate_limit_warning_message( + secondary_used_percent, + &limit_label, + context, )); } } if let Some(primary_used_percent) = primary_used_percent { - let mut highest_primary: Option = None; + let mut crossed_threshold = false; while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] { - highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); + crossed_threshold = true; self.primary_index += 1; } - if let Some(threshold) = highest_primary { + if crossed_threshold { let limit_label = limit_label_for_window(primary_window_minutes, /*is_secondary*/ false); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." + warnings.push(rate_limit_warning_message( + primary_used_percent, + &limit_label, + context, )); } } @@ -84,6 +92,19 @@ impl RateLimitWarningState { } } +fn rate_limit_warning_message( + used_percent: f64, + limit_label: &str, + context: &RateLimitWarningContext<'_>, +) -> String { + let observed_at = context.observed_at.format("%-I:%M %p %b %-d"); + let profile = context.profile.unwrap_or("default"); + let remaining_percent = (100.0 - used_percent).clamp(0.0, 100.0); + format!( + "As of {observed_at}, profile {profile} has {remaining_percent:.0}% of its {limit_label} limit remaining." + ) +} + pub(crate) fn limit_label_for_window(window_minutes: Option, is_secondary: bool) -> String { broker_limit_label_for_window(window_minutes, is_secondary) } @@ -129,14 +150,18 @@ pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) - } #[derive(Clone, Copy)] -enum RateLimitSnapshotSource { +pub(super) enum RateLimitSnapshotSource { AccountUsage, RollingUpdate, } impl ChatWidget { pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { - self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage); + self.on_rate_limit_snapshot_from( + snapshot, + RateLimitSnapshotSource::AccountUsage, + Local::now(), + ); } pub(crate) fn on_auth_profile_rate_limit_snapshots( @@ -172,7 +197,11 @@ impl ChatWidget { pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) { // Rolling app-server notifications are sparse. Preserve metadata learned from the full read. - self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate); + self.on_rate_limit_snapshot_from( + Some(snapshot), + RateLimitSnapshotSource::RollingUpdate, + Local::now(), + ); } pub(crate) fn begin_authoritative_selected_rate_limit_refresh(&mut self) { @@ -180,10 +209,11 @@ impl ChatWidget { self.codex_rate_limit_reached_type = None; } - fn on_rate_limit_snapshot_from( + pub(super) fn on_rate_limit_snapshot_from( &mut self, snapshot: Option, source: RateLimitSnapshotSource, + observed_at: chrono::DateTime, ) { if let Some(mut snapshot) = snapshot { let limit_id = snapshot @@ -223,28 +253,36 @@ impl ChatWidget { { self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); } - let warnings = if is_codex_limit { - self.rate_limit_warnings.take_warnings( - snapshot - .secondary - .as_ref() - .map(|window| f64::from(window.used_percent)), - snapshot - .secondary - .as_ref() - .and_then(|window| window.window_duration_mins), - snapshot - .primary - .as_ref() - .map(|window| f64::from(window.used_percent)), - snapshot - .primary - .as_ref() - .and_then(|window| window.window_duration_mins), - ) - } else { - vec![] - }; + // Rolling notifications do not identify their profile, so only an authoritative + // account-usage read can emit or consume profile-attributed warning thresholds. + let warnings = + if is_codex_limit && matches!(source, RateLimitSnapshotSource::AccountUsage) { + let selected_auth_profile = self.config.selected_auth_profile.clone(); + self.rate_limit_warnings.take_warnings( + snapshot + .secondary + .as_ref() + .map(|window| f64::from(window.used_percent)), + snapshot + .secondary + .as_ref() + .and_then(|window| window.window_duration_mins), + snapshot + .primary + .as_ref() + .map(|window| f64::from(window.used_percent)), + snapshot + .primary + .as_ref() + .and_then(|window| window.window_duration_mins), + &RateLimitWarningContext { + observed_at: &observed_at, + profile: selected_auth_profile.as_deref(), + }, + ) + } else { + vec![] + }; let high_usage = is_codex_limit && (snapshot @@ -282,7 +320,7 @@ impl ChatWidget { } let mut display = - rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); + rate_limit_snapshot_display_for_limit(&snapshot, limit_label, observed_at); if display.individual_limit.is_none() { display.individual_limit = preserved_individual_limit; } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 5d80476e94..10fbe81ae0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,7 +1,10 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; +use crate::chatwidget::rate_limits::RateLimitSnapshotSource; +use crate::chatwidget::rate_limits::RateLimitWarningContext; use crate::chatwidget::rate_limits::get_limits_duration; +use chrono::TimeZone; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::SpendControlLimitSnapshot; @@ -64,6 +67,17 @@ fn rate_limit_snapshot_for_window( } } +fn weekly_rate_limit_snapshot(used_percent: i32) -> RateLimitSnapshot { + let mut limits = snapshot(f64::from(used_percent)); + limits.primary = None; + limits.secondary = Some(RateLimitWindow { + used_percent, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }); + limits +} + fn status_line_test_schedule( thread_id: ThreadId, schedule_id: &str, @@ -648,49 +662,126 @@ async fn prefetch_rate_limits_is_gated_on_chatgpt_auth_provider() { async fn rate_limit_warnings_emit_thresholds() { let mut state = RateLimitWarningState::default(); let mut warnings: Vec = Vec::new(); + let observed_at = chrono::Local + .with_ymd_and_hms(2026, 7, 23, 17, 0, 0) + .single() + .expect("valid local timestamp"); + let context = RateLimitWarningContext { + observed_at: &observed_at, + profile: Some("account006"), + }; - warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299))); - warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299))); - warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299))); - warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299))); + warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299), &context)); + warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299), &context)); + warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299), &context)); + warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299), &context)); + warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299), &context)); + warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299), &context)); assert_eq!( warnings, vec![ String::from( - "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." + "As of 5:00 PM Jul 23, profile account006 has 20% of its 5h limit remaining." ), String::from( - "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", + "As of 5:00 PM Jul 23, profile account006 has 20% of its weekly limit remaining.", ), String::from( - "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." + "As of 5:00 PM Jul 23, profile account006 has 5% of its 5h limit remaining." ), String::from( - "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", + "As of 5:00 PM Jul 23, profile account006 has 5% of its weekly limit remaining.", ), ], "expected one warning per limit for the highest crossed threshold" ); } +#[tokio::test] +async fn authoritative_rate_limit_warning_snapshot_uses_live_context_and_actual_remaining() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.selected_auth_profile = Some("account006".to_string()); + let observed_at = chrono::Local + .with_ymd_and_hms(2026, 7, 23, 17, 0, 0) + .single() + .expect("valid local timestamp"); + + chat.on_rate_limit_snapshot_from( + Some(weekly_rate_limit_snapshot(/*used_percent*/ 95)), + RateLimitSnapshotSource::RollingUpdate, + observed_at, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + assert_eq!(chat.rate_limit_warnings.secondary_index, 0); + + chat.on_rate_limit_snapshot_from( + Some(weekly_rate_limit_snapshot(/*used_percent*/ 80)), + RateLimitSnapshotSource::AccountUsage, + observed_at, + ); + let history = drain_insert_history(&mut rx); + assert_eq!(history.len(), 1); + insta::assert_snapshot!( + lines_to_single_string(&history[0]).trim(), + @" +⚠ As of 5:00 PM Jul 23, profile account006 has 20% of its weekly limit + remaining. +" + ); + assert_eq!(chat.rate_limit_warnings.secondary_index, 1); +} + +#[tokio::test] +async fn rate_limit_warnings_ignore_caps_and_non_codex_limits() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.selected_auth_profile = Some("account006".to_string()); + let observed_at = chrono::Local + .with_ymd_and_hms(2026, 7, 23, 17, 0, 0) + .single() + .expect("valid local timestamp"); + + chat.on_rate_limit_snapshot_from( + Some(weekly_rate_limit_snapshot(/*used_percent*/ 100)), + RateLimitSnapshotSource::AccountUsage, + observed_at, + ); + let mut non_codex_limits = weekly_rate_limit_snapshot(/*used_percent*/ 95); + non_codex_limits.limit_id = Some("other".to_string()); + chat.on_rate_limit_snapshot_from( + Some(non_codex_limits), + RateLimitSnapshotSource::AccountUsage, + observed_at, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert_eq!(chat.rate_limit_warnings.secondary_index, 0); + assert_eq!(chat.rate_limit_warnings.primary_index, 0); +} + #[tokio::test] async fn test_rate_limit_warnings_monthly() { let mut state = RateLimitWarningState::default(); let mut warnings: Vec = Vec::new(); + let observed_at = chrono::Local + .with_ymd_and_hms(2026, 7, 23, 17, 0, 0) + .single() + .expect("valid local timestamp"); warnings.extend(state.take_warnings( Some(75.0), Some(43199), /*primary_used_percent*/ None, /*primary_window_minutes*/ None, + &RateLimitWarningContext { + observed_at: &observed_at, + profile: Some("account006"), + }, )); assert_eq!( warnings, vec![String::from( - "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", + "As of 5:00 PM Jul 23, profile account006 has 25% of its monthly limit remaining.", ),], "expected one warning per limit for the highest crossed threshold" ); @@ -709,6 +800,10 @@ fn rate_limit_duration_labels_only_render_supported_windows() { #[tokio::test] async fn test_rate_limit_warnings_use_generic_fallback_labels() { let mut state = RateLimitWarningState::default(); + let observed_at = chrono::Local + .with_ymd_and_hms(2026, 7, 23, 17, 0, 0) + .single() + .expect("valid local timestamp"); assert_eq!( state.take_warnings( @@ -716,13 +811,17 @@ async fn test_rate_limit_warnings_use_generic_fallback_labels() { /*secondary_window_minutes*/ None, /*primary_used_percent*/ Some(75.0), /*primary_window_minutes*/ None, + &RateLimitWarningContext { + observed_at: &observed_at, + profile: None, + }, ), vec![ String::from( - "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", + "As of 5:00 PM Jul 23, profile default has 25% of its secondary usage limit remaining.", ), String::from( - "Heads up, you have less than 25% of your usage limit left. Run /status for a breakdown.", + "As of 5:00 PM Jul 23, profile default has 25% of its usage limit remaining.", ), ], ); @@ -731,6 +830,10 @@ async fn test_rate_limit_warnings_use_generic_fallback_labels() { #[tokio::test] async fn test_rate_limit_warnings_use_secondary_fallback_for_unsupported_window() { let mut state = RateLimitWarningState::default(); + let observed_at = chrono::Local + .with_ymd_and_hms(2026, 7, 23, 17, 0, 0) + .single() + .expect("valid local timestamp"); assert_eq!( state.take_warnings( @@ -738,9 +841,13 @@ async fn test_rate_limit_warnings_use_secondary_fallback_for_unsupported_window( /*secondary_window_minutes*/ Some(2 * 60), /*primary_used_percent*/ None, /*primary_window_minutes*/ None, + &RateLimitWarningContext { + observed_at: &observed_at, + profile: Some("account006"), + }, ), vec![String::from( - "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", + "As of 5:00 PM Jul 23, profile account006 has 25% of its secondary usage limit remaining.", )], ); }