diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index db6dc9e9a878..ac8055c1a728 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) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some(limit_id.to_string()), + limit_name: None, + primary: Some(codex_app_server_protocol::RateLimitWindow { + used_percent: 0, + 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"), + rate_limits_by_limit_id: Some(HashMap::from([ + ("codex".to_string(), rate_limit_snapshot("codex")), + ("other".to_string(), rate_limit_snapshot("other")), + ])), + }; + + 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"); 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..a44919c216ff 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 => { @@ -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 9f0f8ed5fbb2..71353c298adc 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", @@ -169,10 +169,49 @@ 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 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, @@ -188,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 f37991bc0024..b9ab39054ec9 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)", @@ -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.rs b/codex-rs/tui/src/chatwidget.rs index 207c5475626e..48f47c898d5f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -375,8 +375,9 @@ 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::get_limits_duration; +pub(crate) use self::rate_limits::fallback_limit_label; 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 7eaca908c647..544611f4f0fd 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 { @@ -40,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(|| "weekly".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." @@ -59,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(|| "5h".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." @@ -73,28 +73,51 @@ 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 ROUNDING_BIAS_MINUTES: i64 = 3; + const MINUTES_PER_YEAR: i64 = 365 * MINUTES_PER_DAY; let windows_minutes = windows_minutes.max(0); - 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() + if is_approximate_window(windows_minutes, MINUTES_PER_5_HOURS) { + Some("5h".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_DAY) { + Some("daily".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_WEEK) { + Some("weekly".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_MONTH) { + Some("monthly".to_string()) + } else if is_approximate_window(windows_minutes, MINUTES_PER_YEAR) { + Some("annual".to_string()) + } else { + None + } +} + +pub(crate) fn fallback_limit_label(is_secondary: bool) -> &'static str { + if is_secondary { + SECONDARY_LIMIT_FALLBACK_LABEL } else { - "annual".to_string() + PRIMARY_LIMIT_FALLBACK_LABEL } } +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)] pub(super) enum RateLimitSwitchPromptState { #[default] 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..58d5065ff3aa --- /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] 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 + [ ] project-name Project name (omitted when unavailable) + + 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 new file mode 100644 index 000000000000..eb6ec60729ab --- /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% · 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 new file mode 100644 index 000000000000..e05b29555444 --- /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] 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 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/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index e34fe4688a91..2b2b7d52df3a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -6,6 +6,8 @@ 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; @@ -603,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 = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - 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 = window - .and_then(|window| window.window_minutes) - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - 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 @@ -893,6 +889,102 @@ impl ChatWidget { } } +fn five_hour_status_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + 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_when_primary_is_weekly(snapshot)) +} + +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 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, 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)> { + find_codex_window(snapshot, "weekly")?; + + let secondary = snapshot.secondary.as_ref()?; + if matches_window_label(secondary, label) { + Some((secondary, true)) + } else { + None + } +} + +fn non_weekly_primary_window( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + let primary = snapshot.primary.as_ref()?; + if matches_window_label(primary, "weekly") { + None + } else { + Some((primary, false)) + } +} + +fn non_weekly_secondary_window_when_primary_is_weekly( + snapshot: &RateLimitSnapshotDisplay, +) -> Option<(&RateLimitWindowDisplay, bool)> { + let primary = snapshot.primary.as_ref()?; + if !matches_window_label(primary, "weekly") { + return None; + } + + let secondary = snapshot.secondary.as_ref()?; + if matches_window_label(secondary, "weekly") { + None + } else { + Some((secondary, true)) + } +} + +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 acd6b7111bc2..20e26f884bf4 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,227 @@ async fn test_rate_limit_warnings_monthly() { ); } +#[test] +fn rate_limit_duration_labels_only_render_supported_windows() { + 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] +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 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 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_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; + + 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; + + 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 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; 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..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 { @@ -58,6 +61,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(7 * 24 * 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 +202,79 @@ 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_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; + 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 +343,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/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 3be813beb9cc..1146bb8a315a 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -5,7 +5,8 @@ //! //! 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::get_limits_duration; +use crate::chatwidget::fallback_limit_label; +use crate::chatwidget::limit_label_for_window; use crate::text_formatting::capitalize_first; use super::helpers::format_reset_timestamp; @@ -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, @@ -188,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(|| "5h".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(|| "weekly".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()); @@ -220,12 +213,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(/*is_secondary*/ false) + )) ) } else { format!( "{} limit", - primary_label.clone().unwrap_or_else(|| "5h".to_string()) + primary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ false) + )) ) }; rows.push(StatusRateLimitRow { @@ -242,16 +239,16 @@ pub(crate) fn compose_rate_limit_data_many( format!( "{} {} limit", limit_bucket_label, - secondary_label - .clone() - .unwrap_or_else(|| "weekly".to_string()) + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) ) } else { format!( "{} limit", - secondary_label - .clone() - .unwrap_or_else(|| "weekly".to_string()) + secondary_label.clone().unwrap_or_else(|| capitalize_first( + fallback_limit_label(/*is_secondary*/ true) + )) ) }; rows.push(StatusRateLimitRow { @@ -420,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, }; @@ -434,8 +431,8 @@ mod tests { labels, vec![ "codex-other limit".to_string(), - "1h limit".to_string(), - "Weekly limit".to_string(), + "Usage limit".to_string(), + "Secondary usage limit".to_string(), ] ); } 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");