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 5ce13cd214..bc12580cad 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 9f0f8ed5fb..26f72113c2 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 f37991bc00..be4e9c97da 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 207c547562..4b2ea6a9a6 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 7eaca908c6..d72fa24ed7 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 { - "annual".to_string() + 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 { + None } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index e34fe4688a..df28a59a4a 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 acd6b7111b..8440da83ca 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 3be813beb9..f8183e72f0 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(), ] ); }