diff --git a/codex-rs/tui/src/chatwidget/statusline_state/account.rs b/codex-rs/tui/src/chatwidget/statusline_state/account.rs index b52e056028..d266874455 100644 --- a/codex-rs/tui/src/chatwidget/statusline_state/account.rs +++ b/codex-rs/tui/src/chatwidget/statusline_state/account.rs @@ -1,10 +1,13 @@ //! Projects the account values displayed by the built-in status line. use super::ChatWidget; +use crate::status::RateLimitWindowDisplay; +use codex_protocol::protocol::RateLimitWindow; use serde::Serialize; #[derive(Serialize)] pub(super) struct Account { + rate_limits: Vec, #[serde(skip_serializing_if = "Option::is_none")] workspace: Option, } @@ -14,9 +17,40 @@ struct Workspace { headline: String, } +#[derive(Serialize)] +struct RateLimit { + limit_id: &'static str, + limit_name: Option, + primary_limit: Option, + secondary_limit: Option, +} + +impl From<&RateLimitWindowDisplay> for RateLimitWindow { + fn from(window: &RateLimitWindowDisplay) -> Self { + Self { + used_percent: window.used_percent, + window_minutes: window.window_minutes, + resets_at: window.resets_at_unix, + } + } +} + impl From<&ChatWidget> for Account { fn from(chat: &ChatWidget) -> Self { + let codex = chat + .rate_limit_snapshots_by_limit_id + .get("codex") + .filter(|sample| sample.primary.is_some() || sample.secondary.is_some()); Self { + rate_limits: codex + .into_iter() + .map(|sample| RateLimit { + limit_id: "codex", + limit_name: (!sample.limit_name.is_empty()).then(|| sample.limit_name.clone()), + primary_limit: sample.primary.as_ref().map(Into::into), + secondary_limit: sample.secondary.as_ref().map(Into::into), + }) + .collect(), workspace: chat .status_line_workspace_headline .clone() diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c752998aad..b945dbc557 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1339,6 +1339,8 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { limit_id: Some("codex".to_string()), limit_name: Some("codex".to_string()), @@ -1348,7 +1350,11 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { window_duration_mins: Some(300), resets_at: Some(100), }), - secondary: None, + secondary: Some(RateLimitWindow { + used_percent: 8, + window_duration_mins: Some(10_080), + resets_at: None, + }), credits: Some(CreditsSnapshot { has_credits: true, unlimited: false, @@ -1377,6 +1383,15 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { rate_limit_reached_type: None, })); + let state = serde_json::to_value(chat.statusline_state()).unwrap(); + assert_eq!( + state["account"]["rate_limits"], + serde_json::json!([{ + "limit_id": "codex", "limit_name": "codex", + "primary_limit": {"used_percent": 20.0, "window_minutes": 300, "resets_at": 100}, + "secondary_limit": {"used_percent": 8.0, "window_minutes": 10_080, "resets_at": null} + }]) + ); let codex = chat .rate_limit_snapshots_by_limit_id .get("codex") @@ -3983,10 +3998,8 @@ async fn status_line_workspace_headline_renders_cached_value() { ); let state = serde_json::to_value(chat.statusline_state()).unwrap(); assert_eq!( - state["account"], - serde_json::json!({ - "workspace": {"headline": "Workspace maintenance starts at 5pm"} - }) + state["account"]["workspace"], + serde_json::json!({"headline": "Workspace maintenance starts at 5pm"}) ); assert!( drain_insert_history(&mut rx).is_empty(), diff --git a/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs b/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs index 4a31fa5586..46752ff475 100644 --- a/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/statusline_state_tests.rs @@ -30,7 +30,7 @@ async fn statusline_state_projects_the_displayed_settings() { assert_eq!( serde_json::to_value(chat.statusline_state()).unwrap(), json!({ - "account": {}, + "account": {"rate_limits": []}, "app": {"cli_version": CODEX_CLI_VERSION, "display_mode": "standard"}, "config": {"statusline_items": ["run-state"]}, "thread": { diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index c22868b621..97afbf113b 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -70,6 +70,8 @@ pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15; pub(crate) struct RateLimitWindowDisplay { /// Percent used for the window. pub used_percent: f64, + /// Unix time from the server, before formatting for the local clock. + pub resets_at_unix: Option, /// Human-readable local reset time. pub resets_at: Option, /// Window length in minutes when provided by the server. @@ -91,6 +93,7 @@ impl RateLimitWindowDisplay { Self { used_percent: f64::from(window.used_percent), + resets_at_unix: window.resets_at, resets_at, window_minutes: window.window_duration_mins, } @@ -459,6 +462,7 @@ mod tests { fn window(used_percent: f64) -> RateLimitWindowDisplay { RateLimitWindowDisplay { used_percent, + resets_at_unix: None, resets_at: Some("soon".to_string()), window_minutes: Some(300), } @@ -521,11 +525,13 @@ mod tests { captured_at: now, primary: Some(RateLimitWindowDisplay { used_percent: 20.0, + resets_at_unix: None, resets_at: Some("soon".to_string()), window_minutes: Some(60), }), secondary: Some(RateLimitWindowDisplay { used_percent: 40.0, + resets_at_unix: None, resets_at: Some("later".to_string()), window_minutes: Some(2 * 60), }), diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 1301926691..967a8928fa 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -69,6 +69,7 @@ fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() { captured_at: now, primary: Some(RateLimitWindowDisplay { used_percent: 20.0, + resets_at_unix: None, resets_at: Some("soon".to_string()), window_minutes: Some(300), }), @@ -1910,11 +1911,13 @@ async fn transcript_overlay_remeasures_status_after_rate_limit_refresh() { captured_at: now, primary: Some(RateLimitWindowDisplay { used_percent: 45.0, + resets_at_unix: None, resets_at: Some("soon".to_string()), window_minutes: Some(300), }), secondary: Some(RateLimitWindowDisplay { used_percent: 30.0, + resets_at_unix: None, resets_at: Some("later".to_string()), window_minutes: Some(10_080), }),