diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index c60f4dd3d8..06b1995d77 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -36,6 +36,7 @@ use std::fmt; pub(crate) mod analytics; mod chatgpt_turn_cost; +pub(crate) mod plan_history; mod rate_limit_resets; mod thread_usage; pub(crate) mod turn_usage; diff --git a/codex-rs/backend-client/src/client/plan_history.rs b/codex-rs/backend-client/src/client/plan_history.rs new file mode 100644 index 0000000000..875c450168 --- /dev/null +++ b/codex-rs/backend-client/src/client/plan_history.rs @@ -0,0 +1,85 @@ +//! Seven-day allowance snapshots, preserving historical limits and unknown accounting. + +use super::Client; +use super::PathStyle; +use super::RequestError; +use http::Method; +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct PlanLimitHistory { + pub data_as_of: Option, + pub coverage_start: Option, + pub coverage_complete: bool, + #[serde(default = "approximate_by_default")] + pub approximate: bool, + pub boundary_tolerance_seconds: Option, + pub periods: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct PlanLimitPeriod { + pub id: String, + pub window_minutes: u32, + pub plan_type: String, + pub starts_at: String, + pub ends_at: String, + pub accounting_complete: bool, + /// Hundredths of a percent of this period's historical allowance; null is unknown. + pub used_basis_points: Option, + pub breakdowns: Option>, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PlanLimitDimension { + ThreadSource, + TurnTrigger, + Model, + Surface, + /// Additive server dimensions must not discard the supported breakdowns. + #[serde(other)] + Unknown, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct PlanLimitBreakdown { + pub dimension: PlanLimitDimension, + pub rows: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct PlanLimitValue { + pub key: String, + pub basis_points: f64, +} + +fn approximate_by_default() -> bool { + true +} + +impl Client { + /// Fetches seven days of snapshot history. An undeployed route has no report. + pub async fn get_plan_limit_history(&self) -> Result, RequestError> { + let prefix = match self.path_style { + PathStyle::CodexApi => "api/codex", + PathStyle::ChatGptApi => "wham", + }; + let url = format!("{}/{prefix}/usage/plan_limit_history?days=7", self.base_url); + let request = self.request(Method::GET, &url).headers(self.headers()); + let (body, _) = match self.exec_request_detailed(request, "GET", &url).await { + Ok(response) => response, + Err(error) if error.status().is_some_and(|status| status.as_u16() == 404) => { + return Ok(None); + } + Err(error) => return Err(error), + }; + serde_json::from_str(&body) + .map(Some) + .map_err(|_| RequestError::Other(anyhow::anyhow!("Invalid plan history response."))) + } +} + +#[cfg(test)] +#[path = "plan_history_tests.rs"] +mod tests; diff --git a/codex-rs/backend-client/src/client/plan_history_tests.rs b/codex-rs/backend-client/src/client/plan_history_tests.rs new file mode 100644 index 0000000000..ae7302e374 --- /dev/null +++ b/codex-rs/backend-client/src/client/plan_history_tests.rs @@ -0,0 +1,51 @@ +//! Plan history's undeployed route and wire defaults remain distinct from failures. +use super::*; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +#[tokio::test] +async fn plan_history_handles_unavailable_errors_and_approximation_default() { + let server = MockServer::start().await; + let client = Client::new( + server.uri(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); + for status in [404, 503, 200] { + server.reset().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage/plan_limit_history")) + .and(query_param("days", "7")) + .respond_with(ResponseTemplate::new(status).set_body_json(json!({ + "data_as_of": null, "coverage_start": null, "coverage_complete": false, + "boundary_tolerance_seconds": null, "periods": [] + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let response = client.get_plan_limit_history().await; + match status { + 404 => assert_eq!(response.unwrap(), None), + 503 => assert_eq!(response.unwrap_err().status().unwrap().as_u16(), status), + 200 => assert_eq!( + response.unwrap(), + Some(PlanLimitHistory { + data_as_of: None, + coverage_start: None, + coverage_complete: false, + approximate: true, + boundary_tolerance_seconds: None, + periods: vec![], + }) + ), + _ => unreachable!(), + } + } +} diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 2cb459de7d..4df5b36eca 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -42,4 +42,9 @@ pub use types::TokenUsageProfileDailyBucket; pub use types::TokenUsageProfileStats; pub use types::TurnAttemptsSiblingTurnsResponse; +pub use client::plan_history::PlanLimitBreakdown; +pub use client::plan_history::PlanLimitDimension; +pub use client::plan_history::PlanLimitHistory; +pub use client::plan_history::PlanLimitPeriod; +pub use client::plan_history::PlanLimitValue; pub use codex_backend_openapi_models::models::analytics as analytics_models; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b53c690aea..724a0ccbf9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -635,6 +635,9 @@ "default": null, "description": "Optional feature toggles scoped to this profile.", "properties": { + "analytics_plan_history": { + "type": "boolean" + }, "api_key_model_discovery": { "type": "boolean" }, @@ -6263,6 +6266,9 @@ "default": null, "description": "Centralized feature flags (new). Prefer this over individual toggles.", "properties": { + "analytics_plan_history": { + "type": "boolean" + }, "api_key_model_discovery": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 399f19978c..9c1b8dfec0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -91,6 +91,8 @@ impl Stage { /// Unique features toggled via configuration. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Feature { + /// Preview consumer five-hour and weekly allowance history. + AnalyticsPlanHistory, /// Discover model catalogs for OpenAI API-key authentication. ApiKeyModelDiscovery, /// Enable the interactive transcript composer and turn-selection UI. @@ -906,6 +908,12 @@ pub struct FeatureSpec { } pub const FEATURES: &[FeatureSpec] = &[ + FeatureSpec { + id: Feature::AnalyticsPlanHistory, + key: "analytics_plan_history", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::TranscriptV2, key: "transcript_v2", diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs index bd434a3f96..f79c3bcf43 100644 --- a/codex-rs/tui/src/analytics.rs +++ b/codex-rs/tui/src/analytics.rs @@ -13,6 +13,8 @@ mod fixture; mod models; mod normalize; mod panels; +mod plan; +mod plan_panel; mod plot; mod render; mod report_data; @@ -47,6 +49,7 @@ pub(crate) struct AnalyticsView { model_names: std::collections::HashMap, sections: SectionStates, chats: Load, + plan: plan::State, show_zero_credit_groups: bool, account: Load, reports_started: bool, @@ -75,6 +78,7 @@ impl AnalyticsView { model_names: std::collections::HashMap::new(), sections: SectionStates(std::array::from_fn(|_| SectionState::default())), chats: Load::Unavailable, + plan: plan::State::default(), show_zero_credit_groups: false, account: Load::Unavailable, reports_started: false, @@ -93,6 +97,7 @@ impl AnalyticsView { is_done: false, }; view.sections[Section::Usage].group = 1; + view.sections[Section::Plan].group = 1; view.sections[Section::Chats].cursor = 0; view.sections[Section::Activity].group = 2; view.refresh(); @@ -110,6 +115,9 @@ impl AnalyticsView { .into_iter() .map(|model| (model.model, model.display_name)) .collect(); + self.plan.enabled = config + .features + .enabled(codex_features::Feature::AnalyticsPlanHistory); self.connection = Some((config, handle, frame)); self.is_done = false; self.refresh(); @@ -129,6 +137,7 @@ impl AnalyticsView { } self.sections[Section::Chats].detail = None; self.chats = Load::Unavailable; + self.plan.report = Load::Unavailable; self.reports_started = false; self.token_model = None; self.account = if let (Some((_, _, frame)), Some(live)) = (&self.connection, &self.live) { @@ -159,6 +168,7 @@ impl AnalyticsView { section.history = Load::Unavailable; } self.chats = Load::Unavailable; + self.plan.report = Load::Unavailable; self.account = Load::Unavailable; self.live = None; self.connection = None; @@ -199,6 +209,7 @@ impl AnalyticsView { return &[]; } match self.section { + Section::Plan => &[1, 2, 0, 3], Section::Usage if self.business() => &[6, 2], Section::Usage if self.consumer_attribution() => &[1, 2, 0, 3], Section::Usage => &[0, 2], @@ -226,6 +237,7 @@ impl AnalyticsView { Section::Activity => "Messages", Section::Skills => "Skills used", Section::Chats => "Top chats", + Section::Plan => "Plan usage history", } } @@ -335,7 +347,7 @@ impl AnalyticsView { return; } if key_hint::plain(KeyCode::Char('r')).is_press(key) - && self.section != Section::Chats + && !matches!(self.section, Section::Chats | Section::Plan) && !self.visible_sections().is_empty() { self.change_range(); @@ -373,6 +385,10 @@ impl AnalyticsView { self.group_picker = None; return; } + if self.section == Section::Plan { + self.plan_action(action); + return; + } if matches!( self.section, Section::Plugins | Section::Activity | Section::Skills @@ -447,6 +463,7 @@ impl AnalyticsView { } } self.chats = Load::Unavailable; + self.plan.report = Load::Unavailable; self.account = Load::Unavailable; self.connection = None; } diff --git a/codex-rs/tui/src/analytics/account_layout_tests.rs b/codex-rs/tui/src/analytics/account_layout_tests.rs index 9f485452d8..dfcdc69439 100644 --- a/codex-rs/tui/src/analytics/account_layout_tests.rs +++ b/codex-rs/tui/src/analytics/account_layout_tests.rs @@ -66,7 +66,7 @@ async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() { view.sections[Section::Usage].detail, view.sections[Section::Usage].group ), - ([1, 0, 0], [28, 6, 6, 0, 6, 6], Some(28), 2) + ([1, 0, 0], [28, 6, 6, 0, 6, 6, 6], Some(28), 2) ); let context = ( view.ranges, @@ -95,7 +95,7 @@ async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() { .0 .each_ref() .map(|state| state.history.ready().map(|history| history.data.len())), - [Some(30), Some(7), None, None, Some(30), Some(7)] + [Some(30), Some(7), None, None, Some(30), Some(7), None] ); let server = test_support::server().await; let (_business_home, _business_server, mut view) = @@ -112,7 +112,7 @@ async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() { .0 .each_ref() .map(|state| state.history.ready().map(|history| history.data.len())), - [Some(30), Some(7), Some(7), None, None, Some(7)] + [Some(30), Some(7), Some(7), None, None, Some(7), None] ); } @@ -222,9 +222,9 @@ async fn account_reports_request_only_eligible_endpoints_and_refresh() { assert_eq!( ready, if plan == "plus" { - [true, true, false, false, true, true] + [true, true, false, false, true, true, false] } else { - [true, true, true, false, false, true] + [true, true, true, false, false, true, false] } ); press(&mut view, KeyCode::Char('R')); diff --git a/codex-rs/tui/src/analytics/chart.rs b/codex-rs/tui/src/analytics/chart.rs index a9811f485c..448b7ab73c 100644 --- a/codex-rs/tui/src/analytics/chart.rs +++ b/codex-rs/tui/src/analytics/chart.rs @@ -93,7 +93,7 @@ impl AnalyticsView { Section::Activity => "messages", Section::Plugins => "calls", Section::Skills => "uses", - Section::Usage | Section::Credits | Section::Chats => "credits", + Section::Usage | Section::Credits | Section::Chats | Section::Plan => "credits", }; let empty_breakdown = matches!( section, @@ -194,7 +194,7 @@ impl AnalyticsView { Section::Plugins => "Plugin", Section::Skills => "Skill", Section::Chats => "Chat", - Section::Usage | Section::Credits | Section::Activity => { + Section::Usage | Section::Credits | Section::Activity | Section::Plan => { self.group_label(section, self.sections[section].group) } } diff --git a/codex-rs/tui/src/analytics/client.rs b/codex-rs/tui/src/analytics/client.rs index 084f82244c..df21094f05 100644 --- a/codex-rs/tui/src/analytics/client.rs +++ b/codex-rs/tui/src/analytics/client.rs @@ -95,6 +95,23 @@ impl Live { .await } + pub(super) async fn plan_history(&self) -> Result, String> { + let session = self.session().await?; + if session.kind != AccountKind::Consumer { + return Ok(None); + } + let history = session + .backend + .request(|client| async move { client.get_plan_limit_history().await }) + .await; + session.backend.ensure_identity().await?; + history + .map_err(request_error)? + .map(super::plan::Report::parse) + .transpose() + .map(Option::flatten) + } + pub(super) async fn history( &self, report: Report, diff --git a/codex-rs/tui/src/analytics/client_tests.rs b/codex-rs/tui/src/analytics/client_tests.rs index 8ecca93778..3c6181e442 100644 --- a/codex-rs/tui/src/analytics/client_tests.rs +++ b/codex-rs/tui/src/analytics/client_tests.rs @@ -819,3 +819,30 @@ async fn live_account_identity_is_visible_and_closing_aborts_pending_loads() { view.cancel_loads(); assert!(cancelled_rx.await.is_err()); } + +#[tokio::test] +async fn plan_history_rejects_account_change_during_response() { + let server = MockServer::start().await; + for status in [200, 404, 503] { + server.reset().await; + let (home, live) = live(&server, "plus").await; + let home_path = home.path().to_path_buf(); + Mock::given(method("GET")) + .and(path("/backend-api/wham/usage/plan_limit_history")) + .and(header("chatgpt-account-id", "account-a")) + .respond_with(move |_: &wiremock::Request| { + sign_in(&home_path, "account-a", "user-b", "plus"); + ResponseTemplate::new(status).set_body_json(json!({ + "data_as_of":"2026-09-02T00:00:00Z", "coverage_start":null, + "coverage_complete":true, "periods":[] + })) + }) + .expect(/*r*/ 1) + .mount(&server) + .await; + assert_eq!( + live.plan_history().await.err().unwrap(), + "Account changed. Press R to refresh Analytics." + ); + } +} diff --git a/codex-rs/tui/src/analytics/dashboard.rs b/codex-rs/tui/src/analytics/dashboard.rs index f8022bf4c9..d86828df03 100644 --- a/codex-rs/tui/src/analytics/dashboard.rs +++ b/codex-rs/tui/src/analytics/dashboard.rs @@ -43,7 +43,7 @@ impl AnalyticsView { secondary_style().dim() }; let mut content = Vec::new(); - if !matches!(section, Section::Chats) { + if !matches!(section, Section::Chats | Section::Plan) { content.push( format!( "{}d{}", @@ -67,7 +67,7 @@ impl AnalyticsView { } if matches!( section, - Section::Usage | Section::Credits | Section::Activity + Section::Usage | Section::Credits | Section::Activity | Section::Plan ) { content.push( format!( @@ -112,6 +112,7 @@ impl AnalyticsView { ) .lines, ), + Section::Plan => content.extend(self.plan_lines(inner_width).0), Section::Chats => { content.push( "30d active · lifetime credits" diff --git a/codex-rs/tui/src/analytics/panels.rs b/codex-rs/tui/src/analytics/panels.rs index 99f2412792..4c45b68f2e 100644 --- a/codex-rs/tui/src/analytics/panels.rs +++ b/codex-rs/tui/src/analytics/panels.rs @@ -35,7 +35,7 @@ impl AnalyticsView { let group = self.sections[section].group; let heading = if matches!( section, - Section::Usage | Section::Credits | Section::Activity + Section::Usage | Section::Credits | Section::Activity | Section::Plan ) { columns( heading, @@ -79,7 +79,11 @@ impl AnalyticsView { }), ); } - if section == Section::Chats { + if section == Section::Plan { + let (plan, detail) = self.plan_lines(width); + selection = lines.len() + detail.start..lines.len() + detail.end; + lines.extend(plan); + } else if section == Section::Chats { let (chats, detail) = self.chat_lines(width); selection = lines.len() + detail.start..lines.len() + detail.end; lines.extend(chats); diff --git a/codex-rs/tui/src/analytics/plan.rs b/codex-rs/tui/src/analytics/plan.rs new file mode 100644 index 0000000000..d4d9d1ec2b --- /dev/null +++ b/codex-rs/tui/src/analytics/plan.rs @@ -0,0 +1,121 @@ +//! Historical allowance snapshots, separate from daily usage and current chat allowances. +//! Period IDs are snapshot-scoped; unknown accounting is never converted to zero. +use super::data::Load; +use chrono::DateTime; +use chrono::Utc; +use codex_backend_client::PlanLimitBreakdown; +use codex_backend_client::PlanLimitHistory; + +pub(super) struct Period { + pub id: String, + pub start: DateTime, + pub end: DateTime, + pub complete: bool, + pub used: Option, + pub breakdowns: Option>, +} + +pub(super) struct Report { + pub as_of: DateTime, + pub coverage_start: Option>, + pub coverage_complete: bool, + pub approximate: bool, + pub periods: [Vec; 2], +} + +impl Report { + pub(super) fn parse(history: PlanLimitHistory) -> Result, String> { + let parse = |value: &str| { + DateTime::parse_from_rfc3339(value) + .map(|date| date.with_timezone(&Utc)) + .map_err(|_| "Invalid plan history timestamp.".to_string()) + }; + let Some(as_of) = history.data_as_of.as_deref().map(parse).transpose()? else { + return Ok(None); + }; + let mut periods = [Vec::new(), Vec::new()]; + let mut ids = std::collections::HashSet::new(); + if history.periods.len() > 1_000 { + return Err("Plan history contains too many periods.".into()); + } + for period in history.periods { + let window = match period.window_minutes { + 300 => 0, + 10080 => 1, + _ => return Err("Unsupported plan history window.".into()), + }; + let start = parse(&period.starts_at)?; + let end = parse(&period.ends_at)?; + if start >= end + || !ids.insert(period.id.clone()) + || period + .used_basis_points + .is_some_and(|amount| !amount.is_finite()) + || period + .breakdowns + .iter() + .flatten() + .flat_map(|group| &group.rows) + .any(|row| !row.basis_points.is_finite()) + { + return Err("Invalid plan history period.".into()); + } + periods[window].push(Period { + id: period.id, + start, + end, + complete: period.accounting_complete, + used: period.used_basis_points, + breakdowns: period.breakdowns, + }); + } + for window in &mut periods { + window.sort_by_key(|period| std::cmp::Reverse(period.start)); + } + Ok(Some(Self { + as_of, + coverage_start: history.coverage_start.as_deref().map(parse).transpose()?, + coverage_complete: history.coverage_complete, + approximate: history.approximate, + periods, + })) + } +} + +pub(super) struct State { + pub enabled: bool, + pub report: Load, + pub window: usize, + pub cursor: [usize; 2], + pub expanded: [Option; 2], + snapshot: Option>, +} + +impl Default for State { + fn default() -> Self { + Self { + enabled: false, + report: Load::Unavailable, + window: 0, + cursor: [0; 2], + expanded: [None, None], + snapshot: None, + } + } +} + +impl State { + pub(super) fn poll(&mut self) { + self.report.poll(); + let snapshot = self.report.ready().map(|report| report.as_of); + if snapshot != self.snapshot { + self.snapshot = snapshot; + self.cursor = [0; 2]; + self.expanded = [None, None]; + } + } +} + +#[cfg(test)] +#[path = "plan_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/plan_panel.rs b/codex-rs/tui/src/analytics/plan_panel.rs new file mode 100644 index 0000000000..ee3bfc7801 --- /dev/null +++ b/codex-rs/tui/src/analytics/plan_panel.rs @@ -0,0 +1,259 @@ +//! Five-hour and weekly period tables share grouping, with independent row expansion. +use super::AnalyticsView; +use super::data; +use super::plan; +use super::render::columns; +use super::render::join_columns; +use super::sections::Section; +use super::styles::secondary_style; +use crate::keymap::ListAction; +use crate::style::accent_style; +use codex_backend_client::PlanLimitDimension; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::ops::Range; + +impl AnalyticsView { + pub(super) fn plan_action(&mut self, action: Option) { + let window = self.plan.window; + let periods = self + .plan + .report + .ready() + .map(|report| &report.periods[window]); + let count = periods.map_or(/*default*/ 0, Vec::len); + let cursor = &mut self.plan.cursor[window]; + let previous_cursor = *cursor; + match action { + Some(ListAction::MoveLeft | ListAction::MoveRight) => self.plan.window ^= 1, + Some(ListAction::MoveUp) => *cursor = cursor.saturating_sub(/*rhs*/ 1), + Some(ListAction::MoveDown) => { + *cursor = (*cursor + 1).min(count.saturating_sub(/*rhs*/ 1)) + } + Some(ListAction::JumpTop) => *cursor = 0, + Some(ListAction::JumpBottom) => *cursor = count.saturating_sub(/*rhs*/ 1), + Some(ListAction::Accept) => { + if let Some(period) = periods.and_then(|periods| periods.get(*cursor)) { + self.plan.expanded[window] = + if self.plan.expanded[window].as_ref() == Some(&period.id) { + None + } else { + Some(period.id.clone()) + }; + } + } + Some(ListAction::Cancel) => { + self.is_done |= !self.zoomed || self.plan.expanded[window].take().is_none(); + } + Some(ListAction::PageDown) => { + self.scroll_offset += self.viewport_height; + self.follow_selection = false; + } + Some(ListAction::PageUp) => { + self.scroll_offset = self.scroll_offset.saturating_sub(self.viewport_height); + self.follow_selection = false; + } + None => {} + } + if *cursor != previous_cursor { + self.plan.expanded[window] = None; + } + } + + pub(super) fn plan_lines(&self, width: usize) -> (Vec>, Range) { + let side_by_side = width >= 100; + let table_width = if side_by_side { (width - 4) / 2 } else { width }; + let group = self.sections[Section::Plan].group; + let dimension = match group { + 1 => PlanLimitDimension::ThreadSource, + 2 => PlanLimitDimension::Model, + 3 => PlanLimitDimension::TurnTrigger, + _ => PlanLimitDimension::Surface, + }; + let mut tables = Vec::new(); + let mut selections = Vec::new(); + for (window, title) in ["5-hour limits", "Weekly limits"].into_iter().enumerate() { + let heading = format!( + "{} {title}", + if self.plan.window == window { + "›" + } else { + " " + } + ); + let mut lines = vec![heading.bold().into(), Line::default()]; + let mut selection = 0..2; + if let Some(report) = self.plan.report.ready() { + if report.periods[window].is_empty() { + lines.push( + if report.coverage_complete { + "No limit periods in this date range" + } else { + "Limit history isn't available yet" + } + .set_style(secondary_style()) + .into(), + ); + } + for (index, period) in report.periods[window] + .iter() + .take(if self.zoomed { usize::MAX } else { 1 }) + .enumerate() + { + let start = lines.len(); + let selected = self.plan.cursor[window] == index; + let label = if !self.zoomed { + format!("Latest period · {} total", report.periods[window].len()) + } else { + format!( + "{} {} – {}{}", + if selected { "›" } else { " " }, + period.start.format("%b %-d %H:%M"), + period.end.format("%b %-d %H:%M"), + if period.start <= report.as_of && period.end > report.as_of { + " *" + } else { + "" + } + ) + }; + let amount = period + .used + .map(|value| format!("{}%", data::amount(value / 100.0))) + .unwrap_or_else(|| "Not available".into()); + let row = columns(label.into(), amount.into(), table_width); + lines.push(if selected && self.plan.window == window { + row.set_style(accent_style()) + } else { + row + }); + if self.zoomed && self.plan.expanded[window].as_ref() == Some(&period.id) { + if !period.complete { + lines.push( + " Some usage is unavailable; this period may be incomplete" + .dim() + .into(), + ); + } + if let Some(breakdown) = period.breakdowns.as_ref().and_then(|groups| { + groups.iter().find(|group| group.dimension == dimension) + }) { + for row in &breakdown.rows { + let key = if dimension == PlanLimitDimension::TurnTrigger { + format!("start-{}", row.key) + } else { + row.key.clone() + }; + let label = if dimension == PlanLimitDimension::Model { + self.model_name(&key) + } else { + super::normalize::label(&key) + }; + lines.push(columns( + format!(" {label}").into(), + format!("{}%", data::amount(row.basis_points / 100.0)).into(), + table_width, + )); + } + } else { + lines.push(" Breakdown isn't available for this period".dim().into()); + } + } + if selected { + selection = start..lines.len(); + } + } + } else { + lines.push( + match &self.plan.report { + data::Load::Unavailable => "Limit history isn't available yet", + state => state + .message() + .unwrap_or("Limit history isn't available yet"), + } + .to_string() + .set_style(secondary_style()) + .into(), + ); + } + // Wrap each column before joining so long timestamps never collide with its neighbor. + let mut wrapped = Vec::new(); + let mut wrapped_selection = 0..0; + for (index, line) in lines.into_iter().enumerate() { + if index == selection.start { + wrapped_selection.start = wrapped.len(); + } + wrapped.extend(crate::wrapping::word_wrap_lines( + [line], + crate::wrapping::RtOptions::new(table_width.max(/*other*/ 1)), + )); + if index + 1 == selection.end { + wrapped_selection.end = wrapped.len(); + } + } + tables.push(wrapped); + selections.push(wrapped_selection); + } + let mut selection = selections[self.plan.window].clone(); + let mut lines = if side_by_side { + join_columns(&tables[0], &tables[1], table_width) + } else { + if self.plan.window == 1 { + selection = + selection.start + tables[0].len() + 1..selection.end + tables[0].len() + 1; + } + let mut lines = std::mem::take(&mut tables[0]); + lines.push(Line::default()); + lines.append(&mut tables[1]); + lines + }; + if group == 3 { + lines.push( + "By turn start includes Tasks only; percentages use the full period limit" + .dim() + .into(), + ); + } + if let Some(plan::Report { + as_of, + coverage_start, + coverage_complete, + approximate, + .. + }) = self.plan.report.ready() + { + if !*coverage_complete { + lines.push("Some periods aren't available yet".dim().into()); + } + lines.push( + format!( + "Usage as of {} UTC{}", + as_of.format("%b %-d %H:%M"), + if self.zoomed { + " · * current at last update" + } else { + "" + } + ) + .dim() + .into(), + ); + if let Some(start) = coverage_start { + lines.push( + format!("Available since {} UTC", start.format("%b %-d %H:%M")) + .dim() + .into(), + ); + } + if *approximate { + lines.push( + "Amounts and period boundaries are approximate; recent activity may be delayed" + .dim() + .into(), + ); + } + } + (lines, selection) + } +} diff --git a/codex-rs/tui/src/analytics/plan_tests.rs b/codex-rs/tui/src/analytics/plan_tests.rs new file mode 100644 index 0000000000..bbb902dd62 --- /dev/null +++ b/codex-rs/tui/src/analytics/plan_tests.rs @@ -0,0 +1,252 @@ +//! Snapshot semantics and independent plan-period navigation without a live billing service. +use super::*; +use crate::analytics::fixture; +use crate::analytics::models::AccountKind; +use crate::analytics::sections::Section; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn response() -> PlanLimitHistory { + serde_json::from_value(json!({ + "data_as_of":"2026-09-02T12:00:00Z", "coverage_start":"2026-09-01T00:00:00Z", + "coverage_complete":false, "boundary_tolerance_seconds":60, + "periods":[ + {"id":"five", "window_minutes":300,"plan_type":"plus", + "starts_at":"2026-09-02T10:00:00Z","ends_at":"2026-09-02T15:00:00Z", + "accounting_complete":false,"used_basis_points":12500, + "breakdowns":[ + {"dimension":"future_dimension","rows":[{"key":"future","basis_points":500}]}, + {"dimension":"thread_source","rows":[{"key":"user","basis_points":12500}]}]}, + {"id":"weekly", "window_minutes":10080,"plan_type":"plus", + "starts_at":"2026-09-01T00:00:00Z","ends_at":"2026-09-08T00:00:00Z", + "accounting_complete":true,"used_basis_points":null,"breakdowns":null} + ] + })) + .unwrap() +} + +#[test] +fn period_navigation_preserves_unknowns_and_resets_on_new_snapshot() { + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.plan.report = Load::Ready(Report::parse(response()).unwrap().unwrap()); + view.plan.poll(); + view.section = Section::Plan; + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + view.plan.expanded, + [Some("five".into()), Some("weekly".into())] + ); + let mut output = Vec::new(); + for width in [120, 58] { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + width, /*height*/ 30, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + output.push(terminal.backend().to_string()); + } + insta::assert_snapshot!(output.join("\n")); + let mut next = response(); + next.data_as_of = Some("2026-09-02T13:00:00Z".into()); + view.plan.report = Load::Ready(Report::parse(next).unwrap().unwrap()); + view.plan.poll(); + assert_eq!( + (view.plan.cursor, view.plan.expanded), + ([0, 0], [None, None]) + ); +} + +#[test] +fn unavailable_empty_and_zero_history_are_distinct() { + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.section = Section::Plan; + let mut states = Vec::new(); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let mut history = response(); + history.coverage_complete = true; + history.periods.clear(); + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let mut history = response(); + history.periods[0].used_basis_points = Some(0.0); + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let mut future = response(); + future.periods[0].starts_at = "2026-09-02T13:00:00Z".into(); + view.plan.report = Load::Ready(Report::parse(future).unwrap().unwrap()); + states.push( + view.plan_lines(/*width*/ 100) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + let snapshot = states.join("\n\n"); + let snapshot = snapshot + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!(snapshot); + let mut missing = response(); + missing.data_as_of = None; + assert!(Report::parse(missing).unwrap().is_none()); +} + +#[tokio::test] +async fn plan_gate_prevents_requests_and_unavailable_plan_does_not_block_other_reports() { + use crate::analytics::client; + use crate::analytics::test_support; + use wiremock::Mock; + use wiremock::ResponseTemplate; + use wiremock::matchers::path; + let server = test_support::server().await; + let (_home, _server, mut view) = client::tests::connected_view(&server, "plus").await; + test_support::settle(&mut view).await; + assert!( + !server + .received_requests() + .await + .unwrap() + .iter() + .any(|request| request.url.path().ends_with("plan_limit_history")) + ); + assert!(!view.visible_sections().contains(&Section::Plan)); + Mock::given(path("/backend-api/wham/usage/plan_limit_history")) + .respond_with(ResponseTemplate::new(/*s*/ 404)) + .with_priority(/*p*/ 1) + .expect(/*r*/ 1) + .mount(&server) + .await; + let (mut config, handle, frame) = view.connection.clone().unwrap(); + std::sync::Arc::make_mut(&mut config) + .features + .enable(codex_features::Feature::AnalyticsPlanHistory) + .unwrap(); + view.open(handle, frame, Vec::new(), config); + test_support::settle(&mut view).await; + assert!(matches!(view.plan.report, Load::Unavailable)); + assert!(view.sections[Section::Usage].history.ready().is_some()); + assert!(view.visible_sections().contains(&Section::Plan)); + // Closing cancels the pending plan task along with the other account-owned work. + let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + view.plan.report = Load::start( + async move { + let _sender = sender; + std::future::pending().await + }, + crate::tui::FrameRequester::test_dummy(), + ); + view.cancel_loads(); + assert!(receiver.await.is_err()); + server.verify().await; + let server = test_support::server().await; + let (_home, _server, mut business) = client::tests::connected_view(&server, "business").await; + let (mut config, handle, frame) = business.connection.clone().unwrap(); + std::sync::Arc::make_mut(&mut config) + .features + .enable(codex_features::Feature::AnalyticsPlanHistory) + .unwrap(); + business.open(handle, frame, Vec::new(), config); + test_support::settle(&mut business).await; + assert!(!business.visible_sections().contains(&Section::Plan)); + assert!(matches!(business.plan.report, Load::Unavailable)); + server.verify().await; +} + +#[test] +fn plan_overview_escape_closes_with_retained_expansion() { + let mut history = response(); + for index in 1..12 { + let mut period = history.periods[0].clone(); + period.id = format!("five-{index}"); + history.periods.push(period); + } + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + view.plan.poll(); + view.section = Section::Plan; + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 120, /*height*/ 60, + )) + .unwrap(); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("Latest period · 12 total") && output.contains("Weekly limits")); + insta::assert_snapshot!(output); + view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(view.is_done); +} + +#[test] +fn moving_period_selection_collapses_its_previous_details() { + let mut history = response(); + let mut older = history.periods[0].clone(); + older.id = "older-five".into(); + older.starts_at = "2026-09-02T05:00:00Z".into(); + older.ends_at = "2026-09-02T10:00:00Z".into(); + history.periods.push(older); + let mut view = fixture::view(AccountKind::Consumer); + view.plan.enabled = true; + view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); + view.plan.poll(); + view.section = Section::Plan; + view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!( + (view.plan.cursor, view.plan.expanded), + ([1, 0], [None, None]) + ); +} + +#[test] +fn invalid_history_returns_safe_errors() { + for invalid in 0..5 { + let mut history = response(); + match invalid { + 0 => history.periods[0].starts_at = "private wire value".into(), + 1 => history.periods[0].ends_at = history.periods[0].starts_at.clone(), + 2 => history.periods.push(history.periods[0].clone()), + 3 => history.periods[0].window_minutes = 1, + _ => history.periods[0].used_basis_points = Some(f64::INFINITY), + } + let error = Report::parse(history).err().unwrap(); + assert!(!error.contains("private wire value")); + } +} diff --git a/codex-rs/tui/src/analytics/render.rs b/codex-rs/tui/src/analytics/render.rs index 8c4a167fca..5509d2b768 100644 --- a/codex-rs/tui/src/analytics/render.rs +++ b/codex-rs/tui/src/analytics/render.rs @@ -92,7 +92,8 @@ impl AnalyticsView { pub(super) fn render(&mut self, area: Rect, buf: &mut Buffer) { self.poll_reports(); let width = usize::from(area.width.saturating_sub(/*rhs*/ 4)).max(/*other*/ 1); - let show_range = self.section != Section::Chats && !self.visible_sections().is_empty(); + let show_range = !matches!(self.section, Section::Chats | Section::Plan) + && !self.visible_sections().is_empty(); let hint = |action| { self.keymap .primary_hint(action) @@ -112,6 +113,15 @@ impl AnalyticsView { } else { let navigation = if !self.zoomed { format!("{}/z maximize", hint(ListAction::Accept)) + } else if self.section == Section::Plan { + format!( + "{}/{} window · {}/{} period · {} details", + hint(ListAction::MoveLeft), + hint(ListAction::MoveRight), + hint(ListAction::MoveUp), + hint(ListAction::MoveDown), + hint(ListAction::Accept) + ) } else if self.section != Section::Chats { format!( "{}/{} day · {} details", diff --git a/codex-rs/tui/src/analytics/sections.rs b/codex-rs/tui/src/analytics/sections.rs index 2eec853f5c..8d684b3c21 100644 --- a/codex-rs/tui/src/analytics/sections.rs +++ b/codex-rs/tui/src/analytics/sections.rs @@ -15,6 +15,7 @@ pub(super) enum Section { Chats, Activity, Skills, + Plan, } impl Section { @@ -24,7 +25,7 @@ impl Section { Self::Usage => Some(Report::Usage), Self::Plugins => Some(Report::Plugins), Self::Credits => Some(Report::Credits), - Self::Chats => None, + Self::Chats | Self::Plan => None, Self::Activity => Some(Report::Messages), Self::Skills => Some(Report::Skills), } @@ -32,7 +33,7 @@ impl Section { } /// Section-only indexing keeps report identity separate from array positions. -pub(super) struct SectionStates(pub(super) [SectionState; 6]); +pub(super) struct SectionStates(pub(super) [SectionState; 7]); impl std::ops::Index
for SectionStates { type Output = SectionState; @@ -80,6 +81,7 @@ impl AnalyticsView { section.history.poll(); } self.chats.poll(); + self.plan.poll(); self.account.poll(); self.start_reports(); if !self.business() @@ -104,7 +106,7 @@ impl AnalyticsView { pub(super) fn range_group(&self, section: Section) -> RangeGroup { match section { Section::Usage if self.business() => RangeGroup::Activity, - Section::Usage | Section::Credits | Section::Chats => RangeGroup::Usage, + Section::Usage | Section::Credits | Section::Chats | Section::Plan => RangeGroup::Usage, Section::Activity => RangeGroup::Activity, Section::Plugins | Section::Skills => RangeGroup::Tools, } @@ -137,6 +139,13 @@ impl AnalyticsView { pub(super) fn visible_sections(&self) -> &'static [Section] { match self.account_kind() { + Some(AccountKind::Consumer) if self.plan.enabled => &[ + Section::Usage, + Section::Plan, + Section::Activity, + Section::Plugins, + Section::Skills, + ], Some(AccountKind::Consumer) => &[ Section::Usage, Section::Activity, @@ -201,6 +210,12 @@ impl AnalyticsView { self.load_report(*section); } } + if visible.contains(&Section::Plan) + && let (Some((_, _, frame)), Some(live)) = (&self.connection, &self.live) + { + let live = std::sync::Arc::clone(live); + self.plan.report = Load::start(async move { live.plan_history().await }, frame.clone()); + } if visible.contains(&Section::Chats) { self.chats = if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) { @@ -228,7 +243,9 @@ impl AnalyticsView { let index = self.range_group(self.section); self.ranges[index as usize] ^= 1; for section in self.visible_sections() { - if matches!(section, Section::Chats) || self.range_group(*section) != index { + if matches!(section, Section::Chats | Section::Plan) + || self.range_group(*section) != index + { continue; } self.sections[*section].cursor = if self.ranges[index as usize] == 1 { diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap new file mode 100644 index 0000000000..c5a08a30a1 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap @@ -0,0 +1,66 @@ +--- +source: tui/src/analytics/plan_tests.rs +assertion_line: 53 +expression: "output.join(\"\\n\")" +--- +" Analytics · Live account " +" " +" 1 Total usage history [2 Plan usage history] 3 Messages 4 Plugins called 5 Skills used " +" " +" ▎ Plan usage history By feature " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" 5-hour limits › Weekly limits " +" " +" › Sep 2 10:00 – Sep 2 15:00 * 125% › Sep 1 00:00 – Sep 8 00:00 * Not available " +" Some usage is unavailable; this period may be Breakdown isn't available for this period " +" incomplete " +" Tasks 125% " +" Some periods aren't available yet " +" Usage as of Sep 2 12:00 UTC · * current at last update " +" Available since Sep 1 00:00 UTC " +" Amounts and period boundaries are approximate; recent activity may be delayed " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · z dashboard · ←/→ window · ↑/↓ period · enter details " +" g group · R refresh · pgup/pgdn scroll · esc back · q close " + +" Analytics · Live account " +" " +" Total usage history [Plan usage history] Messages " +" Plugins called Skills used " +" " +" ▎ Plan usage history By feature " +" ────────────────────────────────────────────────────── " +" 5-hour limits " +" " +" › Sep 2 10:00 – Sep 2 15:00 * 125% " +" Some usage is unavailable; this period may be " +" incomplete " +" Tasks 125% " +" " +" › Weekly limits " +" " +" › Sep 1 00:00 – Sep 8 00:00 * Not available " +" Breakdown isn't available for this period " +" Some periods aren't available yet " +" Usage as of Sep 2 12:00 UTC · * current at last update " +" Available since Sep 1 00:00 UTC " +" Amounts and period boundaries are approximate; recent " +" activity may be delayed " +" " +" " +" " +" tab/1–5 section · z dashboard · ←/→ window · ↑/↓ " +" period · enter details " +" g group · R refresh · pgup/pgdn scroll · esc back · q " +" close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap new file mode 100644 index 0000000000..43cc027334 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap @@ -0,0 +1,65 @@ +--- +source: tui/src/analytics/plan_tests.rs +assertion_line: 183 +expression: output +--- +" Analytics · Live account " +" " +" ╭─ 1 Total usage history ────────────────────────────╮ ╭─ ▸ 2 Plan usage history ─────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ By feature │ " +" │ By feature │ │ › 5-hour limits │ " +" │ Total usage · relative units │ │ │ " +" │ 86 │ │ Latest period · 12 total 125% │ " +" │ 100 ▁▁▁▁ ▅▅▅▅ ▅▅▅▅ ▇▇▇▇ ▆▆▆▆ ████ ▆▆▆▆ │ │ │ " +" │ 0 ─────────────────────────────────────────────── │ │ Weekly limits │ " +" │ ▲ │ │ │ " +" │ 27 28 29 30 31 1 2 │ │ Latest period · 1 total Not available │ " +" │ │ │ Some periods aren't available yet │ " +" │ Sep 2 · 86 usage units │ │ Usage as of Sep 2 12:00 UTC │ " +" │ ● Tasks 64.0% │ │ Available since Sep 1 00:00 UTC │ " +" │ ● Code review 14.0% │ │ Amounts and period boundaries are approximate; │ " +" │ │ │ recent activity may be delayed │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " +" " +" ╭─ 3 Messages ───────────────────────────────────────╮ ╭─ 4 Plugins called ─────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 7d │ " +" │ By model │ │ 186 calls │ " +" │ 574 reported messages │ │ 29 │ " +" │ 103 │ │ 40 ▆▆▆▆ ▄▄▄▄ ▅▅▅▅ ▅▅▅▅ ▄▄▄▄ ▄▄▄▄ ▅▅▅▅ │ " +" │ 150 ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ ▄▄▄▄ ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ │ │ 0 ──────────────────────────────────────────────── │ " +" │ 0 ─────────────────────────────────────────────── │ │ ▲ │ " +" │ ▲ │ │ 27 28 29 30 31 1 2 │ " +" │ 27 28 29 30 31 1 2 │ │ │ " +" │ │ │ Sep 2 · 29 calls │ " +" │ Sep 2 · 103 messages │ │ ● Slack 10 │ " +" │ ● GPT-5.6-Sol 61 │ │ ● Figma 10 │ " +" │ ● GPT-5.6-Terra 25 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " +" " +" ╭─ 5 Skills used ────────────────────────────────────╮ " +" │ │ " +" │ 7d │ " +" │ 54 uses │ " +" │ 11 │ " +" │ 20 ▃▃▃▃ ▂▂▂▂ ▃▃▃▃ ▃▃▃▃ ▂▂▂▂ ▂▂▂▂ ▄▄▄▄ │ " +" │ 0 ──────────────────────────────────────────────── │ " +" │ ▲ │ " +" │ 27 28 29 30 31 1 2 │ " +" │ │ " +" │ Sep 2 · 11 uses │ " +" │ ● Testing 4 │ " +" │ ● Code review 3 │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰──────────────────────────────────────────────────────╯ " +" tab/1–5 section · enter/z maximize " +" g group · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap new file mode 100644 index 0000000000..18e7fda2e0 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/analytics/plan_tests.rs +assertion_line: 105 +expression: snapshot +--- +› 5-hour limits Weekly limits + +Limit history isn't available yet Limit history isn't available yet + +› 5-hour limits Weekly limits + +No limit periods in this date range No limit periods in this date range +Usage as of Sep 2 12:00 UTC · * current at last update +Available since Sep 1 00:00 UTC +Amounts and period boundaries are approximate; recent activity may be delayed + +› 5-hour limits Weekly limits + +› Sep 2 10:00 – Sep 2 15:00 * 0% › Sep 1 00:00 – Sep 8 00:00 * Not available +Some periods aren't available yet +Usage as of Sep 2 12:00 UTC · * current at last update +Available since Sep 1 00:00 UTC +Amounts and period boundaries are approximate; recent activity may be delayed + +› 5-hour limits Weekly limits + +› Sep 2 13:00 – Sep 2 15:00 125% › Sep 1 00:00 – Sep 8 00:00 * Not available +Some periods aren't available yet +Usage as of Sep 2 12:00 UTC · * current at last update +Available since Sep 1 00:00 UTC +Amounts and period boundaries are approximate; recent activity may be delayed diff --git a/codex-rs/tui/src/analytics/test_support.rs b/codex-rs/tui/src/analytics/test_support.rs index 6fc7ca7ce5..7bdcf10956 100644 --- a/codex-rs/tui/src/analytics/test_support.rs +++ b/codex-rs/tui/src/analytics/test_support.rs @@ -62,6 +62,7 @@ pub(super) async fn settle(view: &mut super::AnalyticsView) { .iter() .any(|state| matches!(state.history, Load::Loading(_))) && !matches!(view.chats, Load::Loading(_)) + && !matches!(view.plan.report, Load::Loading(_)) { break; } diff --git a/codex-rs/tui/src/analytics_tests.rs b/codex-rs/tui/src/analytics_tests.rs index 77720b36a0..91833e5b1c 100644 --- a/codex-rs/tui/src/analytics_tests.rs +++ b/codex-rs/tui/src/analytics_tests.rs @@ -668,7 +668,7 @@ fn analytics_details_reflow_and_keep_focus() { view.is_done, view.sections.0.each_ref().map(|state| state.detail) ), - (false, [None; 6]) + (false, [None; 7]) ); press(&mut view, KeyCode::Esc); assert!(view.is_done);