Add consumer Top chats usage analytics (#45768)

## Why

Consumer analytics lacked a per-chat view of allowance usage and balance credit debits.

## What changed

- Add a Top chats panel for up to 100 local chats active in the past 30 days, including discovered descendants and excluding archived roots.
- Show weekly and five-hour usage as percentages of current full limits, plus balance credits including adjustments. Preserve exact credit decimals and distinguish missing values from zero.
- Support cycling sort metrics with `s`, a top-five dashboard summary, and expandable model, reasoning effort, and speed breakdowns.
- Query task usage through `query_v2` with bounded, disjoint descendant groups. Retain partial and unavailable rows, hide titles when usage is unavailable, and report ranking coverage and freshness.

## Testing

Add backend and TUI tests covering decimal precision, request and response validation, paginated archived descendants, unavailable responses, metric selection, title hiding, and top-five rendering.

GitOrigin-RevId: b8f233166576389c8212bb97eceb7c6be7f5f51d
This commit is contained in:
Felipe Coury
2026-09-15 18:45:00 +00:00
committed by copyberry
parent 0d0979f457
commit 8f0d2459ac
41 changed files with 1825 additions and 194 deletions

View File

@@ -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;

View File

@@ -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<String>,
pub descendant_thread_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct TaskUsageResponse {
pub data_as_of: Option<String>,
pub threads: Vec<TaskUsage>,
}
#[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<TaskUsageGroup>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct TaskUsageAmounts {
#[serde(default, deserialize_with = "percentage")]
pub five_hour_limit_percent: Option<f64>,
#[serde(default, deserialize_with = "percentage")]
pub weekly_limit_percent: Option<f64>,
pub balance_usage_credits: Option<TaskCredits>,
}
// 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<Option<f64>, D::Error> {
Option::<serde_json::Number>::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<String>,
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub speed: Option<String>,
#[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<TaskUsageResponse, RequestError> {
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::<i64>().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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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::<f64>().is_err()
|| exponent.parse::<i32>().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<std::cmp::Ordering> {
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;

View File

@@ -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<TaskCredits> = 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<_>>(),
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::<TaskCredits>(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::<Vec<_>>()});
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query_v2"))
.respond_with(move |request: &wiremock::Request| {
assert_eq!(
request.body_json::<serde_json::Value>().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());
}

View File

@@ -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;

View File

@@ -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<String, String>,
sections: SectionStates,
chats: Load<chats::Chats>,
tasks: Load<tasks::Chats>,
chat_metric: usize,
plan: plan::State,
show_zero_credit_groups: bool,
account: Load<codex_protocol::account::PlanType>,
@@ -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;

View File

@@ -14,6 +14,7 @@ fn account_layouts_show_reports_and_wrap_navigation() {
Section::Activity,
Section::Plugins,
Section::Skills,
Section::Chats,
],
),
(

View File

@@ -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<_>>(),
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::<Vec<_>>()
.join("\n");
assert!(content.contains("Task 5") && !content.contains("Task 0"));
insta::assert_snapshot!(content);
press(&mut view, KeyCode::Esc);
assert!(view.is_done);
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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 {
""

View File

@@ -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(

View File

@@ -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/15 section · z dashboard · ←/→ window · ↑/↓ period · enter details "
" tab/16 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/15 section · z dashboard · ←/→ window · ↑/↓ "
" tab/16 section · z dashboard · ←/→ window · ↑/↓ "
" period · enter details "
" g group · R refresh · pgup/pgdn scroll · esc back · q "
" close "

View File

@@ -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/15 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/16 section · enter/z maximize "
" g group · R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 section · z dashboard · ←/→ day · enter details "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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/15 section · s sort · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · enter/z maximize "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" tab/15 section · s sort · enter/z maximize "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 2, 2026 "

View File

@@ -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 13 of 3 chats "
" Local chats · includes discovered descendants · excludes archived roots "
" "
" tab/15 section · s sort · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -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 13 of 3 chats "
" Local chats · includes discovered descendants · "
" excludes archived roots "
" "
" "
" "
" "
" "
" "
" "
" tab/15 section · s sort · z dashboard · ↑/↓ row · "
" enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -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 13 of 3 chats "
" Local chats · includes discovered descendants · excludes archived roots "
" "
" "
" "
" tab/15 section · s sort · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -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

View File

@@ -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/14 section · enter/z maximize "
" ╭─ 5 Top chats ──────────────────────────────────────────────────╮ "
" "
" │ No history has been reported. "
" "
" "
" "
" "
" "
" "
" "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" ╰──────────────────────────────────────────────────────────────────╯ "
" tab/15 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/14 section · enter/z maximize "
" tab/15 section · enter/z maximize "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q "
" close "

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 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

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day "
" tab/15 section · z dashboard · ←/→ day "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day "
" tab/15 section · z dashboard · ←/→ day "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day "
" tab/15 section · z dashboard · ←/→ day "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -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 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 section · z dashboard · ←/→ day · enter details "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day · enter "
" tab/15 section · z dashboard · ←/→ day · enter "
" details "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q "
" close "

View File

@@ -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<usize> {
(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::<Vec<_>>())
.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<Line<'static>>, Range<usize>) {
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::<Vec<_>>();
let show_all = widths.iter().sum::<usize>() + 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::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>();
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)
}
}
}

View File

@@ -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<TaskUsage>,
}
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<Chat>,
pub truncated: bool,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
pub(super) async fn read(
handle: AppServerRequestHandle,
live: Arc<Live>,
) -> Result<Option<Chats>, 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::<Vec<_>>();
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::<Vec<_>>();
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<TaskUsageThread, String> {
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::<Vec<_>>();
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;

View File

@@ -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::<Vec<_>>();
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::<Vec<_>>()
),
(
2,
if unsupported || status != 200 {
vec![]
} else {
vec![codex_backend_client::TaskUsageStatus::Partial]
}
)
);
assert_eq!(rpc.await.unwrap(), if unsupported { 4 } else { 6 });
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,12 +1,13 @@
---
source: tui/src/analytics_tests.rs
assertion_line: 628
expression: "snapshots.join(\"\\n\")"
---
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day "
" tab/15 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 27Sep 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/14 section · z dashboard · ←/→ day "
" tab/15 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 27Sep 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/14 section · z dashboard · ←/→ day "
" tab/15 section · z dashboard · ←/→ day "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day "
" tab/15 section · z dashboard · ←/→ day "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -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/14 section · enter/z maximize "
" ╭─ 5 Top chats ────────────────────────────────────────────────╮ "
" "
" │ No history has been reported. "
" "
" "
" "
" "
" "
" "
" "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" ╰────────────────────────────────────────────────────────────────╯ "
" tab/15 section · enter/z maximize "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 section · z dashboard · ←/→ day · enter details "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -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/14 section · enter/z maximize "
" tab/15 section · enter/z maximize "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q "
" close "

View File

@@ -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/14 section · enter/z maximize "
" tab/15 section · enter/z maximize "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "

View File

@@ -1,11 +1,12 @@
---
source: tui/src/analytics_tests.rs
assertion_line: 573
expression: selected_day
---
" Analytics · Live account "
" [7d] 30d · Aug 27Sep 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/14 section · z dashboard · ←/→ day · enter details "
" tab/15 section · z dashboard · ←/→ day · enter details "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "