From 6a0a112152105c012d35d9b6a62809fa9f7fb45d Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Fri, 15 May 2026 16:46:18 -0700 Subject: [PATCH 01/18] Harden CLI rate limit window labels --- .../tui/src/bottom_pane/status_line_setup.rs | 10 +-- .../src/bottom_pane/status_surface_preview.rs | 4 +- codex-rs/tui/src/bottom_pane/title_setup.rs | 8 +-- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/rate_limits.rs | 68 ++++++++++++++++--- .../tui/src/chatwidget/status_surfaces.rs | 5 +- .../src/chatwidget/tests/status_and_layout.rs | 31 +++++++++ codex-rs/tui/src/status/rate_limits.rs | 25 ++++--- 8 files changed, 121 insertions(+), 31 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 5ce13cd214ce..bc12580cad5b 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -15,7 +15,7 @@ //! - Permissions profile //! - Approval mode //! - Context usage (remaining %, used %, window size) -//! - Usage limits (5-hour, weekly) +//! - Usage limits (primary, secondary) //! - Session info (thread title, thread ID, tokens used) //! - Application version @@ -100,10 +100,10 @@ pub(crate) enum StatusLineItem { #[strum(to_string = "context-used", serialize = "context-usage")] ContextUsed, - /// Remaining usage on the 5-hour rate limit. + /// Remaining usage on the primary rate limit. FiveHourLimit, - /// Remaining usage on the weekly rate limit. + /// Remaining usage on the secondary rate limit. WeeklyLimit, /// Codex application version. @@ -163,10 +163,10 @@ impl StatusLineItem { "Percentage of context window used (omitted when unknown)" } StatusLineItem::FiveHourLimit => { - "Remaining usage on 5-hour usage limit (omitted when unavailable)" + "Remaining usage on the primary usage limit (omitted when unavailable)" } StatusLineItem::WeeklyLimit => { - "Remaining usage on weekly usage limit (omitted when unavailable)" + "Remaining usage on the secondary usage limit (omitted when unavailable)" } StatusLineItem::CodexVersion => "Codex application version", StatusLineItem::ContextWindowSize => { diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 9f0f8ed5fbb2..26f72113c2e8 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -51,8 +51,8 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::ApprovalMode => "on-request", StatusSurfacePreviewItem::ContextRemaining => "Context 0% left", StatusSurfacePreviewItem::ContextUsed => "Context 0% used", - StatusSurfacePreviewItem::FiveHourLimit => "5h 0%", - StatusSurfacePreviewItem::WeeklyLimit => "weekly 0%", + StatusSurfacePreviewItem::FiveHourLimit => "primary 0%", + StatusSurfacePreviewItem::WeeklyLimit => "secondary 0%", StatusSurfacePreviewItem::CodexVersion => "0.0.0", StatusSurfacePreviewItem::ContextWindowSize => "0 window", StatusSurfacePreviewItem::UsedTokens => "0 used", diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index f37991bc0024..be4e9c97daa7 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -60,9 +60,9 @@ pub(crate) enum TerminalTitleItem { /// Percentage of context window used. #[strum(to_string = "context-used", serialize = "context-usage")] ContextUsed, - /// Remaining usage on the 5-hour rate limit. + /// Remaining usage on the primary rate limit. FiveHourLimit, - /// Remaining usage on the weekly rate limit. + /// Remaining usage on the secondary rate limit. WeeklyLimit, /// Codex application version. CodexVersion, @@ -107,10 +107,10 @@ impl TerminalTitleItem { "Percentage of context window used (omitted when unknown)" } TerminalTitleItem::FiveHourLimit => { - "Remaining usage on 5-hour usage limit (omitted when unavailable)" + "Remaining usage on the primary usage limit (omitted when unavailable)" } TerminalTitleItem::WeeklyLimit => { - "Remaining usage on weekly usage limit (omitted when unavailable)" + "Remaining usage on the secondary usage limit (omitted when unavailable)" } TerminalTitleItem::CodexVersion => "Codex application version", TerminalTitleItem::UsedTokens => "Total tokens used in session (omitted when zero)", diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 207c5475626e..4b2ea6a9a6c4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -375,6 +375,7 @@ use self::rate_limits::RateLimitErrorKind; use self::rate_limits::RateLimitSwitchPromptState; use self::rate_limits::RateLimitWarningState; use self::rate_limits::app_server_rate_limit_error_kind; +pub(crate) use self::rate_limits::fallback_limit_label; pub(crate) use self::rate_limits::get_limits_duration; use self::rate_limits::is_app_server_cyber_policy_error; mod realtime; diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 7eaca908c647..d72fa24ed74c 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -7,6 +7,8 @@ pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; +const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage"; +const SECONDARY_LIMIT_FALLBACK_LABEL: &str = "secondary usage"; #[derive(Default)] pub(super) struct RateLimitWarningState { @@ -42,7 +44,7 @@ impl RateLimitWarningState { if let Some(threshold) = highest_secondary { let limit_label = secondary_window_minutes .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); + .unwrap_or_else(|| SECONDARY_LIMIT_FALLBACK_LABEL.to_string()); 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." @@ -61,7 +63,7 @@ impl RateLimitWarningState { if let Some(threshold) = highest_primary { let limit_label = primary_window_minutes .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); + .unwrap_or_else(|| PRIMARY_LIMIT_FALLBACK_LABEL.to_string()); 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." @@ -82,16 +84,66 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { let windows_minutes = windows_minutes.max(0); - if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { + if windows_minutes == 0 { + PRIMARY_LIMIT_FALLBACK_LABEL.to_string() + } else if let Some(months) = + approximate_window_count(windows_minutes, MINUTES_PER_MONTH, MINUTES_PER_DAY) + { + if months == 1 { + "monthly".to_string() + } else { + format!("{months}-month") + } + } else if let Some(weeks) = + approximate_window_count(windows_minutes, MINUTES_PER_WEEK, ROUNDING_BIAS_MINUTES) + { + if weeks == 1 { + "weekly".to_string() + } else { + format!("{weeks}-week") + } + } else if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); format!("{hours}h") - } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { - "weekly".to_string() - } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { - "monthly".to_string() + } else if let Some(days) = + approximate_window_count(windows_minutes, MINUTES_PER_DAY, ROUNDING_BIAS_MINUTES) + { + format!("{days}-day") + } else if windows_minutes < MINUTES_PER_WEEK { + let days = windows_minutes.saturating_add(MINUTES_PER_DAY - 1) / MINUTES_PER_DAY; + format!("{days}-day") + } else if windows_minutes < MINUTES_PER_MONTH { + let weeks = windows_minutes.saturating_add(MINUTES_PER_WEEK - 1) / MINUTES_PER_WEEK; + format!("{weeks}-week") + } else { + let months = windows_minutes.saturating_add(MINUTES_PER_MONTH - 1) / MINUTES_PER_MONTH; + format!("{months}-month") + } +} + +pub(crate) fn fallback_limit_label(is_secondary: bool) -> &'static str { + if is_secondary { + SECONDARY_LIMIT_FALLBACK_LABEL + } else { + PRIMARY_LIMIT_FALLBACK_LABEL + } +} + +fn approximate_window_count( + minutes: i64, + unit_minutes: i64, + tolerance_minutes: i64, +) -> Option { + if minutes <= 0 || unit_minutes <= 0 { + return None; + } + let count = std::cmp::max(1, (minutes + unit_minutes / 2) / unit_minutes); + let target_minutes = count.saturating_mul(unit_minutes); + if (minutes - target_minutes).abs() <= tolerance_minutes { + Some(count) } else { - "annual".to_string() + None } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index e34fe4688a91..df28a59a4a61 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -6,6 +6,7 @@ use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; +use crate::chatwidget::fallback_limit_label; use crate::legacy_core::config::Config; use crate::status::format_tokens_compact; use codex_app_server_protocol::AskForApproval; @@ -610,7 +611,7 @@ impl ChatWidget { let label = window .and_then(|window| window.window_minutes) .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); + .unwrap_or_else(|| fallback_limit_label(false).to_string()); self.status_line_limit_display(window, &label) } StatusLineItem::WeeklyLimit => { @@ -621,7 +622,7 @@ impl ChatWidget { let label = window .and_then(|window| window.window_minutes) .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); + .unwrap_or_else(|| fallback_limit_label(true).to_string()); self.status_line_limit_display(window, &label) } StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), 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 acd6b7111bc2..8440da83ca59 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,6 +1,7 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; +use crate::chatwidget::rate_limits::get_limits_duration; use pretty_assertions::assert_eq; use ratatui::backend::TestBackend; use serial_test::serial; @@ -517,6 +518,36 @@ async fn test_rate_limit_warnings_monthly() { ); } +#[test] +fn rate_limit_duration_labels_preserve_non_standard_windows() { + assert_eq!(get_limits_duration(2 * 60), "2h"); + assert_eq!(get_limits_duration(2 * 24 * 60), "2-day"); + assert_eq!(get_limits_duration(2 * 7 * 24 * 60), "2-week"); + assert_eq!(get_limits_duration(30 * 24 * 60), "monthly"); +} + +#[tokio::test] +async fn test_rate_limit_warnings_use_generic_fallback_labels() { + let mut state = RateLimitWarningState::default(); + + assert_eq!( + state.take_warnings( + /*secondary_used_percent*/ Some(75.0), + /*secondary_window_minutes*/ None, + /*primary_used_percent*/ Some(75.0), + /*primary_window_minutes*/ None, + ), + vec![ + String::from( + "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", + ), + String::from( + "Heads up, you have less than 25% of your usage limit left. Run /status for a breakdown.", + ), + ], + ); +} + #[tokio::test] async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 3be813beb9cc..f8183e72f08f 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -5,6 +5,7 @@ //! //! The key contract is that time-sensitive values are interpreted relative to a caller-provided //! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle. +use crate::chatwidget::fallback_limit_label; use crate::chatwidget::get_limits_duration; use crate::text_formatting::capitalize_first; @@ -23,7 +24,7 @@ const STATUS_LIMIT_BAR_EMPTY: &str = "░"; #[derive(Debug, Clone)] pub(crate) struct StatusRateLimitRow { - /// Human-readable row label, such as `"5h limit"` or `"Credits"`. + /// Human-readable row label, such as `"5h limit"`, `"Monthly limit"`, or `"Credits"`. pub label: String, /// Value payload for the row. pub value: StatusRateLimitValue, @@ -92,9 +93,9 @@ pub(crate) struct RateLimitSnapshotDisplay { pub limit_name: String, /// Local timestamp representing when this display snapshot was captured. pub captured_at: DateTime, - /// Primary usage window (typically short duration). + /// Primary usage window. pub primary: Option, - /// Secondary usage window (typically weekly). + /// Secondary usage window. pub secondary: Option, /// Optional credits metadata when available. pub credits: Option, @@ -191,7 +192,7 @@ pub(crate) fn compose_rate_limit_data_many( window .window_minutes .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()) + .unwrap_or_else(|| fallback_limit_label(false).to_string()) }) .map(|label| capitalize_first(&label)); let secondary_label = snapshot @@ -201,7 +202,7 @@ pub(crate) fn compose_rate_limit_data_many( window .window_minutes .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()) + .unwrap_or_else(|| fallback_limit_label(true).to_string()) }) .map(|label| capitalize_first(&label)); let window_count = @@ -220,12 +221,16 @@ pub(crate) fn compose_rate_limit_data_many( format!( "{} {} limit", limit_bucket_label, - primary_label.clone().unwrap_or_else(|| "5h".to_string()) + primary_label + .clone() + .unwrap_or_else(|| capitalize_first(fallback_limit_label(false))) ) } else { format!( "{} limit", - primary_label.clone().unwrap_or_else(|| "5h".to_string()) + primary_label + .clone() + .unwrap_or_else(|| capitalize_first(fallback_limit_label(false))) ) }; rows.push(StatusRateLimitRow { @@ -244,14 +249,14 @@ pub(crate) fn compose_rate_limit_data_many( limit_bucket_label, secondary_label .clone() - .unwrap_or_else(|| "weekly".to_string()) + .unwrap_or_else(|| capitalize_first(fallback_limit_label(true))) ) } else { format!( "{} limit", secondary_label .clone() - .unwrap_or_else(|| "weekly".to_string()) + .unwrap_or_else(|| capitalize_first(fallback_limit_label(true))) ) }; rows.push(StatusRateLimitRow { @@ -435,7 +440,7 @@ mod tests { vec![ "codex-other limit".to_string(), "1h limit".to_string(), - "Weekly limit".to_string(), + "Secondary usage limit".to_string(), ] ); } From 594359fc3870b711eecfb90c6f0f91f58f3ac608 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Fri, 15 May 2026 17:11:38 -0700 Subject: [PATCH 02/18] Fix argument comments for rate limit labels --- .../tui/src/chatwidget/status_surfaces.rs | 4 +-- codex-rs/tui/src/status/rate_limits.rs | 28 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index df28a59a4a61..93f9543c680a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -611,7 +611,7 @@ impl ChatWidget { let label = window .and_then(|window| window.window_minutes) .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(false).to_string()); + .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ false).to_string()); self.status_line_limit_display(window, &label) } StatusLineItem::WeeklyLimit => { @@ -622,7 +622,7 @@ impl ChatWidget { let label = window .and_then(|window| window.window_minutes) .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(true).to_string()); + .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ true).to_string()); self.status_line_limit_display(window, &label) } StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index f8183e72f08f..d110d6abd8f1 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -192,7 +192,7 @@ pub(crate) fn compose_rate_limit_data_many( window .window_minutes .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(false).to_string()) + .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ false).to_string()) }) .map(|label| capitalize_first(&label)); let secondary_label = snapshot @@ -202,7 +202,7 @@ pub(crate) fn compose_rate_limit_data_many( window .window_minutes .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(true).to_string()) + .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ true).to_string()) }) .map(|label| capitalize_first(&label)); let window_count = @@ -221,16 +221,16 @@ pub(crate) fn compose_rate_limit_data_many( format!( "{} {} limit", limit_bucket_label, - primary_label - .clone() - .unwrap_or_else(|| capitalize_first(fallback_limit_label(false))) + primary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ false) + )) ) } else { format!( "{} limit", - primary_label - .clone() - .unwrap_or_else(|| capitalize_first(fallback_limit_label(false))) + primary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ false) + )) ) }; rows.push(StatusRateLimitRow { @@ -247,16 +247,16 @@ pub(crate) fn compose_rate_limit_data_many( format!( "{} {} limit", limit_bucket_label, - secondary_label - .clone() - .unwrap_or_else(|| capitalize_first(fallback_limit_label(true))) + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) ) } else { format!( "{} limit", - secondary_label - .clone() - .unwrap_or_else(|| capitalize_first(fallback_limit_label(true))) + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) ) }; rows.push(StatusRateLimitRow { From 6c2fb5b069650ed2ac7efc9db50c85175a6586c5 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Fri, 15 May 2026 17:23:24 -0700 Subject: [PATCH 03/18] Handle annual rate limit labels --- codex-rs/tui/src/chatwidget/rate_limits.rs | 9 +++++++++ codex-rs/tui/src/chatwidget/tests/status_and_layout.rs | 2 ++ 2 files changed, 11 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index d72fa24ed74c..3aca3b9f108c 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -80,6 +80,7 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const MINUTES_PER_YEAR: i64 = 365 * MINUTES_PER_DAY; const ROUNDING_BIAS_MINUTES: i64 = 3; let windows_minutes = windows_minutes.max(0); @@ -94,6 +95,14 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { } else { format!("{months}-month") } + } else if let Some(years) = + approximate_window_count(windows_minutes, MINUTES_PER_YEAR, MINUTES_PER_DAY) + { + if years == 1 { + "annual".to_string() + } else { + format!("{years}-year") + } } else if let Some(weeks) = approximate_window_count(windows_minutes, MINUTES_PER_WEEK, ROUNDING_BIAS_MINUTES) { 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 8440da83ca59..88e120845b64 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -524,6 +524,8 @@ fn rate_limit_duration_labels_preserve_non_standard_windows() { assert_eq!(get_limits_duration(2 * 24 * 60), "2-day"); assert_eq!(get_limits_duration(2 * 7 * 24 * 60), "2-week"); assert_eq!(get_limits_duration(30 * 24 * 60), "monthly"); + assert_eq!(get_limits_duration(365 * 24 * 60), "annual"); + assert_eq!(get_limits_duration(2 * 365 * 24 * 60), "2-year"); } #[tokio::test] From 8e2d72efaedf189795ccfa6a76657ed308b2d51b Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sat, 16 May 2026 12:09:43 -0700 Subject: [PATCH 04/18] Limit rate limit labels to supported windows --- codex-rs/tui/src/chatwidget/rate_limits.rs | 73 ++++--------------- .../src/chatwidget/tests/status_and_layout.rs | 14 ++-- codex-rs/tui/src/status/rate_limits.rs | 2 +- 3 files changed, 23 insertions(+), 66 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 3aca3b9f108c..b94aad0c929b 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -77,57 +77,23 @@ impl RateLimitWarningState { pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_5_HOURS: i64 = 5 * MINUTES_PER_HOUR; const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; - const MINUTES_PER_YEAR: i64 = 365 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: i64 = 3; let windows_minutes = windows_minutes.max(0); - if windows_minutes == 0 { - PRIMARY_LIMIT_FALLBACK_LABEL.to_string() - } else if let Some(months) = - approximate_window_count(windows_minutes, MINUTES_PER_MONTH, MINUTES_PER_DAY) - { - if months == 1 { - "monthly".to_string() - } else { - format!("{months}-month") - } - } else if let Some(years) = - approximate_window_count(windows_minutes, MINUTES_PER_YEAR, MINUTES_PER_DAY) - { - if years == 1 { - "annual".to_string() - } else { - format!("{years}-year") - } - } else if let Some(weeks) = - approximate_window_count(windows_minutes, MINUTES_PER_WEEK, ROUNDING_BIAS_MINUTES) - { - if weeks == 1 { - "weekly".to_string() - } else { - format!("{weeks}-week") - } - } else if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { - let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); - let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); - format!("{hours}h") - } else if let Some(days) = - approximate_window_count(windows_minutes, MINUTES_PER_DAY, ROUNDING_BIAS_MINUTES) - { - format!("{days}-day") - } else if windows_minutes < MINUTES_PER_WEEK { - let days = windows_minutes.saturating_add(MINUTES_PER_DAY - 1) / MINUTES_PER_DAY; - format!("{days}-day") - } else if windows_minutes < MINUTES_PER_MONTH { - let weeks = windows_minutes.saturating_add(MINUTES_PER_WEEK - 1) / MINUTES_PER_WEEK; - format!("{weeks}-week") + if is_approximate_window(windows_minutes, MINUTES_PER_5_HOURS) { + "5h".to_string() + } else if is_approximate_window(windows_minutes, MINUTES_PER_DAY) { + "daily".to_string() + } else if is_approximate_window(windows_minutes, MINUTES_PER_WEEK) { + "weekly".to_string() + } else if is_approximate_window(windows_minutes, MINUTES_PER_MONTH) { + "monthly".to_string() } else { - let months = windows_minutes.saturating_add(MINUTES_PER_MONTH - 1) / MINUTES_PER_MONTH; - format!("{months}-month") + PRIMARY_LIMIT_FALLBACK_LABEL.to_string() } } @@ -139,21 +105,10 @@ pub(crate) fn fallback_limit_label(is_secondary: bool) -> &'static str { } } -fn approximate_window_count( - minutes: i64, - unit_minutes: i64, - tolerance_minutes: i64, -) -> Option { - if minutes <= 0 || unit_minutes <= 0 { - return None; - } - let count = std::cmp::max(1, (minutes + unit_minutes / 2) / unit_minutes); - let target_minutes = count.saturating_mul(unit_minutes); - if (minutes - target_minutes).abs() <= tolerance_minutes { - Some(count) - } else { - None - } +fn is_approximate_window(minutes: i64, expected_minutes: i64) -> bool { + let minutes = minutes as f64; + let expected_minutes = expected_minutes as f64; + minutes >= expected_minutes * 0.95 && minutes <= expected_minutes * 1.05 } #[derive(Default)] 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 88e120845b64..5ca3e8b6e94f 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -519,13 +519,15 @@ async fn test_rate_limit_warnings_monthly() { } #[test] -fn rate_limit_duration_labels_preserve_non_standard_windows() { - assert_eq!(get_limits_duration(2 * 60), "2h"); - assert_eq!(get_limits_duration(2 * 24 * 60), "2-day"); - assert_eq!(get_limits_duration(2 * 7 * 24 * 60), "2-week"); +fn rate_limit_duration_labels_only_render_supported_windows() { + assert_eq!(get_limits_duration(2 * 60), "usage"); + assert_eq!(get_limits_duration(299), "5h"); + assert_eq!(get_limits_duration(24 * 60), "daily"); + assert_eq!(get_limits_duration(2 * 24 * 60), "usage"); + assert_eq!(get_limits_duration(7 * 24 * 60), "weekly"); + assert_eq!(get_limits_duration(2 * 7 * 24 * 60), "usage"); assert_eq!(get_limits_duration(30 * 24 * 60), "monthly"); - assert_eq!(get_limits_duration(365 * 24 * 60), "annual"); - assert_eq!(get_limits_duration(2 * 365 * 24 * 60), "2-year"); + assert_eq!(get_limits_duration(365 * 24 * 60), "usage"); } #[tokio::test] diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index d110d6abd8f1..b89346dfa909 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -439,7 +439,7 @@ mod tests { labels, vec![ "codex-other limit".to_string(), - "1h limit".to_string(), + "Usage limit".to_string(), "Secondary usage limit".to_string(), ] ); From 1f46010dc348cad9de5296d0b76c1985601269a0 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sat, 16 May 2026 12:20:31 -0700 Subject: [PATCH 05/18] Trim rate limit duration label tests --- codex-rs/tui/src/chatwidget/tests/status_and_layout.rs | 6 ------ 1 file changed, 6 deletions(-) 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 5ca3e8b6e94f..a39f03c0643e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -521,13 +521,7 @@ async fn test_rate_limit_warnings_monthly() { #[test] fn rate_limit_duration_labels_only_render_supported_windows() { assert_eq!(get_limits_duration(2 * 60), "usage"); - assert_eq!(get_limits_duration(299), "5h"); assert_eq!(get_limits_duration(24 * 60), "daily"); - assert_eq!(get_limits_duration(2 * 24 * 60), "usage"); - assert_eq!(get_limits_duration(7 * 24 * 60), "weekly"); - assert_eq!(get_limits_duration(2 * 7 * 24 * 60), "usage"); - assert_eq!(get_limits_duration(30 * 24 * 60), "monthly"); - assert_eq!(get_limits_duration(365 * 24 * 60), "usage"); } #[tokio::test] From 49f4c996257952bc74669bb8075e8e8add37195e Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sat, 16 May 2026 18:18:16 -0700 Subject: [PATCH 06/18] Handle annual rate limit labels --- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/rate_limits.rs | 31 +++++++----- .../tui/src/chatwidget/status_surfaces.rs | 18 +++---- .../src/chatwidget/tests/status_and_layout.rs | 49 ++++++++++++++++++- codex-rs/tui/src/status/rate_limits.rs | 16 ++---- 5 files changed, 80 insertions(+), 36 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4b2ea6a9a6c4..48f47c898d5f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -376,8 +376,8 @@ use self::rate_limits::RateLimitSwitchPromptState; use self::rate_limits::RateLimitWarningState; use self::rate_limits::app_server_rate_limit_error_kind; pub(crate) use self::rate_limits::fallback_limit_label; -pub(crate) use self::rate_limits::get_limits_duration; use self::rate_limits::is_app_server_cyber_policy_error; +pub(crate) use self::rate_limits::limit_label_for_window; mod realtime; mod rendering; mod replay; diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index b94aad0c929b..544611f4f0fd 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -42,9 +42,8 @@ impl RateLimitWarningState { self.secondary_index += 1; } if let Some(threshold) = highest_secondary { - let limit_label = secondary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| SECONDARY_LIMIT_FALLBACK_LABEL.to_string()); + 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." @@ -61,9 +60,8 @@ impl RateLimitWarningState { self.primary_index += 1; } if let Some(threshold) = highest_primary { - let limit_label = primary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| PRIMARY_LIMIT_FALLBACK_LABEL.to_string()); + 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." @@ -75,25 +73,34 @@ impl RateLimitWarningState { } } -pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { +pub(crate) fn limit_label_for_window(window_minutes: Option, is_secondary: bool) -> String { + window_minutes + .and_then(get_limits_duration) + .unwrap_or_else(|| fallback_limit_label(is_secondary).to_string()) +} + +pub(crate) fn get_limits_duration(windows_minutes: i64) -> Option { const MINUTES_PER_HOUR: i64 = 60; const MINUTES_PER_5_HOURS: i64 = 5 * MINUTES_PER_HOUR; const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const MINUTES_PER_YEAR: i64 = 365 * MINUTES_PER_DAY; let windows_minutes = windows_minutes.max(0); if is_approximate_window(windows_minutes, MINUTES_PER_5_HOURS) { - "5h".to_string() + Some("5h".to_string()) } else if is_approximate_window(windows_minutes, MINUTES_PER_DAY) { - "daily".to_string() + Some("daily".to_string()) } else if is_approximate_window(windows_minutes, MINUTES_PER_WEEK) { - "weekly".to_string() + Some("weekly".to_string()) } else if is_approximate_window(windows_minutes, MINUTES_PER_MONTH) { - "monthly".to_string() + Some("monthly".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_YEAR) { + Some("annual".to_string()) } else { - PRIMARY_LIMIT_FALLBACK_LABEL.to_string() + None } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 93f9543c680a..115e25ba0dc2 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -6,7 +6,7 @@ use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; -use crate::chatwidget::fallback_limit_label; +use crate::chatwidget::limit_label_for_window; use crate::legacy_core::config::Config; use crate::status::format_tokens_compact; use codex_app_server_protocol::AskForApproval; @@ -608,10 +608,10 @@ impl ChatWidget { .rate_limit_snapshots_by_limit_id .get("codex") .and_then(|s| s.primary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ false).to_string()); + let label = limit_label_for_window( + window.and_then(|window| window.window_minutes), + /*is_secondary*/ false, + ); self.status_line_limit_display(window, &label) } StatusLineItem::WeeklyLimit => { @@ -619,10 +619,10 @@ impl ChatWidget { .rate_limit_snapshots_by_limit_id .get("codex") .and_then(|s| s.secondary.as_ref()); - let label = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ true).to_string()); + let label = limit_label_for_window( + window.and_then(|window| window.window_minutes), + /*is_secondary*/ true, + ); self.status_line_limit_display(window, &label) } StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), 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 a39f03c0643e..624ccb34e3e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -520,8 +520,12 @@ async fn test_rate_limit_warnings_monthly() { #[test] fn rate_limit_duration_labels_only_render_supported_windows() { - assert_eq!(get_limits_duration(2 * 60), "usage"); - assert_eq!(get_limits_duration(24 * 60), "daily"); + assert_eq!(get_limits_duration(2 * 60), None); + assert_eq!(get_limits_duration(24 * 60).as_deref(), Some("daily")); + assert_eq!( + get_limits_duration(365 * 24 * 60).as_deref(), + Some("annual") + ); } #[tokio::test] @@ -546,6 +550,47 @@ 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(); + + assert_eq!( + state.take_warnings( + /*secondary_used_percent*/ Some(75.0), + /*secondary_window_minutes*/ Some(2 * 60), + /*primary_used_percent*/ None, + /*primary_window_minutes*/ None, + ), + vec![String::from( + "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", + )], + ); +} + +#[tokio::test] +async fn status_line_uses_secondary_fallback_for_unsupported_window() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(2 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("secondary usage 50%".to_string()) + ); +} + #[tokio::test] async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index b89346dfa909..1146bb8a315a 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -6,7 +6,7 @@ //! The key contract is that time-sensitive values are interpreted relative to a caller-provided //! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle. use crate::chatwidget::fallback_limit_label; -use crate::chatwidget::get_limits_duration; +use crate::chatwidget::limit_label_for_window; use crate::text_formatting::capitalize_first; use super::helpers::format_reset_timestamp; @@ -189,21 +189,13 @@ pub(crate) fn compose_rate_limit_data_many( .primary .as_ref() .map(|window| { - window - .window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ false).to_string()) + limit_label_for_window(window.window_minutes, /*is_secondary*/ false) }) .map(|label| capitalize_first(&label)); let secondary_label = snapshot .secondary .as_ref() - .map(|window| { - window - .window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| fallback_limit_label(/*is_secondary*/ true).to_string()) - }) + .map(|window| limit_label_for_window(window.window_minutes, /*is_secondary*/ true)) .map(|label| capitalize_first(&label)); let window_count = usize::from(snapshot.primary.is_some()) + usize::from(snapshot.secondary.is_some()); @@ -425,7 +417,7 @@ mod tests { secondary: Some(RateLimitWindowDisplay { used_percent: 40.0, resets_at: Some("later".to_string()), - window_minutes: None, + window_minutes: Some(2 * 60), }), credits: None, }; From 4cd372556b8a3d06891fe4b9513ae4da8a222923 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sat, 16 May 2026 22:16:52 -0700 Subject: [PATCH 07/18] Add CLI rate limit snapshot coverage --- ...__status_line_setup_popup_rate_limits.snap | 20 ++++++ ...__status_surface_previews_rate_limits.snap | 6 ++ ...erminal_title_setup_popup_rate_limits.snap | 20 ++++++ .../tests/status_surface_previews.rs | 67 +++++++++++++++++++ ..._limit_labels_for_unsupported_windows.snap | 24 +++++++ codex-rs/tui/src/status/tests.rs | 67 +++++++++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap new file mode 100644 index 000000000000..49ed7577393a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] five-hour-limit Remaining usage on the primary usage limit (omitted when unavailable) + [x] weekly-limit Remaining usage on the secondary usage limit (omitted when unavailabl… + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] current-dir Current working directory + [ ] project-name Project name (omitted when unavailable) + + monthly 65% · secondary usage 50% + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap new file mode 100644 index 000000000000..90f2118c24b3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: snapshot +--- +status line: monthly 65% · secondary usage 50% +terminal title: monthly 65% | secondary usage 50% diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap new file mode 100644 index 000000000000..05c2fc75a6d3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: terminal_title_popup_snapshot(&mut chat) +--- + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] five-hour-limit Remaining usage on the primary usage limit (omitted when unavailable) + [x] weekly-limit Remaining usage on the secondary usage limit (omitted when unavailable) + [ ] app-name Codex app name + [ ] project-name Project name (falls back to current directory name) + [ ] current-dir Current working directory + [ ] activity Spinner while working, action-required message while blocked. + [ ] run-state Compact session run-state text (Ready, Working, Thinking) + [ ] thread-title Current thread title, or thread identifier when unnamed + + monthly 65% | secondary usage 50% + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8837f04d8d8d..12cfead80624 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -58,6 +58,26 @@ fn cache_project_root(chat: &mut ChatWidget, root_name: &str) { }); } +fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(2 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); +} + #[tokio::test] async fn status_surface_preview_lines_live_only_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -179,6 +199,38 @@ async fn status_surface_preview_lines_mixed_snapshot() { assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot); } +#[tokio::test] +async fn status_surface_preview_lines_rate_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_rate_limit_snapshot(&mut chat); + + let snapshot = combined_preview_snapshot( + &mut chat, + &[StatusLineItem::FiveHourLimit, StatusLineItem::WeeklyLimit], + &[ + TerminalTitleItem::FiveHourLimit, + TerminalTitleItem::WeeklyLimit, + ], + ); + + assert_chatwidget_snapshot!("status_surface_previews_rate_limits", snapshot); +} + +#[tokio::test] +async fn status_line_setup_popup_rate_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_rate_limit_snapshot(&mut chat); + chat.config.tui_status_line = Some(vec![ + "five-hour-limit".to_string(), + "weekly-limit".to_string(), + ]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_rate_limits", + status_line_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn status_line_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -247,6 +299,21 @@ async fn terminal_title_setup_popup_mixed_snapshot() { ); } +#[tokio::test] +async fn terminal_title_setup_popup_rate_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + cache_rate_limit_snapshot(&mut chat); + chat.config.tui_terminal_title = Some(vec![ + "five-hour-limit".to_string(), + "weekly-limit".to_string(), + ]); + + assert_chatwidget_snapshot!( + "terminal_title_setup_popup_rate_limits", + terminal_title_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn missing_project_root_uses_different_status_and_title_preview_sources() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap new file mode 100644 index 000000000000..f983cf478dd2 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Usage limit: [█████████████░░░░░░░] 65% left │ +│ (resets 07:08 on 7 May) │ +│ Secondary usage limit: [██████████░░░░░░░░░░] 50% left │ +│ (resets 07:08 on 8 May) │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 938071819669..fa88bb07e1e4 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -861,6 +861,73 @@ async fn status_snapshot_includes_monthly_limit() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(2 * 60), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 86_400)), + }), + secondary: Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(3 * 60), + resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 172_800)), + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_snapshot_shows_unlimited_credits() { let temp_home = TempDir::new().expect("temp home"); From 4268aba78da19b9da0a2033f3dc0d886a644d9f6 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sat, 16 May 2026 22:49:44 -0700 Subject: [PATCH 08/18] Update rate limit preview snapshots --- ...hatwidget__tests__status_line_setup_popup_rate_limits.snap | 2 +- ...hatwidget__tests__status_surface_previews_rate_limits.snap | 4 ++-- ...widget__tests__terminal_title_setup_popup_rate_limits.snap | 2 +- codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap index 49ed7577393a..254d6dacdc0e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap @@ -16,5 +16,5 @@ expression: status_line_popup_snapshot(&mut chat) [ ] current-dir Current working directory [ ] project-name Project name (omitted when unavailable) - monthly 65% · secondary usage 50% + monthly 65% · weekly 50% Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap index 90f2118c24b3..eb6ec60729ab 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_rate_limits.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests/status_surface_previews.rs expression: snapshot --- -status line: monthly 65% · secondary usage 50% -terminal title: monthly 65% | secondary usage 50% +status line: monthly 65% · weekly 50% +terminal title: monthly 65% | weekly 50% diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap index 05c2fc75a6d3..587acbf540fa 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap @@ -16,5 +16,5 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] run-state Compact session run-state text (Ready, Working, Thinking) [ ] thread-title Current thread title, or thread identifier when unnamed - monthly 65% | secondary usage 50% + monthly 65% | weekly 50% Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 12cfead80624..555b79a6201b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -69,7 +69,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { }), secondary: Some(RateLimitWindow { used_percent: 50, - window_duration_mins: Some(2 * 60), + window_duration_mins: Some(7 * 24 * 60), resets_at: None, }), credits: None, From b396b2617261d30159434cad8f972ceac5064453 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sat, 16 May 2026 23:26:13 -0700 Subject: [PATCH 09/18] Map legacy rate limit items by window --- .../tui/src/bottom_pane/status_line_setup.rs | 4 +- codex-rs/tui/src/bottom_pane/title_setup.rs | 4 +- ...__status_line_setup_popup_rate_limits.snap | 4 +- ...erminal_title_setup_popup_rate_limits.snap | 4 +- .../tui/src/chatwidget/status_surfaces.rs | 75 +++++++++++++++---- .../src/chatwidget/tests/status_and_layout.rs | 60 +++++++++++++++ 6 files changed, 129 insertions(+), 22 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index bc12580cad5b..44aaa0434677 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -163,10 +163,10 @@ impl StatusLineItem { "Percentage of context window used (omitted when unknown)" } StatusLineItem::FiveHourLimit => { - "Remaining usage on the primary usage limit (omitted when unavailable)" + "Remaining usage on the 5-hour or only usage limit (omitted when unavailable)" } StatusLineItem::WeeklyLimit => { - "Remaining usage on the secondary usage limit (omitted when unavailable)" + "Remaining usage on the weekly or secondary usage limit (omitted when unavailable)" } StatusLineItem::CodexVersion => "Codex application version", StatusLineItem::ContextWindowSize => { diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index be4e9c97daa7..70dbfb2073df 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -107,10 +107,10 @@ impl TerminalTitleItem { "Percentage of context window used (omitted when unknown)" } TerminalTitleItem::FiveHourLimit => { - "Remaining usage on the primary usage limit (omitted when unavailable)" + "Remaining usage on the 5-hour or only usage limit (omitted when unavailable)" } TerminalTitleItem::WeeklyLimit => { - "Remaining usage on the secondary usage limit (omitted when unavailable)" + "Remaining usage on the weekly or secondary usage limit (omitted when unavailable)" } TerminalTitleItem::CodexVersion => "Codex application version", TerminalTitleItem::UsedTokens => "Total tokens used in session (omitted when zero)", diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap index 254d6dacdc0e..e067b068088e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap @@ -9,8 +9,8 @@ expression: status_line_popup_snapshot(&mut chat) > › [x] Use theme colors Apply colors from the active /theme ─────────────────────── - [x] five-hour-limit Remaining usage on the primary usage limit (omitted when unavailable) - [x] weekly-limit Remaining usage on the secondary usage limit (omitted when unavailabl… + [x] five-hour-limit Remaining usage on the 5-hour or only usage limit (omitted when unava… + [x] weekly-limit Remaining usage on the weekly or secondary usage limit (omitted when … [ ] model Current model name [ ] model-with-reasoning Current model name with reasoning level [ ] current-dir Current working directory diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap index 587acbf540fa..af9acd7634e5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap @@ -7,8 +7,8 @@ expression: terminal_title_popup_snapshot(&mut chat) Type to search > -› [x] five-hour-limit Remaining usage on the primary usage limit (omitted when unavailable) - [x] weekly-limit Remaining usage on the secondary usage limit (omitted when unavailable) +› [x] five-hour-limit Remaining usage on the 5-hour or only usage limit (omitted when unavailabl… + [x] weekly-limit Remaining usage on the weekly or secondary usage limit (omitted when unava… [ ] app-name Codex app name [ ] project-name Project name (falls back to current directory name) [ ] current-dir Current working directory diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 115e25ba0dc2..bca91b1bedbf 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -7,6 +7,7 @@ use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; use crate::chatwidget::limit_label_for_window; +use crate::chatwidget::rate_limits::get_limits_duration; use crate::legacy_core::config::Config; use crate::status::format_tokens_compact; use codex_app_server_protocol::AskForApproval; @@ -604,26 +605,20 @@ impl ChatWidget { .status_line_context_used_percent() .map(|used| format!("Context {used}% used")), StatusLineItem::FiveHourLimit => { - let window = self + let (window, is_secondary) = self .rate_limit_snapshots_by_limit_id .get("codex") - .and_then(|s| s.primary.as_ref()); - let label = limit_label_for_window( - window.and_then(|window| window.window_minutes), - /*is_secondary*/ false, - ); - self.status_line_limit_display(window, &label) + .and_then(five_hour_status_window)?; + let label = limit_label_for_window(window.window_minutes, is_secondary); + self.status_line_limit_display(Some(window), &label) } StatusLineItem::WeeklyLimit => { - let window = self + let (window, is_secondary) = self .rate_limit_snapshots_by_limit_id .get("codex") - .and_then(|s| s.secondary.as_ref()); - let label = limit_label_for_window( - window.and_then(|window| window.window_minutes), - /*is_secondary*/ true, - ); - self.status_line_limit_display(window, &label) + .and_then(weekly_status_window)?; + let label = limit_label_for_window(window.window_minutes, is_secondary); + self.status_line_limit_display(Some(window), &label) } StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), StatusLineItem::ContextWindowSize => self @@ -894,6 +889,58 @@ impl ChatWidget { } } +fn five_hour_status_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + find_codex_window(snapshot, "5h") + .or_else(|| only_codex_window(snapshot)) + .or_else(|| snapshot.primary.as_ref().map(|window| (window, false))) +} + +fn weekly_status_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + find_codex_window(snapshot, "weekly") + .or_else(|| snapshot.secondary.as_ref().map(|window| (window, true))) +} + +fn find_codex_window<'a>( + snapshot: &'a RateLimitSnapshotDisplay, + label: &str, +) -> Option<(&'a RateLimitWindowDisplay, bool)> { + if let Some(primary) = snapshot.primary.as_ref() + && matches_window_label(primary, label) + { + return Some((primary, false)); + } + + if let Some(secondary) = snapshot.secondary.as_ref() + && matches_window_label(secondary, label) + { + return Some((secondary, true)); + } + + None +} + +fn only_codex_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + match (snapshot.primary.as_ref(), snapshot.secondary.as_ref()) { + (Some(primary), None) => Some((primary, false)), + (None, Some(secondary)) => Some((secondary, true)), + _ => None, + } +} + +fn matches_window_label(window: &RateLimitWindowDisplay, label: &str) -> bool { + window + .window_minutes + .and_then(get_limits_duration) + .as_deref() + == Some(label) +} + fn permissions_display(config: &Config) -> String { let active_permission_profile = config.permissions.active_permission_profile(); if let Some(active_permission_profile) = active_permission_profile.as_ref() 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 624ccb34e3e0..224411548109 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -591,6 +591,66 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() { ); } +#[tokio::test] +async fn status_line_legacy_limit_items_prefer_matching_windows() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 94, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 40, + window_duration_mins: Some(5 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + Some("5h 60%".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("weekly 6%".to_string()) + ); +} + +#[tokio::test] +async fn status_line_single_monthly_primary_omits_weekly_limit_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + Some("monthly 65%".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + None + ); +} + #[tokio::test] async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From f2da71128493e4ec9fa6a494ccec93e92d7d28ac Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sat, 16 May 2026 23:37:05 -0700 Subject: [PATCH 10/18] Avoid duplicate weekly status limits --- .../tui/src/chatwidget/status_surfaces.rs | 25 ++++++++++++++--- .../src/chatwidget/tests/status_and_layout.rs | 28 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index bca91b1bedbf..361fc6a83e52 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -893,8 +893,8 @@ fn five_hour_status_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { find_codex_window(snapshot, "5h") - .or_else(|| only_codex_window(snapshot)) - .or_else(|| snapshot.primary.as_ref().map(|window| (window, false))) + .or_else(|| non_weekly_primary_window(snapshot)) + .or_else(|| only_non_weekly_codex_window(snapshot)) } fn weekly_status_window( @@ -923,13 +923,30 @@ fn find_codex_window<'a>( None } -fn only_codex_window( +fn non_weekly_primary_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { - match (snapshot.primary.as_ref(), snapshot.secondary.as_ref()) { + let primary = snapshot.primary.as_ref()?; + if matches_window_label(primary, "weekly") { + None + } else { + Some((primary, false)) + } +} + +fn only_non_weekly_codex_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + let (window, is_secondary) = match (snapshot.primary.as_ref(), snapshot.secondary.as_ref()) { (Some(primary), None) => Some((primary, false)), (None, Some(secondary)) => Some((secondary, true)), _ => None, + }?; + + if matches_window_label(window, "weekly") { + None + } else { + Some((window, is_secondary)) } } 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 224411548109..064f1a3eaa9d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -623,6 +623,34 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() { ); } +#[tokio::test] +async fn status_line_five_hour_item_omits_weekly_only_limit() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 9, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + None + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("weekly 91%".to_string()) + ); +} + #[tokio::test] async fn status_line_single_monthly_primary_omits_weekly_limit_item() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From a3ae8144282bdb2dc179116dde883027b042bd1a Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sun, 17 May 2026 00:01:47 -0700 Subject: [PATCH 11/18] Deduplicate rate limit snapshots from app server --- codex-rs/tui/src/app_server_session.rs | 52 ++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index db6dc9e9a878..a7954d55f81f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1591,10 +1591,19 @@ async fn thread_session_state_from_thread_response( pub(crate) fn app_server_rate_limit_snapshots( response: GetAccountRateLimitsResponse, ) -> Vec { - let mut snapshots = Vec::new(); - snapshots.push(response.rate_limits); + let primary_limit_id = response.rate_limits.limit_id.clone(); + let mut snapshots = vec![response.rate_limits]; if let Some(by_limit_id) = response.rate_limits_by_limit_id { - snapshots.extend(by_limit_id.into_values()); + snapshots.extend(by_limit_id.into_iter().filter_map(|(limit_id, snapshot)| { + if primary_limit_id.as_deref().is_some_and(|primary_limit_id| { + primary_limit_id == limit_id + || Some(primary_limit_id) == snapshot.limit_id.as_deref() + }) { + None + } else { + Some(snapshot) + } + })); } snapshots } @@ -1635,6 +1644,43 @@ mod tests { .expect("config should build") } + fn rate_limit_snapshot(limit_id: &str, used_percent: i32) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some(limit_id.to_string()), + limit_name: None, + primary: Some(codex_app_server_protocol::RateLimitWindow { + used_percent, + window_duration_mins: Some(10_080), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + } + } + + #[test] + fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() { + let response = GetAccountRateLimitsResponse { + rate_limits: rate_limit_snapshot("codex", 9), + rate_limits_by_limit_id: Some(HashMap::from([ + ("codex".to_string(), rate_limit_snapshot("codex", 9)), + ("other".to_string(), rate_limit_snapshot("other", 20)), + ])), + }; + + let snapshots = app_server_rate_limit_snapshots(response); + + assert_eq!( + snapshots + .iter() + .map(|snapshot| snapshot.limit_id.as_deref()) + .collect::>(), + vec![Some("codex"), Some("other")] + ); + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); From 0fec042e0ca3c0cb778ff22b3205165d4a3bb3a5 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sun, 17 May 2026 00:24:40 -0700 Subject: [PATCH 12/18] Fix rate limit snapshot test lint --- codex-rs/tui/src/app_server_session.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index a7954d55f81f..ac8055c1a728 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1644,12 +1644,12 @@ mod tests { .expect("config should build") } - fn rate_limit_snapshot(limit_id: &str, used_percent: i32) -> RateLimitSnapshot { + fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot { RateLimitSnapshot { limit_id: Some(limit_id.to_string()), limit_name: None, primary: Some(codex_app_server_protocol::RateLimitWindow { - used_percent, + used_percent: 0, window_duration_mins: Some(10_080), resets_at: None, }), @@ -1663,10 +1663,10 @@ mod tests { #[test] fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() { let response = GetAccountRateLimitsResponse { - rate_limits: rate_limit_snapshot("codex", 9), + rate_limits: rate_limit_snapshot("codex"), rate_limits_by_limit_id: Some(HashMap::from([ - ("codex".to_string(), rate_limit_snapshot("codex", 9)), - ("other".to_string(), rate_limit_snapshot("other", 20)), + ("codex".to_string(), rate_limit_snapshot("codex")), + ("other".to_string(), rate_limit_snapshot("other")), ])), }; From edb60fd671283925eaf9a26d1ddd8f3fec38aa01 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sun, 17 May 2026 00:32:51 -0700 Subject: [PATCH 13/18] Hide unavailable rate limit preview items --- .../src/bottom_pane/status_surface_preview.rs | 10 ++++ .../tui/src/chatwidget/status_controls.rs | 23 ++++++-- .../tests/status_surface_previews.rs | 52 +++++++++++++++++-- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 26f72113c2e8..d3a5ca4d487f 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -169,6 +169,16 @@ impl StatusSurfacePreviewData { ); } + pub(crate) fn suppress_placeholder(&mut self, item: StatusSurfacePreviewItem) { + if self + .values + .get(&item) + .is_some_and(|value| value.is_placeholder) + { + self.values.remove(&item); + } + } + pub(crate) fn value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> { self.values.get(&item).map(|value| value.text.as_str()) } diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 70f213bee480..b1970d48dd7e 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -298,10 +298,25 @@ impl ChatWidget { } pub(super) fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { - StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { - self.status_surface_preview_value_for_item(item) - .map(|value| (item, value)) - })) + let mut preview_data = StatusSurfacePreviewData::from_iter( + StatusSurfacePreviewItem::iter().filter_map(|item| { + self.status_surface_preview_value_for_item(item) + .map(|value| (item, value)) + }), + ); + + if self.rate_limit_snapshots_by_limit_id.contains_key("codex") { + for item in [ + StatusSurfacePreviewItem::FiveHourLimit, + StatusSurfacePreviewItem::WeeklyLimit, + ] { + if self.status_surface_preview_value_for_item(item).is_none() { + preview_data.suppress_placeholder(item); + } + } + } + + preview_data } pub(super) fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 555b79a6201b..b9433ace8408 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -10,12 +10,15 @@ fn line_text(line: Line<'static>) -> String { .collect() } -fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String { +fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -> Option { let preview_data = chat.status_surface_preview_data(); - let preview = preview_data + preview_data .status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true) - .expect("status preview line"); - line_text(preview) + .map(line_text) +} + +fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String { + status_preview_line_option(chat, items).expect("status preview line") } fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> String { @@ -216,6 +219,47 @@ async fn status_surface_preview_lines_rate_limits_snapshot() { assert_chatwidget_snapshot!("status_surface_previews_rate_limits", snapshot); } +#[tokio::test] +async fn status_surface_preview_omits_unavailable_rate_limit_items() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 9, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: None, + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + status_preview_line_option(&mut chat, &[StatusLineItem::FiveHourLimit]), + None + ); + assert_eq!( + status_preview_line( + &mut chat, + &[StatusLineItem::FiveHourLimit, StatusLineItem::WeeklyLimit] + ), + "weekly 91%" + ); + assert_eq!( + title_preview_line( + &mut chat, + &[ + TerminalTitleItem::FiveHourLimit, + TerminalTitleItem::WeeklyLimit + ], + ), + "weekly 91%" + ); +} + #[tokio::test] async fn status_line_setup_popup_rate_limits_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 3e08635796fbcb4479a122b5a691cc1b5bd89f6d Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sun, 17 May 2026 00:52:42 -0700 Subject: [PATCH 14/18] Use live rate limit names in setup pickers --- .../tui/src/bottom_pane/status_line_setup.rs | 32 ++++++-- .../src/bottom_pane/status_surface_preview.rs | 76 +++++++++++++++++++ codex-rs/tui/src/bottom_pane/title_setup.rs | 29 +++++-- ...__status_line_setup_popup_rate_limits.snap | 4 +- ...erminal_title_setup_popup_rate_limits.snap | 16 ++-- 5 files changed, 137 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 44aaa0434677..2f23a0c41422 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -268,7 +268,11 @@ impl StatusLineSetupView { if !used_ids.insert(item_id.clone()) { continue; } - items.push(Self::status_line_select_item(item, /*enabled*/ true)); + items.push(Self::status_line_select_item( + item, + /*enabled*/ true, + &preview_data, + )); } } @@ -277,7 +281,11 @@ impl StatusLineSetupView { if used_ids.contains(&item_id) { continue; } - items.push(Self::status_line_select_item(item, /*enabled*/ false)); + items.push(Self::status_line_select_item( + item, + /*enabled*/ false, + &preview_data, + )); } Self { @@ -324,11 +332,25 @@ impl StatusLineSetupView { } /// Converts a [`StatusLineItem`] into a [`MultiSelectItem`] for the picker. - fn status_line_select_item(item: StatusLineItem, enabled: bool) -> MultiSelectItem { + fn status_line_select_item( + item: StatusLineItem, + enabled: bool, + preview_data: &StatusSurfacePreviewData, + ) -> MultiSelectItem { + let default_name = item.to_string(); + let default_description = item.description(); + let (name, description) = match item { + StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => ( + preview_data.rate_limit_item_name(item.preview_item(), &default_name), + preview_data.rate_limit_item_description(item.preview_item(), default_description), + ), + _ => (default_name, default_description.to_string()), + }; + MultiSelectItem { id: item.to_string(), - name: item.to_string(), - description: Some(item.description().to_string()), + name, + description: Some(description), enabled, orderable: true, section_break_after: false, diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index d3a5ca4d487f..71353c298adc 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -179,10 +179,39 @@ impl StatusSurfacePreviewData { } } + pub(crate) fn rate_limit_item_name( + &self, + item: StatusSurfacePreviewItem, + fallback: &str, + ) -> String { + self.live_value_for(item) + .and_then(rate_limit_preview_copy) + .map(|copy| copy.name.to_string()) + .unwrap_or_else(|| fallback.to_string()) + } + + pub(crate) fn rate_limit_item_description( + &self, + item: StatusSurfacePreviewItem, + fallback: &str, + ) -> String { + self.live_value_for(item) + .and_then(rate_limit_preview_copy) + .map(|copy| copy.description.to_string()) + .unwrap_or_else(|| fallback.to_string()) + } + pub(crate) fn value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> { self.values.get(&item).map(|value| value.text.as_str()) } + fn live_value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> { + self.values + .get(&item) + .filter(|value| !value.is_placeholder) + .map(|value| value.text.as_str()) + } + pub(crate) fn status_line_for_items( &self, items: I, @@ -198,3 +227,50 @@ impl StatusSurfacePreviewData { status_line_from_segments(segments, use_theme_colors) } } + +struct RateLimitPreviewCopy { + name: &'static str, + description: &'static str, +} + +fn rate_limit_preview_copy(value: &str) -> Option { + let value = value.trim_start(); + if value.starts_with("secondary usage ") { + Some(RateLimitPreviewCopy { + name: "secondary-usage-limit", + description: "Remaining usage on the secondary usage limit (omitted when unavailable)", + }) + } else if value.starts_with("usage ") { + Some(RateLimitPreviewCopy { + name: "usage-limit", + description: "Remaining usage on the primary usage limit (omitted when unavailable)", + }) + } else if value.starts_with("5h ") { + Some(RateLimitPreviewCopy { + name: "five-hour-limit", + description: "Remaining usage on the 5-hour usage limit (omitted when unavailable)", + }) + } else if value.starts_with("daily ") { + Some(RateLimitPreviewCopy { + name: "daily-limit", + description: "Remaining usage on the daily usage limit (omitted when unavailable)", + }) + } else if value.starts_with("weekly ") { + Some(RateLimitPreviewCopy { + name: "weekly-limit", + description: "Remaining usage on the weekly usage limit (omitted when unavailable)", + }) + } else if value.starts_with("monthly ") { + Some(RateLimitPreviewCopy { + name: "monthly-limit", + description: "Remaining usage on the monthly usage limit (omitted when unavailable)", + }) + } else if value.starts_with("annual ") { + Some(RateLimitPreviewCopy { + name: "annual-limit", + description: "Remaining usage on the annual usage limit (omitted when unavailable)", + }) + } else { + None + } +} diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index 70dbfb2073df..a0fa29231397 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -259,11 +259,13 @@ impl TerminalTitleSetupView { .collect::>(); let items = selected_items .into_iter() - .map(|item| Self::title_select_item(item, /*enabled*/ true)) + .map(|item| Self::title_select_item(item, /*enabled*/ true, &preview_data)) .chain( TerminalTitleItem::iter() .filter(|item| !selected_set.contains(item)) - .map(|item| Self::title_select_item(item, /*enabled*/ false)), + .map(|item| { + Self::title_select_item(item, /*enabled*/ false, &preview_data) + }), ) .collect(); @@ -309,11 +311,28 @@ impl TerminalTitleSetupView { } } - fn title_select_item(item: TerminalTitleItem, enabled: bool) -> MultiSelectItem { + fn title_select_item( + item: TerminalTitleItem, + enabled: bool, + preview_data: &StatusSurfacePreviewData, + ) -> MultiSelectItem { + let default_name = item.to_string(); + let default_description = item.description(); + let (name, description) = match item.preview_item() { + Some( + preview_item @ (StatusSurfacePreviewItem::FiveHourLimit + | StatusSurfacePreviewItem::WeeklyLimit), + ) => ( + preview_data.rate_limit_item_name(preview_item, &default_name), + preview_data.rate_limit_item_description(preview_item, default_description), + ), + _ => (default_name, default_description.to_string()), + }; + MultiSelectItem { id: item.to_string(), - name: item.to_string(), - description: Some(item.description().to_string()), + name, + description: Some(description), enabled, orderable: true, section_break_after: false, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap index e067b068088e..58d5065ff3aa 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_rate_limits.snap @@ -9,8 +9,8 @@ expression: status_line_popup_snapshot(&mut chat) > › [x] Use theme colors Apply colors from the active /theme ─────────────────────── - [x] five-hour-limit Remaining usage on the 5-hour or only usage limit (omitted when unava… - [x] weekly-limit Remaining usage on the weekly or secondary usage limit (omitted when … + [x] monthly-limit Remaining usage on the monthly usage limit (omitted when unavailable) + [x] weekly-limit Remaining usage on the weekly usage limit (omitted when unavailable) [ ] model Current model name [ ] model-with-reasoning Current model name with reasoning level [ ] current-dir Current working directory diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap index af9acd7634e5..e05b29555444 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap @@ -7,14 +7,14 @@ expression: terminal_title_popup_snapshot(&mut chat) Type to search > -› [x] five-hour-limit Remaining usage on the 5-hour or only usage limit (omitted when unavailabl… - [x] weekly-limit Remaining usage on the weekly or secondary usage limit (omitted when unava… - [ ] app-name Codex app name - [ ] project-name Project name (falls back to current directory name) - [ ] current-dir Current working directory - [ ] activity Spinner while working, action-required message while blocked. - [ ] run-state Compact session run-state text (Ready, Working, Thinking) - [ ] thread-title Current thread title, or thread identifier when unnamed +› [x] monthly-limit Remaining usage on the monthly usage limit (omitted when unavailable) + [x] weekly-limit Remaining usage on the weekly usage limit (omitted when unavailable) + [ ] app-name Codex app name + [ ] project-name Project name (falls back to current directory name) + [ ] current-dir Current working directory + [ ] activity Spinner while working, action-required message while blocked. + [ ] run-state Compact session run-state text (Ready, Working, Thinking) + [ ] thread-title Current thread title, or thread identifier when unnamed monthly 65% | weekly 50% Press space to toggle; ←/→ to move; enter to confirm and close; esc to close From 7796f7ddf6eee174701bb13aeff66dbbc9678f08 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Sun, 17 May 2026 22:39:38 -0700 Subject: [PATCH 15/18] Clarify rate limit setup descriptions --- codex-rs/tui/src/bottom_pane/status_line_setup.rs | 4 ++-- codex-rs/tui/src/bottom_pane/title_setup.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 2f23a0c41422..a44919c216ff 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -163,10 +163,10 @@ impl StatusLineItem { "Percentage of context window used (omitted when unknown)" } StatusLineItem::FiveHourLimit => { - "Remaining usage on the 5-hour or only usage limit (omitted when unavailable)" + "Remaining usage on the primary usage limit (omitted when unavailable)" } StatusLineItem::WeeklyLimit => { - "Remaining usage on the weekly or secondary usage limit (omitted when unavailable)" + "Remaining usage on the secondary usage limit (omitted when unavailable)" } StatusLineItem::CodexVersion => "Codex application version", StatusLineItem::ContextWindowSize => { diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index a0fa29231397..b9ab39054ec9 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -107,10 +107,10 @@ impl TerminalTitleItem { "Percentage of context window used (omitted when unknown)" } TerminalTitleItem::FiveHourLimit => { - "Remaining usage on the 5-hour or only usage limit (omitted when unavailable)" + "Remaining usage on the primary usage limit (omitted when unavailable)" } TerminalTitleItem::WeeklyLimit => { - "Remaining usage on the weekly or secondary usage limit (omitted when unavailable)" + "Remaining usage on the secondary usage limit (omitted when unavailable)" } TerminalTitleItem::CodexVersion => "Codex application version", TerminalTitleItem::UsedTokens => "Total tokens used in session (omitted when zero)", From 4ea06e18bb1fd5d95498c2c5660d2b7257ebc7e1 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Mon, 18 May 2026 10:50:34 -0700 Subject: [PATCH 16/18] Show secondary non-weekly status limits --- .../tui/src/chatwidget/status_surfaces.rs | 12 +++++++ .../src/chatwidget/tests/status_and_layout.rs | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 361fc6a83e52..e7ab1bc39b96 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -894,6 +894,7 @@ fn five_hour_status_window( ) -> Option<(&RateLimitWindowDisplay, bool)> { find_codex_window(snapshot, "5h") .or_else(|| non_weekly_primary_window(snapshot)) + .or_else(|| non_weekly_secondary_window(snapshot)) .or_else(|| only_non_weekly_codex_window(snapshot)) } @@ -934,6 +935,17 @@ fn non_weekly_primary_window( } } +fn non_weekly_secondary_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + let secondary = snapshot.secondary.as_ref()?; + if matches_window_label(secondary, "weekly") { + None + } else { + Some((secondary, true)) + } +} + fn only_non_weekly_codex_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { 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 064f1a3eaa9d..24141a289cc8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -623,6 +623,38 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() { ); } +#[tokio::test] +async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: Some(RateLimitWindow { + used_percent: 94, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }), + secondary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + Some("monthly 65%".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("weekly 6%".to_string()) + ); +} + #[tokio::test] async fn status_line_five_hour_item_omits_weekly_only_limit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 2518ba866ab133bcd935b0b5e4bf8f89ba7d3c16 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Mon, 18 May 2026 22:18:58 -0700 Subject: [PATCH 17/18] Avoid duplicate secondary rate limit status item --- .../tui/src/chatwidget/status_surfaces.rs | 57 ++++++++++++------- .../src/chatwidget/tests/status_and_layout.rs | 28 +++++++++ 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index e7ab1bc39b96..ca1f8a4ddad7 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -892,10 +892,10 @@ impl ChatWidget { fn five_hour_status_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { - find_codex_window(snapshot, "5h") + find_primary_codex_window(snapshot, "5h") + .or_else(|| secondary_window_with_label_when_weekly_is_available(snapshot, "5h")) .or_else(|| non_weekly_primary_window(snapshot)) - .or_else(|| non_weekly_secondary_window(snapshot)) - .or_else(|| only_non_weekly_codex_window(snapshot)) + .or_else(|| non_weekly_secondary_window_when_primary_is_weekly(snapshot)) } fn weekly_status_window( @@ -924,41 +924,58 @@ fn find_codex_window<'a>( None } -fn non_weekly_primary_window( - snapshot: &RateLimitSnapshotDisplay, -) -> Option<(&RateLimitWindowDisplay, bool)> { +fn find_primary_codex_window<'a>( + snapshot: &'a RateLimitSnapshotDisplay, + label: &str, +) -> Option<(&'a RateLimitWindowDisplay, bool)> { let primary = snapshot.primary.as_ref()?; - if matches_window_label(primary, "weekly") { + if matches_window_label(primary, label) { + Some((primary, false)) + } else { None + } +} + +fn secondary_window_with_label_when_weekly_is_available<'a>( + snapshot: &'a RateLimitSnapshotDisplay, + label: &str, +) -> Option<(&'a RateLimitWindowDisplay, bool)> { + if find_codex_window(snapshot, "weekly").is_none() { + return None; + } + + let secondary = snapshot.secondary.as_ref()?; + if matches_window_label(secondary, label) { + Some((secondary, true)) } else { - Some((primary, false)) + None } } -fn non_weekly_secondary_window( +fn non_weekly_primary_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { - let secondary = snapshot.secondary.as_ref()?; - if matches_window_label(secondary, "weekly") { + let primary = snapshot.primary.as_ref()?; + if matches_window_label(primary, "weekly") { None } else { - Some((secondary, true)) + Some((primary, false)) } } -fn only_non_weekly_codex_window( +fn non_weekly_secondary_window_when_primary_is_weekly( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { - let (window, is_secondary) = match (snapshot.primary.as_ref(), snapshot.secondary.as_ref()) { - (Some(primary), None) => Some((primary, false)), - (None, Some(secondary)) => Some((secondary, true)), - _ => None, - }?; + let primary = snapshot.primary.as_ref()?; + if !matches_window_label(primary, "weekly") { + return None; + } - if matches_window_label(window, "weekly") { + let secondary = snapshot.secondary.as_ref()?; + if matches_window_label(secondary, "weekly") { None } else { - Some((window, is_secondary)) + Some((secondary, true)) } } 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 24141a289cc8..20e26f884bf4 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -711,6 +711,34 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() { ); } +#[tokio::test] +async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: Some(RateLimitWindow { + used_percent: 35, + window_duration_mins: Some(30 * 24 * 60), + resets_at: None, + }), + credits: None, + plan_type: None, + rate_limit_reached_type: None, + })); + + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), + None + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), + Some("monthly 65%".to_string()) + ); +} + #[tokio::test] async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 2cc0cfb7276c8c45a049588b842bd3f94d339304 Mon Sep 17 00:00:00 2001 From: Arun Eswara Date: Mon, 18 May 2026 22:47:38 -0700 Subject: [PATCH 18/18] Fix clippy rate limit guard --- codex-rs/tui/src/chatwidget/status_surfaces.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index ca1f8a4ddad7..2b2b7d52df3a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -940,9 +940,7 @@ fn secondary_window_with_label_when_weekly_is_available<'a>( snapshot: &'a RateLimitSnapshotDisplay, label: &str, ) -> Option<(&'a RateLimitWindowDisplay, bool)> { - if find_codex_window(snapshot, "weekly").is_none() { - return None; - } + find_codex_window(snapshot, "weekly")?; let secondary = snapshot.secondary.as_ref()?; if matches_window_label(secondary, label) {