Add Top chats to usage analytics (#45765)

## What changed

- Show local chats active in the past 30 days, ranked by estimated lifetime credits, for supported Business and Enterprise plans. Exclude archived chats and subagents.
- Add expandable model, reasoning effort, and speed breakdowns, optional zero-credit groups, and dollar estimates when supplied by the backend.
- Load estimates in bounded batches of up to 100 distinct threads, retaining available results when some estimates are unavailable. Hide chat titles without a returned estimate and distinguish missing usage from zero.

## Testing

Add coverage for batch validation, partial failures, request limits, pagination and ranking, plan eligibility, title visibility, and responsive table navigation and details.

GitOrigin-RevId: 783b960a780f4e0fd39f57e2fa78fb31b3090c44
This commit is contained in:
Felipe Coury
2026-09-15 19:07:03 +00:00
committed by copyberry
parent ca53e19c75
commit 0e7ab7c1b5
47 changed files with 2491 additions and 356 deletions
@@ -1,4 +1,4 @@
//! Authoritative estimated credit and dollar usage for an individual Codex thread.
//! Authoritative estimated credit and dollar usage for bounded batches of Codex threads.
use super::Client;
use super::PathStyle;
@@ -36,39 +36,77 @@ pub struct ThreadUsage {
#[derive(Serialize)]
struct ThreadUsageQueryRequest<'a> {
thread_ids: [&'a str; 1],
thread_ids: &'a [&'a str],
}
#[derive(Deserialize)]
struct ThreadUsageQueryResponse {
threads: Vec<ThreadUsage>,
threads: Vec<ThreadUsageEstimate>,
}
// Preserve unavailable rows until all returned IDs have been validated.
#[derive(Deserialize)]
struct ThreadUsageEstimate {
thread_id: String,
estimated_usage_credits_micros: Option<i64>,
estimated_usage_usd_micros: Option<i64>,
groups: Option<Vec<ThreadUsageBreakdownGroup>>,
}
impl Client {
/// Reads authoritative estimated totals without maintaining a second usage ledger.
pub async fn get_thread_usage(&self, thread_id: &str) -> Result<ThreadUsage, RequestError> {
self.get_threads_usage(&[thread_id])
.await?
.into_iter()
.find(|usage| usage.thread_id == thread_id)
.ok_or_else(|| {
RequestError::from(anyhow!("thread usage response omitted requested thread"))
})
}
/// Reads at most 100 distinct threads; omitted results are unavailable, not zero.
pub async fn get_threads_usage(
&self,
thread_ids: &[&str],
) -> Result<Vec<ThreadUsage>, RequestError> {
let requested: std::collections::HashSet<_> = thread_ids.iter().copied().collect();
if thread_ids.is_empty() || thread_ids.len() > 100 || requested.len() != thread_ids.len() {
return Err(RequestError::from(anyhow!(
"expected 1–100 distinct thread IDs"
)));
}
let url = self.thread_usage_url();
let request = self
.request(Method::POST, &url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ThreadUsageQueryRequest {
thread_ids: [thread_id],
});
let (body, content_type) = self.exec_request_detailed(request, "POST", &url).await?;
let response = self
.decode_json::<ThreadUsageQueryResponse>(&url, &content_type, &body)
.map_err(RequestError::from)?;
response
.json(&ThreadUsageQueryRequest { thread_ids });
let (body, _) = self.exec_request_detailed(request, "POST", &url).await?;
let response: ThreadUsageQueryResponse = serde_json::from_str(&body)
.map_err(|_| RequestError::Other(anyhow!("Invalid thread usage response.")))?;
let mut seen = std::collections::HashSet::new();
if response
.threads
.iter()
.any(|row| !requested.contains(row.thread_id.as_str()) || !seen.insert(&row.thread_id))
{
return Err(RequestError::from(anyhow!(
"thread usage returned unexpected threads"
)));
}
Ok(response
.threads
.into_iter()
.find(|usage| usage.thread_id == thread_id)
.ok_or_else(|| {
RequestError::from(anyhow!(
"thread usage response did not contain requested thread {thread_id}"
))
.filter_map(|row| {
Some(ThreadUsage {
thread_id: row.thread_id,
estimated_usage_credits_micros: row.estimated_usage_credits_micros?,
estimated_usage_usd_micros: row.estimated_usage_usd_micros,
groups: row.groups.unwrap_or_default(),
})
})
.collect())
}
fn thread_usage_url(&self) -> String {
@@ -23,7 +23,7 @@ fn thread_usage_contract_uses_expected_paths_and_payload() {
);
assert_eq!(
serde_json::to_value(ThreadUsageQueryRequest {
thread_ids: ["thread-123"],
thread_ids: &["thread-123"],
})
.expect("serialize thread usage request"),
json!({ "thread_ids": ["thread-123"] })
@@ -142,5 +142,59 @@ async fn get_thread_usage_rejects_totals_for_another_thread() {
.get_thread_usage("thread-123")
.await
.expect_err("reject usage for a different thread");
assert!(error.to_string().contains("requested thread thread-123"));
assert!(error.to_string().contains("unexpected threads"));
}
#[tokio::test]
async fn batch_usage_rejects_invalid_requests_before_http() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(/*s*/ 500))
.expect(/*r*/ 0)
.mount(&server)
.await;
let client = Client::new(
server.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let too_many = (0..101)
.map(|id| format!("thread-{id}"))
.collect::<Vec<_>>();
for ids in [
Vec::new(),
vec!["duplicate", "duplicate"],
too_many.iter().map(String::as_str).collect(),
] {
let error = client.get_threads_usage(&ids).await.unwrap_err();
assert!(error.to_string().contains("1–100 distinct thread IDs"));
}
}
#[tokio::test]
async fn batch_usage_rejects_duplicate_and_unrequested_response_rows() {
for returned in [["first", "first"], ["first", "unexpected"]] {
for second_amount in [Some(1), None] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_json(json!({"thread_ids": ["first", "second"]})))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"threads": returned.iter().enumerate().map(|(index, thread_id)| json!({
"thread_id": thread_id,
"estimated_usage_credits_micros": if index == 0 { Some(1) } else { second_amount }
})).collect::<Vec<_>>()
})))
.expect(/*r*/ 1)
.mount(&server)
.await;
let client = Client::new(
server.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let error = client
.get_threads_usage(&["first", "second"])
.await
.unwrap_err();
assert!(error.to_string().contains("unexpected threads"));
}
}
}
+61 -9
View File
@@ -2,6 +2,8 @@
//! A maximized section and an overview share bounded report loads, selection, and inline details.
mod chart;
mod chat_panel;
mod chats;
mod client;
mod dashboard;
mod data;
@@ -44,7 +46,9 @@ use sections::SectionStates;
pub(crate) struct AnalyticsView {
model_names: std::collections::HashMap<String, String>,
sections: SectionStates,
account: Load<models::AccountKind>,
chats: Load<chats::Chats>,
show_zero_credit_groups: bool,
account: Load<codex_protocol::account::PlanType>,
reports_started: bool,
connection: Option<(
std::sync::Arc<crate::legacy_core::config::Config>,
@@ -70,6 +74,8 @@ impl AnalyticsView {
let mut view = Self {
model_names: std::collections::HashMap::new(),
sections: SectionStates(std::array::from_fn(|_| SectionState::default())),
chats: Load::Unavailable,
show_zero_credit_groups: false,
account: Load::Unavailable,
reports_started: false,
connection: None,
@@ -87,6 +93,7 @@ impl AnalyticsView {
is_done: false,
};
view.sections[Section::Usage].group = 1;
view.sections[Section::Chats].cursor = 0;
view.sections[Section::Activity].group = 2;
view.refresh();
view
@@ -120,12 +127,24 @@ impl AnalyticsView {
for section in &mut self.sections.0 {
section.history = Load::Unavailable;
}
self.sections[Section::Chats].detail = None;
self.chats = Load::Unavailable;
self.reports_started = false;
self.token_model = None;
self.account = if let (Some((_, _, frame)), Some(live)) = (&self.connection, &self.live) {
let live = std::sync::Arc::clone(live);
Load::start(
async move { live.session().await.map(|session| Some(session.kind)) },
async move {
live.session().await.map(|session| {
Some(
session
.backend
.account()
.plan_type
.unwrap_or(codex_protocol::account::PlanType::Unknown),
)
})
},
frame.clone(),
)
} else {
@@ -139,6 +158,7 @@ impl AnalyticsView {
for section in &mut self.sections.0 {
section.history = Load::Unavailable;
}
self.chats = Load::Unavailable;
self.account = Load::Unavailable;
self.live = None;
self.connection = None;
@@ -184,7 +204,7 @@ impl AnalyticsView {
Section::Usage => &[0, 2],
Section::Credits => self.live.as_ref().map_or(&[0], |live| live.credit_groups()),
Section::Activity => &[2, 0],
Section::Plugins | Section::Skills => &[],
Section::Plugins | Section::Skills | Section::Chats => &[],
}
}
@@ -205,6 +225,7 @@ impl AnalyticsView {
Section::Credits => "Credits usage history",
Section::Activity => "Messages",
Section::Skills => "Skills used",
Section::Chats => "Top chats",
}
}
@@ -221,7 +242,11 @@ impl AnalyticsView {
}
fn row_count(&self) -> usize {
if self.ranges[self.range_group(self.section) as usize] == 0 {
if self.section == Section::Chats {
self.chats
.ready()
.map_or(/*default*/ 0, |chats| chats.rows.len())
} else if self.ranges[self.range_group(self.section) as usize] == 0 {
7
} else {
30
@@ -283,6 +308,13 @@ impl AnalyticsView {
self.refresh();
return;
}
if self.section == Section::Chats
&& self.sections[Section::Chats].detail.is_some()
&& key_hint::plain(KeyCode::Char('a')).is_press(key)
{
self.show_zero_credit_groups = !self.show_zero_credit_groups;
return;
}
if self.section == Section::Usage
&& self.business()
&& key_hint::plain(KeyCode::Char('m')).is_press(key)
@@ -302,7 +334,9 @@ impl AnalyticsView {
self.load_report(Section::Usage);
return;
}
if key_hint::plain(KeyCode::Char('r')).is_press(key) && !self.visible_sections().is_empty()
if key_hint::plain(KeyCode::Char('r')).is_press(key)
&& self.section != Section::Chats
&& !self.visible_sections().is_empty()
{
self.change_range();
return;
@@ -347,22 +381,37 @@ 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()
{
return;
}
let chart = self.section != Section::Chats;
let count = self.row_count().max(/*other*/ 1);
let SectionState { cursor, detail, .. } = &mut self.sections[self.section];
let previous_cursor = *cursor;
match action {
Some(ListAction::MoveUp) => *cursor = cursor.saturating_sub(/*rhs*/ 1),
Some(ListAction::MoveDown) => *cursor = (*cursor + 1).min(count - 1),
Some(ListAction::MoveLeft) => *cursor = cursor.saturating_sub(/*rhs*/ 1),
Some(ListAction::MoveRight) => *cursor = (*cursor + 1).min(count - 1),
Some(ListAction::MoveLeft) if chart => *cursor = cursor.saturating_sub(/*rhs*/ 1),
Some(ListAction::MoveRight) if chart => *cursor = (*cursor + 1).min(count - 1),
Some(ListAction::JumpTop) => *cursor = 0,
Some(ListAction::JumpBottom) => *cursor = count - 1,
Some(ListAction::Accept) => {
Some(ListAction::Accept | ListAction::MoveRight) => {
*detail = if *detail == Some(*cursor) {
None
} else {
Some(*cursor)
};
}
Some(ListAction::MoveLeft) => *detail = None,
Some(ListAction::Cancel) => {
self.is_done |= !self.zoomed || detail.take().is_none();
}
@@ -376,8 +425,10 @@ impl AnalyticsView {
}
None => {}
}
if detail.is_some() {
if chart && detail.is_some() {
*detail = Some(*cursor);
} else if !chart && *cursor != previous_cursor {
*detail = None;
}
}
@@ -395,6 +446,7 @@ impl AnalyticsView {
section.history = Load::Unavailable;
}
}
self.chats = Load::Unavailable;
self.account = Load::Unavailable;
self.connection = None;
}
@@ -23,6 +23,7 @@ fn account_layouts_show_reports_and_wrap_navigation() {
Section::Usage,
Section::Plugins,
Section::Skills,
Section::Chats,
],
),
] {
@@ -65,7 +66,7 @@ async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() {
view.sections[Section::Usage].detail,
view.sections[Section::Usage].group
),
([1, 0, 0], [28, 6, 6, 6, 6], Some(28), 2)
([1, 0, 0], [28, 6, 6, 0, 6, 6], Some(28), 2)
);
let context = (
view.ranges,
@@ -94,7 +95,7 @@ async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() {
.0
.each_ref()
.map(|state| state.history.ready().map(|history| history.data.len())),
[Some(30), Some(7), None, Some(30), Some(7)]
[Some(30), Some(7), None, None, Some(30), Some(7)]
);
let server = test_support::server().await;
let (_business_home, _business_server, mut view) =
@@ -111,7 +112,7 @@ async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() {
.0
.each_ref()
.map(|state| state.history.ready().map(|history| history.data.len())),
[Some(30), Some(7), Some(7), None, Some(7)]
[Some(30), Some(7), Some(7), None, None, Some(7)]
);
}
@@ -221,9 +222,9 @@ async fn account_reports_request_only_eligible_endpoints_and_refresh() {
assert_eq!(
ready,
if plan == "plus" {
[true, true, false, true, true]
[true, true, false, false, true, true]
} else {
[true, true, true, false, true]
[true, true, true, false, false, true]
}
);
press(&mut view, KeyCode::Char('R'));
@@ -253,7 +254,7 @@ async fn account_reports_request_only_eligible_endpoints_and_refresh() {
async fn legacy_response_closes_an_attribution_picker_that_is_no_longer_valid() {
let server = test_support::server().await;
let (_home, _app_server, mut view) = client::tests::connected_view(&server, "plus").await;
view.account = Load::Ready(models::AccountKind::Consumer);
view.account = Load::Ready(codex_protocol::account::PlanType::Plus);
view.start_reports();
view.group_picker = Some(3);
test_support::settle(&mut view).await;
@@ -273,7 +274,7 @@ async fn legacy_response_invalidates_picker_before_the_next_draw() {
let server = test_support::server().await;
for choice in [2, 3] {
let (_home, _app_server, mut view) = client::tests::connected_view(&server, "plus").await;
view.account = Load::Ready(models::AccountKind::Consumer);
view.account = Load::Ready(codex_protocol::account::PlanType::Plus);
view.start_reports();
view.group_picker = Some(choice);
// Complete the request without polling the view, as can happen between key events.
@@ -290,3 +291,77 @@ async fn legacy_response_invalidates_picker_before_the_next_draw() {
assert_eq!(view.sections[Section::Usage].group, 0);
}
}
#[test]
fn business_chats_display_only_supplied_dollars() {
let mut view = fixture::view(models::AccountKind::Enterprise);
let chats = match &mut view.chats {
Load::Ready(chats) => chats,
_ => panic!("fixture chats"),
};
for chat in &mut chats.rows {
if let Some(usage) = &mut chat.usage {
usage.estimated_usage_usd_micros = None;
}
}
press(&mut view, KeyCode::Char('5'));
assert!(!screen(&mut view, /*width*/ 100, /*height*/ 30).contains("Est. $ spent"));
if let Load::Ready(chats) = &mut view.chats {
chats.rows[0]
.usage
.as_mut()
.unwrap()
.estimated_usage_usd_micros = Some(123_450_000);
}
let dollars = screen(&mut view, /*width*/ 100, /*height*/ 30);
assert!(dollars.contains("$123.45") && dollars.contains("Est. $ spent"));
let narrow = screen(&mut view, /*width*/ 42, /*height*/ 30);
insta::assert_snapshot!(format!("{dollars}\n{narrow}"));
}
#[tokio::test]
async fn unsupported_workspace_plans_hide_top_chats_and_skip_loading() {
for plan in [
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"enterprise",
"ent26",
"edu",
"edu_plus",
"edu_pro",
] {
let server = test_support::server().await;
let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await;
test_support::settle(&mut view).await;
assert_eq!(
view.visible_sections(),
&[
Section::Credits,
Section::Usage,
Section::Plugins,
Section::Skills
],
"{plan}"
);
assert!(matches!(view.chats, Load::Unavailable), "{plan}");
let selected = view.section;
press(&mut view, KeyCode::Char('5'));
assert_eq!(view.section, selected);
}
for plan in [
"business",
"enterprise_cbp_usage_based",
"enterprise_cbp_automation",
] {
let server = test_support::server().await;
let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await;
test_support::settle(&mut view).await;
assert!(view.visible_sections().contains(&Section::Chats), "{plan}");
press(&mut view, KeyCode::Char('5'));
assert_eq!(view.section, Section::Chats);
}
let mut view = fixture::view(models::AccountKind::Enterprise);
view.account = Load::Ready(codex_protocol::account::PlanType::Enterprise);
insta::assert_snapshot!(screen(&mut view, /*width*/ 110, /*height*/ 24));
}
+2 -1
View File
@@ -93,7 +93,7 @@ impl AnalyticsView {
Section::Activity => "messages",
Section::Plugins => "calls",
Section::Skills => "uses",
Section::Usage | Section::Credits => "credits",
Section::Usage | Section::Credits | Section::Chats => "credits",
};
let empty_breakdown = matches!(
section,
@@ -193,6 +193,7 @@ impl AnalyticsView {
match section {
Section::Plugins => "Plugin",
Section::Skills => "Skill",
Section::Chats => "Chat",
Section::Usage | Section::Credits | Section::Activity => {
self.group_label(section, self.sections[section].group)
}
+317
View File
@@ -0,0 +1,317 @@
//! Bounded chat tables with inline credit breakdowns and a viewport-sized row window.
//! Zero-credit groups are optional; missing dimensions and signed adjustments stay distinct.
use super::AnalyticsView;
use super::data;
use super::render::columns;
use super::styles::number;
use super::styles::secondary_style;
use crate::analytics::sections::Section;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::style::accent_style;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use std::ops::Range;
impl AnalyticsView {
pub(super) fn chat_lines(&self, width: usize) -> (Vec<Line<'static>>, Range<usize>) {
let width = width.clamp(/*min*/ 1, /*max*/ 96);
let wrap = |lines: Vec<Line<'static>>| word_wrap_lines(lines, RtOptions::new(width));
let mut lines = wrap(vec![
"Active in past 30 days · sorted by lifetime credits"
.set_style(secondary_style())
.into(),
]);
if let Some(message) = self.chats.message() {
lines.extend(wrap(vec![
message.to_string().set_style(secondary_style()).into(),
]));
}
let Some(chats) = self.chats.ready() else {
return (lines, 0..1);
};
if chats.rows.is_empty() {
lines.extend(wrap(vec![
"No recent local chats.".set_style(secondary_style()).into(),
]));
return (lines, 0..1);
}
let missing = chats.rows.iter().filter(|row| row.usage.is_none()).count();
if missing > 0 {
lines.extend(wrap(vec![
format!(
"{missing} chat estimate{} unavailable",
if missing == 1 { "" } else { "s" }
)
.set_style(secondary_style())
.into(),
]));
}
lines.push(Line::default());
let show_usd = chats.rows.iter().any(|chat| {
chat.usage
.as_ref()
.is_some_and(|usage| usage.estimated_usage_usd_micros.is_some())
});
let stacked_amounts = show_usd && width < 70;
lines.extend(wrap(vec![columns(
" Chat".bold().into(),
if stacked_amounts {
"Est. $ / credits"
} else if show_usd {
"Est. $ spent Lifetime credits"
} else {
"Lifetime credits"
}
.bold()
.into(),
width,
)]));
let rows = chats
.rows
.iter()
.enumerate()
.map(|(index, chat)| {
let selected =
self.section == Section::Chats && index == self.sections[Section::Chats].cursor;
let amount = chat.usage.as_ref().map_or_else(
|| "—".into(),
|usage| data::credits(usage.estimated_usage_credits_micros),
);
let amount = if show_usd {
let usd = chat
.usage
.as_ref()
.and_then(|usage| usage.estimated_usage_usd_micros)
.map(|micros| format!("${:.2}", micros as f64 / 1_000_000.0))
.unwrap_or_else(|| "—".into());
format!("{usd:>12} {amount:>16}")
} else {
amount
};
let label = format!(
"{} {}",
if selected { "›" } else { " " },
chat.display_title()
);
let label: Line<'static> = if selected {
label.set_style(accent_style()).into()
} else {
label.into()
};
let label_width = if stacked_amounts {
width
} else {
width.saturating_sub(amount.len() + 1)
};
let truncated = label.width() > label_width;
let mut row = if stacked_amounts {
vec![
truncate_line_with_ellipsis_if_overflow(label, label_width),
format!(" {}", amount.trim()).into(),
]
} else {
vec![columns(
truncate_line_with_ellipsis_if_overflow(label, label_width),
if chat.usage.is_none() {
amount.set_style(secondary_style()).into()
} else if selected {
number(amount).into()
} else {
amount.into()
},
width,
)]
};
if self.sections[Section::Chats].detail == Some(index)
&& let Some(usage) = &chat.usage
{
row.push(
format!(" {}", "─".repeat(width.saturating_sub(/*rhs*/ 2)))
.dim()
.into(),
);
if truncated {
row.extend(word_wrap_lines(
[chat.display_title().to_string().bold()],
RtOptions::new(width)
.initial_indent(" ".into())
.subsequent_indent(" ".into()),
));
}
let zeros = usage
.groups
.iter()
.filter(|group| group.estimated_usage_credits_micros == 0)
.count();
let mut groups = usage
.groups
.iter()
.filter(|group| {
self.show_zero_credit_groups
|| group.estimated_usage_credits_micros != 0
})
.collect::<Vec<_>>();
groups.sort_by_key(|group| {
std::cmp::Reverse(group.estimated_usage_credits_micros)
});
// On narrow terminals, retain every dimension on a second line.
let table = width >= 70;
if !groups.is_empty() {
row.push(columns(
if table {
format!(
" {:<model_width$} {:<12} {:<12}",
"Model",
"Effort",
"Speed",
model_width = width - 46
)
.set_style(secondary_style())
.into()
} else {
" Model / effort / speed"
.set_style(secondary_style())
.into()
},
"Credits".bold().into(),
width,
));
}
for group in groups {
let model =
self.model_name(group.model.as_deref().unwrap_or("Not reported"));
let effort = group.reasoning_effort.as_deref().unwrap_or("Not reported");
let speed = group.speed.as_deref().unwrap_or("Not reported");
let amount = data::credits(group.estimated_usage_credits_micros);
let label = if table {
// Wrap long model names instead of hiding distinctions between groups.
let model_width = width - 46;
let models = textwrap::wrap(model, model_width);
for model in models.iter().take(models.len().saturating_sub(/*rhs*/ 1))
{
row.push(format!(" {model}").bold().into());
}
vec![
format!(
" {:<model_width$}",
models
.last()
.map(std::convert::AsRef::as_ref)
.unwrap_or_default()
)
.bold(),
format!(" {effort:<12} {speed:<12}").set_style(secondary_style()),
]
.into()
} else {
let label = Line::from(format!(" {model}").bold());
if label.width() + amount.len() + 1 > width {
row.push(label);
" ".into()
} else {
label
}
};
row.push(columns(label, amount.into(), width));
if !table {
row.push(
format!(" {effort} · {speed}")
.set_style(secondary_style())
.into(),
);
}
}
if usage.groups.is_empty() {
row.push(
" Breakdown unavailable"
.set_style(secondary_style())
.into(),
);
}
if zeros > 0 {
row.push(
format!(
" {zeros} zero-credit {}{} · a {}",
if zeros == 1 { "group" } else { "groups" },
if self.show_zero_credit_groups {
""
} else {
" hidden"
},
if self.show_zero_credit_groups {
"hide zeros"
} else {
"show all"
},
)
.set_style(secondary_style())
.into(),
);
}
row.push(Line::default());
}
wrap(row)
})
.collect::<Vec<_>>();
let coverage = wrap(vec![
"Local chats · excludes archived chats and subagent usage"
.set_style(secondary_style())
.into(),
]);
self.chat_window(lines, rows, coverage, width)
}
pub(super) fn chat_window(
&self,
mut lines: Vec<Line<'static>>,
rows: Vec<Vec<Line<'static>>>,
coverage: Vec<Line<'static>>,
width: usize,
) -> (Vec<Line<'static>>, Range<usize>) {
let wrap = |lines: Vec<Line<'static>>| word_wrap_lines(lines, RtOptions::new(width));
let count = rows.len();
let range_height = wrap(vec![
format!("Showing {count}–{count} of {count} chats").into(),
])
.len();
// Measure wrapped details before choosing neighbors so the range describes the rendered rows.
let budget = if self.zoomed {
self.viewport_height
.saturating_sub(lines.len() + coverage.len() + range_height + 4)
} else {
usize::MAX
};
let row_limit = if self.zoomed { count } else { 5 };
let cursor = self.sections[Section::Chats].cursor.min(count - 1);
let mut first = cursor;
let mut end = cursor + 1;
let mut used = rows[cursor].len();
while first > 0 && end - first < row_limit && used + rows[first - 1].len() <= budget {
first -= 1;
used += rows[first].len();
}
while end < count && end - first < row_limit && used + rows[end].len() <= budget {
used += rows[end].len();
end += 1;
}
let mut selection = 0..1;
for (index, row) in rows.into_iter().enumerate().take(end).skip(first) {
if index == cursor {
selection = lines.len()..lines.len() + row.len();
}
lines.extend(row);
}
lines.push(Line::default());
lines.extend(wrap(vec![
format!("Showing {}–{end} of {count} chats", first + 1)
.set_style(secondary_style())
.into(),
]));
lines.extend(coverage);
(lines, selection)
}
}
@@ -0,0 +1,215 @@
//! Chat list coverage, responsive expansion, and controls for fixed-period data.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn chats_range_controls_and_visible_rows() {
let mut view = fixture::view(models::AccountKind::Enterprise);
let mut chats = fixture::chats();
let usage = chats.rows[0].usage.clone();
chats.rows = (1..=30)
.map(|index| chats::Chat {
title: format!("Chat {index:02}"),
usage: usage.clone(),
})
.collect();
view.chats = Load::Ready(chats);
press(&mut view, KeyCode::Char('5'));
let before = (
view.ranges,
view.sections.0.each_ref().map(|state| state.cursor),
view.sections.0.each_ref().map(|state| state.detail),
);
press(&mut view, KeyCode::Char('r'));
assert_eq!(
(
view.ranges,
view.sections.0.each_ref().map(|state| state.cursor),
view.sections.0.each_ref().map(|state| state.detail)
),
before
);
let mut screens = Vec::new();
for key in [KeyCode::Home, KeyCode::End, KeyCode::Enter] {
press(&mut view, key);
let output = screen(&mut view, /*width*/ 110, /*height*/ 24);
assert!(!output.contains("7d") && !output.contains("r 7/30d"));
let visible = (1..=30)
.filter(|index| output.contains(&format!("Chat {index:02}")))
.collect::<Vec<_>>();
assert!(output.contains(&format!(
"Showing {}–{} of 30 chats",
visible.first().unwrap(),
visible.last().unwrap()
)));
screens.push(output);
}
press(&mut view, KeyCode::Char('z'));
press(&mut view, KeyCode::Char('r'));
assert_eq!(view.ranges, [0; 3]);
press(&mut view, KeyCode::Char('1'));
press(&mut view, KeyCode::Char('r'));
press(&mut view, KeyCode::Char('z'));
assert!(screen(&mut view, /*width*/ 110, /*height*/ 24).contains("[30d]"));
insta::assert_snapshot!(screens.join("\n"));
}
#[test]
fn chats_breakdown_ranks_nonzero_groups_and_reflows() {
let mut view = fixture::view(models::AccountKind::Enterprise);
let mut chats = fixture::chats();
let usage = chats.rows[0].usage.as_mut().unwrap();
let seed = usage.groups[0].clone();
usage.groups = [
("Zero model", 0),
("Small model", 215_610_920),
("Top model", 19_134_430_635),
("Refund model", -4),
]
.map(
|(model, credits)| codex_backend_client::ThreadUsageBreakdownGroup {
model: Some(model.into()),
speed: (credits != 19_134_430_635).then(|| "standard".into()),
estimated_usage_credits_micros: credits,
..seed.clone()
},
)
.to_vec();
usage.estimated_usage_credits_micros = usage
.groups
.iter()
.map(|group| group.estimated_usage_credits_micros)
.sum();
view.chats = Load::Ready(chats);
press(&mut view, KeyCode::Char('5'));
press(&mut view, KeyCode::Enter);
let mut screens = Vec::new();
for width in [110, 58] {
view.follow_selection = true;
let output = screen(&mut view, width, /*height*/ 32);
assert!(output.find("Top model").unwrap() < output.find("Small model").unwrap());
assert!(output.find("Small model").unwrap() < output.find("Refund model").unwrap());
assert!(!output.contains("Zero model"));
assert!(output.contains("-0.000004") && output.contains("Not reported"));
assert!(output.contains("19,350.04") && output.contains("19,134.43"));
assert!(output.contains("1 zero-credit group hidden · a show all"));
assert!(output.contains("Refactor billing usage"));
screens.push(output);
}
press(&mut view, KeyCode::Char('a'));
let expanded = screen(&mut view, /*width*/ 110, /*height*/ 32);
assert!(expanded.contains("Zero model") && expanded.contains("a hide zeros"));
screens.push(expanded);
press(&mut view, KeyCode::Char('a'));
assert!(!view.show_zero_credit_groups);
let wide = view.chat_lines(/*width*/ 220).0;
assert!(wide.iter().all(|line| line.width() <= 96));
insta::assert_snapshot!(screens.join("\n"));
}
#[test]
fn chats_long_title_keeps_detail_indent_and_counts_missing_estimates() {
let mut view = fixture::view(models::AccountKind::Enterprise);
let mut chats = fixture::chats();
chats.rows[0].title = "Create a comprehensive list of areas to investigate across the runtime and describe how each area affects performance in a long conversation".into();
chats.rows[1].usage = None;
chats.rows[2].usage = None;
view.chats = Load::Ready(chats);
press(&mut view, KeyCode::Char('5'));
press(&mut view, KeyCode::Enter);
let output = screen(&mut view, /*width*/ 110, /*height*/ 30);
assert!(output.contains("2 chat estimates unavailable"));
insta::assert_snapshot!(output);
}
#[tokio::test]
async fn unavailable_chats_do_not_open_invisible_details() {
for key in [KeyCode::Enter, KeyCode::Right] {
for state in 0..5 {
let mut view = fixture::view(models::AccountKind::Enterprise);
view.section = Section::Chats;
view.chats = match state {
0 => Load::Unavailable,
1 => Load::Error("Temporary failure".into()),
2 => Load::Ready(chats::Chats::default()),
3 => Load::Ready(chats::Chats {
rows: vec![chats::Chat {
title: "Another workspace's private title".into(),
usage: None,
}],
}),
_ => Load::start(std::future::pending(), FrameRequester::test_dummy()),
};
press(&mut view, key);
assert_eq!(view.sections[Section::Chats].detail, None);
press(&mut view, KeyCode::Esc);
assert!(view.is_done);
}
}
}
#[test]
fn unavailable_chat_titles_are_hidden_in_panel_and_overview() {
let mut view = fixture::view(models::AccountKind::Enterprise);
view.section = Section::Chats;
view.chats = Load::Ready(chats::Chats {
rows: vec![chats::Chat {
title: "Another workspace's private title".into(),
usage: None,
}],
});
let mut screens = Vec::new();
for zoomed in [true, false] {
view.zoomed = zoomed;
view.follow_selection = true;
let output = screen(&mut view, /*width*/ 110, /*height*/ 28);
assert!(!output.contains("Another workspace's private title"));
assert!(output.contains("Chat usage unavailable"));
screens.push(output);
}
insta::assert_snapshot!(screens.join("\n"));
}
#[test]
fn refreshing_chats_clears_details_before_replacing_the_ranked_rows() {
for unavailable in [true, false] {
let mut view = fixture::view(models::AccountKind::Enterprise);
view.section = Section::Chats;
press(&mut view, KeyCode::Enter);
assert_eq!(view.sections[Section::Chats].detail, Some(0));
press(&mut view, KeyCode::Char('R'));
assert_eq!(view.sections[Section::Chats].detail, None);
view.account = Load::Ready(codex_protocol::account::PlanType::EnterpriseCbpUsageBased);
view.chats = if unavailable {
Load::Error("Temporarily unavailable".into())
} else {
Load::Ready(chats::Chats::default())
};
press(&mut view, KeyCode::Esc);
assert!(view.is_done);
}
}
#[test]
fn moving_chat_selection_collapses_details_before_they_leave_the_viewport() {
for (start, key) in [
(0, KeyCode::Down),
(1, KeyCode::Up),
(0, KeyCode::End),
(2, KeyCode::Home),
] {
let mut view = fixture::view(models::AccountKind::Enterprise);
view.section = Section::Chats;
view.sections[Section::Chats].cursor = start;
press(&mut view, KeyCode::Enter);
assert_eq!(view.sections[Section::Chats].detail, Some(start));
press(&mut view, key);
assert_eq!(view.sections[Section::Chats].detail, None);
if key == KeyCode::End {
insta::assert_snapshot!(screen(&mut view, /*width*/ 110, /*height*/ 24));
}
press(&mut view, KeyCode::Esc);
assert!(view.is_done);
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Discover recent local roots and load business chat credit estimates.
use super::client::Live;
use super::client::Session;
use super::data;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_backend_client::ThreadUsage;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
pub(super) struct Chat {
pub title: String,
pub usage: Option<ThreadUsage>,
}
impl Chat {
/// Local history can span accounts; expose titles only with a returned account estimate.
pub(super) fn display_title(&self) -> &str {
if self.usage.is_some() {
&self.title
} else {
"Chat usage unavailable"
}
}
}
#[derive(Default)]
pub(super) struct Chats {
pub rows: Vec<Chat>,
}
pub(super) async fn read(
handle: AppServerRequestHandle,
live: Arc<Live>,
) -> Result<Option<Chats>, String> {
let session = live.session().await?;
if !super::models::thread_usage_supported(session.backend.account().plan_type) {
return Ok(None);
}
session.backend.ensure_identity().await?;
let threads = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 60), roots(&handle))
.await
.map_err(|_| "Chat listing timed out. Press R to retry.".to_string())??;
session.backend.ensure_identity().await?;
if threads.is_empty() {
return Ok(Some(Chats { rows: Vec::new() }));
}
let ids = threads
.iter()
.map(|thread| thread.id.as_str())
.collect::<Vec<_>>();
// Repairing local history has its own budget; it must not consume estimate time.
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 25);
let mut data = Vec::new();
for pair in ids.chunks(/*chunk_size*/ 200) {
let midpoint = pair.len().min(/*other*/ 100);
let (first, second) = tokio::join!(
tokio::time::timeout_at(deadline, estimates(session, &pair[..midpoint])),
tokio::time::timeout_at(deadline, estimates(session, &pair[midpoint..])),
);
for batch in [first, second].into_iter().flatten() {
data.extend(batch?);
}
if tokio::time::Instant::now() >= deadline {
break;
}
}
session.backend.ensure_identity().await?;
let mut usage: HashMap<_, _> = data
.into_iter()
.map(|row| (row.thread_id.clone(), row))
.collect();
let mut rows = threads
.into_iter()
.map(|thread| Chat {
usage: 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::<Vec<_>>();
rows.sort_by_key(|row| {
std::cmp::Reverse(
row.usage
.as_ref()
.map(|usage| usage.estimated_usage_credits_micros),
)
});
Ok(Some(Chats { rows }))
}
/// Recent local roots are shared by consumer task and business credit reporting.
pub(super) async fn roots(
handle: &AppServerRequestHandle,
) -> Result<Vec<codex_app_server_protocol::Thread>, String> {
let cutoff = chrono::Utc::now().timestamp() - 30 * 24 * 60 * 60;
let mut threads = Vec::new();
let mut cursor = None;
let mut seen_cursors = HashSet::new();
loop {
let page: ThreadListResponse = handle
.request_typed(ClientRequest::ThreadList {
request_id: RequestId::String(uuid::Uuid::new_v4().to_string()),
params: ThreadListParams {
cursor,
..list_params()
},
})
.await
.map_err(data::error)?;
let reached_cutoff = page.data.iter().any(|thread| thread.updated_at < cutoff);
threads.extend(page.data.into_iter().filter(|thread| {
thread.updated_at >= cutoff && !thread.ephemeral && thread.parent_thread_id.is_none()
}));
match page.next_cursor {
Some(next) if !seen_cursors.insert(next.clone()) => {
return Err("Chat listing repeated a cursor. Press R to retry.".into());
}
Some(next) if !reached_cutoff => cursor = Some(next),
_ => break,
}
}
// Keep repeated rows from overlapping pages out of the backend's distinct-ID batches.
let mut seen_ids = HashSet::new();
threads.retain(|thread| seen_ids.insert(thread.id.clone()));
Ok(threads)
}
pub(super) fn list_params() -> ThreadListParams {
ThreadListParams {
limit: Some(100),
sort_key: Some(ThreadSortKey::UpdatedAt),
sort_direction: Some(SortDirection::Desc),
model_providers: Some(Vec::new()),
source_kinds: None,
originators: None,
archived: Some(false),
cursor: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
}
}
/// Isolate unavailable estimates within the existing concurrency slot; keep healthy rows.
async fn estimates(session: &Session, ids: &[&str]) -> Result<Vec<ThreadUsage>, String> {
let mut pending = if ids.is_empty() {
Vec::new()
} else {
vec![ids]
};
let mut rows = Vec::new();
// At most 15 requests per batch; leave remaining estimates unavailable during an outage.
for _ in 0..15 {
let Some(ids) = pending.pop() else { break };
match session
.backend
.request(|client| async move { client.get_threads_usage(ids).await })
.await
{
Ok(batch) => rows.extend(batch),
Err(error) if error.status().is_some_and(|status| status.as_u16() == 503) => {
if ids.len() > 1 {
let midpoint = ids.len().div_ceil(/*rhs*/ 2);
pending.push(&ids[midpoint..]);
pending.push(&ids[..midpoint]);
}
}
Err(error) => return Err(super::client::request_error(error)),
}
}
Ok(rows)
}
#[cfg(test)]
#[path = "chats_tests.rs"]
mod tests;
+178
View File
@@ -0,0 +1,178 @@
//! Verify bounded chat discovery, account eligibility, and partial estimate handling.
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 incomplete_chat_batches_retain_available_estimates_without_inventing_zeroes() {
let server = MockServer::start().await;
let (_home, live) = live(&server, "enterprise_cbp_usage_based").await;
Mock::given(method("POST")).and(path("/backend-api/wham/usage/thread_usage/query"))
.respond_with(|request: &wiremock::Request| {
let body: serde_json::Value = request.body_json().unwrap();
let ids = body["thread_ids"].as_array().unwrap();
if ids.len() > 1 || ids[0] == "incomplete" {
ResponseTemplate::new(/*s*/ 503).set_body_json(json!({"detail": "Thread usage is temporarily unavailable."}))
} else {
ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"threads": [{
"thread_id": ids[0], "estimated_usage_credits_micros": if ids[0] == "missing" { None } else { Some(1250000) },
"estimated_usage_usd_micros": null, "groups": null,
}]}))
}
}).expect(/*r*/ 5).mount(&server).await;
let session = live.session().await.unwrap();
assert_eq!(
estimates(session, &["available", "incomplete", "missing"])
.await
.unwrap(),
vec![ThreadUsage {
thread_id: "available".into(),
estimated_usage_credits_micros: 1250000,
estimated_usage_usd_micros: None,
groups: Vec::new(),
}]
);
}
#[tokio::test]
async fn slow_repairing_chat_listing_keeps_estimate_budget_and_ranks_across_pages() {
use codex_app_server_client::RemoteAppServerClient;
use codex_app_server_client::RemoteAppServerConnectArgs;
use codex_app_server_client::RemoteAppServerEndpoint;
use futures::SinkExt;
use futures::StreamExt;
use tokio_tungstenite::tungstenite::Message;
let server = MockServer::start().await;
let (_home, live) = live(&server, "enterprise_cbp_usage_based").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 = Vec::new();
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") => {
let params = &request["params"];
pages.push(json!([
params["cursor"],
params["limit"],
params["useStateDbOnly"].as_bool().unwrap_or_default(),
params["modelProviders"]
]));
if pages.len() == 1 {
// Simulate repair exceeding the HTTP estimate budget without a wall-clock wait.
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 26)).await;
tokio::time::resume();
}
let range = if pages.len() == 1 { 0..100 } else { 100..101 };
let data = range.map(|index| json!({
"id": format!("chat-{index}"), "sessionId": format!("chat-{index}"),
"preview": "", "name": format!("Chat {index}"), "ephemeral": false,
"modelProvider": "openai", "createdAt": 0,
"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 pages.len() == 1 { 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.len() == 2 {
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"))
.respond_with(|request: &wiremock::Request| {
let body: serde_json::Value = request.body_json().unwrap();
let ids = body["thread_ids"].as_array().unwrap();
assert!(ids.len() <= 100);
if ids.iter().any(|id| id == "chat-35") {
return ResponseTemplate::new(/*s*/ 503)
.set_body_json(json!({"detail": "Thread usage is temporarily unavailable."}));
}
let threads = ids.iter().map(|id| json!({
"thread_id": id,
"estimated_usage_credits_micros": id.as_str().unwrap().strip_prefix("chat-").unwrap().parse::<i64>().unwrap(),
})).collect::<Vec<_>>();
ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"threads": threads}))
}).expect(/*r*/ 2..=16).mount(&server).await;
let chats = read(
AppServerRequestHandle::Remote(remote.request_handle()),
Arc::new(live),
)
.await
.unwrap()
.unwrap();
assert_eq!(
(
chats.rows.len(),
chats.rows[0].title.as_str(),
chats.rows.last().unwrap().title.as_str(),
chats.rows.last().unwrap().usage.clone()
),
(101, "Chat 100", "Chat 35", None)
);
assert_eq!(
rpc.await.unwrap(),
vec![
json!([null, 100, false, []]),
json!(["next", 100, false, []])
]
);
}
#[tokio::test]
async fn unavailable_chat_batches_bound_requests_during_an_outage() {
let server = MockServer::start().await;
let (_home, live) = live(&server, "enterprise_cbp_usage_based").await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(/*s*/ 503))
.expect(/*r*/ 15)
.mount(&server)
.await;
let ids = (0..100).map(|i| format!("chat-{i}")).collect::<Vec<_>>();
let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
assert_eq!(
estimates(live.session().await.unwrap(), &ids)
.await
.unwrap(),
Vec::<ThreadUsage>::new()
);
}
+88 -25
View File
@@ -2,8 +2,10 @@
//! Card heights depend on the viewport, never on the selected day or load state.
use super::AnalyticsView;
use super::data;
use super::render::columns;
use super::render::join_columns;
use super::styles::number;
use super::styles::secondary_style;
use crate::analytics::sections::Section;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow as truncate;
@@ -41,23 +43,28 @@ impl AnalyticsView {
secondary_style().dim()
};
let mut content = Vec::new();
content.push(
format!(
"{}d{}",
if self.ranges[self.range_group(section) as usize] == 0 {
7
} else {
30
},
if section == Section::Usage && self.business() {
format!(" · {}", self.token_model.as_deref().unwrap_or("All models"))
} else {
String::new()
}
)
.set_style(secondary_style())
.into(),
);
if !matches!(section, Section::Chats) {
content.push(
format!(
"{}d{}",
if self.ranges[self.range_group(section) as usize] == 0 {
7
} else {
30
},
if section == Section::Usage && self.business() {
format!(
" · {}",
self.token_model.as_deref().unwrap_or("All models")
)
} else {
String::new()
}
)
.set_style(secondary_style())
.into(),
);
}
if matches!(
section,
Section::Usage | Section::Credits | Section::Activity
@@ -92,14 +99,70 @@ impl AnalyticsView {
},
));
} else {
content.extend(
self.history_lines(
section,
inner_width,
inner_height.saturating_sub(/*rhs*/ 13).max(/*other*/ 1),
)
.lines,
);
match section {
Section::Usage
| Section::Plugins
| Section::Credits
| Section::Activity
| Section::Skills => content.extend(
self.history_lines(
section,
inner_width,
inner_height.saturating_sub(/*rhs*/ 13).max(/*other*/ 1),
)
.lines,
),
Section::Chats => {
content.push(
"30d active · lifetime credits"
.set_style(secondary_style())
.into(),
);
content.push(Line::default());
if let Some(chats) = self.chats.ready() {
if chats.rows.is_empty() {
content.push(
"No recent local chats."
.set_style(secondary_style())
.into(),
);
}
for chat in chats.rows.iter().take(/*n*/ 5) {
let amount = chat
.usage
.as_ref()
.map(|usage| {
data::credits(usage.estimated_usage_credits_micros)
})
.unwrap_or_else(|| "—".into());
content.push(columns(
truncate(
chat.display_title().to_string().into(),
inner_width.saturating_sub(amount.len() + 1),
),
number(amount).into(),
inner_width,
));
}
content.push(Line::default());
content.push(
"Local chats · excludes subagents"
.set_style(secondary_style())
.into(),
);
if chats.rows.iter().any(|chat| chat.usage.is_none()) {
content.push(
"Some estimates unavailable"
.set_style(secondary_style())
.into(),
);
}
} else if let Some(message) = self.chats.message() {
content
.push(message.to_string().set_style(secondary_style()).into());
}
}
}
}
let mut content = word_wrap_lines(content, RtOptions::new(inner_width));
if content.len() > inner_height {
+51 -14
View File
@@ -50,22 +50,29 @@ impl<T: Send + 'static> Load<T> {
pub(super) fn start(
future: impl std::future::Future<Output = Result<Option<T>, String>> + Send + 'static,
frame: FrameRequester,
) -> Self {
Self::start_with_timeout(future, frame, std::time::Duration::from_secs(/*secs*/ 30))
}
pub(super) fn start_with_timeout(
future: impl std::future::Future<Output = Result<Option<T>, String>> + Send + 'static,
frame: FrameRequester,
timeout: std::time::Duration,
) -> Self {
let (sender, receiver) = oneshot::channel();
let task = tokio::spawn(async move {
let result = match tokio::time::timeout(
std::time::Duration::from_secs(/*secs*/ 30),
AssertUnwindSafe(future).catch_unwind(),
)
.await
{
Ok(Ok(result)) => result,
Ok(Err(_)) => Err("Request interrupted. Press R to retry.".into()),
Err(_) => Err("Request timed out. Press R to retry.".into()),
};
let _ = sender.send(result);
frame.schedule_frame();
});
let task =
tokio::spawn(async move {
let result =
match tokio::time::timeout(timeout, AssertUnwindSafe(future).catch_unwind())
.await
{
Ok(Ok(result)) => result,
Ok(Err(_)) => Err("Request interrupted. Press R to retry.".into()),
Err(_) => Err("Request timed out. Press R to retry.".into()),
};
let _ = sender.send(result);
frame.schedule_frame();
});
Self::Loading(Pending { receiver, task })
}
@@ -101,6 +108,11 @@ impl<T: Send + 'static> Load<T> {
}
}
/// Keep untyped server and transport diagnostics out of account reports.
pub(super) fn error(_error: codex_app_server_client::TypedRequestError) -> String {
"Couldn't load analytics. Press R to retry.".into()
}
/// Keep tiny refunds visible while avoiding noise on ordinary credit amounts.
pub(super) fn amount(value: f64) -> String {
if value == 0.0 {
@@ -147,6 +159,31 @@ pub(super) fn credit_amount(value: f64) -> String {
}
}
pub(super) fn credits(micros: i64) -> String {
let magnitude = micros.unsigned_abs();
if magnitude > 0 && magnitude < 10_000 {
let fractional = format!("{magnitude:06}");
return format!(
"{}0.{}",
if micros < 0 { "-" } else { "" },
fractional.trim_end_matches('0')
);
}
let cents = (magnitude + 5_000) / 10_000;
let mut whole = (cents / 100).to_string();
let digits = whole.len();
for index in (1..digits).rev() {
if (digits - index).is_multiple_of(/*rhs*/ 3) {
whole.insert(index, ',');
}
}
format!(
"{}{whole}.{:02}",
if micros < 0 { "-" } else { "" },
cents % 100
)
}
pub(super) fn date(date: &str) -> String {
chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map(|date| date.format("%b %-d").to_string())
+37
View File
@@ -57,3 +57,40 @@ async fn panicking_loads_request_a_frame_and_report_interruption() {
Some("Request interrupted. Press R to retry.")
);
}
#[test]
fn rpc_errors_do_not_expose_server_diagnostics() {
let error = codex_app_server_client::TypedRequestError::Server {
method: "thread/list".into(),
source: codex_app_server_protocol::JSONRPCErrorError {
code: -32603,
message: "Failed opening /private/workspace: token=secret".into(),
data: Some(serde_json::json!({"credential": "secret"})),
},
};
assert_eq!(
super::error(error),
"Couldn't load analytics. Press R to retry."
);
}
#[tokio::test(start_paused = true)]
async fn extended_chat_load_budget_still_times_out_and_can_be_cancelled() {
let (cancelled_tx, cancelled_rx) = oneshot::channel::<()>();
let mut load = Load::<()>::start_with_timeout(
async move {
let _cancelled = cancelled_tx;
std::future::pending().await
},
FrameRequester::test_dummy(),
std::time::Duration::from_secs(/*secs*/ 90),
);
tokio::task::yield_now().await;
tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 31)).await;
load.poll();
assert!(matches!(load, Load::Loading(_)));
tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 60)).await;
assert!(cancelled_rx.await.is_err());
load.poll();
assert_eq!(load.message(), Some("Request timed out. Press R to retry."));
}
+12
View File
@@ -22,6 +22,18 @@ impl From<Option<PlanType>> for AccountKind {
}
}
/// Match the existing CLI thread-usage capability without widening other workspace reports.
pub(super) fn thread_usage_supported(plan: Option<PlanType>) -> bool {
matches!(
plan,
Some(
PlanType::Business
| PlanType::EnterpriseCbpUsageBased
| PlanType::EnterpriseCbpAutomation
)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AccountAnalyticsReport {
Usage,
+11 -4
View File
@@ -59,6 +59,7 @@ impl AnalyticsView {
);
}
let mut selection;
let mut chart_bands = 0;
if focused && let Some(choice) = self.group_picker {
lines.extend(
self.group_options()
@@ -78,10 +79,16 @@ impl AnalyticsView {
}),
);
}
let history = self.history_lines(section, width, chart_height);
let chart_bands = history.bands;
lines.extend(history.lines);
selection = 0..lines.len();
if section == Section::Chats {
let (chats, detail) = self.chat_lines(width);
selection = lines.len() + detail.start..lines.len() + detail.end;
lines.extend(chats);
} else {
let history = self.history_lines(section, width, chart_height);
chart_bands = history.bands;
lines.extend(history.lines);
selection = 0..lines.len();
}
if focused && self.group_picker.is_some() {
selection = 0..2 + self.group_options().len();
}
+9 -2
View File
@@ -92,7 +92,7 @@ impl AnalyticsView {
pub(super) fn render(&mut self, area: Rect, buf: &mut Buffer) {
self.poll_reports();
let width = usize::from(area.width.saturating_sub(/*rhs*/ 4)).max(/*other*/ 1);
let show_range = !self.visible_sections().is_empty();
let show_range = self.section != Section::Chats && !self.visible_sections().is_empty();
let hint = |action| {
self.keymap
.primary_hint(action)
@@ -112,13 +112,20 @@ impl AnalyticsView {
} else {
let navigation = if !self.zoomed {
format!("{}/z maximize", hint(ListAction::Accept))
} else {
} else if self.section != Section::Chats {
format!(
"{}/{} day · {} details",
hint(ListAction::MoveLeft),
hint(ListAction::MoveRight),
hint(ListAction::Accept)
)
} else {
format!(
"{}/{} row · {} details",
hint(ListAction::MoveUp),
hint(ListAction::MoveDown),
hint(ListAction::Accept)
)
};
format!(
"tab/1–{} section · {}{navigation}\n{}{}R refresh · {}/{} scroll · {} back · q close",
+45 -6
View File
@@ -2,6 +2,7 @@
//! Resolving the account precedes report loading; refreshing drops all account-owned data.
use super::AnalyticsView;
use super::chats;
use super::data::Load;
use super::models::AccountKind;
@@ -11,6 +12,7 @@ pub(super) enum Section {
Usage,
Plugins,
Credits,
Chats,
Activity,
Skills,
}
@@ -22,6 +24,7 @@ impl Section {
Self::Usage => Some(Report::Usage),
Self::Plugins => Some(Report::Plugins),
Self::Credits => Some(Report::Credits),
Self::Chats => None,
Self::Activity => Some(Report::Messages),
Self::Skills => Some(Report::Skills),
}
@@ -29,7 +32,7 @@ impl Section {
}
/// Section-only indexing keeps report identity separate from array positions.
pub(super) struct SectionStates(pub(super) [SectionState; 5]);
pub(super) struct SectionStates(pub(super) [SectionState; 6]);
impl std::ops::Index<Section> for SectionStates {
type Output = SectionState;
@@ -76,6 +79,7 @@ impl AnalyticsView {
for section in &mut self.sections.0 {
section.history.poll();
}
self.chats.poll();
self.account.poll();
self.start_reports();
if !self.business()
@@ -90,12 +94,17 @@ impl AnalyticsView {
{
self.group_picker = None;
}
if let Some(chats) = self.chats.ready() {
self.sections[Section::Chats].cursor = self.sections[Section::Chats]
.cursor
.min(chats.rows.len().saturating_sub(/*rhs*/ 1));
}
}
pub(super) fn range_group(&self, section: Section) -> RangeGroup {
match section {
Section::Usage if self.business() => RangeGroup::Activity,
Section::Usage | Section::Credits => RangeGroup::Usage,
Section::Usage | Section::Credits | Section::Chats => RangeGroup::Usage,
Section::Activity => RangeGroup::Activity,
Section::Plugins | Section::Skills => RangeGroup::Tools,
}
@@ -112,21 +121,39 @@ impl AnalyticsView {
}
}
fn account_kind(&self) -> Option<AccountKind> {
self.account
.ready()
.copied()
.map(|plan| AccountKind::from(Some(plan)))
}
pub(super) fn business(&self) -> bool {
matches!(
self.account.ready(),
self.account_kind(),
Some(AccountKind::Business | AccountKind::Enterprise)
)
}
pub(super) fn visible_sections(&self) -> &'static [Section] {
match self.account.ready() {
match self.account_kind() {
Some(AccountKind::Consumer) => &[
Section::Usage,
Section::Activity,
Section::Plugins,
Section::Skills,
],
Some(AccountKind::Business | AccountKind::Enterprise)
if super::models::thread_usage_supported(self.account.ready().copied()) =>
{
&[
Section::Credits,
Section::Usage,
Section::Plugins,
Section::Skills,
Section::Chats,
]
}
Some(AccountKind::Business | AccountKind::Enterprise) => &[
Section::Credits,
Section::Usage,
@@ -156,7 +183,7 @@ impl AnalyticsView {
};
if !usage_groups.contains(&self.sections[Section::Usage].group) {
self.sections[Section::Usage].group =
if matches!(self.account.ready(), Some(AccountKind::Business)) {
if matches!(self.account_kind(), Some(AccountKind::Business)) {
2
} else {
usage_groups[0]
@@ -174,6 +201,18 @@ impl AnalyticsView {
self.load_report(*section);
}
}
if visible.contains(&Section::Chats) {
self.chats =
if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) {
Load::start_with_timeout(
chats::read(handle.clone(), std::sync::Arc::clone(live)),
frame.clone(),
std::time::Duration::from_secs(/*secs*/ 90),
)
} else {
Load::Unavailable
};
}
}
pub(super) fn section_date_range(
@@ -189,7 +228,7 @@ impl AnalyticsView {
let index = self.range_group(self.section);
self.ranges[index as usize] ^= 1;
for section in self.visible_sections() {
if self.range_group(*section) != index {
if matches!(section, Section::Chats) || self.range_group(*section) != index {
continue;
}
self.sections[*section].cursor = if self.ranges[index as usize] == 1 {
@@ -194,7 +194,7 @@ expression: "screens.join(\"\\n\")"
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Credits usage history By product "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -219,13 +219,13 @@ expression: "screens.join(\"\\n\")"
" ● IDE extension 10.00 "
" ● Web 9.00 "
" "
" 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 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Token usage history By token type "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -250,13 +250,13 @@ expression: "screens.join(\"\\n\")"
" ● Output 43,000 "
" Updated Sep 2 16:00 UTC "
" "
" 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 Credits usage history 2 Token usage history [3 Plugins called] 4 Skills used "
" 1 Credits usage history 2 Token usage history [3 Plugins called] 4 Skills used 5 Top chats "
" "
" ▎ Plugins called "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -281,13 +281,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 Credits usage history 2 Token usage history 3 Plugins called [4 Skills used] "
" 1 Credits usage history 2 Token usage history 3 Plugins called [4 Skills used] 5 Top chats "
" "
" ▎ Skills used "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -312,25 +312,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 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 120.00 "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" tab/1–5 section · 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 Credits usage history ────────────────────────────────────╮ ╭─ 2 Token usage history ──────────────────────────────────────╮ "
" │ │ │ │ "
" │ 7d │ │ 7d · All models │ "
" │ By product │ │ By token type │ "
" │ 431.00 credits │ │ 4,377,500 tokens │ "
" │ │ │ │ "
" │ 150 │ │ 1,000,000 731,000 │ "
" │ │ │ ▃▃▃▃▃ ▇▇▇▇ │ "
" │ █████ │ │ ▂▂▂▂ █████ ▆▆▆▆ ████ █████ │ "
" │ 75 ▇▇▇▇▇ ▄▄▄▄▄ █████ 64.00 │ │ 500,000 ████ ▆▆▆▆ █████ ████ ████ █████ │ "
" │ ▅▅▅▅▅ █████ █████ █████ ▇▇▇▇▇▇ │ │ ████ ████ █████ ████ ████ █████ │ "
" │ ▇▇▇▇▇ █████ █████ █████ █████ ██████ │ │ ▁▁▁▁ ████ ████ █████ ████ ████ █████ │ "
" │ 0 █████ █████ █████ █████ █████ ██████ │ │ ████ ████ ████ █████ ████ ████ █████ │ "
" │ │ │ 731,000 │ "
" │ 150 ▁▁▁▁▁ ▃▃▃▃▃ 64.00 │ │ 1,000,000 ▁▁▁▁ ▄▄▄▄▄ ▃▃▃▃ ▅▅▅▅ ▃▃▃▃▃ │ "
" │ 0 ▄▄▄▄▄ ▆▆▆▆▆ █████ █████ █████ ▆▆▆▆▆▆ │ │ ▂▂▂▂ ████ ████ █████ ████ ████ █████ │ "
" │ 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 │ "
@@ -343,18 +368,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 │ "
@@ -368,14 +388,25 @@ expression: "screens.join(\"\\n\")"
" │ │ │ │ "
" ╰────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────╯ "
" "
" ╭─ ▸ 5 Top chats ────────────────────────────────────────────────╮ "
" │ │ "
" │ 30d active · lifetime credits │ "
" │ │ "
" │ Q3 planning analysis 120.00 │ "
" │ Refactor billing usage 113.00 │ "
" │ Other usage 109.00 │ "
" │ Launch readiness review 85.00 │ "
" │ Plan the week 0.00 │ "
" │ │ "
" │ Local chats · excludes subagents │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" ╰────────────────────────────────────────────────────────────────╯ "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" tab/1–4 section · enter/z maximize "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" tab/1–5 section · enter/z maximize "
" R refresh · pgup/pgdn scroll · esc back · q close "
@@ -0,0 +1,65 @@
---
source: tui/src/analytics/account_layout_tests.rs
expression: "format!(\"{dollars}\\n{narrow}\")"
---
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Est. $ spent Lifetime credits "
" › Q3 planning analysis $123.45 120.00 "
" Refactor billing usage — 113.00 "
" Other usage — 109.00 "
" Launch readiness review — 85.00 "
" Plan the week — 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" "
" Credits usage history Token usage "
" history Plugins called Skills used "
" [Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────── "
" Active in past 30 days · sorted by "
" lifetime credits "
" "
" Chat Est. $ / credits "
" › Q3 planning analysis "
" $123.45 120.00 "
" Refactor billing usage "
" — 113.00 "
" Other usage "
" — 109.00 "
" Launch readiness review "
" — 85.00 "
" "
" Showing 1–4 of 5 chats "
" Local chats · excludes archived chats "
" and subagent usage "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ "
" row · enter details "
" R refresh · pgup/pgdn scroll · esc "
" back · q close "
@@ -5,7 +5,7 @@ expression: "format!(\"{all}\\n{filtered}\")"
" Analytics · Live account · analytics@example.test · account-a "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Token usage history By token type "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -30,13 +30,13 @@ expression: "format!(\"{all}\\n{filtered}\")"
" ● Output 43,000 "
" Updated Sep 2 16:00 UTC "
" "
" 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 · analytics@example.test · account-a "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Token usage history By token type "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -61,5 +61,5 @@ expression: "format!(\"{all}\\n{filtered}\")"
" ● Output 43,000 "
" Updated Sep 2 16:00 UTC "
" "
" 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 "
@@ -0,0 +1,29 @@
---
source: tui/src/analytics/account_layout_tests.rs
assertion_line: 341
expression: "screen(&mut view, 110, 24)"
---
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used "
" "
" ▎ Token usage history By token type "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" All models · m change model "
" 4,377,500 tokens "
" 731,000 "
" 1,000,000 ▁▁▁▁▁▁▁▁▁ ▄▄▄▄▄▄▄▄▄ ▃▃▃▃▃▃▃▃ ▅▅▅▅▅▅▅▅▅ ▃▃▃▃▃▃▃▃▃ "
" ▂▂▂▂▂▂▂▂ █████████ ████████ █████████ ████████ █████████ █████████ "
" 0 ─────────────────────────────────────────────────────────────────────────────────────────────── "
" ▲ "
" Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 "
" "
" ──────────────────────────────────────────────────────── "
" Sep 2 · 731,000 tokens "
" Token type Tokens "
" ● Cached input 602,000 "
" ● Uncached input 86,000 "
" ● Output 43,000 "
" tab/1–4 section · z dashboard · ←/→ day · enter details "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "
@@ -0,0 +1,102 @@
---
source: tui/src/analytics/chat_panel_tests.rs
expression: "screens.join(\"\\n\")"
---
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 19,350.04 "
" ────────────────────────────────────────────────────────────────────────────────────────────── "
" Model Effort Speed Credits "
" Top model high Not reported 19,134.43 "
" Small model high standard 215.61 "
" Refund model high standard -0.000004 "
" 1 zero-credit group hidden · a show all "
" "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" "
" Credits usage history Token usage history Plugins "
" called Skills used [Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 19,350.04 "
" ──────────────────────────────────────────────────── "
" Model / effort / speed Credits "
" Top model 19,134.43 "
" high · Not reported "
" Small model 215.61 "
" high · standard "
" Refund model -0.000004 "
" high · standard "
" 1 zero-credit group hidden · a show all "
" "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" "
" Showing 1–4 of 5 chats "
" Local chats · excludes archived chats and subagent "
" usage "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter "
" details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 19,350.04 "
" ────────────────────────────────────────────────────────────────────────────────────────────── "
" Model Effort Speed Credits "
" Top model high Not reported 19,134.43 "
" Small model high standard 215.61 "
" Zero model high standard 0.00 "
" Refund model high standard -0.000004 "
" 1 zero-credit group · a hide zeros "
" "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
@@ -0,0 +1,35 @@
---
source: tui/src/analytics/chat_panel_tests.rs
assertion_line: 123
expression: output
---
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" 2 chat estimates unavailable "
" "
" Chat Lifetime credits "
" › Create a comprehensive list of areas to investigate across the runtime and describe ho… 120.00 "
" ────────────────────────────────────────────────────────────────────────────────────────────── "
" Create a comprehensive list of areas to investigate across the runtime and describe how each "
" area affects performance in a long conversation "
" Model Effort Speed Credits "
" GPT-5.6-Sol high standard 120.00 "
" "
" Chat usage unavailable — "
" Chat usage unavailable — "
" Launch readiness review 85.00 "
" Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
@@ -0,0 +1,78 @@
---
source: tui/src/analytics/chat_panel_tests.rs
expression: "screens.join(\"\\n\")"
---
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Chat 01 120.00 "
" Chat 02 120.00 "
" Chat 03 120.00 "
" Chat 04 120.00 "
" Chat 05 120.00 "
" Chat 06 120.00 "
" Chat 07 120.00 "
" Chat 08 120.00 "
" Chat 09 120.00 "
" "
" Showing 1–9 of 30 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" Chat 22 120.00 "
" Chat 23 120.00 "
" Chat 24 120.00 "
" Chat 25 120.00 "
" Chat 26 120.00 "
" Chat 27 120.00 "
" Chat 28 120.00 "
" Chat 29 120.00 "
" › Chat 30 120.00 "
" "
" Showing 22–30 of 30 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" Chat 26 120.00 "
" Chat 27 120.00 "
" Chat 28 120.00 "
" Chat 29 120.00 "
" › Chat 30 120.00 "
" ────────────────────────────────────────────────────────────────────────────────────────────── "
" Model Effort Speed Credits "
" GPT-5.6-Sol high standard 120.00 "
" "
" "
" Showing 26–30 of 30 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
@@ -0,0 +1,29 @@
---
source: tui/src/analytics/chat_panel_tests.rs
assertion_line: 205
expression: "screen(&mut view, 110, 24)"
---
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" Q3 planning analysis 120.00 "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" › Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
@@ -0,0 +1,62 @@
---
source: tui/src/analytics/chat_panel_tests.rs
assertion_line: 165
expression: "screens.join(\"\\n\")"
---
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" 1 chat estimate unavailable "
" "
" Chat Lifetime credits "
" › Chat usage unavailable — "
" "
" Showing 1–1 of 1 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" "
" │ │ │ │ "
" │ Sep 2 · 29 calls │ │ Sep 2 · 11 uses │ "
" │ ● Slack 10 │ │ ● Testing 4 │ "
" │ ● Figma 10 │ │ ● Code review 3 │ "
" │ │ │ │ "
" │ │ │ │ "
" ╰─────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────╯ "
" "
" ╭─ ▸ 5 Top chats ─────────────────────────────────╮ "
" │ │ "
" │ 30d active · lifetime credits │ "
" │ │ "
" │ Chat usage unavailable — │ "
" │ │ "
" │ Local chats · excludes subagents │ "
" │ Some estimates unavailable │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" │ │ "
" ╰─────────────────────────────────────────────────╯ "
" tab/1–5 section · enter/z maximize "
" R refresh · pgup/pgdn scroll · esc back · q close "
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Token usage history By token type "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -44,7 +44,7 @@ Text:
" ● Output 43,000 "
" Updated Sep 2 16:00 UTC "
" "
" 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 +61,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 27:3 52:2 83:0
3: 0:0 2:2 27:3 52:2 96:0
4: 0:0
5: 0:0 2:3 23:0 105:2 118:0
6: 0:0 2:4 118:0
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history 2 Token usage history [3 Plugins called] 4 Skills used "
" 1 Credits usage history 2 Token usage history [3 Plugins called] 4 Skills used 5 Top chats "
" "
" ▎ Plugins called "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -44,7 +44,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 +61,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 50:3 70:2 83:0
3: 0:0 2:2 50:3 70:2 96:0
4: 0:0
5: 0:0 2:3 18:0
6: 0:0 2:4 118:0
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Credits usage history By product "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -44,7 +44,7 @@ Text:
" ● IDE extension 400.00 "
" ● Web 360.00 "
" "
" 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 +61,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 29:2 83:0
3: 0:0 2:3 29:2 96:0
4: 0:0
5: 0:0 2:3 25:0 108:2 118:0
6: 0:0 2:4 118:0
@@ -0,0 +1,98 @@
---
source: tui/src/analytics/styles_tests.rs
expression: snapshot
---
120x42
Text:
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 120.00 "
" ────────────────────────────────────────────────────────────────────────────────────────────── "
" Model Effort Speed Credits "
" GPT-5.6-Sol high standard 120.00 "
" "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" tab/1–5 section · 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 83:3 96:0
3: 0:0
4: 0:0 2:3 13:0
5: 0:0 2:4 118:0
6: 0:0 2:2 53:0
7: 0:0
8: 0:0 2:1 8:0 82:1 98:0
9: 0:0 2:3 24:0 92:3 98:0
10: 0:0 2:4 98:0
11: 0:0 2:2 82:0 91:1 98:0
12: 0:0 2:1 54:2 82:0
13: 0:0
14: 0:0
15: 0:0
16: 0:0
17: 0:0
18: 0:0
19: 0:0 2:2 24:0
20: 0:0 2:2 58: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 34:5 37:2 44:5 49:2 118:0
41: 0:0 2:5 3:2 14:5 23:2 33:5 36:2 44:5 45:2 118:0
@@ -7,45 +7,45 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used "
" 1 Credits usage history 2 Token usage history 3 Plugins called [4 Skills used] 5 Top chats "
" "
" ▎ Messages By model "
" ▎ Skills used "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" 574 reported messages "
" 54 uses "
" "
" 150 "
" 20 "
" "
" "
" "
" 103 "
" ▄▄▄▄▄▄▄▄▄▄ "
" ██████████ ██████████ "
" ██████████ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" 75 ██████████ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ "
" ▆▆▆▆▆▆▆▆▆▆ ██████████ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ ██████████ "
" "
" "
" "
" 11 "
" ▃▃▃▃▃▃▃▃▃▃ "
" 10 ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ██████████ ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ "
" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" 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 "
" "
" ──────────────────────────────────────────────────────── "
" Sep 2 · 103 messages "
" Model Messages "
" ● GPT-5.6-Sol 61 "
" ● GPT-5.6-Terra 25 "
" ● GPT-5.5 10 "
" ● Other 7 "
" Sep 2 · 11 uses "
" Skill Uses "
" ● Testing 4 "
" ● Code review 3 "
" ● Planning 3 "
" ● Debugging 1 "
" "
" tab/1–4 section · z dashboard · ←/→ day · enter details "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "
" 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
1: Reset|Reset|Reset|BOLD
@@ -61,42 +61,42 @@ 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 68:3 85:2 96:0
4: 0:0
5: 0:0 2:3 12:0 110:2 118:0
5: 0:0 2:3 15:0
6: 0:0 2:4 118:0
7: 0:0 2:3 23:0
7: 0:0 2:3 9:0
8: 0:0
9: 0:0 3:2 6:0
9: 0:0 3:2 5:0
10: 0:0
11: 0:0
12: 0:0
13: 0:0 108:3 111:0
13: 0:0
14: 0:0
15: 0:0 41:5 51:0
16: 0:0 41:5 51:0 105:5 115:0
17: 0:0 41:6 51:0 105:6 115:0
18: 0:0 4:2 6:0 25:5 35:0 41:6 51:0 89:5 99:0 105:6 115:0
19: 0:0 25:6 35:0 41:6 51:0 57:5 67:0 89:6 99:0 105:6 115:0
20: 0:0 25:6 35:0 41:7 51:0 57:6 67:0 89:6 99:0 105:7 115:0
21: 0:0 9:6 19:0 25:6 35:0 41:7 51:0 57:6 67:0 73:5 83:0 89:6 99:0 105:7 115:0
22: 0:0 9:6 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
23: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
24: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
15: 0:0
16: 0:0 108:3 110:0
17: 0:0
18: 0:0 3:2 5:0
19: 0:0 105:5 115:0
20: 0:0 41:5 51:0 105:5 115:0
21: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 105:6 115:0
22: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 73:5 83:0 105:6 115:0
23: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0
24: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
28: 0:0 5:2 6:0 7:4 118:0
28: 0:0 4:2 5:0 6:4 118:0
29: 0:0 109:3 110:0
30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
31: 0:0
32: 0:0 2:4 58:0
33: 0:0 2:1 7:3 22:0
34: 0:0 2:1 7:0 50:1 58:0
33: 0:0 2:1 7:3 17:0
34: 0:0 2:1 7:0 54:1 58:0
35: 0:0 2:7 4:0
36: 0:0 2:6 4:0
37: 0:0 2:5 4:0
38: 0:0
39: 0:0
40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 22:9 23:2 34:9 43:2 53:9 56:2 64:9 65:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 24:9 33:2 43:9 46:2 54:9 55:2 118:0
@@ -7,45 +7,45 @@ 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 "
" "
" ▎ Skills used "
" ▎ Messages By model "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" 54 uses "
" 574 reported messages "
" "
" 20 "
" 150 "
" "
" "
" "
" "
" "
" "
" 11 "
" ▃▃▃▃▃▃▃▃▃▃ "
" 10 ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ██████████ ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ "
" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" 103 "
" ▄▄▄▄▄▄▄▄▄▄ "
" ██████████ ██████████ "
" ██████████ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" 75 ██████████ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ "
" ▆▆▆▆▆▆▆▆▆▆ ██████████ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" 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 "
" "
" ──────────────────────────────────────────────────────── "
" Sep 2 · 11 uses "
" Skill Uses "
" ● Testing 4 "
" ● Code review 3 "
" ● Planning 3 "
" ● Debugging 1 "
" Sep 2 · 103 messages "
" Model Messages "
" ● GPT-5.6-Sol 61 "
" ● GPT-5.6-Terra 25 "
" ● GPT-5.5 10 "
" ● Other 7 "
" "
" tab/1–4 section · z dashboard · ←/→ day · enter details "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "
Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
@@ -61,42 +61,42 @@ 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 25:3 39:2 70:0
4: 0:0
5: 0:0 2:3 15:0
5: 0:0 2:3 12:0 110:2 118:0
6: 0:0 2:4 118:0
7: 0:0 2:3 9:0
7: 0:0 2:3 23:0
8: 0:0
9: 0:0 3:2 5:0
9: 0:0 3:2 6:0
10: 0:0
11: 0:0
12: 0:0
13: 0:0
13: 0:0 108:3 111:0
14: 0:0
15: 0:0
16: 0:0 108:3 110:0
17: 0:0
18: 0:0 3:2 5:0
19: 0:0 105:5 115:0
20: 0:0 41:5 51:0 105:5 115:0
21: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 105:6 115:0
22: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 73:5 83:0 105:6 115:0
23: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0
24: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
15: 0:0 41:5 51:0
16: 0:0 41:5 51:0 105:5 115:0
17: 0:0 41:6 51:0 105:6 115:0
18: 0:0 4:2 6:0 25:5 35:0 41:6 51:0 89:5 99:0 105:6 115:0
19: 0:0 25:6 35:0 41:6 51:0 57:5 67:0 89:6 99:0 105:6 115:0
20: 0:0 25:6 35:0 41:7 51:0 57:6 67:0 89:6 99:0 105:7 115:0
21: 0:0 9:6 19:0 25:6 35:0 41:7 51:0 57:6 67:0 73:5 83:0 89:6 99:0 105:7 115:0
22: 0:0 9:6 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
23: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
24: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
28: 0:0 4:2 5:0 6:4 118:0
28: 0:0 5:2 6:0 7:4 118:0
29: 0:0 109:3 110:0
30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
31: 0:0
32: 0:0 2:4 58:0
33: 0:0 2:1 7:3 17:0
34: 0:0 2:1 7:0 54:1 58:0
33: 0:0 2:1 7:3 22:0
34: 0:0 2:1 7:0 50:1 58:0
35: 0:0 2:7 4:0
36: 0:0 2:6 4:0
37: 0:0 2:5 4:0
38: 0:0
39: 0:0
40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 24:9 33:2 43:9 46:2 54:9 55:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 22:9 23:2 34:9 43:2 53:9 56:2 64:9 65:2 118:0
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called [4 Skills used] "
" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] "
" "
" ▎ Skills used "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -61,7 +61,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 68:3 83:0
3: 0:0 2:2 55:3 70:0
4: 0:0
5: 0:0 2:3 15:0
6: 0:0 2:4 118:0
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used "
" 1 Credits usage history [2 Token usage history] 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Token usage history By token type "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -44,7 +44,7 @@ Text:
" ● Output 43,000 "
" Updated Sep 2 16:00 UTC "
" "
" 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 +61,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 27:3 52:2 83:0
3: 0:0 2:2 27:3 52:2 96:0
4: 0:0
5: 0:0 2:3 23:0 105:2 118:0
6: 0:0 2:4 118:0
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history 2 Token usage history [3 Plugins called] 4 Skills used "
" 1 Credits usage history 2 Token usage history [3 Plugins called] 4 Skills used 5 Top chats "
" "
" ▎ Plugins called "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -44,7 +44,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 +61,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 50:3 70:2 83:0
3: 0:0 2:2 50:3 70:2 96:0
4: 0:0
5: 0:0 2:3 18:0
6: 0:0 2:4 118:0
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Credits usage history By product "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -44,7 +44,7 @@ Text:
" ● IDE extension 400.00 "
" ● Web 360.00 "
" "
" 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 +61,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 29:2 83:0
3: 0:0 2:3 29:2 96:0
4: 0:0
5: 0:0 2:3 25:0 108:2 118:0
6: 0:0 2:4 118:0
@@ -0,0 +1,98 @@
---
source: tui/src/analytics/styles_tests.rs
expression: snapshot
---
120x42
Text:
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 120.00 "
" ────────────────────────────────────────────────────────────────────────────────────────────── "
" Model Effort Speed Credits "
" GPT-5.6-Sol high standard 120.00 "
" "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" tab/1–5 section · 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 83:3 96:0
3: 0:0
4: 0:0 2:3 13:0
5: 0:0 2:4 118:0
6: 0:0 2:2 53:0
7: 0:0
8: 0:0 2:1 8:0 82:1 98:0
9: 0:0 2:3 24:0 92:3 98:0
10: 0:0 2:4 98:0
11: 0:0 2:2 82:0 91:1 98:0
12: 0:0 2:1 54:2 82:0
13: 0:0
14: 0:0
15: 0:0
16: 0:0
17: 0:0
18: 0:0
19: 0:0 2:2 24:0
20: 0:0 2:2 58: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 34:5 37:2 44:5 49:2 118:0
41: 0:0 2:5 3:2 14:5 23:2 33:5 36:2 44:5 45:2 118:0
@@ -7,45 +7,45 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Total usage history [2 Messages] 3 Plugins called 4 Skills used "
" 1 Credits usage history 2 Token usage history 3 Plugins called [4 Skills used] 5 Top chats "
" "
" ▎ Messages By model "
" ▎ Skills used "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" 574 reported messages "
" 54 uses "
" "
" 150 "
" 20 "
" "
" "
" "
" 103 "
" ▄▄▄▄▄▄▄▄▄▄ "
" ██████████ ██████████ "
" ██████████ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" 75 ██████████ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ "
" ▆▆▆▆▆▆▆▆▆▆ ██████████ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ ██████████ "
" "
" "
" "
" 11 "
" ▃▃▃▃▃▃▃▃▃▃ "
" 10 ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ██████████ ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ "
" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" 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 "
" "
" ──────────────────────────────────────────────────────── "
" Sep 2 · 103 messages "
" Model Messages "
" ● GPT-5.6-Sol 61 "
" ● GPT-5.6-Terra 25 "
" ● GPT-5.5 10 "
" ● Other 7 "
" Sep 2 · 11 uses "
" Skill Uses "
" ● Testing 4 "
" ● Code review 3 "
" ● Planning 3 "
" ● Debugging 1 "
" "
" tab/1–4 section · z dashboard · ←/→ day · enter details "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "
" 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
1: Reset|Reset|Reset|BOLD
@@ -61,42 +61,42 @@ 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 68:3 85:2 96:0
4: 0:0
5: 0:0 2:3 12:0 110:2 118:0
5: 0:0 2:3 15:0
6: 0:0 2:4 118:0
7: 0:0 2:3 23:0
7: 0:0 2:3 9:0
8: 0:0
9: 0:0 3:2 6:0
9: 0:0 3:2 5:0
10: 0:0
11: 0:0
12: 0:0
13: 0:0 108:3 111:0
13: 0:0
14: 0:0
15: 0:0 41:5 51:0
16: 0:0 41:5 51:0 105:5 115:0
17: 0:0 41:6 51:0 105:6 115:0
18: 0:0 4:2 6:0 25:5 35:0 41:6 51:0 89:5 99:0 105:6 115:0
19: 0:0 25:6 35:0 41:6 51:0 57:5 67:0 89:6 99:0 105:6 115:0
20: 0:0 25:6 35:0 41:7 51:0 57:6 67:0 89:6 99:0 105:7 115:0
21: 0:0 9:6 19:0 25:6 35:0 41:7 51:0 57:6 67:0 73:5 83:0 89:6 99:0 105:7 115:0
22: 0:0 9:6 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
23: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
24: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
15: 0:0
16: 0:0 108:3 110:0
17: 0:0
18: 0:0 3:2 5:0
19: 0:0 105:5 115:0
20: 0:0 41:5 51:0 105:5 115:0
21: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 105:6 115:0
22: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 73:5 83:0 105:6 115:0
23: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0
24: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
28: 0:0 5:2 6:0 7:4 118:0
28: 0:0 4:2 5:0 6:4 118:0
29: 0:0 109:3 110:0
30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
31: 0:0
32: 0:0 2:4 58:0
33: 0:0 2:1 7:3 22:0
34: 0:0 2:1 7:0 50:1 58:0
33: 0:0 2:1 7:3 17:0
34: 0:0 2:1 7:0 54:1 58:0
35: 0:0 2:7 4:0
36: 0:0 2:6 4:0
37: 0:0 2:5 4:0
38: 0:0
39: 0:0
40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 22:9 23:2 34:9 43:2 53:9 56:2 64:9 65:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 24:9 33:2 43:9 46:2 54:9 55:2 118:0
@@ -7,45 +7,45 @@ 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 "
" "
" ▎ Skills used "
" ▎ Messages By model "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" 54 uses "
" 574 reported messages "
" "
" 20 "
" 150 "
" "
" "
" "
" "
" "
" "
" 11 "
" ▃▃▃▃▃▃▃▃▃▃ "
" 10 ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ "
" ██████████ ██████████ ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ "
" ██████████ ▅▅▅▅▅▅▅▅▅▅ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" 103 "
" ▄▄▄▄▄▄▄▄▄▄ "
" ██████████ ██████████ "
" ██████████ ██████████ "
" ▄▄▄▄▄▄▄▄▄▄ ██████████ ▆▆▆▆▆▆▆▆▆▆ ██████████ "
" 75 ██████████ ██████████ ▄▄▄▄▄▄▄▄▄▄ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ "
" ▆▆▆▆▆▆▆▆▆▆ ██████████ ██████████ ██████████ ▃▃▃▃▃▃▃▃▃▃ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ ██████████ "
" 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 "
" "
" ──────────────────────────────────────────────────────── "
" Sep 2 · 11 uses "
" Skill Uses "
" ● Testing 4 "
" ● Code review 3 "
" ● Planning 3 "
" ● Debugging 1 "
" Sep 2 · 103 messages "
" Model Messages "
" ● GPT-5.6-Sol 61 "
" ● GPT-5.6-Terra 25 "
" ● GPT-5.5 10 "
" ● Other 7 "
" "
" tab/1–4 section · z dashboard · ←/→ day · enter details "
" r 7/30d · R refresh · pgup/pgdn scroll · esc back · q close "
" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q close "
Palette (foreground|background|underline|modifiers):
0: Reset|Reset|Reset|NONE
1: Reset|Reset|Reset|BOLD
@@ -61,42 +61,42 @@ 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 25:3 39:2 70:0
4: 0:0
5: 0:0 2:3 15:0
5: 0:0 2:3 12:0 110:2 118:0
6: 0:0 2:4 118:0
7: 0:0 2:3 9:0
7: 0:0 2:3 23:0
8: 0:0
9: 0:0 3:2 5:0
9: 0:0 3:2 6:0
10: 0:0
11: 0:0
12: 0:0
13: 0:0
13: 0:0 108:3 111:0
14: 0:0
15: 0:0
16: 0:0 108:3 110:0
17: 0:0
18: 0:0 3:2 5:0
19: 0:0 105:5 115:0
20: 0:0 41:5 51:0 105:5 115:0
21: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 105:6 115:0
22: 0:0 9:5 19:0 41:5 51:0 57:5 67:0 73:5 83:0 105:6 115:0
23: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:5 83:0 89:5 99:0 105:6 115:0
24: 0:0 9:6 19:0 25:5 35:0 41:6 51:0 57:6 67:0 73:6 83:0 89:6 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:6 35:0 41:6 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:6 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
15: 0:0 41:5 51:0
16: 0:0 41:5 51:0 105:5 115:0
17: 0:0 41:6 51:0 105:6 115:0
18: 0:0 4:2 6:0 25:5 35:0 41:6 51:0 89:5 99:0 105:6 115:0
19: 0:0 25:6 35:0 41:6 51:0 57:5 67:0 89:6 99:0 105:6 115:0
20: 0:0 25:6 35:0 41:7 51:0 57:6 67:0 89:6 99:0 105:7 115:0
21: 0:0 9:6 19:0 25:6 35:0 41:7 51:0 57:6 67:0 73:5 83:0 89:6 99:0 105:7 115:0
22: 0:0 9:6 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
23: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:6 83:0 89:7 99:0 105:7 115:0
24: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
25: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
26: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
27: 0:0 9:7 19:0 25:7 35:0 41:7 51:0 57:7 67:0 73:7 83:0 89:7 99:0 105:7 115:0
28: 0:0 4:2 5:0 6:4 118:0
28: 0:0 5:2 6:0 7:4 118:0
29: 0:0 109:3 110:0
30: 0:0 10:2 16:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
30: 0:0 11:2 17:0 26:2 32:0 42:2 48:0 58:2 64:0 74:2 80:0 91:2 96:0 107:8 112:0
31: 0:0
32: 0:0 2:4 58:0
33: 0:0 2:1 7:3 17:0
34: 0:0 2:1 7:0 54:1 58:0
33: 0:0 2:1 7:3 22:0
34: 0:0 2:1 7:0 50:1 58:0
35: 0:0 2:7 4:0
36: 0:0 2:6 4:0
37: 0:0 2:5 4:0
38: 0:0
39: 0:0
40: 0:0 2:9 9:2 20:9 21:2 34:9 37:2 44:9 49:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 24:9 33:2 43:9 46:2 54:9 55:2 118:0
41: 0:0 2:9 3:2 12:9 13:2 22:9 23:2 34:9 43:2 53:9 56:2 64:9 65:2 118:0
@@ -7,7 +7,7 @@ Text:
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called [4 Skills used] "
" 1 Total usage history 2 Messages 3 Plugins called [4 Skills used] "
" "
" ▎ Skills used "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -61,7 +61,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 68:3 83:0
3: 0:0 2:2 55:3 70:0
4: 0:0
5: 0:0 2:3 15:0
6: 0:0 2:4 118:0
@@ -47,6 +47,7 @@ fn analytics_sections_adapt_to_terminal_colors() {
if business {
view = fixture::view(models::AccountKind::Enterprise);
}
view.sections[Section::Chats].detail = Some(0);
// Exercise the reported credit range and preserve exact amounts below the plot.
let mut credits =
fixture::history(/*report*/ 1, /*range*/ 0, /*group*/ 0);
+47 -1
View File
@@ -139,6 +139,42 @@ pub(super) fn history(
}
}
pub(super) fn chats() -> super::chats::Chats {
use codex_backend_client::ThreadUsage;
use codex_backend_client::ThreadUsageBreakdownGroup;
super::chats::Chats {
rows: [
("Q3 planning analysis", 120),
("Refactor billing usage", 113),
("Other usage", 109),
("Launch readiness review", 85),
("Plan the week", 0),
]
.into_iter()
.enumerate()
.map(|(index, (title, credits))| super::chats::Chat {
title: title.into(),
usage: Some(ThreadUsage {
thread_id: format!("test-{index}"),
estimated_usage_credits_micros: credits * 1_000_000,
estimated_usage_usd_micros: None,
groups: vec![ThreadUsageBreakdownGroup {
model: Some("GPT-5.6-Sol".into()),
reasoning_effort: Some("high".into()),
speed: Some("standard".into()),
estimated_usage_credits_micros: credits * 1_000_000,
net_new_input_tokens: None,
cached_input_tokens: None,
input_tokens: None,
output_tokens: None,
total_tokens: None,
}],
}),
})
.collect(),
}
}
pub(super) fn tokens(range: usize, grouping: usize) -> super::models::AccountAnalyticsHistory {
let response = serde_json::json!({"data_freshness_ts":"2026-09-02T16:00:00Z", "data":
daily(Section::Usage, range).into_iter().enumerate().map(|(index, total)| serde_json::json!({
@@ -164,7 +200,14 @@ pub(super) fn tokens(range: usize, grouping: usize) -> super::models::AccountAna
pub(super) fn view(kind: super::models::AccountKind) -> super::AnalyticsView {
let mut view = super::AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list);
view.end_date = END_DATE;
view.account = super::data::Load::Ready(kind);
view.account = super::data::Load::Ready(match kind {
super::models::AccountKind::Consumer => codex_protocol::account::PlanType::Plus,
super::models::AccountKind::Business => codex_protocol::account::PlanType::Team,
super::models::AccountKind::Enterprise => {
codex_protocol::account::PlanType::EnterpriseCbpUsageBased
}
super::models::AccountKind::Unknown => codex_protocol::account::PlanType::Unknown,
});
view.start_reports();
seed_reports(&mut view);
view
@@ -173,6 +216,9 @@ pub(super) fn view(kind: super::models::AccountKind) -> super::AnalyticsView {
/// Populate explicit report state after a test chooses its account, period, and grouping.
pub(super) fn seed_reports(view: &mut super::AnalyticsView) {
use super::data::Load;
if view.business() {
view.chats = Load::Ready(chats());
}
for section in view.visible_sections() {
if let Some(report) = section.report() {
let range = view.ranges[view.range_group(*section) as usize];
@@ -61,6 +61,7 @@ pub(super) async fn settle(view: &mut super::AnalyticsView) {
.0
.iter()
.any(|state| matches!(state.history, Load::Loading(_)))
&& !matches!(view.chats, Load::Loading(_))
{
break;
}
+66
View File
@@ -226,6 +226,18 @@ fn analytics_credit_display_retains_integer_precision() {
]
.map(str::to_string)
);
assert_eq!(
[0, -4, 999_999_999, 19_350_041_555, i64::MIN, i64::MAX].map(data::credits),
[
"0.00",
"-0.000004",
"1,000.00",
"19,350.04",
"-9,223,372,036,854.78",
"9,223,372,036,854.78"
]
.map(str::to_string)
);
assert_eq!(
[
12_746.441206,
@@ -607,3 +619,57 @@ fn analytics_empty_turns_keep_geometry_and_disable_details() {
}
insta::assert_snapshot!(snapshots.join("\n"));
}
#[path = "analytics/chat_panel_tests.rs"]
mod chats_table;
#[test]
fn analytics_details_reflow_and_keep_focus() {
let mut view = fixture::view(models::AccountKind::Enterprise);
view.chats = Load::Ready(fixture::chats());
press(&mut view, KeyCode::Char('z'));
press(&mut view, KeyCode::Char('5'));
press(&mut view, KeyCode::Enter);
assert!(view.zoomed);
press(&mut view, KeyCode::Enter);
let wide = screen(&mut view, /*width*/ 120, /*height*/ 42);
view.follow_selection = true;
let narrow = screen(&mut view, /*width*/ 58, /*height*/ 20);
assert!(narrow.contains("Q3 planning analysis") && narrow.contains("GPT-5.6-Sol"));
insta::assert_snapshot!(format!("{wide}\n{narrow}"));
let before = (
view.section,
view.sections.0.each_ref().map(|state| state.cursor),
view.sections.0.each_ref().map(|state| state.detail),
);
press(&mut view, KeyCode::PageDown);
screen(&mut view, /*width*/ 58, /*height*/ 20);
assert_eq!(
(
view.section,
view.sections.0.each_ref().map(|state| state.cursor),
view.sections.0.each_ref().map(|state| state.detail)
),
before
);
press(&mut view, KeyCode::Tab);
press(&mut view, KeyCode::Char('5'));
assert_eq!(
(
view.section,
view.sections.0.each_ref().map(|state| state.cursor),
view.sections.0.each_ref().map(|state| state.detail)
),
before
);
press(&mut view, KeyCode::Esc);
assert_eq!(
(
view.is_done,
view.sections.0.each_ref().map(|state| state.detail)
),
(false, [None; 6])
);
press(&mut view, KeyCode::Esc);
assert!(view.is_done);
}
@@ -6,6 +6,7 @@ expression: "format!(\"{picker}\\n{}\", screen(&mut view, 85, 40))"
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used "
" 5 Top chats "
" "
" ▎ Credits usage history By product "
" ───────────────────────────────────────────────────────────────────────────────── "
@@ -18,14 +19,13 @@ expression: "format!(\"{picker}\\n{}\", screen(&mut view, 85, 40))"
" 150 "
" "
" 109.00 "
" ▁▁▁▁▁▁▁ "
" ▃▃▃▃▃▃▃ "
" ███████ "
" ███████ "
" ▇▇▇▇▇▇▇ ███████ "
" 75 ███████ ███████ ███████ "
" ▂▂▂▂▂▂▂ ███████ ███████ ███████ ▇▇▇▇▇▇▇ "
" ███████ ███████ ███████ ███████ ███████ "
" ▆▆▆▆▆▆▆ ███████ ███████ ███████ ███████ ███████ "
" ▂▂▂▂▂▂▂ ███████ "
" 75 ███████ ▄▄▄▄▄▄▄ ███████ "
" ███████ ███████ ███████ ▄▄▄▄▄▄▄ "
" ▇▇▇▇▇▇▇ ███████ ███████ ███████ ███████ "
" ▄▄▄▄▄▄▄ ███████ ███████ ███████ ███████ ███████ "
" ███████ ███████ ███████ ███████ ███████ ███████ "
" ███████ ███████ ███████ ███████ ███████ ███████ "
" 0 ███████ ███████ ███████ ███████ ███████ ███████ "
@@ -47,6 +47,7 @@ expression: "format!(\"{picker}\\n{}\", screen(&mut view, 85, 40))"
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used "
" 5 Top chats "
" "
" ▎ Credits usage history By product "
" ───────────────────────────────────────────────────────────────────────────────── "
@@ -56,15 +57,14 @@ expression: "format!(\"{picker}\\n{}\", screen(&mut view, 85, 40))"
" "
" "
" 109.00 "
" ▂▂▂▂▂▂▂ "
" ▅▅▅▅▅▅▅ "
" ███████ "
" ███████ "
" ▅▅▅▅▅▅▅ ███████ "
" 75 ███████ ▄▄▄▄▄▄▄ ███████ "
" ███████ ███████ ███████ ▂▂▂▂▂▂▂ "
" ▃▃▃▃▃▃▃ ███████ ███████ ███████ ███████ "
" ███████ ███████ "
" 75 ███████ ███████ ███████ "
" ███████ ███████ ███████ ▆▆▆▆▆▆▆ "
" ███████ ███████ ███████ ███████ ███████ "
" ▅▅▅▅▅▅▅ ███████ ███████ ███████ ███████ ███████ "
" ▂▂▂▂▂▂▂ ███████ ███████ ███████ ███████ ███████ "
" ███████ ███████ ███████ ███████ ███████ ███████ "
" ███████ ███████ ███████ ███████ ███████ ███████ "
" ███████ ███████ ███████ ███████ ███████ ███████ "
@@ -81,5 +81,5 @@ expression: "format!(\"{picker}\\n{}\", screen(&mut view, 85, 40))"
" ● IDE extension 18.00 "
" ● Web 14.00 "
" "
" 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 "
@@ -6,7 +6,7 @@ expression: "snapshots.join(\"\\n\")"
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [Credits usage history] Token usage history Plugins "
" called Skills used "
" called Skills used Top chats "
" "
" ▎ Credits usage history By product "
" ────────────────────────────────────────────────────── "
@@ -36,7 +36,7 @@ expression: "snapshots.join(\"\\n\")"
" ● IDE extension — "
" ● Web — "
" "
" 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 "
@@ -44,7 +44,7 @@ expression: "snapshots.join(\"\\n\")"
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Credits usage history By product "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -77,5 +77,5 @@ expression: "snapshots.join(\"\\n\")"
" ● IDE extension — "
" ● Web — "
" "
" 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 "
@@ -0,0 +1,67 @@
---
source: tui/src/analytics_tests.rs
expression: "format!(\"{wide}\\n{narrow}\")"
---
" Analytics · Live account "
" "
" 1 Credits usage history 2 Token usage history 3 Plugins called 4 Skills used [5 Top chats] "
" "
" ▎ Top chats "
" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 120.00 "
" ────────────────────────────────────────────────────────────────────────────────────────────── "
" Model Effort Speed Credits "
" GPT-5.6-Sol high standard 120.00 "
" "
" Refactor billing usage 113.00 "
" Other usage 109.00 "
" Launch readiness review 85.00 "
" Plan the week 0.00 "
" "
" Showing 1–5 of 5 chats "
" Local chats · excludes archived chats and subagent usage "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter details "
" R refresh · pgup/pgdn scroll · esc back · q close "
" Analytics · Live account "
" "
" Credits usage history Token usage history Plugins "
" called Skills used [Top chats] "
" "
" ▎ Top chats "
" ────────────────────────────────────────────────────── "
" Active in past 30 days · sorted by lifetime credits "
" "
" Chat Lifetime credits "
" › Q3 planning analysis 120.00 "
" ──────────────────────────────────────────────────── "
" Model / effort / speed Credits "
" GPT-5.6-Sol 120.00 "
" high · standard "
" "
" "
" tab/1–5 section · z dashboard · ↑/↓ row · enter "
" details "
" R refresh · pgup/pgdn scroll · esc back · q close "
@@ -5,7 +5,7 @@ expression: output
" Analytics · Live account "
" [7d] 30d · Aug 27–Sep 2, 2026 "
" "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used "
" [1 Credits usage history] 2 Token usage history 3 Plugins called 4 Skills used 5 Top chats "
" "
" ▎ Credits usage history By product "
" ──────────────────────────────────────────────────────────────────────────────────────────────── "
@@ -32,5 +32,5 @@ expression: output
" ● IDE extension 0.00 "
" ● Web 0.00 "
" "
" 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 "