Harden CLI rate limit window labels (#22929)

## Context

The CLI rate-limit surfaces previously described usage windows as fixed
5-hour and weekly limits. We want the CLI to display whatever supported
rate-limit period the server returns instead of assuming a 5-hour/1-week
pair. This supports generalized Codex rate-limit periods.

## Summary

- Formats CLI rate-limit warning/status labels only for the supported
returned window durations: approximate 5h, daily, weekly, monthly, and
annual.
- Uses generic fallback copy when a primary or secondary window has no
duration, so missing secondary protection data does not produce stale
weekly copy.
- Uses generic fallback copy for unsupported window durations instead of
adding arbitrary hourly, multi-day, multi-week, or multi-year labels.
- Updates status line and terminal title setup descriptions/previews to
talk about primary/secondary usage limits rather than fixed 5h/weekly
limits.
- Adds rendered insta snapshot coverage for the updated rate-limit
status surfaces and `/status` fallback labels.

## Tests
Tested locally:
- one primary window
- one secondary window
- primary and secondary window
This commit is contained in:
Arun Eswara
2026-05-19 11:22:00 -07:00
committed by GitHub
Unverified
parent 3c76081876
commit d269aa2af9
16 changed files with 860 additions and 89 deletions
+49 -3
View File
@@ -1588,10 +1588,19 @@ async fn thread_session_state_from_thread_response(
pub(crate) fn app_server_rate_limit_snapshots(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
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
}
@@ -1631,6 +1640,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<_>>(),
vec![Some("codex"), Some("other")]
);
}
#[tokio::test]
async fn thread_start_params_include_cwd_for_embedded_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
@@ -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,
@@ -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<I>(
&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<RateLimitPreviewCopy> {
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
}
}
+28 -9
View File
@@ -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::<std::collections::HashSet<_>>();
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,
+2 -1
View File
@@ -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;
+40 -17
View File
@@ -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<i64>, 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<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 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 {
"annual".to_string()
None
}
}
pub(crate) fn fallback_limit_label(is_secondary: bool) -> &'static str {
if is_secondary {
SECONDARY_LIMIT_FALLBACK_LABEL
} else {
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]
@@ -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
@@ -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%
@@ -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
+19 -4
View File
@@ -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 {
+106 -14
View File
@@ -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()
@@ -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;
@@ -516,6 +517,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;
@@ -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<String> {
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;
+22 -25
View File
@@ -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<Local>,
/// Primary usage window (typically short duration).
/// Primary usage window.
pub primary: Option<RateLimitWindowDisplay>,
/// Secondary usage window (typically weekly).
/// Secondary usage window.
pub secondary: Option<RateLimitWindowDisplay>,
/// Optional credits metadata when available.
pub credits: Option<CreditsSnapshotDisplay>,
@@ -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(),
]
);
}
@@ -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: <none> │
│ │
│ 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) │
╰──────────────────────────────────────────────────────────────────────────────╯
+67
View File
@@ -878,6 +878,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");