Add gated plan usage history to TUI analytics (#45766)

## What changed

- Add consumer plan usage history behind `analytics_plan_history`, disabled by default, fetching seven days of five-hour and weekly allowance periods.
- Show period usage percentages with expandable breakdowns by feature, model, surface, or turn start, plus snapshot freshness and incomplete coverage indicators.
- Preserve unknown usage separately from zero and handle an unavailable history endpoint without blocking other analytics reports.

## Testing

Add backend and TUI tests for unavailable endpoints, response validation, feature and account gating, account changes during requests, period navigation, snapshot resets, and narrow and wide layouts.

GitOrigin-RevId: 7bea87a3407730b18040d7dda7e7dd0afe67fb2e
This commit is contained in:
Felipe Coury
2026-09-15 19:12:18 +00:00
committed by copyberry
parent 0e7ab7c1b5
commit 0d0979f457
23 changed files with 1062 additions and 18 deletions
+1
View File
@@ -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;
@@ -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<String>,
pub coverage_start: Option<String>,
pub coverage_complete: bool,
#[serde(default = "approximate_by_default")]
pub approximate: bool,
pub boundary_tolerance_seconds: Option<u32>,
pub periods: Vec<PlanLimitPeriod>,
}
#[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<f64>,
pub breakdowns: Option<Vec<PlanLimitBreakdown>>,
}
#[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<PlanLimitValue>,
}
#[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<Option<PlanLimitHistory>, 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;
@@ -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!(),
}
}
}
+5
View File
@@ -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;
+6
View File
@@ -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"
},
+8
View File
@@ -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",
+18 -1
View File
@@ -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<String, String>,
sections: SectionStates,
chats: Load<chats::Chats>,
plan: plan::State,
show_zero_credit_groups: bool,
account: Load<codex_protocol::account::PlanType>,
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;
}
@@ -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'));
+2 -2
View File
@@ -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)
}
}
+17
View File
@@ -95,6 +95,23 @@ impl Live {
.await
}
pub(super) async fn plan_history(&self) -> Result<Option<super::plan::Report>, 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,
@@ -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."
);
}
}
+3 -2
View File
@@ -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"
+6 -2
View File
@@ -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);
+121
View File
@@ -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<Utc>,
pub end: DateTime<Utc>,
pub complete: bool,
pub used: Option<f64>,
pub breakdowns: Option<Vec<PlanLimitBreakdown>>,
}
pub(super) struct Report {
pub as_of: DateTime<Utc>,
pub coverage_start: Option<DateTime<Utc>>,
pub coverage_complete: bool,
pub approximate: bool,
pub periods: [Vec<Period>; 2],
}
impl Report {
pub(super) fn parse(history: PlanLimitHistory) -> Result<Option<Self>, 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<Report>,
pub window: usize,
pub cursor: [usize; 2],
pub expanded: [Option<String>; 2],
snapshot: Option<DateTime<Utc>>,
}
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;
+259
View File
@@ -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<ListAction>) {
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<Line<'static>>, Range<usize>) {
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)
}
}
+252
View File
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join("\n"),
);
let snapshot = states.join("\n\n");
let snapshot = snapshot
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.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"));
}
}
+11 -1
View File
@@ -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",
+21 -4
View File
@@ -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<Section> 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 {
@@ -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 "
@@ -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 "
@@ -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
@@ -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;
}
+1 -1
View File
@@ -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);