Harden CLI rate limit window labels

This commit is contained in:
Arun Eswara
2026-05-15 16:46:18 -07:00
parent bbb5c2811d
commit 6a0a112152
8 changed files with 121 additions and 31 deletions

View File

@@ -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 => {

View File

@@ -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",

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)",

View File

@@ -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;

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 {
@@ -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<i64> {
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
}
}

View File

@@ -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()),

View File

@@ -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;

View File

@@ -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<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>,
@@ -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(),
]
);
}