mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
3c76081876
commit
d269aa2af9
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
+20
@@ -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
|
||||
+6
@@ -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%
|
||||
+20
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
+24
@@ -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) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user