mirror of
https://github.com/openai/codex.git
synced 2026-10-21 07:42:01 +00:00
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:
@@ -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!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+66
@@ -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 "
|
||||
+65
@@ -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 "
|
||||
+31
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user