diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 06b1995d77..d7b0a3e2ec 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -38,6 +38,7 @@ pub(crate) mod analytics; mod chatgpt_turn_cost; pub(crate) mod plan_history; mod rate_limit_resets; +pub(crate) mod task_usage; mod thread_usage; pub(crate) mod turn_usage; diff --git a/codex-rs/backend-client/src/client/task_usage.rs b/codex-rs/backend-client/src/client/task_usage.rs new file mode 100644 index 0000000000..daf0790eeb --- /dev/null +++ b/codex-rs/backend-client/src/client/task_usage.rs @@ -0,0 +1,229 @@ +//! Consumer task accounting: current allowance percentages and actual balance debits. +//! This contract is deliberately separate from enterprise estimated lifetime credits. + +use super::Client; +use super::PathStyle; +use super::RequestError; +use http::Method; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashSet; + +#[derive(Clone, Debug, Serialize)] +pub struct TaskUsageThread { + pub thread_id: String, + pub created_at: Option, + pub descendant_thread_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TaskUsageResponse { + pub data_as_of: Option, + pub threads: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TaskUsageStatus { + Available, + Partial, + Unavailable, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TaskUsage { + pub thread_id: String, + pub data_status: TaskUsageStatus, + pub usage_source: String, + #[serde(flatten)] + pub amounts: TaskUsageAmounts, + pub groups: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TaskUsageAmounts { + #[serde(default, deserialize_with = "percentage")] + pub five_hour_limit_percent: Option, + #[serde(default, deserialize_with = "percentage")] + pub weekly_limit_percent: Option, + pub balance_usage_credits: Option, +} + +// Flattened Serde fields buffer arbitrary-precision JSON numbers as maps. +// Decode through Number so decimal percentages work with either serde_json configuration. +fn percentage<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Option::::deserialize(deserializer)? + .map(|value| { + value + .as_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| serde::de::Error::custom("Invalid task percentage.")) + }) + .transpose() +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TaskUsageGroup { + pub product_experience: Option, + pub model: Option, + pub reasoning_effort: Option, + pub speed: Option, + #[serde(flatten)] + pub amounts: TaskUsageAmounts, +} + +impl Client { + /// Queries disjoint tasks, including legacy descendants, without selecting a comparison plan. + pub async fn get_task_usage( + &self, + threads: &[TaskUsageThread], + ) -> Result { + let mut ids = HashSet::new(); + if threads.is_empty() + || threads.len() > 100 + || threads.iter().any(|thread| { + std::iter::once(&thread.thread_id) + .chain(&thread.descendant_thread_ids) + .any(|id| id.trim().is_empty() || id.len() > 512 || !ids.insert(id.as_str())) + }) + || ids.len() > 1_000 + { + return Err(RequestError::Other(anyhow::anyhow!( + "Expected at most 100 disjoint tasks and 1,000 thread IDs." + ))); + } + let prefix = match self.path_style { + PathStyle::CodexApi => "api/codex", + PathStyle::ChatGptApi => "wham", + }; + let url = format!("{}/{prefix}/usage/thread_usage/query_v2", self.base_url); + #[derive(Serialize)] + struct Query<'a> { + threads: &'a [TaskUsageThread], + } + let request = self + .request(Method::POST, &url) + .headers(self.headers()) + .json(&Query { threads }); + let (body, _) = self.exec_request_detailed(request, "POST", &url).await?; + let response: TaskUsageResponse = serde_json::from_str(&body) + .map_err(|_| RequestError::Other(anyhow::anyhow!("Invalid task usage response.")))?; + let roots: HashSet<_> = threads + .iter() + .map(|thread| thread.thread_id.as_str()) + .collect(); + let mut seen = HashSet::new(); + if response.threads.iter().any(|thread| { + !roots.contains(thread.thread_id.as_str()) + || !seen.insert(&thread.thread_id) + || std::iter::once(&thread.amounts) + .chain(thread.groups.iter().map(|group| &group.amounts)) + .any(|amounts| { + [ + amounts.five_hour_limit_percent, + amounts.weekly_limit_percent, + ] + .into_iter() + .flatten() + .any(|value| !value.is_finite()) + }) + }) { + return Err(RequestError::Other(anyhow::anyhow!( + "Invalid task usage response." + ))); + } + Ok(response) + } +} + +/// Decimal strings retain all backend digits for both rendering and sorting. +#[derive(Clone, Debug, Eq)] +pub struct TaskCredits(String); + +impl TaskCredits { + pub fn as_str(&self) -> &str { + &self.0 + } + + fn parts(&self) -> (bool, i64, String) { + let (mantissa, exponent) = self.0.split_once(['e', 'E']).unwrap_or((&self.0, "0")); + let negative = mantissa.starts_with('-'); + let unsigned = mantissa.trim_start_matches(['-', '+']); + let fraction = unsigned + .split_once('.') + .map_or(/*default*/ 0, |(_, fraction)| fraction.len()); + let digits = unsigned.replace('.', ""); + let digits = digits.trim_start_matches('0'); + let power = + exponent.parse::().unwrap_or_default() - fraction as i64 + digits.len() as i64; + (negative && !digits.is_empty(), power, digits.to_string()) + } +} + +impl<'de> Deserialize<'de> for TaskCredits { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + let mantissa = value + .split(['e', 'E']) + .next() + .unwrap_or_default() + .trim_start_matches(['-', '+']); + let exponent = value + .split_once(['e', 'E']) + .map_or("0", |(_, exponent)| exponent); + if value.len() > 128 + || mantissa.is_empty() + || !mantissa.chars().any(|ch| ch.is_ascii_digit()) + || !mantissa.chars().all(|ch| ch.is_ascii_digit() || ch == '.') + || value.parse::().is_err() + || exponent.parse::().is_err() + { + return Err(serde::de::Error::custom("Invalid decimal credits.")); + } + Ok(Self(value)) + } +} + +impl Ord for TaskCredits { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + let (negative, power, digits) = self.parts(); + let (other_negative, other_power, other_digits) = other.parts(); + let magnitude = match (digits.is_empty(), other_digits.is_empty()) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + (false, false) => power.cmp(&other_power).then_with(|| { + let width = digits.len().max(other_digits.len()); + digits + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width) + .cmp( + other_digits + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width), + ) + }), + }; + other_negative.cmp(&negative).then(if negative { + magnitude.reverse() + } else { + magnitude + }) + } +} +impl PartialOrd for TaskCredits { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl PartialEq for TaskCredits { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +#[cfg(test)] +#[path = "task_usage_tests.rs"] +mod tests; diff --git a/codex-rs/backend-client/src/client/task_usage_tests.rs b/codex-rs/backend-client/src/client/task_usage_tests.rs new file mode 100644 index 0000000000..6ec549a207 --- /dev/null +++ b/codex-rs/backend-client/src/client/task_usage_tests.rs @@ -0,0 +1,132 @@ +//! Exact decimal preservation and sorting for consumer task credits. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn credits_preserve_precision_and_order_signed_decimal_strings() { + let mut amounts: Vec = serde_json::from_value(json!([ + "1.000000000000000002", + "-0.00025", + "0", + "1.000000000000000001", + "1e-20", + "-1E+3" + ])) + .unwrap(); + amounts.sort(); + assert_eq!( + amounts.iter().map(TaskCredits::as_str).collect::>(), + vec![ + "-1E+3", + "-0.00025", + "0", + "1e-20", + "1.000000000000000001", + "1.000000000000000002" + ] + ); + for invalid in ["NaN", "inf", "1..0", "", "1e999999999999"] { + assert!(serde_json::from_value::(json!(invalid)).is_err()); + } +} + +#[tokio::test] +async fn task_queries_validate_request_groups_and_response_ownership() { + use codex_http_client::HttpClientFactory; + use codex_http_client::OutboundProxyPolicy; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + let server = MockServer::start().await; + let client = Client::new( + server.uri(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); + let query = TaskUsageThread { + thread_id: "root".into(), + created_at: None, + descendant_thread_ids: vec!["child".into()], + }; + let task = json!({"thread_id":"root", "data_status":"available", "usage_source":"plan_and_credits", + "five_hour_limit_percent":150.0, "weekly_limit_percent":0.0, + "balance_usage_credits":"-0.000000000000000001", "groups":[{ + "product_experience":"codex", "model":"gpt-5.5", "reasoning_effort":"high", "speed":"standard", + "five_hour_limit_percent":150.25, "weekly_limit_percent":0.125, "balance_usage_credits":"0" + }]}); + for ids in [vec!["root"], vec!["other"], vec!["root", "root"]] { + server.reset().await; + let response = json!({"data_as_of":null, "threads":ids.iter().map(|id| { + let mut task=task.clone();task["thread_id"]=json!(id);task + }).collect::>()}); + Mock::given(method("POST")) + .and(path("/api/codex/usage/thread_usage/query_v2")) + .respond_with(move |request: &wiremock::Request| { + assert_eq!( + request.body_json::().unwrap(), + json!({"threads":[{ + "thread_id":"root", "created_at":null, "descendant_thread_ids":["child"] + }]}) + ); + ResponseTemplate::new(/*s*/ 200).set_body_json(&response) + }) + .expect(/*r*/ 1) + .mount(&server) + .await; + let result = client.get_task_usage(std::slice::from_ref(&query)).await; + if ids == ["root"] { + let response = result.unwrap(); + assert_eq!((response.data_as_of, response.threads.len()), (None, 1)); + let row = &response.threads[0]; + assert_eq!( + ( + row.thread_id.as_str(), + row.data_status, + row.amounts.five_hour_limit_percent, + row.amounts.weekly_limit_percent, + row.amounts + .balance_usage_credits + .as_ref() + .map(TaskCredits::as_str) + ), + ( + "root", + TaskUsageStatus::Available, + Some(150.0), + Some(0.0), + Some("-0.000000000000000001") + ) + ); + assert_eq!( + ( + row.groups[0].amounts.five_hour_limit_percent, + row.groups[0].amounts.weekly_limit_percent + ), + (Some(150.25), Some(0.125)) + ); + } else { + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid task usage response.") + ); + } + } + server.reset().await; + for queries in [ + vec![], + vec![query.clone(), query.clone()], + vec![TaskUsageThread { + thread_id: "root".into(), + created_at: None, + descendant_thread_ids: (0..1_000).map(|n| format!("child-{n}")).collect(), + }], + ] { + assert!(client.get_task_usage(&queries).await.is_err()); + } + assert!(server.received_requests().await.unwrap().is_empty()); +} diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 4df5b36eca..fa988d6dc2 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -47,4 +47,12 @@ 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 client::task_usage::TaskCredits; +pub use client::task_usage::TaskUsage; +pub use client::task_usage::TaskUsageAmounts; +pub use client::task_usage::TaskUsageGroup; +pub use client::task_usage::TaskUsageResponse; +pub use client::task_usage::TaskUsageStatus; +pub use client::task_usage::TaskUsageThread; pub use codex_backend_openapi_models::models::analytics as analytics_models; diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs index f79c3bcf43..ef62ea93db 100644 --- a/codex-rs/tui/src/analytics.rs +++ b/codex-rs/tui/src/analytics.rs @@ -20,6 +20,8 @@ mod render; mod report_data; mod sections; mod styles; +mod task_panel; +mod tasks; #[cfg(test)] #[path = "analytics/test_support.rs"] mod test_support; @@ -49,6 +51,8 @@ pub(crate) struct AnalyticsView { model_names: std::collections::HashMap, sections: SectionStates, chats: Load, + tasks: Load, + chat_metric: usize, plan: plan::State, show_zero_credit_groups: bool, account: Load, @@ -78,6 +82,8 @@ impl AnalyticsView { model_names: std::collections::HashMap::new(), sections: SectionStates(std::array::from_fn(|_| SectionState::default())), chats: Load::Unavailable, + tasks: Load::Unavailable, + chat_metric: 0, plan: plan::State::default(), show_zero_credit_groups: false, account: Load::Unavailable, @@ -137,6 +143,7 @@ impl AnalyticsView { } self.sections[Section::Chats].detail = None; self.chats = Load::Unavailable; + self.tasks = Load::Unavailable; self.plan.report = Load::Unavailable; self.reports_started = false; self.token_model = None; @@ -168,6 +175,7 @@ impl AnalyticsView { section.history = Load::Unavailable; } self.chats = Load::Unavailable; + self.tasks = Load::Unavailable; self.plan.report = Load::Unavailable; self.account = Load::Unavailable; self.live = None; @@ -254,7 +262,11 @@ impl AnalyticsView { } fn row_count(&self) -> usize { - if self.section == Section::Chats { + if self.section == Section::Chats && !self.business() { + self.tasks + .ready() + .map_or(/*default*/ 0, |chats| chats.rows.len()) + } else if self.section == Section::Chats { self.chats .ready() .map_or(/*default*/ 0, |chats| chats.rows.len()) @@ -389,6 +401,19 @@ impl AnalyticsView { self.plan_action(action); return; } + if self.section == Section::Chats + && !self.business() + && key_hint::plain(KeyCode::Char('s')).is_press(key) + { + let metrics = self.task_metrics(); + let index = metrics + .iter() + .position(|metric| *metric == self.task_metric()) + .unwrap_or_default(); + self.chat_metric = metrics[(index + 1) % metrics.len()]; + self.sections[Section::Chats].detail = None; + return; + } if matches!( self.section, Section::Plugins | Section::Activity | Section::Skills @@ -398,14 +423,19 @@ impl AnalyticsView { return; } if self.section == Section::Chats - && self.business() && matches!(action, Some(ListAction::Accept | ListAction::MoveRight)) - && self - .chats - .ready() - .and_then(|chats| chats.rows.get(self.sections[Section::Chats].cursor)) - .and_then(|chat| chat.usage.as_ref()) - .is_none() + && if self.business() { + self.chats + .ready() + .and_then(|chats| chats.rows.get(self.sections[Section::Chats].cursor)) + .and_then(|chat| chat.usage.as_ref()) + .is_none() + } else { + self.task_rows() + .get(self.sections[Section::Chats].cursor) + .and_then(|chat| task_panel::available(chat)) + .is_none() + } { return; } @@ -463,6 +493,7 @@ impl AnalyticsView { } } self.chats = Load::Unavailable; + self.tasks = Load::Unavailable; self.plan.report = Load::Unavailable; self.account = Load::Unavailable; self.connection = None; diff --git a/codex-rs/tui/src/analytics/account_layout_tests.rs b/codex-rs/tui/src/analytics/account_layout_tests.rs index dfcdc69439..544d23693c 100644 --- a/codex-rs/tui/src/analytics/account_layout_tests.rs +++ b/codex-rs/tui/src/analytics/account_layout_tests.rs @@ -14,6 +14,7 @@ fn account_layouts_show_reports_and_wrap_navigation() { Section::Activity, Section::Plugins, Section::Skills, + Section::Chats, ], ), ( diff --git a/codex-rs/tui/src/analytics/consumer_refresh_tests.rs b/codex-rs/tui/src/analytics/consumer_refresh_tests.rs new file mode 100644 index 0000000000..d29c272cf5 --- /dev/null +++ b/codex-rs/tui/src/analytics/consumer_refresh_tests.rs @@ -0,0 +1,105 @@ +//! Consumer chat columns follow reported metrics without substituting billing estimates. +use super::*; +use crate::analytics::tasks::Chat; +use crate::analytics::tasks::Chats; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn consumer_chats_keep_missing_rows_and_only_offer_known_metrics() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.section = Section::Chats; + view.tasks = Load::Ready(Chats { + rows: vec![ + Chat { title: "Private title from another account".into(), task: None }, + Chat { title: "Unverified backend row".into(), task: Some(serde_json::from_value(json!({ + "thread_id":"unavailable", "data_status":"unavailable", "usage_source":"unknown", + "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":null,"groups":[] + })).unwrap()) }, + Chat { title: "Refunded task".into(), task: Some(serde_json::from_value(json!({ + "thread_id":"refund", "data_status":"partial", "usage_source":"credits", + "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":"-0.000004","groups":[{ + "product_experience":"codex", "model":"gpt-5.5", "reasoning_effort":"high", "speed":"fast", + "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":"-0.000004" + }] + })).unwrap()) }, + ], ..Chats::default() + }); + assert_eq!(view.task_metrics(), vec![2]); + assert_eq!( + view.task_rows() + .iter() + .map(|row| row.title.as_str()) + .collect::>(), + vec![ + "Refunded task", + "Private title from another account", + "Unverified backend row" + ] + ); + press(&mut view, KeyCode::Down); + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Chats].detail, None); + let hidden = screen(&mut view, /*width*/ 90, /*height*/ 30); + assert!(!hidden.contains("Private title") && !hidden.contains("Unverified backend row")); + press(&mut view, KeyCode::Up); + press(&mut view, KeyCode::Enter); + insta::assert_snapshot!(screen(&mut view, /*width*/ 90, /*height*/ 30)); + let chats = match &mut view.tasks { + Load::Ready(chats) => chats, + _ => unreachable!(), + }; + chats.rows[2] + .task + .as_mut() + .unwrap() + .amounts + .five_hour_limit_percent = Some(0.0); + chats.rows[2] + .task + .as_mut() + .unwrap() + .amounts + .weekly_limit_percent = Some(125.0); + assert_eq!(view.task_metrics(), vec![0, 1, 2]); + insta::assert_snapshot!( + "consumer_chat_all_metrics", + screen(&mut view, /*width*/ 110, /*height*/ 30) + ); + press(&mut view, KeyCode::Char('s')); + insta::assert_snapshot!( + "consumer_chat_available_limits", + screen(&mut view, /*width*/ 58, /*height*/ 30) + ); +} + +#[test] +fn consumer_overview_shows_top_five_and_closes_with_retained_details() { + let mut view = fixture::view(models::AccountKind::Consumer); + view.section = Section::Chats; + view.tasks = Load::Ready(Chats { + rows: (0..6).map(|index| Chat { + title: format!("Task {index}"), + task: Some(serde_json::from_value(json!({ + "thread_id":format!("task-{index}"), "data_status":"available", "usage_source":"credits", + "balance_usage_credits":index.to_string(), "groups":[] + })).unwrap()), + }).collect(), + ..Chats::default() + }); + press(&mut view, KeyCode::End); + press(&mut view, KeyCode::Enter); + assert_eq!(view.sections[Section::Chats].detail, Some(5)); + press(&mut view, KeyCode::Char('z')); + let content = view + .task_lines(/*width*/ 70) + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert!(content.contains("Task 5") && !content.contains("Task 0")); + insta::assert_snapshot!(content); + press(&mut view, KeyCode::Esc); + assert!(view.is_done); +} diff --git a/codex-rs/tui/src/analytics/dashboard.rs b/codex-rs/tui/src/analytics/dashboard.rs index d86828df03..099862fe3d 100644 --- a/codex-rs/tui/src/analytics/dashboard.rs +++ b/codex-rs/tui/src/analytics/dashboard.rs @@ -113,6 +113,9 @@ impl AnalyticsView { .lines, ), Section::Plan => content.extend(self.plan_lines(inner_width).0), + Section::Chats if !self.business() => { + content.extend(self.task_lines(inner_width).0) + } Section::Chats => { content.push( "30d active · lifetime credits" diff --git a/codex-rs/tui/src/analytics/panels.rs b/codex-rs/tui/src/analytics/panels.rs index 4c45b68f2e..d9492c29a2 100644 --- a/codex-rs/tui/src/analytics/panels.rs +++ b/codex-rs/tui/src/analytics/panels.rs @@ -84,7 +84,11 @@ impl AnalyticsView { selection = lines.len() + detail.start..lines.len() + detail.end; lines.extend(plan); } else if section == Section::Chats { - let (chats, detail) = self.chat_lines(width); + let (chats, detail) = if self.business() { + self.chat_lines(width) + } else { + self.task_lines(width) + }; selection = lines.len() + detail.start..lines.len() + detail.end; lines.extend(chats); } else { diff --git a/codex-rs/tui/src/analytics/render.rs b/codex-rs/tui/src/analytics/render.rs index 5509d2b768..fecaef1e79 100644 --- a/codex-rs/tui/src/analytics/render.rs +++ b/codex-rs/tui/src/analytics/render.rs @@ -140,7 +140,15 @@ impl AnalyticsView { format!( "tab/1–{} section · {}{navigation}\n{}{}R refresh · {}/{} scroll · {} back · q close", self.visible_sections().len(), - if self.zoomed { "z dashboard · " } else { "" }, + if self.section == Section::Chats && !self.business() && !self.zoomed { + "s sort · " + } else if self.section == Section::Chats && !self.business() { + "s sort · z dashboard · " + } else if self.zoomed { + "z dashboard · " + } else { + "" + }, if show_range { "r 7/30d · " } else { "" }, if self.group_options().len() < 2 { "" diff --git a/codex-rs/tui/src/analytics/sections.rs b/codex-rs/tui/src/analytics/sections.rs index 8d684b3c21..bb8435594a 100644 --- a/codex-rs/tui/src/analytics/sections.rs +++ b/codex-rs/tui/src/analytics/sections.rs @@ -81,6 +81,7 @@ impl AnalyticsView { section.history.poll(); } self.chats.poll(); + self.tasks.poll(); self.plan.poll(); self.account.poll(); self.start_reports(); @@ -96,6 +97,11 @@ impl AnalyticsView { { self.group_picker = None; } + if let Some(chats) = self.tasks.ready() { + self.sections[Section::Chats].cursor = self.sections[Section::Chats] + .cursor + .min(chats.rows.len().saturating_sub(/*rhs*/ 1)); + } if let Some(chats) = self.chats.ready() { self.sections[Section::Chats].cursor = self.sections[Section::Chats] .cursor @@ -145,12 +151,14 @@ impl AnalyticsView { Section::Activity, Section::Plugins, Section::Skills, + Section::Chats, ], Some(AccountKind::Consumer) => &[ Section::Usage, Section::Activity, Section::Plugins, Section::Skills, + Section::Chats, ], Some(AccountKind::Business | AccountKind::Enterprise) if super::models::thread_usage_supported(self.account.ready().copied()) => @@ -216,7 +224,15 @@ impl AnalyticsView { 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) { + if visible.contains(&Section::Chats) && !self.business() { + if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) { + self.tasks = Load::start_with_timeout( + super::tasks::read(handle.clone(), std::sync::Arc::clone(live)), + frame.clone(), + std::time::Duration::from_secs(/*secs*/ 120), + ); + } + } else if visible.contains(&Section::Chats) { self.chats = if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) { Load::start_with_timeout( diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap index c5a08a30a1..ad3ee03c0b 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap @@ -1,11 +1,11 @@ --- source: tui/src/analytics/plan_tests.rs -assertion_line: 53 +assertion_line: 56 expression: "output.join(\"\\n\")" --- " Analytics · Live account " " " -" 1 Total usage history [2 Plan usage history] 3 Messages 4 Plugins called 5 Skills used " +" 1 Total usage history [2 Plan usage history] 3 Messages 4 Plugins called 5 Skills used 6 Top chats " " " " ▎ Plan usage history By feature " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -31,13 +31,13 @@ expression: "output.join(\"\\n\")" " " " " " " -" tab/1–5 section · z dashboard · ←/→ window · ↑/↓ period · enter details " +" tab/1–6 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 " +" Plugins called Skills used Top chats " " " " ▎ Plan usage history By feature " " ────────────────────────────────────────────────────── " @@ -60,7 +60,7 @@ expression: "output.join(\"\\n\")" " " " " " " -" tab/1–5 section · z dashboard · ←/→ window · ↑/↓ " +" tab/1–6 section · z dashboard · ←/→ window · ↑/↓ " " period · enter details " " g group · R refresh · pgup/pgdn scroll · esc back · q " " close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap index 43cc027334..2caadc1cfb 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap @@ -1,6 +1,6 @@ --- source: tui/src/analytics/plan_tests.rs -assertion_line: 183 +assertion_line: 210 expression: output --- " Analytics · Live account " @@ -43,23 +43,23 @@ expression: output " │ │ │ │ " " ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " " " -" ╭─ 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 " +" ╭─ 5 Skills used ────────────────────────────────────╮ ╭─ 6 Top chats ──────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ No history has been reported. │ " +" │ 54 uses │ │ │ " +" │ 11 │ │ │ " +" │ 20 ▃▃▃▃ ▂▂▂▂ ▃▃▃▃ ▃▃▃▃ ▂▂▂▂ ▂▂▂▂ ▄▄▄▄ │ │ │ " +" │ 0 ──────────────────────────────────────────────── │ │ │ " +" │ ▲ │ │ │ " +" │ 27 28 29 30 31 1 2 │ │ │ " +" │ │ │ │ " +" │ Sep 2 · 11 uses │ │ │ " +" │ ● Testing 4 │ │ │ " +" │ ● Code review 3 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " +" tab/1–6 section · enter/z maximize " " g group · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_layouts_show_reports_and_wrap_navigation.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_layouts_show_reports_and_wrap_navigation.snap index 8197f593f3..323d7a2b1a 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_layouts_show_reports_and_wrap_navigation.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__account_layout__account_layouts_show_reports_and_wrap_navigation.snap @@ -1,11 +1,12 @@ --- source: tui/src/analytics/account_layout_tests.rs +assertion_line: 47 expression: "screens.join(\"\\n\")" --- " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used " +" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Total usage history By feature " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -30,13 +31,13 @@ expression: "screens.join(\"\\n\")" " ● Images 11.6% " " ● Other 10.5% " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used " +" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Messages By model " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -61,13 +62,13 @@ expression: "screens.join(\"\\n\")" " ● GPT-5.5 10 " " ● Other 7 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used " +" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used 5 Top chats " " " " ▎ Plugins called " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -92,13 +93,13 @@ expression: "screens.join(\"\\n\")" " ● GitHub 5 " " ● Notion 4 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] " +" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] 5 Top chats " " " " ▎ Skills used " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -123,25 +124,50 @@ expression: "screens.join(\"\\n\")" " ● Planning 3 " " ● Debugging 1 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " +" Analytics · Live account " +" " +" 1 Total usage history 2 Messages 3 Plugins called 4 Skills used [5 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · pgup/pgdn scroll · esc back · q close " + " Analytics · Live account " -" [7d] 30d · Aug 27–Sep 2, 2026 " " " " ╭─ 1 Total usage history ──────────────────────────────────────╮ ╭─ 2 Messages ─────────────────────────────────────────────────╮ " " │ │ │ │ " " │ 7d │ │ 7d │ " " │ By feature │ │ By model │ " " │ Total usage · relative units │ │ 574 reported messages │ " -" │ 86 │ │ │ " -" │ 100 ▂▂▂▂▂ █████ │ │ 150 │ " -" │ █████ ▅▅▅▅▅ █████ ██████ │ │ 103 │ " -" │ █████ ▃▃▃▃▃ █████ █████ █████ ██████ │ │ ▇▇▇▇▇ ▆▆▆▆▆▆ │ " -" │ 50 █████ █████ █████ █████ █████ ██████ │ │ 75 ▆▆▆▆▆ █████ ▄▄▄▄▄ ▇▇▇▇▇ ██████ │ " -" │ █████ █████ █████ █████ █████ ██████ │ │ ▇▇▇▇▇ █████ █████ █████ ▆▆▆▆▆ █████ ██████ │ " -" │ ▃▃▃▃▃ █████ █████ █████ █████ █████ ██████ │ │ █████ █████ █████ █████ █████ █████ ██████ │ " -" │ █████ █████ █████ █████ █████ █████ ██████ │ │ █████ █████ █████ █████ █████ █████ ██████ │ " +" │ 86 │ │ 103 │ " +" │ 100 ▃▃▃▃▃ ▂▂▂▂▂ ▆▆▆▆▆ ▄▄▄▄▄ █████ ▅▅▅▅▅▅ │ │ 150 ▃▃▃▃▃ ▁▁▁▁▁ ▂▂▂▂▂▂ │ " +" │ ▃▃▃▃▃ █████ █████ █████ █████ █████ ██████ │ │ ▆▆▆▆▆ █████ █████ █████ ▆▆▆▆▆ █████ ██████ │ " " │ 0 ───────────────────────────────────────────────────────── │ │ 0 ───────────────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " @@ -154,18 +180,13 @@ expression: "screens.join(\"\\n\")" " │ │ │ │ " " ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " " " -" ╭─ 3 Plugins called ───────────────────────────────────────────╮ ╭─ ▸ 4 Skills used ──────────────────────────────────────────────╮ " +" ╭─ 3 Plugins called ───────────────────────────────────────────╮ ╭─ 4 Skills used ──────────────────────────────────────────────╮ " " │ │ │ │ " " │ 7d │ │ 7d │ " " │ 186 calls │ │ 54 uses │ " -" │ │ │ │ " -" │ 40 29 │ │ 20 │ " -" │ ▇▇▇▇▇ │ │ │ " -" │ █████ █████ ▄▄▄▄▄▄ ██████ │ │ 11 │ " -" │ 20 █████ █████ █████ ██████ ▆▆▆▆▆ █████ ██████ │ │ 10 ▁▁▁▁▁ ▆▆▆▆▆▆ │ " -" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ ▆▆▆▆▆ █████ ▆▆▆▆▆▆ ▃▃▃▃▃ ██████ │ " -" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ █████ █████ █████ ██████ █████ ▆▆▆▆▆ ██████ │ " -" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ █████ █████ █████ ██████ █████ █████ ██████ │ " +" │ 29 │ │ 11 │ " +" │ 40 ▅▅▅▅▅ ▁▁▁▁▁ ▃▃▃▃▃ ▂▂▂▂▂▂ ▁▁▁▁▁ ▃▃▃▃▃▃ │ │ 20 │ " +" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ ▆▆▆▆▆ ▄▄▄▄▄ ▇▇▇▇▇ ▆▆▆▆▆▆ ▅▅▅▅▅ ▄▄▄▄▄ ██████ │ " " │ 0 ────────────────────────────────────────────────────────── │ │ 0 ────────────────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " @@ -179,17 +200,28 @@ expression: "screens.join(\"\\n\")" " │ │ │ │ " " ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " " " +" ╭─ ▸ 5 Top chats ────────────────────────────────────────────────╮ " +" │ │ " +" │ No history has been reported. │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰────────────────────────────────────────────────────────────────╯ " " " -" " -" " -" " -" " -" " -" " -" " -" " -" tab/1–4 section · enter/z maximize " -" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " +" tab/1–5 section · s sort · enter/z maximize " +" R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap new file mode 100644 index 0000000000..3e6802acc4 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +assertion_line: 61 +expression: "screen(&mut view, 110, 30)" +--- +" Analytics · Live account " +" " +" 1 Total usage history 2 Messages 3 Plugins called 4 Skills used [5 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Usage estimates · active in past 30 days · sorted by Weekly % " +" Recorded usage / current full limit · may exceed 100% " +" Partial ranking · 1 partial · 2 unavailable " +" " +" Chat Weekly % 5-hour % Balance credits " +" › Refunded task · partial 125.0% 0.0% -0.000004 " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Refunded task " +" Weekly % 125.0% " +" 5-hour % 0.0% " +" Balance credits -0.000004 " +" " +" Model / effort / speed Weekly % " +" gpt-5.5 — " +" high · fast · codex " +" " +" Chat usage unavailable — — — " +" Chat usage unavailable — — — " +" " +" Showing 1–3 of 3 chats " +" Local chats · includes discovered descendants · excludes archived roots " +" " +" tab/1–5 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_available_limits.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_available_limits.snap new file mode 100644 index 0000000000..3821cac405 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_available_limits.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +assertion_line: 66 +expression: "screen(&mut view, 58, 30)" +--- +" Analytics · Live account " +" " +" Total usage history Messages Plugins called Skills " +" used [Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────── " +" Usage estimates · active in past 30 days · sorted by " +" 5-hour % " +" Recorded usage / current full limit · may exceed 100% " +" Partial ranking · 1 partial · 2 unavailable " +" " +" Chat 5-hour % " +" › Refunded task · partial 0.0% " +" Chat usage unavailable — " +" Chat usage unavailable — " +" " +" Showing 1–3 of 3 chats " +" Local chats · includes discovered descendants · " +" excludes archived roots " +" " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · s sort · z dashboard · ↑/↓ row · " +" enter details " +" R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chats_keep_missing_rows_and_only_offer_known_metrics.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chats_keep_missing_rows_and_only_offer_known_metrics.snap new file mode 100644 index 0000000000..0a34dfe3a6 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chats_keep_missing_rows_and_only_offer_known_metrics.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +assertion_line: 43 +expression: "screen(&mut view, 90, 30)" +--- +" Analytics · Live account " +" " +" 1 Total usage history 2 Messages 3 Plugins called 4 Skills used [5 Top chats] " +" " +" ▎ Top chats " +" ────────────────────────────────────────────────────────────────────────────────────── " +" Usage estimates · active in past 30 days · sorted by Balance credits " +" Credits debited from balance · includes adjustments " +" Partial ranking · 1 partial · 2 unavailable " +" " +" Chat Balance credits " +" › Refunded task · partial -0.000004 " +" ────────────────────────────────────────────────────────────────────────────────────── " +" Refunded task " +" Balance credits -0.000004 " +" " +" Model / effort / speed Balance credits " +" gpt-5.5 -0.000004 " +" high · fast · codex " +" " +" Chat usage unavailable — " +" Chat usage unavailable — " +" " +" Showing 1–3 of 3 chats " +" Local chats · includes discovered descendants · excludes archived roots " +" " +" " +" " +" tab/1–5 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_overview_shows_top_five_and_closes_with_retained_details.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_overview_shows_top_five_and_closes_with_retained_details.snap new file mode 100644 index 0000000000..72d74b7c6a --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_overview_shows_top_five_and_closes_with_retained_details.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/analytics/consumer_refresh_tests.rs +assertion_line: 93 +expression: content +--- +Usage estimates · active in past 30 days · sorted by Balance credits +Credits debited from balance · includes adjustments + + Chat Balance credits + Task 5 5 + Task 4 4 + Task 3 3 + Task 2 2 + Task 1 1 + +Showing top 5 of 6 chats +Local chats · includes discovered descendants · excludes archived +roots diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap index 462c62a86a..19792d8188 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/dashboard_tests.rs +assertion_line: 83 expression: "format!(\"{wide}\\n{narrow}\")" --- " Analytics · Live account " @@ -10,14 +11,9 @@ expression: "format!(\"{wide}\\n{narrow}\")" " │ 7d │ │ 7d │ " " │ By feature │ │ By model │ " " │ Total usage · relative units │ │ 574 reported messages │ " -" │ 86 │ │ │ " -" │ 100 ▂▂▂▂▂ █████ │ │ 150 │ " -" │ █████ ▅▅▅▅▅▅ █████ ██████ │ │ 103 │ " -" │ █████ ▃▃▃▃▃▃ █████ ██████ █████ ██████ │ │ ▇▇▇▇▇▇ ▆▆▆▆▆▆ │ " -" │ 50 █████ ██████ █████ ██████ █████ ██████ │ │ 75 ▆▆▆▆▆ ██████ ▄▄▄▄▄ ▇▇▇▇▇ ██████ │ " -" │ █████ ██████ █████ ██████ █████ ██████ │ │ ▇▇▇▇▇ █████ ██████ █████ ▆▆▆▆▆▆ █████ ██████ │ " -" │ ▃▃▃▃▃ █████ ██████ █████ ██████ █████ ██████ │ │ █████ █████ ██████ █████ ██████ █████ ██████ │ " -" │ █████ █████ ██████ █████ ██████ █████ ██████ │ │ █████ █████ ██████ █████ ██████ █████ ██████ │ " +" │ 86 │ │ 103 │ " +" │ 100 ▃▃▃▃▃ ▂▂▂▂▂▂ ▆▆▆▆▆ ▄▄▄▄▄▄ █████ ▅▅▅▅▅▅ │ │ 150 ▃▃▃▃▃▃ ▁▁▁▁▁ ▂▂▂▂▂▂ │ " +" │ ▃▃▃▃▃ █████ ██████ █████ ██████ █████ ██████ │ │ ▆▆▆▆▆ █████ ██████ █████ ▆▆▆▆▆▆ █████ ██████ │ " " │ 0 ─────────────────────────────────────────────────────────── │ │ 0 ─────────────────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " @@ -34,14 +30,9 @@ expression: "format!(\"{wide}\\n{narrow}\")" " │ │ │ │ " " │ 7d │ │ 7d │ " " │ 186 calls │ │ 54 uses │ " -" │ │ │ │ " -" │ 40 29 │ │ 20 │ " -" │ ▇▇▇▇▇ │ │ │ " -" │ █████ █████ ▄▄▄▄▄▄ ██████ │ │ 11 │ " -" │ 20 █████ ██████ █████ ██████ ▆▆▆▆▆ ██████ ██████ │ │ 10 ▁▁▁▁▁ ▆▆▆▆▆▆ │ " -" │ █████ ██████ █████ ██████ █████ ██████ ██████ │ │ ▆▆▆▆▆ █████ ▆▆▆▆▆▆ ▃▃▃▃▃ ██████ │ " -" │ █████ ██████ █████ ██████ █████ ██████ ██████ │ │ █████ ██████ █████ ██████ █████ ▆▆▆▆▆▆ ██████ │ " -" │ █████ ██████ █████ ██████ █████ ██████ ██████ │ │ █████ ██████ █████ ██████ █████ ██████ ██████ │ " +" │ 29 │ │ 11 │ " +" │ 40 ▅▅▅▅▅ ▁▁▁▁▁▁ ▃▃▃▃▃ ▂▂▂▂▂▂ ▁▁▁▁▁▁ ▃▃▃▃▃▃ │ │ 20 │ " +" │ █████ ██████ █████ ██████ █████ ██████ ██████ │ │ ▆▆▆▆▆ ▄▄▄▄▄▄ ▇▇▇▇▇ ▆▆▆▆▆▆ ▅▅▅▅▅ ▄▄▄▄▄▄ ██████ │ " " │ 0 ──────────────────────────────────────────────────────────── │ │ 0 ──────────────────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " @@ -55,16 +46,26 @@ expression: "format!(\"{wide}\\n{narrow}\")" " │ │ │ │ " " ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " " " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" tab/1–4 section · enter/z maximize " +" ╭─ 5 Top chats ──────────────────────────────────────────────────╮ " +" │ │ " +" │ No history has been reported. │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰──────────────────────────────────────────────────────────────────╯ " +" tab/1–5 section · enter/z maximize " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " @@ -112,6 +113,6 @@ expression: "format!(\"{wide}\\n{narrow}\")" " │ │ " " │ │ " " │ │ " -" tab/1–4 section · enter/z maximize " +" tab/1–5 section · enter/z maximize " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q " " close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_1.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_1.snap index 9e229152b2..c1c35c1e88 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_1.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_1.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used " +" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Total usage history By feature " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● Images 11.6% " " ● Other 10.5% " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:3 27:2 70:0 +3: 0:0 2:3 27:2 83:0 4: 0:0 5: 0:0 2:3 23:0 108:2 118:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_2.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_2.snap index 1e87635832..7491345cda 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_2.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_2.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used " +" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used 5 Top chats " " " " ▎ Plugins called " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● GitHub 5 " " ● Notion 4 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:2 37:3 57:2 70:0 +3: 0:0 2:2 37:3 57:2 83:0 4: 0:0 5: 0:0 2:3 18:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_4.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_4.snap new file mode 100644 index 0000000000..5e99ed20a1 --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_4.snap @@ -0,0 +1,99 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 142 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" 1 Total usage history 2 Messages 3 Plugins called 4 Skills used [5 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · pgup/pgdn scroll · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(165, 165, 165)|Reset|Reset|NONE +3: Cyan|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: White|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 2:2 70:3 83:0 +3: 0:0 +4: 0:0 2:3 13:0 +5: 0:0 2:4 118:0 +6: 0:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:5 9:2 20:5 21:2 29:5 30:2 43:5 46:2 53:5 58:2 118:0 +41: 0:0 2:5 3:2 14:5 23:2 33:5 36:2 44:5 45:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_5.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_5.snap index 874894434b..9af31e2ec7 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_5.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_5.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used " +" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Messages By model " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● GPT-5.5 10 " " ● Other 7 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:2 25:3 39:2 70:0 +3: 0:0 2:2 25:3 39:2 83:0 4: 0:0 5: 0:0 2:3 12:0 110:2 118:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_6.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_6.snap index c6d14747fd..2f91d2fe20 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_6.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_dark_consumer_6.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] " +" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] 5 Top chats " " " " ▎ Skills used " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● Planning 3 " " ● Debugging 1 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:2 55:3 70:0 +3: 0:0 2:2 55:3 72:2 83:0 4: 0:0 5: 0:0 2:3 15:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_1.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_1.snap index d32cd8e323..de8e09d47d 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_1.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_1.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used " +" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Total usage history By feature " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● Images 11.6% " " ● Other 10.5% " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:3 27:2 70:0 +3: 0:0 2:3 27:2 83:0 4: 0:0 5: 0:0 2:3 23:0 108:2 118:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_2.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_2.snap index cc9f2f9e1b..ea9d3418aa 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_2.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_2.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used " +" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used 5 Top chats " " " " ▎ Plugins called " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● GitHub 5 " " ● Notion 4 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:2 37:3 57:2 70:0 +3: 0:0 2:2 37:3 57:2 83:0 4: 0:0 5: 0:0 2:3 18:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_4.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_4.snap new file mode 100644 index 0000000000..3ce8a5171e --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_4.snap @@ -0,0 +1,99 @@ +--- +source: tui/src/analytics/styles_tests.rs +assertion_line: 142 +expression: snapshot +--- +120x42 +Text: +" Analytics · Live account " +" " +" 1 Total usage history 2 Messages 3 Plugins called 4 Skills used [5 Top chats] " +" " +" ▎ Top chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" No history has been reported. " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" tab/1–5 section · s sort · z dashboard · ↑/↓ row · enter details " +" R refresh · pgup/pgdn scroll · esc back · q close " +Palette (foreground|background|underline|modifiers): +0: Reset|Reset|Reset|NONE +1: Reset|Reset|Reset|BOLD +2: Rgb(76, 76, 76)|Reset|Reset|NONE +3: Rgb(0, 95, 135)|Reset|Reset|BOLD +4: Reset|Reset|Reset|DIM +5: White|Reset|Reset|BOLD +Rows (column:palette): +0: 0:0 2:1 11:2 28:0 +1: 0:0 +2: 0:0 2:2 70:3 83:0 +3: 0:0 +4: 0:0 2:3 13:0 +5: 0:0 2:4 118:0 +6: 0:0 +7: 0:0 +8: 0:0 +9: 0:0 +10: 0:0 +11: 0:0 +12: 0:0 +13: 0:0 +14: 0:0 +15: 0:0 +16: 0:0 +17: 0:0 +18: 0:0 +19: 0:0 +20: 0:0 +21: 0:0 +22: 0:0 +23: 0:0 +24: 0:0 +25: 0:0 +26: 0:0 +27: 0:0 +28: 0:0 +29: 0:0 +30: 0:0 +31: 0:0 +32: 0:0 +33: 0:0 +34: 0:0 +35: 0:0 +36: 0:0 +37: 0:0 +38: 0:0 +39: 0:0 +40: 0:0 2:5 9:2 20:5 21:2 29:5 30:2 43:5 46:2 53:5 58:2 118:0 +41: 0:0 2:5 3:2 14:5 23:2 33:5 36:2 44:5 45:2 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_5.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_5.snap index 52460f0d5f..e10acdd3b9 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_5.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_5.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used " +" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Messages By model " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● GPT-5.5 10 " " ● Other 7 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:2 25:3 39:2 70:0 +3: 0:0 2:2 25:3 39:2 83:0 4: 0:0 5: 0:0 2:3 12:0 110:2 118:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_6.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_6.snap index 3e0f8610ad..8fd45a95d7 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_6.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__terminal_styles__analytics_light_consumer_6.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics/styles_tests.rs +assertion_line: 142 expression: snapshot --- 120x42 @@ -7,7 +8,7 @@ Text: " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] " +" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] 5 Top chats " " " " ▎ Skills used " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,7 +45,7 @@ Text: " ● Planning 3 " " ● Debugging 1 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " Palette (foreground|background|underline|modifiers): 0: Reset|Reset|Reset|NONE @@ -61,7 +62,7 @@ Rows (column:palette): 0: 0:0 2:1 11:2 28:0 1: 0:0 2:3 6:0 11:2 34:0 2: 0:0 -3: 0:0 2:2 55:3 70:0 +3: 0:0 2:2 55:3 72:2 83:0 4: 0:0 5: 0:0 2:3 15:0 6: 0:0 2:4 118:0 diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_handle_empty_days_and_independent_failures.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_handle_empty_days_and_independent_failures.snap index e98dfe9abc..aeb3f1a814 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_handle_empty_days_and_independent_failures.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_handle_empty_days_and_independent_failures.snap @@ -1,11 +1,12 @@ --- source: tui/src/analytics/tool_panel_tests.rs +assertion_line: 78 expression: "format!(\"{empty}\\n{missing}\\n{partial}\")" --- " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] " +" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] 5 Top chats " " " " ▎ Skills used " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -44,13 +45,13 @@ expression: "format!(\"{empty}\\n{missing}\\n{partial}\")" " " " " " " -" tab/1–4 section · z dashboard · ←/→ day " +" tab/1–5 section · z dashboard · ←/→ day " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] " +" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] 5 Top chats " " " " ▎ Skills used " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -89,13 +90,13 @@ expression: "format!(\"{empty}\\n{missing}\\n{partial}\")" " " " " " " -" tab/1–4 section · z dashboard · ←/→ day " +" tab/1–5 section · z dashboard · ←/→ day " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] " +" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] 5 Top chats " " " " ▎ Skills used " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -134,5 +135,5 @@ expression: "format!(\"{empty}\\n{missing}\\n{partial}\")" " " " " " " -" tab/1–4 section · z dashboard · ←/→ day " +" tab/1–5 section · z dashboard · ←/→ day " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_reflow_and_keep_independent_days.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_reflow_and_keep_independent_days.snap index 9679c35efc..df5d929d6c 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_reflow_and_keep_independent_days.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__tools_panel__analytics_tools_reflow_and_keep_independent_days.snap @@ -1,11 +1,12 @@ --- source: tui/src/analytics/tool_panel_tests.rs +assertion_line: 46 expression: "format!(\"{wide}\\n{narrow}\")" --- " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used " +" 1 Total usage history 2 Messages [3 Plugins called] 4 Skills used 5 Top chats " " " " ▎ Plugins called " " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " @@ -48,14 +49,14 @@ expression: "format!(\"{wide}\\n{narrow}\")" " ● GitHub 5 " " ● Notion 4 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " " Total usage history Messages Plugins called [Skills " -" used] " +" used] Top chats " " " " ▎ Skills used " " ────────────────────────────────────────────────────── " @@ -79,7 +80,7 @@ expression: "format!(\"{wide}\\n{narrow}\")" " ● Planning 1 " " ● Debugging 0 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter " +" tab/1–5 section · z dashboard · ←/→ day · enter " " details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q " " close " diff --git a/codex-rs/tui/src/analytics/task_panel.rs b/codex-rs/tui/src/analytics/task_panel.rs new file mode 100644 index 0000000000..6f9c182610 --- /dev/null +++ b/codex-rs/tui/src/analytics/task_panel.rs @@ -0,0 +1,344 @@ +//! Consumer chat metrics retain actual balance debits, current-limit comparisons, and partial states. + +use super::AnalyticsView; +use super::render::columns; +use super::sections::Section; +use super::styles::number; +use super::styles::secondary_style; +use super::tasks::Chat; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow as truncate; +use crate::style::accent_style; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use codex_backend_client::TaskUsageAmounts; +use codex_backend_client::TaskUsageStatus; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::cmp::Ordering; +use std::ops::Range; + +pub(super) const METRICS: [&str; 3] = ["Weekly %", "5-hour %", "Balance credits"]; + +fn compare(a: Option<&TaskUsageAmounts>, b: Option<&TaskUsageAmounts>, metric: usize) -> Ordering { + if metric == 2 { + a.and_then(|a| a.balance_usage_credits.as_ref()) + .cmp(&b.and_then(|b| b.balance_usage_credits.as_ref())) + } else { + let value = |amounts: &TaskUsageAmounts| { + if metric == 0 { + amounts.weekly_limit_percent + } else { + amounts.five_hour_limit_percent + } + }; + a.and_then(value) + .partial_cmp(&b.and_then(value)) + .unwrap_or(Ordering::Equal) + } +} + +pub(super) fn amount(amounts: Option<&TaskUsageAmounts>, metric: usize) -> String { + let Some(amounts) = amounts else { + return "—".into(); + }; + if metric == 2 { + amounts + .balance_usage_credits + .as_ref() + .map(|amount| amount.as_str().to_string()) + .unwrap_or_else(|| "—".into()) + } else { + (if metric == 0 { + amounts.weekly_limit_percent + } else { + amounts.five_hour_limit_percent + }) + .map(|value| format!("{value:.1}%")) + .unwrap_or_else(|| "—".into()) + } +} + +pub(super) fn available(chat: &Chat) -> Option<&TaskUsageAmounts> { + chat.task + .as_ref() + .filter(|task| task.data_status != TaskUsageStatus::Unavailable) + .map(|task| &task.amounts) +} + +impl AnalyticsView { + pub(super) fn task_metrics(&self) -> Vec { + (0..3) + .filter(|metric| { + *metric == 2 + || self.tasks.ready().is_some_and(|chats| { + chats.rows.iter().any(|chat| { + available(chat).is_some_and(|amounts| { + if *metric == 0 { + amounts.weekly_limit_percent.is_some() + } else { + amounts.five_hour_limit_percent.is_some() + } + }) + }) + }) + }) + .collect() + } + + pub(super) fn task_metric(&self) -> usize { + let metrics = self.task_metrics(); + if metrics.contains(&self.chat_metric) { + self.chat_metric + } else { + metrics[0] + } + } + + pub(super) fn task_rows(&self) -> Vec<&Chat> { + let mut rows = self + .tasks + .ready() + .map(|chats| chats.rows.iter().collect::>()) + .unwrap_or_default(); + rows.sort_by(|a, b| compare(available(b), available(a), self.task_metric())); + rows + } + + pub(super) fn task_lines(&self, width: usize) -> (Vec>, Range) { + let width = width.clamp(/*min*/ 1, /*max*/ 110); + let wrap = |lines| word_wrap_lines(lines, RtOptions::new(width)); + let Some(chats) = self.tasks.ready() else { + return ( + wrap(vec![ + self.tasks + .message() + .unwrap_or("No task usage reported.") + .to_string() + .into(), + ]), + 0..1, + ); + }; + let metric = self.task_metric(); + let mut lines = wrap(vec![ + format!( + "Usage estimates · active in past 30 days · sorted by {}", + METRICS[metric] + ) + .set_style(secondary_style()) + .into(), + if metric == 2 { + "Credits debited from balance · includes adjustments" + } else { + "Recorded usage / current full limit · may exceed 100%" + } + .set_style(secondary_style()) + .into(), + ]); + if chats.rows.is_empty() { + lines.push("No recent local chats.".into()); + return (lines, 0..1); + } + let missing = chats + .rows + .iter() + .filter(|chat| amount(available(chat), metric) == "—") + .count(); + let partial = chats + .rows + .iter() + .filter(|chat| { + chat.task + .as_ref() + .is_some_and(|task| task.data_status == TaskUsageStatus::Partial) + }) + .count(); + if missing + partial > 0 { + lines.extend(wrap(vec![ + format!("Partial ranking · {partial} partial · {missing} unavailable") + .set_style(secondary_style()) + .into(), + ])); + } + let metrics = self.task_metrics(); + let widths = metrics + .iter() + .map(|index| { + chats + .rows + .iter() + .map(|chat| amount(available(chat), *index).len()) + .max() + .unwrap_or_default() + .max(METRICS[*index].len()) + }) + .collect::>(); + let show_all = widths.iter().sum::() + widths.len() * 2 + 24 <= width; + let metric_text = |chat: Option<&Chat>| { + if !show_all { + return chat.map_or_else( + || METRICS[metric].to_string(), + |chat| amount(available(chat), metric), + ); + } + metrics + .iter() + .zip(&widths) + .map(|(index, column_width)| { + let value = chat.map_or_else( + || METRICS[*index].to_string(), + |chat| amount(available(chat), *index), + ); + format!("{value:>column_width$}") + }) + .collect::>() + .join(" ") + }; + lines.push(Line::default()); + lines.push(columns( + " Chat".bold().into(), + metric_text(/*chat*/ None).bold().into(), + width, + )); + let rows = self + .task_rows() + .into_iter() + .enumerate() + .map(|(index, chat)| { + let selected = + self.section == Section::Chats && index == self.sections[Section::Chats].cursor; + let value = metric_text(Some(chat)); + let status = if chat + .task + .as_ref() + .is_some_and(|task| task.data_status == TaskUsageStatus::Partial) + { + " · partial" + } else { + "" + }; + let label = format!( + "{} {}{status}", + if selected { "›" } else { " " }, + chat.display_title() + ); + let label: Line<'static> = if selected { + label.set_style(accent_style()).into() + } else { + label.into() + }; + let mut row = vec![columns( + truncate(label, width.saturating_sub(value.len() + 1)), + if value == "—" { + value.set_style(secondary_style()).into() + } else if selected { + number(value).into() + } else { + value.into() + }, + width, + )]; + if self.zoomed && self.sections[Section::Chats].detail == Some(index) { + row.push("─".repeat(width).dim().into()); + row.extend(word_wrap_lines( + [chat.display_title().to_owned().bold()], + RtOptions::new(width) + .initial_indent(" ".into()) + .subsequent_indent(" ".into()), + )); + for index in self.task_metrics() { + let label = METRICS[index]; + row.push(columns( + format!(" {label}").into(), + amount(available(chat), index).into(), + width, + )); + } + if let Some(task) = &chat.task { + if task.data_status != TaskUsageStatus::Unavailable + && !task.groups.is_empty() + { + let mut groups = task.groups.iter().collect::>(); + groups.sort_by(|a, b| { + compare(Some(&b.amounts), Some(&a.amounts), metric) + }); + row.push(Line::default()); + row.push(columns( + " Model / effort / speed".bold().into(), + METRICS[metric].bold().into(), + width, + )); + for group in groups { + let model = self + .model_name(group.model.as_deref().unwrap_or("Not reported")); + let value = amount(Some(&group.amounts), metric); + row.push(columns( + truncate( + format!(" {model}").bold().into(), + width.saturating_sub(value.len() + 1), + ), + value.into(), + width, + )); + row.push( + format!( + " {} · {} · {}", + group.reasoning_effort.as_deref().unwrap_or("Not reported"), + group.speed.as_deref().unwrap_or("Not reported"), + group + .product_experience + .as_deref() + .unwrap_or("Not reported") + ) + .set_style(secondary_style()) + .into(), + ); + } + } + } else { + row.push( + " Task usage unavailable." + .set_style(secondary_style()) + .into(), + ); + } + row.push(Line::default()); + } + wrap(row) + }) + .collect::>(); + let mut coverage = vec![ + "Local chats · includes discovered descendants · excludes archived roots" + .set_style(secondary_style()) + .into(), + ]; + if chats.truncated { + coverage.push( + "Partial ranking · 100 most recently active chats" + .set_style(secondary_style()) + .into(), + ); + } + if let Some(time) = chats.updated_at { + coverage.push( + format!( + "Updated {} UTC · recent activity may be delayed", + time.format("%b %-d %H:%M") + ) + .set_style(secondary_style()) + .into(), + ); + } + if !self.zoomed { + let count = rows.len(); + lines.extend(rows.into_iter().take(/*n*/ 5).flatten()); + lines.push(Line::default()); + lines.push(format!("Showing top {} of {count} chats", count.min(/*other*/ 5)).into()); + lines.extend(wrap(coverage)); + (lines, 0..1) + } else { + self.chat_window(lines, rows, wrap(coverage), width) + } + } +} diff --git a/codex-rs/tui/src/analytics/tasks.rs b/codex-rs/tui/src/analytics/tasks.rs new file mode 100644 index 0000000000..5888039f55 --- /dev/null +++ b/codex-rs/tui/src/analytics/tasks.rs @@ -0,0 +1,244 @@ +//! Consumer task queries preserve missing amounts and require complete descendant groups. +use super::client::Live; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_backend_client::TaskUsage; +use codex_backend_client::TaskUsageThread; +use futures::StreamExt; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +pub(super) struct Chat { + pub title: String, + pub task: Option, +} +impl Chat { + /// Local history can span accounts; unavailable task data does not verify ownership. + pub(super) fn display_title(&self) -> &str { + if self.task.as_ref().is_some_and(|task| { + task.data_status != codex_backend_client::TaskUsageStatus::Unavailable + }) { + &self.title + } else { + "Chat usage unavailable" + } + } +} + +#[derive(Default)] +pub(super) struct Chats { + pub rows: Vec, + pub truncated: bool, + pub updated_at: Option>, +} + +pub(super) async fn read( + handle: AppServerRequestHandle, + live: Arc, +) -> Result, String> { + let session = live.session().await?; + if session.kind != super::models::AccountKind::Consumer { + return Ok(None); + } + session.backend.ensure_identity().await?; + let mut roots = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 60), + super::chats::roots(&handle), + ) + .await + .map_err(|_| "Chat listing timed out. Press R to retry.".to_string())??; + session.backend.ensure_identity().await?; + roots.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let truncated = roots.len() > 100; + roots.truncate(/*len*/ 100); + let discoveries = roots + .iter() + .map(|root| descendants(&handle, root)) + .collect::>(); + let mut pending = futures::stream::iter(discoveries).buffer_unordered(/*n*/ 2); + let mut queries = Vec::new(); + // Keep complete groups collected before the deadline; never query partial descendants. + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 25); + while let Ok(Some(result)) = tokio::time::timeout_at(deadline, pending.next()).await { + if let Ok(query) = result { + queries.push(query); + } + } + drop(pending); + let mut seen = HashSet::new(); + let mut batches = Vec::new(); + let mut batch = Vec::new(); + let mut count = 0; + for query in queries { + let ids = std::iter::once(&query.thread_id).chain(&query.descendant_thread_ids); + if ids.clone().any(|id| !seen.insert(id.clone())) { + return Err("Task descendant groups overlap.".into()); + } + let size = 1 + query.descendant_thread_ids.len(); + if count + size > 1_000 || batch.len() == 100 { + batches.push(std::mem::take(&mut batch)); + count = 0; + } + count += size; + batch.push(query); + } + if !batch.is_empty() { + batches.push(batch); + } + let requests = batches + .into_iter() + .map(|threads| async move { + session + .backend + .request(|client| { + let threads = threads.clone(); + async move { client.get_task_usage(&threads).await } + }) + .await + }) + .collect::>(); + let expected_batches = requests.len(); + let mut completed_batches = 0; + let mut pending = futures::stream::iter(requests).buffer_unordered(/*n*/ 2); + let mut usage = HashMap::new(); + let mut freshness = Vec::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 25); + while let Ok(Some(result)) = tokio::time::timeout_at(deadline, pending.next()).await { + completed_batches += 1; + match result { + Ok(response) => { + freshness.push( + response + .data_as_of + .as_deref() + .map(chrono::DateTime::parse_from_rfc3339) + .transpose() + .map_err(|_| "Invalid task usage timestamp.")? + .map(|time| time.with_timezone(&chrono::Utc)), + ); + usage.extend( + response + .threads + .into_iter() + .map(|task| (task.thread_id.clone(), task)), + ); + } + Err(error) + if error + .status() + .is_some_and(|status| matches!(status.as_u16(), 404 | 503)) => + { + freshness.push(/*value*/ None); + } + Err(error) => return Err(super::client::request_error(error)), + } + } + if completed_batches < expected_batches { + freshness.push(/*value*/ None); + } + drop(pending); + session.backend.ensure_identity().await?; + Ok(Some(Chats { + truncated, + updated_at: if freshness.iter().all(Option::is_some) { + freshness.into_iter().flatten().min() + } else { + None + }, + rows: roots + .into_iter() + .map(|thread| Chat { + task: usage.remove(&thread.id), + title: thread + .name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| { + if thread.preview.trim().is_empty() { + "Untitled chat".into() + } else { + thread.preview + } + }), + }) + .collect(), + })) +} + +async fn descendants( + handle: &AppServerRequestHandle, + root: &Thread, +) -> Result { + let mut ids = HashSet::new(); + let mut parents = HashMap::new(); + for archived in [false, true] { + let mut cursor = None; + let mut seen = HashSet::new(); + loop { + let page: ThreadListResponse = handle + .request_typed(ClientRequest::ThreadList { + request_id: RequestId::String(uuid::Uuid::new_v4().to_string()), + params: ThreadListParams { + ancestor_thread_id: Some(root.id.clone()), + archived: Some(archived), + cursor, + limit: Some(100), + ..super::chats::list_params() + }, + }) + .await + .map_err(super::data::error)?; + for thread in page.data { + let parent = thread + .parent_thread_id + .filter(|_| thread.id != root.id) + .ok_or("Task descendant discovery is unsupported.")?; + parents.insert(thread.id.clone(), parent); + ids.insert(thread.id); + } + if ids.len() >= 1_000 { + return Err("Task exceeds the reporting limit.".into()); + } + match page.next_cursor { + Some(next) if seen.insert(next.clone()) => cursor = Some(next), + Some(_) => return Err("Task listing repeated a cursor.".into()), + None => break, + } + } + } + // Older servers can ignore the ancestor filter. Prove every returned chain reaches this root. + for id in &ids { + let mut current = id; + let mut chain = HashSet::new(); + while current != &root.id { + if !chain.insert(current) { + return Err("Task descendant cycle.".into()); + } + current = parents + .get(current) + .ok_or("Task descendant discovery is incomplete.")?; + } + } + let mut descendant_thread_ids = ids.into_iter().collect::>(); + descendant_thread_ids.sort(); + Ok(TaskUsageThread { + thread_id: root.id.clone(), + created_at: (root.created_at > 0) + .then(|| chrono::DateTime::from_timestamp(root.created_at, /*nsecs*/ 0)) + .flatten() + .map(|time| time.to_rfc3339()), + descendant_thread_ids, + }) +} + +#[cfg(test)] +#[path = "tasks_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/tasks_tests.rs b/codex-rs/tui/src/analytics/tasks_tests.rs new file mode 100644 index 0000000000..526ebf226f --- /dev/null +++ b/codex-rs/tui/src/analytics/tasks_tests.rs @@ -0,0 +1,132 @@ +//! Consumer HTTP contract, descendant discovery, and partial-result regression coverage. + +use super::super::client::tests::live; +use super::*; +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; + +#[tokio::test] +async fn consumer_roots_include_paginated_archived_descendants_and_keep_missing_usage() { + for (unsupported, status) in [(false, 200), (true, 200), (false, 404), (false, 503)] { + use codex_app_server_client::RemoteAppServerClient; + use codex_app_server_client::RemoteAppServerConnectArgs; + use codex_app_server_client::RemoteAppServerEndpoint; + use futures::SinkExt; + use tokio_tungstenite::tungstenite::Message; + + let server = MockServer::start().await; + let (_home, live) = live(&server, "plus").await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let websocket_url = format!("ws://{}", listener.local_addr().unwrap()); + let rpc = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let mut pages = 0; + while let Some(Ok(Message::Text(text))) = socket.next().await { + let request: serde_json::Value = serde_json::from_str(&text).unwrap(); + let result = match request["method"].as_str() { + Some("initialize") => { + json!({"userAgent":"analytics-test", "codexHome":"/unused"}) + } + Some("thread/list") => { + pages += 1; + if pages == 1 { + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 31)).await; + tokio::time::resume(); + } + let params = &request["params"]; + assert_eq!(params["modelProviders"], json!([])); + assert_ne!(params["useStateDbOnly"], json!(true)); + let ancestor = params["ancestorThreadId"].as_str(); + let archived = params["archived"] == true; + let cursor = params["cursor"].as_str(); + let ids = match (ancestor, archived, cursor) { + (None, false, None) => vec!["root", "missing"], + (Some("root"), false, None) if unsupported => vec!["unrelated"], + (Some("root"), false, None) => vec!["child"], + (Some("root"), false, Some("next")) => vec!["grandchild"], + (Some("root"), true, None) => vec!["archived-child"], + (Some("missing"), _, None) => Vec::new(), + _ => panic!("Unexpected thread listing: {params}"), + }; + let data = ids.into_iter().map(|id| json!({ + "id":id, "sessionId":id, "parentThreadId":if id == "unrelated" { None } else if id == "grandchild" { Some("child") } else { ancestor }, "preview":id, "ephemeral":false, + "modelProvider":"openai", "createdAt":1788220800_i64, + "updatedAt":chrono::Utc::now().timestamp(), "status":{"type":"idle"}, + "cwd":std::env::temp_dir(), "cliVersion":"test", "source":"cli", "turns":[] + })).collect::>(); + json!({"data":data, "nextCursor":if ancestor == Some("root") && !archived && cursor.is_none() { Some("next") } else { None }}) + } + _ => continue, + }; + socket + .send(Message::Text( + json!({"jsonrpc":"2.0", "id":request["id"], "result":result}) + .to_string() + .into(), + )) + .await + .unwrap(); + if pages == if unsupported { 4 } else { 6 } { + break; + } + } + pages + }); + let remote = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }, + client_name: "analytics-test".into(), + client_version: "test".into(), + experimental_api: false, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: 16, + }) + .await + .unwrap(); + Mock::given(method("POST")).and(path("/backend-api/wham/usage/thread_usage/query_v2")) + .respond_with(move |request:&wiremock::Request| { + let mut body:serde_json::Value = request.body_json().unwrap(); + body["threads"].as_array_mut().unwrap().sort_by_key(|thread| thread["thread_id"].as_str().unwrap().to_string()); + let mut expected=json!({"threads":[ + {"thread_id":"missing", "created_at":"2026-09-01T00:00:00+00:00", "descendant_thread_ids":[]}, + {"thread_id":"root", "created_at":"2026-09-01T00:00:00+00:00", "descendant_thread_ids":["archived-child", "child", "grandchild"]} + ]}); + if unsupported { expected["threads"].as_array_mut().unwrap().retain(|thread| thread["thread_id"] == "missing"); } + assert_eq!(body, expected); + let mut response=json!({"data_as_of":null,"threads":[]}); + if !unsupported { response["threads"] = json!([{ "thread_id":"root", "data_status":"partial", "usage_source":"included_plan", "weekly_limit_percent":125, "five_hour_limit_percent":null, "balance_usage_credits":null, "groups":[] }]); } + ResponseTemplate::new(status).set_body_json(response) + }).expect(/*r*/ 1).mount(&server).await; + let handle = AppServerRequestHandle::Remote(remote.request_handle()); + let chats = read(handle, Arc::new(live)).await.unwrap().unwrap(); + assert_eq!( + ( + chats.rows.len(), + chats + .rows + .iter() + .filter_map(|chat| chat.task.as_ref().map(|task| task.data_status)) + .collect::>() + ), + ( + 2, + if unsupported || status != 200 { + vec![] + } else { + vec![codex_backend_client::TaskUsageStatus::Partial] + } + ) + ); + assert_eq!(rpc.await.unwrap(), if unsupported { 4 } else { 6 }); + } +} diff --git a/codex-rs/tui/src/analytics/test_support.rs b/codex-rs/tui/src/analytics/test_support.rs index 7bdcf10956..9e51ba2699 100644 --- a/codex-rs/tui/src/analytics/test_support.rs +++ b/codex-rs/tui/src/analytics/test_support.rs @@ -62,6 +62,7 @@ pub(super) async fn settle(view: &mut super::AnalyticsView) { .iter() .any(|state| matches!(state.history, Load::Loading(_))) && !matches!(view.chats, Load::Loading(_)) + && !matches!(view.tasks, Load::Loading(_)) && !matches!(view.plan.report, Load::Loading(_)) { break; diff --git a/codex-rs/tui/src/analytics_tests.rs b/codex-rs/tui/src/analytics_tests.rs index 91833e5b1c..06b412017c 100644 --- a/codex-rs/tui/src/analytics_tests.rs +++ b/codex-rs/tui/src/analytics_tests.rs @@ -7,6 +7,9 @@ mod dashboard; #[path = "analytics/account_layout_tests.rs"] mod account_layout; +#[path = "analytics/consumer_refresh_tests.rs"] +mod consumer_refresh; + #[path = "analytics/tool_panel_tests.rs"] mod tools_panel; diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_empty_turns_keep_geometry_and_disable_details.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_empty_turns_keep_geometry_and_disable_details.snap index f20b0f11b7..506043edaf 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_empty_turns_keep_geometry_and_disable_details.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_empty_turns_keep_geometry_and_disable_details.snap @@ -1,12 +1,13 @@ --- source: tui/src/analytics_tests.rs +assertion_line: 628 expression: "snapshots.join(\"\\n\")" --- " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " " Total usage history [Messages] Plugins called " -" Skills used " +" Skills used Top chats " " " " ▎ Messages By model " " ────────────────────────────────────────────────────── " @@ -36,7 +37,7 @@ expression: "snapshots.join(\"\\n\")" " " " " " " -" tab/1–4 section · z dashboard · ←/→ day " +" tab/1–5 section · z dashboard · ←/→ day " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc " " back · q close " " " @@ -45,7 +46,7 @@ expression: "snapshots.join(\"\\n\")" " [7d] 30d · Aug 27–Sep 2, 2026 " " " " Total usage history [Messages] Plugins called " -" Skills used " +" Skills used Top chats " " " " ▎ Messages By model " " ────────────────────────────────────────────────────── " @@ -75,7 +76,7 @@ expression: "snapshots.join(\"\\n\")" " " " " " " -" tab/1–4 section · z dashboard · ←/→ day " +" tab/1–5 section · z dashboard · ←/→ day " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc " " back · q close " " " @@ -83,7 +84,7 @@ expression: "snapshots.join(\"\\n\")" " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used " +" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Messages By model " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -116,13 +117,13 @@ expression: "snapshots.join(\"\\n\")" " " " " " " -" tab/1–4 section · z dashboard · ←/→ day " +" tab/1–5 section · z dashboard · ←/→ day " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used " +" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Messages By model " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -155,5 +156,5 @@ expression: "snapshots.join(\"\\n\")" " " " " " " -" tab/1–4 section · z dashboard · ←/→ day " +" tab/1–5 section · z dashboard · ←/→ day " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_model_labels_and_tool_remainders_are_unambiguous.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_model_labels_and_tool_remainders_are_unambiguous.snap index 3d625bc96a..5b65050c75 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_model_labels_and_tool_remainders_are_unambiguous.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_model_labels_and_tool_remainders_are_unambiguous.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics_tests.rs +assertion_line: 345 expression: "format!(\"{overview}\\n{skills}\")" --- " Analytics · Live account " @@ -10,14 +11,9 @@ expression: "format!(\"{overview}\\n{skills}\")" " │ 7d │ │ 7d │ " " │ By feature │ │ By model │ " " │ Total usage · relative units │ │ 574 reported messages │ " -" │ 86 │ │ │ " -" │ 100 ▂▂▂▂▂ █████ │ │ 150 │ " -" │ █████ ▅▅▅▅▅ █████ ██████ │ │ 103 │ " -" │ █████ ▃▃▃▃▃ █████ █████ █████ ██████ │ │ ▇▇▇▇▇ ▆▆▆▆▆▆ │ " -" │ 50 █████ █████ █████ █████ █████ ██████ │ │ 75 ▆▆▆▆▆ █████ ▄▄▄▄▄ ▇▇▇▇▇ ██████ │ " -" │ █████ █████ █████ █████ █████ ██████ │ │ ▇▇▇▇▇ █████ █████ █████ ▆▆▆▆▆ █████ ██████ │ " -" │ ▃▃▃▃▃ █████ █████ █████ █████ █████ ██████ │ │ █████ █████ █████ █████ █████ █████ ██████ │ " -" │ █████ █████ █████ █████ █████ █████ ██████ │ │ █████ █████ █████ █████ █████ █████ ██████ │ " +" │ 86 │ │ 103 │ " +" │ 100 ▃▃▃▃▃ ▂▂▂▂▂ ▆▆▆▆▆ ▄▄▄▄▄ █████ ▅▅▅▅▅▅ │ │ 150 ▃▃▃▃▃ ▁▁▁▁▁ ▂▂▂▂▂▂ │ " +" │ ▃▃▃▃▃ █████ █████ █████ █████ █████ ██████ │ │ ▆▆▆▆▆ █████ █████ █████ ▆▆▆▆▆ █████ ██████ │ " " │ 0 ───────────────────────────────────────────────────────── │ │ 0 ───────────────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " @@ -34,14 +30,9 @@ expression: "format!(\"{overview}\\n{skills}\")" " │ │ │ │ " " │ 7d │ │ 7d │ " " │ 186 calls │ │ 54 uses │ " -" │ │ │ │ " -" │ 40 29 │ │ 20 │ " -" │ ▇▇▇▇▇ │ │ │ " -" │ █████ █████ ▄▄▄▄▄▄ ██████ │ │ 11 │ " -" │ 20 █████ █████ █████ ██████ ▆▆▆▆▆ █████ ██████ │ │ 10 ▁▁▁▁▁ ▆▆▆▆▆▆ │ " -" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ ▆▆▆▆▆ █████ ▆▆▆▆▆▆ ▃▃▃▃▃ ██████ │ " -" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ █████ █████ █████ ██████ █████ ▆▆▆▆▆ ██████ │ " -" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ █████ █████ █████ ██████ █████ █████ ██████ │ " +" │ 29 │ │ 11 │ " +" │ 40 ▅▅▅▅▅ ▁▁▁▁▁ ▃▃▃▃▃ ▂▂▂▂▂▂ ▁▁▁▁▁ ▃▃▃▃▃▃ │ │ 20 │ " +" │ █████ █████ █████ ██████ █████ █████ ██████ │ │ ▆▆▆▆▆ ▄▄▄▄▄ ▇▇▇▇▇ ▆▆▆▆▆▆ ▅▅▅▅▅ ▄▄▄▄▄ ██████ │ " " │ 0 ────────────────────────────────────────────────────────── │ │ 0 ────────────────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " @@ -55,22 +46,32 @@ expression: "format!(\"{overview}\\n{skills}\")" " │ │ │ │ " " ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ " " " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" tab/1–4 section · enter/z maximize " +" ╭─ 5 Top chats ────────────────────────────────────────────────╮ " +" │ │ " +" │ No history has been reported. │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰────────────────────────────────────────────────────────────────╯ " +" tab/1–5 section · enter/z maximize " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] " +" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] 5 Top chats " " " " ▎ Skills used " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -97,5 +98,5 @@ expression: "format!(\"{overview}\\n{skills}\")" " ● Other 3 " " ● Debugging 1 " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_narrow_charts_and_small_terminals_keep_valid_cursors.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_narrow_charts_and_small_terminals_keep_valid_cursors.snap index 916de7dfe2..1d55c137a6 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_narrow_charts_and_small_terminals_keep_valid_cursors.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_narrow_charts_and_small_terminals_keep_valid_cursors.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics_tests.rs +assertion_line: 74 expression: tools --- " Analytics · Live account " @@ -19,6 +20,6 @@ expression: tools " │ ● Testing 4 │ " " │ ● Code review 3 │ " " │ │ " -" tab/1–4 section · enter/z maximize " +" tab/1–5 section · enter/z maximize " " r 7/30d · R refresh · pgup/pgdn scroll · esc back · q " " close " diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_overview.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_overview.snap index f7f7cd87c0..956414af48 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_overview.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_overview.snap @@ -1,5 +1,6 @@ --- source: tui/src/analytics_tests.rs +assertion_line: 46 expression: wide --- " Analytics · Live account " @@ -10,14 +11,8 @@ expression: wide " │ 7d │ │ 7d │ " " │ By feature │ │ By model │ " " │ Total usage · relative units │ │ 574 reported messages │ " -" │ 86 │ │ │ " -" │ 100 ▂▂▂▂ ████ │ │ 150 │ " -" │ ████ ▅▅▅▅ ████ ████ │ │ 103 │ " -" │ ████ ▃▃▃▃ ████ ████ ████ ████ │ │ ▇▇▇▇ ▆▆▆▆ │ " -" │ 50 ████ ████ ████ ████ ████ ████ │ │ 75 ▆▆▆▆ ████ ▄▄▄▄ ▇▇▇▇ ████ │ " -" │ ████ ████ ████ ████ ████ ████ │ │ ▇▇▇▇ ████ ████ ████ ▆▆▆▆ ████ ████ │ " -" │ ▃▃▃▃ ████ ████ ████ ████ ████ ████ │ │ ████ ████ ████ ████ ████ ████ ████ │ " -" │ ████ ████ ████ ████ ████ ████ ████ │ │ ████ ████ ████ ████ ████ ████ ████ │ " +" │ 86 │ │ 103 │ " +" │ 100 ▁▁▁▁ ▅▅▅▅ ▅▅▅▅ ▇▇▇▇ ▆▆▆▆ ████ ▆▆▆▆ │ │ 150 ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ ▄▄▄▄ ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ │ " " │ 0 ─────────────────────────────────────────────── │ │ 0 ─────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ 27 28 29 30 31 1 2 │ │ 27 28 29 30 31 1 2 │ " @@ -27,21 +22,14 @@ expression: wide " │ ● Code review 14.0% │ │ ● GPT-5.6-Terra 25 │ " " │ │ │ │ " " │ │ │ │ " -" │ │ │ │ " " ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " " " " ╭─ 3 Plugins called ─────────────────────────────────╮ ╭─ 4 Skills used ────────────────────────────────────╮ " " │ │ │ │ " " │ 7d │ │ 7d │ " " │ 186 calls │ │ 54 uses │ " -" │ │ │ │ " -" │ 40 29 │ │ 20 │ " -" │ ▇▇▇▇ │ │ │ " -" │ ████ ████ ▄▄▄▄ ████ │ │ 11 │ " -" │ 20 ████ ████ ████ ████ ▆▆▆▆ ████ ████ │ │ 10 ▁▁▁▁ ▆▆▆▆ │ " -" │ ████ ████ ████ ████ ████ ████ ████ │ │ ▆▆▆▆ ████ ▆▆▆▆ ▃▃▃▃ ████ │ " -" │ ████ ████ ████ ████ ████ ████ ████ │ │ ████ ████ ████ ████ ████ ▆▆▆▆ ████ │ " -" │ ████ ████ ████ ████ ████ ████ ████ │ │ ████ ████ ████ ████ ████ ████ ████ │ " +" │ 29 │ │ 11 │ " +" │ 40 ▆▆▆▆ ▄▄▄▄ ▅▅▅▅ ▅▅▅▅ ▄▄▄▄ ▄▄▄▄ ▅▅▅▅ │ │ 20 ▃▃▃▃ ▂▂▂▂ ▃▃▃▃ ▃▃▃▃ ▂▂▂▂ ▂▂▂▂ ▄▄▄▄ │ " " │ 0 ──────────────────────────────────────────────── │ │ 0 ──────────────────────────────────────────────── │ " " │ ▲ │ │ ▲ │ " " │ 27 28 29 30 31 1 2 │ │ 27 28 29 30 31 1 2 │ " @@ -52,13 +40,26 @@ expression: wide " │ │ │ │ " " │ │ │ │ " " │ │ │ │ " -" │ │ │ │ " " ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ " " " +" ╭─ 5 Top chats ──────────────────────────────────────╮ " +" │ │ " +" │ No history has been reported. │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰──────────────────────────────────────────────────────╯ " " " " " -" " -" " -" " -" tab/1–4 section · enter/z maximize " +" tab/1–5 section · enter/z maximize " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close " diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_usage_ranks_its_own_models_after_turns_load.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_usage_ranks_its_own_models_after_turns_load.snap index 04b4edf11a..716ca7ecf4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_usage_ranks_its_own_models_after_turns_load.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_usage_ranks_its_own_models_after_turns_load.snap @@ -1,11 +1,12 @@ --- source: tui/src/analytics_tests.rs +assertion_line: 573 expression: selected_day --- " Analytics · Live account " " [7d] 30d · Aug 27–Sep 2, 2026 " " " -" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used " +" [1 Total usage history] 2 Messages 3 Plugins called 4 Skills used 5 Top chats " " " " ▎ Total usage history By model " " ──────────────────────────────────────────────────────────────────────────────────────────────── " @@ -38,5 +39,5 @@ expression: selected_day " ● Remote Other 2.1% " " … 3 more 0.0% " " " -" tab/1–4 section · z dashboard · ←/→ day · enter details " +" tab/1–5 section · z dashboard · ←/→ day · enter details " " r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "