SaylorTwift HF Staff commited on
Commit
a980f24
·
verified ·
1 Parent(s): ea39c0e

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. codex-rs/tui/src/analytics/account_layout_tests.rs +491 -0
  2. codex-rs/tui/src/analytics/account_plan_tests.rs +167 -0
  3. codex-rs/tui/src/analytics/activity_chart.rs +319 -0
  4. codex-rs/tui/src/analytics/activity_chart/palette.rs +151 -0
  5. codex-rs/tui/src/analytics/activity_chart/palette_tests.rs +115 -0
  6. codex-rs/tui/src/analytics/activity_chart_tests.rs +239 -0
  7. codex-rs/tui/src/analytics/chart.rs +338 -0
  8. codex-rs/tui/src/analytics/chat_panel.rs +317 -0
  9. codex-rs/tui/src/analytics/chat_panel_tests.rs +215 -0
  10. codex-rs/tui/src/analytics/chats.rs +195 -0
  11. codex-rs/tui/src/analytics/chats_tests.rs +178 -0
  12. codex-rs/tui/src/analytics/client.rs +267 -0
  13. codex-rs/tui/src/analytics/client_tests.rs +851 -0
  14. codex-rs/tui/src/analytics/compact_tests.rs +48 -0
  15. codex-rs/tui/src/analytics/connection_tests.rs +237 -0
  16. codex-rs/tui/src/analytics/consumer_refresh_tests.rs +105 -0
  17. codex-rs/tui/src/analytics/dashboard.rs +225 -0
  18. codex-rs/tui/src/analytics/dashboard_tests.rs +99 -0
  19. codex-rs/tui/src/analytics/data.rs +216 -0
  20. codex-rs/tui/src/analytics/data_tests.rs +132 -0
  21. codex-rs/tui/src/analytics/models.rs +99 -0
  22. codex-rs/tui/src/analytics/navigation_tests.rs +272 -0
  23. codex-rs/tui/src/analytics/normalize.rs +364 -0
  24. codex-rs/tui/src/analytics/normalize_tests.rs +358 -0
  25. codex-rs/tui/src/analytics/panels.rs +106 -0
  26. codex-rs/tui/src/analytics/plan.rs +121 -0
  27. codex-rs/tui/src/analytics/plan_panel.rs +259 -0
  28. codex-rs/tui/src/analytics/plan_tests.rs +252 -0
  29. codex-rs/tui/src/analytics/plot.rs +176 -0
  30. codex-rs/tui/src/analytics/plot/annotations.rs +213 -0
  31. codex-rs/tui/src/analytics/plot/layout.rs +55 -0
  32. codex-rs/tui/src/analytics/plot/painting.rs +132 -0
  33. codex-rs/tui/src/analytics/plot_tests.rs +535 -0
  34. codex-rs/tui/src/analytics/profile_identity_tests.rs +28 -0
  35. codex-rs/tui/src/analytics/render.rs +391 -0
  36. codex-rs/tui/src/analytics/report_data.rs +289 -0
  37. codex-rs/tui/src/analytics/report_data_tests.rs +84 -0
  38. codex-rs/tui/src/analytics/sections.rs +294 -0
  39. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__account_plan_error.snap +44 -0
  40. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_error.snap +52 -0
  41. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_recovered.snap +52 -0
  42. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap +28 -0
  43. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap +65 -0
  44. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap +64 -0
  45. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap +31 -0
  46. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap +42 -0
  47. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap +105 -0
  48. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_equal_height_totals_remain_separate_numbers.snap +30 -0
  49. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap +102 -0
  50. codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap +138 -0
codex-rs/tui/src/analytics/account_layout_tests.rs ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Account layouts, authenticated report loading, independent ranges, and model filters.
2
+ use super::*;
3
+ use codex_config::types::KeybindingSpec;
4
+ use codex_config::types::KeybindingsSpec;
5
+ use codex_config::types::TuiKeymap;
6
+ use pretty_assertions::assert_eq;
7
+
8
+ use crate::analytics::sections::Section;
9
+ #[test]
10
+ fn account_layouts_show_reports_and_wrap_navigation() {
11
+ let mut screens = Vec::new();
12
+ for (kind, expected) in [
13
+ (
14
+ models::AccountKind::Consumer,
15
+ vec![
16
+ Section::Summary,
17
+ Section::Usage,
18
+ Section::Activity,
19
+ Section::Plugins,
20
+ Section::Skills,
21
+ Section::Chats,
22
+ ],
23
+ ),
24
+ (
25
+ models::AccountKind::Enterprise,
26
+ vec![
27
+ Section::Summary,
28
+ Section::Credits,
29
+ Section::Usage,
30
+ Section::Plugins,
31
+ Section::Skills,
32
+ Section::Chats,
33
+ ],
34
+ ),
35
+ ] {
36
+ let mut view = fixture::view(kind);
37
+ for (index, section) in expected.iter().enumerate() {
38
+ press(
39
+ &mut view,
40
+ KeyCode::Char(char::from_digit(index as u32 + 1, /*radix*/ 10).unwrap()),
41
+ );
42
+ assert_eq!(view.section, *section);
43
+ screens.push(screen(&mut view, /*width*/ 100, /*height*/ 30));
44
+ }
45
+ press(&mut view, KeyCode::Tab);
46
+ assert_eq!(view.section, expected[0]);
47
+ press(&mut view, KeyCode::BackTab);
48
+ assert_eq!(view.section, *expected.last().unwrap());
49
+ view.zoomed = false;
50
+ screens.push(screen(&mut view, /*width*/ 140, /*height*/ 64));
51
+ }
52
+ insta::assert_snapshot!(screens.join("\n"));
53
+ }
54
+
55
+ #[tokio::test]
56
+ async fn changing_ranges_and_grouping_preserves_other_reports_and_focus() {
57
+ let server = test_support::server().await;
58
+ let (_home, _app_server, mut view) = client::tests::connected_view(&server, "plus").await;
59
+ test_support::settle(&mut view).await;
60
+ view.section = Section::Usage;
61
+ press(&mut view, KeyCode::Left);
62
+ press(&mut view, KeyCode::Enter);
63
+ press(&mut view, KeyCode::Char('r'));
64
+ test_support::settle(&mut view).await;
65
+ press(&mut view, KeyCode::Char('g'));
66
+ test_support::settle(&mut view).await;
67
+ assert_eq!(
68
+ (
69
+ view.ranges,
70
+ view.sections.0.each_ref().map(|state| state.cursor),
71
+ view.sections[Section::Usage].detail,
72
+ view.sections[Section::Usage].group
73
+ ),
74
+ ([1, 0, 0], [28, 6, 6, 0, 6, 6, 6, 6], Some(28), 2)
75
+ );
76
+ let context = (
77
+ view.ranges,
78
+ view.sections.0.each_ref().map(|state| state.cursor),
79
+ view.sections.0.each_ref().map(|state| state.detail),
80
+ view.sections.0.each_ref().map(|state| state.group),
81
+ );
82
+ press(&mut view, KeyCode::Char('z'));
83
+ screen(&mut view, /*width*/ 58, /*height*/ 25);
84
+ press(&mut view, KeyCode::Char('z'));
85
+ assert_eq!(
86
+ (
87
+ view.ranges,
88
+ view.sections.0.each_ref().map(|state| state.cursor),
89
+ view.sections.0.each_ref().map(|state| state.detail),
90
+ view.sections.0.each_ref().map(|state| state.group)
91
+ ),
92
+ context
93
+ );
94
+ press(&mut view, KeyCode::Char('3'));
95
+ press(&mut view, KeyCode::Char('r'));
96
+ test_support::settle(&mut view).await;
97
+ assert_eq!(view.ranges, [1, 1, 0]);
98
+ assert_eq!(
99
+ view.sections
100
+ .0
101
+ .each_ref()
102
+ .map(|state| state.history.ready().map(|history| history.data.len())),
103
+ [Some(30), Some(7), None, None, Some(30), Some(7), None, None]
104
+ );
105
+ let server = test_support::server().await;
106
+ let (_business_home, _business_server, mut view) =
107
+ client::tests::connected_view(&server, "business").await;
108
+ test_support::settle(&mut view).await;
109
+ view.ranges = [1, 1, 0];
110
+ view.load_report(Section::Usage);
111
+ press(&mut view, KeyCode::Char('2'));
112
+ press(&mut view, KeyCode::Char('r'));
113
+ test_support::settle(&mut view).await;
114
+ assert_eq!(view.ranges, [0, 1, 0]);
115
+ assert_eq!(
116
+ view.sections
117
+ .0
118
+ .each_ref()
119
+ .map(|state| state.history.ready().map(|history| history.data.len())),
120
+ [Some(30), Some(7), Some(7), None, None, Some(7), None, None]
121
+ );
122
+ }
123
+
124
+ #[tokio::test]
125
+ async fn business_tokens_filter_models() {
126
+ let server = test_support::server().await;
127
+ let (_home, _app_server, mut view) = client::tests::connected_view(&server, "business").await;
128
+ test_support::settle(&mut view).await;
129
+ view.end_date = fixture::END_DATE;
130
+ fixture::seed_reports(&mut view);
131
+ press(&mut view, KeyCode::Char('3'));
132
+ let all = screen(&mut view, /*width*/ 100, /*height*/ 30);
133
+ assert!(all.contains("All models") && all.contains("Cached input"));
134
+ press(&mut view, KeyCode::Char('m'));
135
+ test_support::settle(&mut view).await;
136
+ assert_eq!(
137
+ view.sections[Section::Usage]
138
+ .history
139
+ .ready()
140
+ .unwrap()
141
+ .data
142
+ .iter()
143
+ .map(|day| day.total)
144
+ .sum::<f64>(),
145
+ 420.0
146
+ );
147
+ fixture::seed_reports(&mut view);
148
+ assert_eq!(view.token_model.as_deref(), Some("GPT-5.5"));
149
+ let filtered = screen(&mut view, /*width*/ 100, /*height*/ 30);
150
+ press(&mut view, KeyCode::Char('m'));
151
+ test_support::settle(&mut view).await;
152
+ fixture::seed_reports(&mut view);
153
+ assert_eq!(view.token_model, None);
154
+ insta::assert_snapshot!(format!("{all}\n{filtered}"));
155
+ }
156
+
157
+ #[tokio::test]
158
+ async fn account_pending_and_error_do_not_start_reports() {
159
+ let mut view = AnalyticsView::new(RuntimeKeymap::defaults().list);
160
+ let (send, receive) = tokio::sync::oneshot::channel();
161
+ view.account = Load::start(
162
+ async move { receive.await.unwrap() },
163
+ FrameRequester::test_dummy(),
164
+ );
165
+ screen(&mut view, /*width*/ 80, /*height*/ 24);
166
+ assert!(
167
+ view.sections
168
+ .0
169
+ .iter()
170
+ .all(|state| matches!(state.history, Load::Unavailable))
171
+ );
172
+ send.send(Err("Sign in again to view Analytics.".to_string()))
173
+ .unwrap();
174
+ tokio::task::yield_now().await;
175
+ let error = screen(&mut view, /*width*/ 80, /*height*/ 24);
176
+ assert!(error.contains("Sign in again"));
177
+ assert!(
178
+ view.sections
179
+ .0
180
+ .iter()
181
+ .all(|state| matches!(state.history, Load::Unavailable))
182
+ );
183
+ insta::assert_snapshot!(error);
184
+ }
185
+
186
+ #[tokio::test]
187
+ async fn account_reports_request_only_eligible_endpoints_and_refresh() {
188
+ for (plan, usage, activity) in [
189
+ (
190
+ "plus",
191
+ "usage/daily-token-usage-breakdown",
192
+ "analytics/daily-workspace-usage-counts",
193
+ ),
194
+ (
195
+ "business",
196
+ "usage/daily-workspace-user-token-usage-breakdown",
197
+ "usage/daily-workspace-user-credit-usage",
198
+ ),
199
+ ] {
200
+ let server = test_support::server().await;
201
+ wiremock::Mock::given(wiremock::matchers::method("GET"))
202
+ .and(wiremock::matchers::path("/backend-api/wham/profiles/me"))
203
+ .respond_with(
204
+ wiremock::ResponseTemplate::new(/*s*/ 200)
205
+ .set_body_json(serde_json::json!({"stats":{}})),
206
+ )
207
+ .mount(&server)
208
+ .await;
209
+ let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await;
210
+ test_support::settle(&mut view).await;
211
+ let mut expected = [
212
+ "accounts/check",
213
+ usage,
214
+ activity,
215
+ "analytics/daily-plugin-usage-metrics",
216
+ "analytics/daily-skill-usage-metrics",
217
+ "profiles/me",
218
+ ]
219
+ .map(|endpoint| format!("/backend-api/wham/{endpoint}"));
220
+ expected.sort();
221
+ let mut paths = server
222
+ .received_requests()
223
+ .await
224
+ .unwrap()
225
+ .into_iter()
226
+ .filter(|request| request.url.path().starts_with("/backend-api/wham/"))
227
+ .map(|request| request.url.path().to_string())
228
+ .collect::<Vec<_>>();
229
+ paths.sort();
230
+ assert_eq!(paths, expected);
231
+ let ready = view
232
+ .sections
233
+ .0
234
+ .each_ref()
235
+ .map(|state| state.history.ready().is_some());
236
+ assert_eq!(
237
+ ready,
238
+ if plan == "plus" {
239
+ [true, true, false, false, true, true, false, false]
240
+ } else {
241
+ [true, true, true, false, false, true, false, false]
242
+ }
243
+ );
244
+ press(&mut view, KeyCode::Char('R'));
245
+ test_support::settle(&mut view).await;
246
+ assert_eq!(
247
+ view.sections
248
+ .0
249
+ .each_ref()
250
+ .map(|state| state.history.ready().is_some()),
251
+ ready
252
+ );
253
+ assert_eq!(
254
+ server
255
+ .received_requests()
256
+ .await
257
+ .unwrap()
258
+ .iter()
259
+ .filter(|request| request.url.path().starts_with("/backend-api/wham/"))
260
+ .count(),
261
+ 12
262
+ );
263
+ view.cancel_loads();
264
+ }
265
+ }
266
+
267
+ #[tokio::test]
268
+ async fn grouping_cycle_uses_current_server_capabilities() {
269
+ let server = test_support::server().await;
270
+ let (_home, _app_server, mut view) = client::tests::connected_view(&server, "plus").await;
271
+ view.section = Section::Usage;
272
+ view.account = Load::Ready(codex_protocol::account::PlanType::Plus);
273
+ view.sections[Section::Usage].group = 3;
274
+ view.start_reports();
275
+ test_support::settle(&mut view).await;
276
+ assert_eq!(
277
+ (view.group_options(), view.sections[Section::Usage].group),
278
+ (&[0, 2][..], 0)
279
+ );
280
+ press(&mut view, KeyCode::Char('g'));
281
+ assert_eq!(view.sections[Section::Usage].group, 2);
282
+ press(&mut view, KeyCode::Char('g'));
283
+ assert_eq!(view.sections[Section::Usage].group, 0);
284
+ }
285
+
286
+ #[test]
287
+ fn ungrouped_sections_preserve_configured_g_jump_top() {
288
+ let mut config = TuiKeymap::default();
289
+ config.list.jump_top = Some(KeybindingsSpec::One(KeybindingSpec("g".into())));
290
+ let keymap = RuntimeKeymap::from_config(&config).expect("valid list keymap");
291
+ let mut view = fixture::view(models::AccountKind::Enterprise);
292
+ view.keymap = keymap.list;
293
+ for section in [
294
+ Section::Chats,
295
+ Section::Plugins,
296
+ Section::Skills,
297
+ Section::Credits,
298
+ ] {
299
+ view.section = section;
300
+ press(&mut view, KeyCode::End);
301
+ assert!(view.sections[section].cursor > 0, "{section:?}");
302
+ press(&mut view, KeyCode::Char('g'));
303
+ assert_eq!(view.sections[section].cursor, 0, "{section:?}");
304
+ }
305
+ }
306
+
307
+ #[test]
308
+ fn business_chats_display_only_supplied_dollars() {
309
+ let mut view = fixture::view(models::AccountKind::Enterprise);
310
+ let chats = match &mut view.chats {
311
+ Load::Ready(chats) => chats,
312
+ _ => panic!("fixture chats"),
313
+ };
314
+ for chat in &mut chats.rows {
315
+ if let Some(usage) = &mut chat.usage {
316
+ usage.estimated_usage_usd_micros = None;
317
+ }
318
+ }
319
+ press(&mut view, KeyCode::Char('6'));
320
+ assert!(!screen(&mut view, /*width*/ 100, /*height*/ 30).contains("Est. $ spent"));
321
+ if let Load::Ready(chats) = &mut view.chats {
322
+ chats.rows[0]
323
+ .usage
324
+ .as_mut()
325
+ .unwrap()
326
+ .estimated_usage_usd_micros = Some(123_450_000);
327
+ }
328
+ let dollars = screen(&mut view, /*width*/ 100, /*height*/ 30);
329
+ assert!(dollars.contains("$123.45") && dollars.contains("Est. $ spent"));
330
+ let narrow = screen(&mut view, /*width*/ 42, /*height*/ 30);
331
+ insta::assert_snapshot!(format!("{dollars}\n{narrow}"));
332
+ }
333
+
334
+ #[tokio::test]
335
+ async fn unsupported_workspace_plans_hide_top_chats_and_skip_loading() {
336
+ for plan in [
337
+ "team",
338
+ "self_serve_business_prolite",
339
+ "self_serve_business_usage_based",
340
+ "enterprise",
341
+ "ent26",
342
+ "edu",
343
+ "edu_plus",
344
+ "edu_pro",
345
+ ] {
346
+ let server = test_support::server().await;
347
+ let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await;
348
+ test_support::settle(&mut view).await;
349
+ assert_eq!(
350
+ view.visible_sections(),
351
+ &[
352
+ Section::Summary,
353
+ Section::Credits,
354
+ Section::Usage,
355
+ Section::Plugins,
356
+ Section::Skills
357
+ ],
358
+ "{plan}"
359
+ );
360
+ assert!(matches!(view.chats, Load::Unavailable), "{plan}");
361
+ let selected = view.section;
362
+ press(&mut view, KeyCode::Char('6'));
363
+ assert_eq!(view.section, selected);
364
+ }
365
+ for plan in [
366
+ "business",
367
+ "enterprise_cbp_usage_based",
368
+ "enterprise_cbp_automation",
369
+ ] {
370
+ let server = test_support::server().await;
371
+ let (_home, _app_server, mut view) = client::tests::connected_view(&server, plan).await;
372
+ test_support::settle(&mut view).await;
373
+ assert!(view.visible_sections().contains(&Section::Chats), "{plan}");
374
+ press(&mut view, KeyCode::Char('6'));
375
+ assert_eq!(view.section, Section::Chats);
376
+ }
377
+ let mut view = fixture::view(models::AccountKind::Enterprise);
378
+ view.account = Load::Ready(codex_protocol::account::PlanType::Enterprise);
379
+ insta::assert_snapshot!(screen(&mut view, /*width*/ 110, /*height*/ 24));
380
+ }
381
+
382
+ #[tokio::test]
383
+ async fn unknown_plan_keeps_summary_available_without_billing_reports() {
384
+ let server = test_support::server().await;
385
+ wiremock::Mock::given(wiremock::matchers::method("GET"))
386
+ .and(wiremock::matchers::path("/backend-api/wham/profiles/me"))
387
+ .respond_with(
388
+ wiremock::ResponseTemplate::new(/*s*/ 200)
389
+ .set_body_json(serde_json::json!({"stats":{"lifetime_tokens":12345}})),
390
+ )
391
+ .expect(/*r*/ 1)
392
+ .mount(&server)
393
+ .await;
394
+ let (_home, _app_server, mut view) =
395
+ client::tests::connected_view(&server, "future-plan").await;
396
+ test_support::settle(&mut view).await;
397
+ assert_eq!(view.visible_sections(), &[Section::Summary]);
398
+ assert_eq!(
399
+ view.profile.ready().unwrap().stats.tokens.lifetime_tokens,
400
+ Some(12345)
401
+ );
402
+ assert!(
403
+ view.sections
404
+ .0
405
+ .iter()
406
+ .all(|section| matches!(section.history, Load::Unavailable))
407
+ );
408
+ let paths = server
409
+ .received_requests()
410
+ .await
411
+ .unwrap()
412
+ .into_iter()
413
+ .filter(|request| request.url.path().starts_with("/backend-api/wham/"))
414
+ .map(|request| request.url.path().to_owned())
415
+ .collect::<Vec<_>>();
416
+ assert_eq!(
417
+ paths,
418
+ [
419
+ "/backend-api/wham/accounts/check",
420
+ "/backend-api/wham/profiles/me"
421
+ ]
422
+ );
423
+ view.end_date = "2026-09-09".parse().unwrap();
424
+ let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(
425
+ /*width*/ 100, /*height*/ 40,
426
+ ))
427
+ .unwrap();
428
+ terminal
429
+ .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
430
+ .unwrap();
431
+ insta::assert_snapshot!(terminal.backend().to_string());
432
+ }
433
+
434
+ #[test]
435
+ fn navigation_keeps_its_row_when_sections_change() {
436
+ for width in [64, 120] {
437
+ let mut view = fixture::view(models::AccountKind::Consumer);
438
+ let mut positions = Vec::new();
439
+ for section in [
440
+ Section::Summary,
441
+ Section::Usage,
442
+ Section::Chats,
443
+ Section::Summary,
444
+ ] {
445
+ view.section = section;
446
+ let rendered = screen(&mut view, width, /*height*/ 40);
447
+ positions.push(
448
+ rendered
449
+ .lines()
450
+ .position(|line| line.contains("Summary") && line.contains("usage history"))
451
+ .unwrap(),
452
+ );
453
+ }
454
+ assert_eq!(positions, vec![positions[0]; 4]);
455
+ }
456
+ }
457
+
458
+ #[test]
459
+ fn taller_report_reveals_more_legend_rows_and_keeps_timestamp_in_footer() {
460
+ let mut view = fixture::view(models::AccountKind::Consumer);
461
+ view.section = Section::Usage;
462
+ let mut history = fixture::history(/*report*/ 0, /*range*/ 0, /*group*/ 1);
463
+ history.updated_at = Some(1_788_364_800);
464
+ for day in &mut history.data {
465
+ day.values = (0..10)
466
+ .map(|index| models::AccountAnalyticsValue {
467
+ key: format!("feature-{index}"),
468
+ label: format!("Feature {}", index + 1),
469
+ value: day.total / 10.0,
470
+ })
471
+ .collect();
472
+ }
473
+ view.sections[Section::Usage].history = data::Load::Ready(history);
474
+ let short = screen(&mut view, /*width*/ 120, /*height*/ 30);
475
+ let tall = screen(&mut view, /*width*/ 120, /*height*/ 60);
476
+ assert!(
477
+ tall.lines().filter(|line| line.contains('●')).count()
478
+ > short.lines().filter(|line| line.contains('●')).count()
479
+ );
480
+ assert!(
481
+ tall.lines()
482
+ .rev()
483
+ .take(3)
484
+ .any(|line| line.contains("Updated"))
485
+ );
486
+ insta::assert_snapshot!("legend_tall_report", tall);
487
+ for width in [24, 40, 64] {
488
+ let narrow = screen(&mut view, width, /*height*/ 60);
489
+ assert!(narrow.lines().last().unwrap().contains("UTC"));
490
+ }
491
+ }
codex-rs/tui/src/analytics/account_plan_tests.rs ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Server plan discovery survives stale token claims and remains bound to the local identity.
2
+
3
+ use super::tests::live;
4
+ use super::tests::sign_in;
5
+ use super::*;
6
+ use pretty_assertions::assert_eq;
7
+ use serde_json::json;
8
+ use std::sync::atomic::AtomicUsize;
9
+ use std::sync::atomic::Ordering;
10
+ use wiremock::Mock;
11
+ use wiremock::MockServer;
12
+ use wiremock::ResponseTemplate;
13
+ use wiremock::matchers::header;
14
+ use wiremock::matchers::method;
15
+ use wiremock::matchers::path;
16
+
17
+ #[tokio::test]
18
+ async fn refresh_uses_server_plan_and_routes_with_unchanged_local_credentials() {
19
+ let server = MockServer::start().await;
20
+ let (_home, client) = live(&server, "plus").await;
21
+ let reads = Arc::new(AtomicUsize::new(/*v*/ 0));
22
+ let plan_reads = Arc::clone(&reads);
23
+ Mock::given(method("GET"))
24
+ .and(path("/backend-api/wham/accounts/check"))
25
+ .and(header("chatgpt-account-id", "account-a"))
26
+ .respond_with(move |_: &wiremock::Request| {
27
+ let plan = if plan_reads.fetch_add(/*val*/ 1, Ordering::SeqCst) == 0 {
28
+ "team"
29
+ } else {
30
+ "enterprise"
31
+ };
32
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
33
+ "accounts": [
34
+ {"id": "other-account", "plan_type": "free"},
35
+ {"id": "account-a", "plan_type": plan}
36
+ ],
37
+ "account_ordering": ["other-account", "account-a"]
38
+ }))
39
+ })
40
+ .with_priority(/*p*/ 1)
41
+ .expect(/*r*/ 2)
42
+ .mount(&server)
43
+ .await;
44
+ for endpoint in [
45
+ "/backend-api/wham/usage/daily-workspace-user-token-usage-breakdown",
46
+ "/backend-api/wham/usage/daily-workspace-user-credit-usage",
47
+ ] {
48
+ Mock::given(method("GET"))
49
+ .and(path(endpoint))
50
+ .and(header("chatgpt-account-id", "account-a"))
51
+ .respond_with(
52
+ ResponseTemplate::new(/*s*/ 200)
53
+ .set_body_json(json!({"breakdown": "product", "series": [], "data": []})),
54
+ )
55
+ .expect(/*r*/ 1)
56
+ .mount(&server)
57
+ .await;
58
+ }
59
+ let first = client
60
+ .history(Report::Credits, /*days*/ 7, Grouping::Surface)
61
+ .await
62
+ .unwrap();
63
+ assert_eq!(client.session().await.unwrap().kind, AccountKind::Business);
64
+ assert_eq!(
65
+ client
66
+ .history(Report::Credits, /*days*/ 7, Grouping::Surface)
67
+ .await
68
+ .unwrap(),
69
+ first
70
+ );
71
+ assert_eq!(reads.load(Ordering::SeqCst), 1);
72
+ let refreshed = Live::new(Arc::clone(&client.config), client.end_date);
73
+ refreshed
74
+ .history(Report::Credits, /*days*/ 7, Grouping::Surface)
75
+ .await
76
+ .unwrap();
77
+ assert_eq!(
78
+ refreshed.session().await.unwrap().kind,
79
+ AccountKind::Enterprise
80
+ );
81
+ server.verify().await;
82
+ }
83
+
84
+ #[tokio::test]
85
+ async fn account_plan_lookup_recovers_from_unauthorized() {
86
+ let server = MockServer::start().await;
87
+ let (_home, client) = live(&server, "plus").await;
88
+ let reads = AtomicUsize::new(/*v*/ 0);
89
+ Mock::given(method("GET"))
90
+ .and(path("/backend-api/wham/accounts/check"))
91
+ .and(header("chatgpt-account-id", "account-a"))
92
+ .respond_with(move |_: &wiremock::Request| {
93
+ if reads.fetch_add(/*val*/ 1, Ordering::SeqCst) == 0 {
94
+ ResponseTemplate::new(/*s*/ 401)
95
+ } else {
96
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
97
+ "accounts": {"account-a": {"account": {
98
+ "account_id": "account-a", "plan_type": "enterprise"
99
+ }}},
100
+ "account_ordering": ["account-a"]
101
+ }))
102
+ }
103
+ })
104
+ .with_priority(/*p*/ 1)
105
+ .expect(/*r*/ 2)
106
+ .mount(&server)
107
+ .await;
108
+ assert_eq!(
109
+ client.session().await.unwrap().kind,
110
+ AccountKind::Enterprise
111
+ );
112
+ server.verify().await;
113
+ }
114
+
115
+ #[tokio::test]
116
+ async fn failed_plan_discovery_can_retry_without_using_token_plan() {
117
+ for response in [
118
+ ResponseTemplate::new(/*s*/ 503),
119
+ ResponseTemplate::new(/*s*/ 200).set_body_string("invalid account response"),
120
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"accounts": [{"id": "account-a"}]})),
121
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
122
+ "accounts": [{"id": "other-account", "plan_type": "enterprise"}]
123
+ })),
124
+ ] {
125
+ let server = MockServer::start().await;
126
+ let (_home, client) = live(&server, "plus").await;
127
+ Mock::given(method("GET"))
128
+ .and(path("/backend-api/wham/accounts/check"))
129
+ .respond_with(response)
130
+ .with_priority(/*p*/ 1)
131
+ .expect(/*r*/ 1)
132
+ .up_to_n_times(/*n*/ 1)
133
+ .mount(&server)
134
+ .await;
135
+ assert_eq!(
136
+ client.session().await.err().unwrap(),
137
+ "Couldn't load account plan. Press R to retry Analytics."
138
+ );
139
+ assert_eq!(client.session().await.unwrap().kind, AccountKind::Consumer);
140
+ server.verify().await;
141
+ }
142
+ }
143
+
144
+ #[tokio::test]
145
+ async fn account_plan_lookup_rejects_account_and_user_switches() {
146
+ for (account, user) in [("account-b", "user-a"), ("account-a", "user-b")] {
147
+ let server = MockServer::start().await;
148
+ let (home, client) = live(&server, "plus").await;
149
+ Mock::given(method("GET"))
150
+ .and(path("/backend-api/wham/accounts/check"))
151
+ .respond_with(move |_: &wiremock::Request| {
152
+ sign_in(home.path(), account, user, "plus");
153
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
154
+ "accounts": [{"id": "account-a", "plan_type": "enterprise"}]
155
+ }))
156
+ })
157
+ .with_priority(/*p*/ 1)
158
+ .expect(/*r*/ 1)
159
+ .mount(&server)
160
+ .await;
161
+ assert_eq!(
162
+ client.session().await.err().unwrap(),
163
+ "Account changed. Press R to refresh Analytics."
164
+ );
165
+ server.verify().await;
166
+ }
167
+ }
codex-rs/tui/src/analytics/activity_chart.rs ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Calendar and weekly token charts for the account Summary. Bucketing is independent of view state.
2
+
3
+ mod palette;
4
+
5
+ use std::collections::BTreeMap;
6
+
7
+ use chrono::Datelike;
8
+ use chrono::Duration;
9
+ use chrono::NaiveDate;
10
+ use ratatui::style::Style;
11
+ use ratatui::style::Stylize;
12
+ use ratatui::text::Line;
13
+ use ratatui::text::Span;
14
+
15
+ use crate::render::highlight::foreground_style_for_scopes;
16
+ use palette::TokenActivityPalette;
17
+
18
+ const WEEK_COUNT: usize = 52;
19
+ const DAY_COUNT: usize = 7;
20
+ const CELL_COUNT: usize = WEEK_COUNT * DAY_COUNT;
21
+ const CHART_LEFT_WIDTH: usize = 4;
22
+
23
+ /// Selects the aggregation represented by the token activity chart.
24
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
25
+ pub(crate) enum TokenActivityView {
26
+ Daily,
27
+ Weekly,
28
+ Cumulative,
29
+ }
30
+
31
+ impl TokenActivityView {
32
+ /// Parses the optional `/usage` argument into a supported chart view.
33
+ ///
34
+ /// An empty argument selects Daily when a mode is requested. The bare `/usage`
35
+ /// command opens its menu. Returning `None` lets the slash-command dispatcher
36
+ /// report unsupported arguments instead of silently choosing a view.
37
+ pub(crate) fn parse(value: &str) -> Option<Self> {
38
+ match value.trim().to_ascii_lowercase().as_str() {
39
+ "" | "day" | "daily" => Some(Self::Daily),
40
+ "week" | "weekly" => Some(Self::Weekly),
41
+ "cumulative" => Some(Self::Cumulative),
42
+ _ => None,
43
+ }
44
+ }
45
+
46
+ pub(crate) fn label(self) -> &'static str {
47
+ match self {
48
+ Self::Daily => "Daily",
49
+ Self::Weekly => "Weekly",
50
+ Self::Cumulative => "Cumulative",
51
+ }
52
+ }
53
+ }
54
+
55
+ pub(super) fn chart_lines(
56
+ view: TokenActivityView,
57
+ buckets: &[codex_backend_client::TokenUsageProfileDailyBucket],
58
+ today: NaiveDate,
59
+ width: u16,
60
+ ) -> Vec<Line<'static>> {
61
+ let mut lines = Vec::new();
62
+ let values = daily_values(buckets, today);
63
+ let shown_columns = shown_columns(width);
64
+ if shown_columns == 0 {
65
+ lines.push(" Widen terminal to show activity graph".dim().into());
66
+ return lines;
67
+ }
68
+
69
+ let palette = TokenActivityPalette::current();
70
+ let levels = levels_for_view(&values, view);
71
+ let first_column = WEEK_COUNT - shown_columns;
72
+ lines.push(month_labels(today, first_column, shown_columns));
73
+ for row in 0..DAY_COUNT {
74
+ let mut spans = vec![weekday_label(view, row)];
75
+ for column in first_column..WEEK_COUNT {
76
+ if column > first_column {
77
+ spans.push(" ".into());
78
+ }
79
+ let index = column * DAY_COUNT + row;
80
+ if view == TokenActivityView::Daily
81
+ && cell_date(today, index).is_some_and(|date| date > today)
82
+ {
83
+ spans.push(" ".into());
84
+ } else {
85
+ let style = if view == TokenActivityView::Daily {
86
+ palette.for_level(levels[index])
87
+ } else {
88
+ palette.for_bar_level(levels[index])
89
+ };
90
+ spans.push(Span::styled(palette.glyph(view, levels[index]), style));
91
+ }
92
+ }
93
+ lines.push(spans.into());
94
+ }
95
+ // Separate the calendar from the legend/footer below.
96
+ lines.push(Line::default());
97
+ match view {
98
+ TokenActivityView::Daily => lines.push(legend_line(&palette)),
99
+ TokenActivityView::Weekly | TokenActivityView::Cumulative => {
100
+ lines.push(bar_caption(view, &values))
101
+ }
102
+ }
103
+ lines
104
+ }
105
+
106
+ fn shown_columns(width: u16) -> usize {
107
+ (usize::from(width)
108
+ .saturating_sub(CHART_LEFT_WIDTH)
109
+ .saturating_add(/*rhs*/ 1)
110
+ / 2)
111
+ .min(WEEK_COUNT)
112
+ }
113
+
114
+ fn numeric_style() -> Style {
115
+ foreground_style_for_scopes(&["constant.numeric", "constant"])
116
+ .unwrap_or_else(|| Style::default().green())
117
+ }
118
+
119
+ fn label_style() -> Style {
120
+ foreground_style_for_scopes(&["comment"]).unwrap_or_else(|| Style::default().dim())
121
+ }
122
+
123
+ fn weekday_label(view: TokenActivityView, row: usize) -> Span<'static> {
124
+ if view != TokenActivityView::Daily {
125
+ // Bar views fill from the bottom (row 6) upward, so the gutter doubles
126
+ // as a coarse Y-axis: peak at the top, baseline at the bottom.
127
+ return Span::styled(
128
+ match row {
129
+ 0 => "max ",
130
+ 6 => " 0 ",
131
+ _ => " ",
132
+ },
133
+ label_style(),
134
+ );
135
+ }
136
+ Span::styled(
137
+ match row {
138
+ 0 => " Su ",
139
+ 1 => " Mo ",
140
+ 2 => " Tu ",
141
+ 3 => " We ",
142
+ 4 => " Th ",
143
+ 5 => " Fr ",
144
+ 6 => " Sa ",
145
+ _ => " ",
146
+ },
147
+ label_style(),
148
+ )
149
+ }
150
+
151
+ fn legend_line(palette: &TokenActivityPalette) -> Line<'static> {
152
+ let mut spans = vec![Span::styled(" Less ", label_style())];
153
+ for level in 0..=4 {
154
+ if level > 0 {
155
+ spans.push(" ".into());
156
+ }
157
+ spans.push(Span::styled(
158
+ palette.glyph(TokenActivityView::Daily, level),
159
+ palette.for_level(level),
160
+ ));
161
+ }
162
+ spans.push(Span::styled(" More", label_style()));
163
+ spans.into()
164
+ }
165
+
166
+ /// Caption for the bar-chart views, where the 5-step daily legend would be
167
+ /// misleading. States what each bar represents and the peak it is scaled to.
168
+ fn bar_caption(view: TokenActivityView, values: &[i64]) -> Line<'static> {
169
+ let weeks = weekly_totals(values);
170
+ let (lead, peak) = match view {
171
+ TokenActivityView::Weekly => (
172
+ "Each column = 1 week · tallest ",
173
+ weeks.iter().copied().max().unwrap_or(/*default*/ 0),
174
+ ),
175
+ TokenActivityView::Cumulative => (
176
+ "Running total · top ",
177
+ weeks
178
+ .iter()
179
+ .fold(/*init*/ 0_i64, |sum, value| sum.saturating_add(*value)),
180
+ ),
181
+ TokenActivityView::Daily => ("", 0),
182
+ };
183
+ if peak <= 0 {
184
+ return Span::styled(" No token activity in the last 12 months", label_style()).into();
185
+ }
186
+ vec![
187
+ Span::styled(format!(" {lead}"), label_style()),
188
+ Span::styled(super::data::compact_amount(peak as f64), numeric_style()),
189
+ ]
190
+ .into()
191
+ }
192
+
193
+ fn month_labels(today: NaiveDate, first_column: usize, shown_columns: usize) -> Line<'static> {
194
+ let mut cells = vec![' '; shown_columns * 2 - 1];
195
+ let start = chart_start(today);
196
+ let mut last_end = 0;
197
+ for column in first_column..WEEK_COUNT {
198
+ let date = start + Duration::days((column * DAY_COUNT) as i64);
199
+ if date.day() > 7 {
200
+ continue;
201
+ }
202
+ let label = date.format("%b").to_string();
203
+ let offset = (column - first_column) * 2;
204
+ if offset < last_end || offset + label.len() > cells.len() {
205
+ continue;
206
+ }
207
+ for (index, ch) in label.chars().enumerate() {
208
+ cells[offset + index] = ch;
209
+ }
210
+ last_end = offset + label.len() + 1;
211
+ }
212
+ vec![
213
+ " ".into(),
214
+ Span::styled(cells.into_iter().collect::<String>(), label_style()),
215
+ ]
216
+ .into()
217
+ }
218
+
219
+ /// Normalizes backend daily buckets into the fixed 52-week display window.
220
+ ///
221
+ /// The returned vector is ordered by chart cell, starting with the oldest Sunday.
222
+ /// Invalid, out-of-window, and future dates are ignored. Duplicate dates are
223
+ /// accumulated and negative token values do not reduce activity.
224
+ fn daily_values(
225
+ buckets: &[codex_backend_client::TokenUsageProfileDailyBucket],
226
+ today: NaiveDate,
227
+ ) -> Vec<i64> {
228
+ let start = chart_start(today);
229
+ let end = start + Duration::days(CELL_COUNT as i64);
230
+ let mut by_date = BTreeMap::new();
231
+ for bucket in buckets {
232
+ let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") else {
233
+ continue;
234
+ };
235
+ if date < start || date >= end || date > today {
236
+ continue;
237
+ }
238
+ let value: &mut i64 = by_date.entry(date).or_insert(/*default*/ 0);
239
+ *value = value.saturating_add(bucket.tokens.max(/*other*/ 0));
240
+ }
241
+ (0..CELL_COUNT)
242
+ .map(|offset| {
243
+ by_date
244
+ .get(&(start + Duration::days(offset as i64)))
245
+ .copied()
246
+ .unwrap_or(/*default*/ 0)
247
+ })
248
+ .collect()
249
+ }
250
+
251
+ fn levels_for_view(values: &[i64], view: TokenActivityView) -> Vec<usize> {
252
+ match view {
253
+ TokenActivityView::Daily => graded_levels(values),
254
+ TokenActivityView::Weekly => bar_levels(&weekly_totals(values)),
255
+ TokenActivityView::Cumulative => {
256
+ let cumulative = weekly_totals(values)
257
+ .into_iter()
258
+ .scan(/*initial_state*/ 0, |sum, value| {
259
+ *sum = i64::saturating_add(*sum, value);
260
+ Some(*sum)
261
+ })
262
+ .collect::<Vec<_>>();
263
+ bar_levels(&cumulative)
264
+ }
265
+ }
266
+ }
267
+
268
+ fn graded_levels(values: &[i64]) -> Vec<usize> {
269
+ let max = values.iter().copied().max().unwrap_or(/*default*/ 0);
270
+ values
271
+ .iter()
272
+ .map(|value| match (*value, max) {
273
+ (0, _) | (_, 0) => 0,
274
+ (value, max) if i128::from(value) * 4 > i128::from(max) * 3 => 4,
275
+ (value, max) if i128::from(value) * 2 > i128::from(max) => 3,
276
+ (value, max) if i128::from(value) * 4 > i128::from(max) => 2,
277
+ _ => 1,
278
+ })
279
+ .collect()
280
+ }
281
+
282
+ fn weekly_totals(values: &[i64]) -> Vec<i64> {
283
+ values
284
+ .chunks(DAY_COUNT)
285
+ .map(|week| {
286
+ week.iter()
287
+ .fold(/*init*/ 0_i64, |sum, value| sum.saturating_add(*value))
288
+ })
289
+ .collect()
290
+ }
291
+
292
+ fn bar_levels(totals: &[i64]) -> Vec<usize> {
293
+ let max = totals.iter().copied().max().unwrap_or(/*default*/ 0);
294
+ totals
295
+ .iter()
296
+ .flat_map(|value| {
297
+ let height = if *value <= 0 || max <= 0 {
298
+ 0
299
+ } else {
300
+ ((i128::from(*value) * DAY_COUNT as i128 + i128::from(max) - 1) / i128::from(max))
301
+ as usize
302
+ };
303
+ (0..DAY_COUNT).map(move |row| if DAY_COUNT - row <= height { 4 } else { 0 })
304
+ })
305
+ .collect()
306
+ }
307
+
308
+ fn chart_start(today: NaiveDate) -> NaiveDate {
309
+ let week_start = today - Duration::days(i64::from(today.weekday().num_days_from_sunday()));
310
+ week_start - Duration::weeks((WEEK_COUNT - 1) as i64)
311
+ }
312
+
313
+ fn cell_date(today: NaiveDate, index: usize) -> Option<NaiveDate> {
314
+ chart_start(today).checked_add_signed(Duration::days(index as i64))
315
+ }
316
+
317
+ #[cfg(test)]
318
+ #[path = "activity_chart_tests.rs"]
319
+ mod tests;
codex-rs/tui/src/analytics/activity_chart/palette.rs ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Builds terminal-aware styles and glyph choices for token activity charts.
2
+ //!
3
+ //! The palette adapts theme colors to the active terminal color level while
4
+ //! keeping chart-specific glyph policy local to the token activity renderer.
5
+
6
+ use ratatui::style::Color;
7
+ use ratatui::style::Style;
8
+
9
+ use super::TokenActivityView;
10
+ use crate::color::blend;
11
+ use crate::render::highlight::foreground_style_for_scopes;
12
+ use crate::style::accent_style;
13
+ use crate::terminal_palette::StdoutColorLevel;
14
+ use crate::terminal_palette::best_color_for_level;
15
+ use crate::terminal_palette::default_bg;
16
+ use crate::terminal_palette::default_fg;
17
+ use crate::terminal_palette::stdout_color_level;
18
+
19
+ // In low-color terminals we distinguish empty vs active cells by glyph (a
20
+ // width-matched filled/hollow pair). In truecolor terminals the grid uses a
21
+ // single glyph and lets color carry the intensity (GitHub-style), which keeps
22
+ // the grid perfectly aligned and free of texture noise.
23
+ const EMPTY_CELL_GLYPH: &str = "□";
24
+ const ACTIVE_CELL_GLYPH: &str = "■";
25
+ const BAR_CELL_GLYPH: &str = "█";
26
+
27
+ /// Stores the terminal-specific styles and glyph strategy for token activity cells.
28
+ pub(super) struct TokenActivityPalette {
29
+ styles: [Style; 5],
30
+ bar_style: Style,
31
+ /// True when the terminal supports a truecolor gradient, so the grid can
32
+ /// encode intensity purely by color and render every cell with a single
33
+ /// glyph. False on low-color terminals, where we fall back to a
34
+ /// filled/hollow glyph pair so empty and active cells stay distinguishable.
35
+ uses_color: bool,
36
+ }
37
+
38
+ impl TokenActivityPalette {
39
+ pub(super) fn current() -> Self {
40
+ Self::from_parts(
41
+ default_fg(),
42
+ default_bg(),
43
+ stdout_color_level(),
44
+ theme_activity_style(),
45
+ )
46
+ }
47
+
48
+ fn from_parts(
49
+ default_fg: Option<(u8, u8, u8)>,
50
+ default_bg: Option<(u8, u8, u8)>,
51
+ color_level: StdoutColorLevel,
52
+ active_style: Style,
53
+ ) -> Self {
54
+ let fallback_palette = || Self::fallback(active_style);
55
+ let (Some(fg), Some(bg), Some(anchor)) =
56
+ (default_fg, default_bg, activity_anchor_rgb(active_style))
57
+ else {
58
+ return fallback_palette();
59
+ };
60
+ if matches!(
61
+ color_level,
62
+ StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown
63
+ ) {
64
+ return fallback_palette();
65
+ }
66
+
67
+ let empty_alpha = if crate::color::is_light(bg) {
68
+ 0.18
69
+ } else {
70
+ 0.14
71
+ };
72
+ let alphas = [empty_alpha, 0.22, 0.42, 0.68, 1.00];
73
+ let styles = std::array::from_fn(|index| {
74
+ let color = if index == 0 {
75
+ blend(fg, bg, alphas[index])
76
+ } else {
77
+ blend(anchor, bg, alphas[index])
78
+ };
79
+ Style::default().fg(best_color_for_level(color, color_level))
80
+ });
81
+ let bar_style = Style::default().fg(best_color_for_level(
82
+ blend(anchor, bg, /*alpha*/ 0.78),
83
+ color_level,
84
+ ));
85
+ Self {
86
+ styles,
87
+ bar_style,
88
+ uses_color: true,
89
+ }
90
+ }
91
+
92
+ fn fallback(active_style: Style) -> Self {
93
+ let empty_style = Style::default().dim();
94
+ Self {
95
+ styles: [
96
+ empty_style,
97
+ active_style,
98
+ active_style,
99
+ active_style,
100
+ active_style,
101
+ ],
102
+ bar_style: active_style,
103
+ uses_color: false,
104
+ }
105
+ }
106
+
107
+ pub(super) fn for_level(&self, level: usize) -> Style {
108
+ self.styles[level.min(/*other*/ 4)]
109
+ }
110
+
111
+ pub(super) fn for_bar_level(&self, level: usize) -> Style {
112
+ if level == 0 {
113
+ self.for_level(/*level*/ 0)
114
+ } else {
115
+ self.bar_style
116
+ }
117
+ }
118
+
119
+ /// The glyph for a cell at `level`. Daily truecolor renders every visible
120
+ /// cell with the same square glyph and lets color carry the intensity; in
121
+ /// low-color we use the hollow glyph for empty cells so they remain visible
122
+ /// without a color gradient. Bar views use full blocks for filled height and
123
+ /// spaces for empty height so the silhouette reads as a column chart.
124
+ pub(super) fn glyph(&self, view: TokenActivityView, level: usize) -> &'static str {
125
+ if view != TokenActivityView::Daily {
126
+ return if level == 0 { " " } else { BAR_CELL_GLYPH };
127
+ }
128
+ if self.uses_color || level > 0 {
129
+ ACTIVE_CELL_GLYPH
130
+ } else {
131
+ EMPTY_CELL_GLYPH
132
+ }
133
+ }
134
+ }
135
+
136
+ fn theme_activity_style() -> Style {
137
+ foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"])
138
+ .unwrap_or_else(accent_style)
139
+ .bold()
140
+ }
141
+
142
+ fn activity_anchor_rgb(style: Style) -> Option<(u8, u8, u8)> {
143
+ match style.fg? {
144
+ Color::Rgb(r, g, b) => Some((r, g, b)),
145
+ _ => None,
146
+ }
147
+ }
148
+
149
+ #[cfg(test)]
150
+ #[path = "palette_tests.rs"]
151
+ mod tests;
codex-rs/tui/src/analytics/activity_chart/palette_tests.rs ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::terminal_palette::rgb_color;
3
+ use pretty_assertions::assert_eq;
4
+ use ratatui::style::Modifier;
5
+
6
+ #[test]
7
+ fn truecolor_palette_blends_theme_accent_against_dark_background() {
8
+ let default_fg = Some((240, 240, 240));
9
+ let default_bg = Some((0, 0, 0));
10
+ let active_style = Style::default().fg(rgb_color((100, 200, 50))).bold();
11
+ let palette = TokenActivityPalette::from_parts(
12
+ default_fg,
13
+ default_bg,
14
+ StdoutColorLevel::TrueColor,
15
+ active_style,
16
+ );
17
+
18
+ assert_eq!(
19
+ palette.for_level(/*level*/ 0).fg,
20
+ Some(rgb_color((33, 33, 33)))
21
+ );
22
+ assert_eq!(
23
+ palette.for_level(/*level*/ 1).fg,
24
+ Some(rgb_color((22, 44, 11)))
25
+ );
26
+ assert_eq!(
27
+ palette.for_level(/*level*/ 4).fg,
28
+ Some(rgb_color((100, 200, 50)))
29
+ );
30
+ assert_eq!(
31
+ palette.for_bar_level(/*level*/ 4).fg,
32
+ Some(rgb_color((78, 156, 39)))
33
+ );
34
+ assert!(palette.uses_color);
35
+ }
36
+
37
+ #[test]
38
+ fn truecolor_palette_blends_empty_cell_for_light_background() {
39
+ let default_fg = Some((0, 0, 0));
40
+ let default_bg = Some((255, 255, 255));
41
+ let active_style = Style::default().fg(rgb_color((0, 95, 135))).bold();
42
+ let palette = TokenActivityPalette::from_parts(
43
+ default_fg,
44
+ default_bg,
45
+ StdoutColorLevel::TrueColor,
46
+ active_style,
47
+ );
48
+
49
+ assert_eq!(
50
+ palette.for_level(/*level*/ 0).fg,
51
+ Some(rgb_color((209, 209, 209)))
52
+ );
53
+ assert_eq!(
54
+ palette.for_level(/*level*/ 4).fg,
55
+ Some(rgb_color((0, 95, 135)))
56
+ );
57
+ assert!(palette.uses_color);
58
+ }
59
+
60
+ #[test]
61
+ fn ansi16_palette_uses_theme_accent_without_green_fallback() {
62
+ let default_fg = Some((240, 240, 240));
63
+ let default_bg = Some((0, 0, 0));
64
+ let active_style = Style::default().fg(Color::Magenta).bold();
65
+ let palette = TokenActivityPalette::from_parts(
66
+ default_fg,
67
+ default_bg,
68
+ StdoutColorLevel::Ansi16,
69
+ active_style,
70
+ );
71
+
72
+ assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim());
73
+ assert_eq!(palette.for_level(/*level*/ 1), active_style);
74
+ assert_eq!(palette.for_bar_level(/*level*/ 4), active_style);
75
+ assert!(!palette.uses_color);
76
+ }
77
+
78
+ #[test]
79
+ fn non_rgb_theme_accent_remains_active_fallback() {
80
+ let default_fg = Some((240, 240, 240));
81
+ let default_bg = Some((0, 0, 0));
82
+ let active_style = Style::default().fg(Color::Cyan).bold();
83
+ let palette = TokenActivityPalette::from_parts(
84
+ default_fg,
85
+ default_bg,
86
+ StdoutColorLevel::TrueColor,
87
+ active_style,
88
+ );
89
+
90
+ assert_eq!(palette.for_level(/*level*/ 1), active_style);
91
+ assert!(
92
+ palette
93
+ .for_level(/*level*/ 1)
94
+ .add_modifier
95
+ .contains(Modifier::BOLD)
96
+ );
97
+ assert!(!palette.uses_color);
98
+ }
99
+
100
+ #[test]
101
+ fn missing_terminal_colors_use_theme_accent_fallback() {
102
+ let default_fg = None;
103
+ let default_bg = Some((0, 0, 0));
104
+ let active_style = Style::default().fg(Color::Blue).bold();
105
+ let palette = TokenActivityPalette::from_parts(
106
+ default_fg,
107
+ default_bg,
108
+ StdoutColorLevel::TrueColor,
109
+ active_style,
110
+ );
111
+
112
+ assert_eq!(palette.for_level(/*level*/ 0), Style::default().dim());
113
+ assert_eq!(palette.for_level(/*level*/ 4), active_style);
114
+ assert!(!palette.uses_color);
115
+ }
codex-rs/tui/src/analytics/activity_chart_tests.rs ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Calendar, aggregation, and chart rendering regressions.
2
+ use super::*;
3
+ use codex_backend_client::TokenUsageProfileDailyBucket as AccountTokenUsageDailyBucket;
4
+ use insta::assert_snapshot;
5
+ use pretty_assertions::assert_eq;
6
+
7
+ fn graph_width(width: u16) -> u16 {
8
+ if width == u16::MAX {
9
+ return width;
10
+ }
11
+ (CHART_LEFT_WIDTH + shown_columns(width) * 2 - 1) as u16
12
+ }
13
+
14
+ #[test]
15
+ fn duplicate_dates_sum_and_negative_values_clamp() {
16
+ let today =
17
+ NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
18
+ let buckets = vec![
19
+ AccountTokenUsageDailyBucket {
20
+ start_date: "2026-05-29".to_string(),
21
+ tokens: 10,
22
+ },
23
+ AccountTokenUsageDailyBucket {
24
+ start_date: "2026-05-29".to_string(),
25
+ tokens: 5,
26
+ },
27
+ AccountTokenUsageDailyBucket {
28
+ start_date: "2026-05-28".to_string(),
29
+ tokens: -4,
30
+ },
31
+ ];
32
+
33
+ let values = daily_values(&buckets, today);
34
+
35
+ assert_eq!(values.iter().sum::<i64>(), 15);
36
+ }
37
+
38
+ #[test]
39
+ fn bar_levels_fill_from_bottom() {
40
+ let levels = bar_levels(&[0, 10]);
41
+
42
+ assert_eq!(&levels[..DAY_COUNT], &[0; DAY_COUNT]);
43
+ assert_eq!(&levels[DAY_COUNT..], &[4; DAY_COUNT]);
44
+ }
45
+
46
+ #[test]
47
+ fn token_activity_view_aliases_parse() {
48
+ assert_eq!(TokenActivityView::parse(""), Some(TokenActivityView::Daily));
49
+ assert_eq!(
50
+ TokenActivityView::parse("day"),
51
+ Some(TokenActivityView::Daily)
52
+ );
53
+ assert_eq!(
54
+ TokenActivityView::parse("week"),
55
+ Some(TokenActivityView::Weekly)
56
+ );
57
+ assert_eq!(
58
+ TokenActivityView::parse("cumulative"),
59
+ Some(TokenActivityView::Cumulative)
60
+ );
61
+ assert_eq!(TokenActivityView::parse("year"), None);
62
+ }
63
+
64
+ #[test]
65
+ fn daily_graph_snapshot_uses_distinct_empty_and_active_cells() {
66
+ let today =
67
+ NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
68
+ let buckets = vec![
69
+ AccountTokenUsageDailyBucket {
70
+ start_date: "2026-05-25".to_string(),
71
+ tokens: 1,
72
+ },
73
+ AccountTokenUsageDailyBucket {
74
+ start_date: "2026-05-29".to_string(),
75
+ tokens: 4,
76
+ },
77
+ ];
78
+
79
+ let rendered = chart_lines(TokenActivityView::Daily, &buckets, today, /*width*/ 22)
80
+ .into_iter()
81
+ .map(|line| line.to_string().trim_end().to_string())
82
+ .collect::<Vec<_>>()
83
+ .join("\n");
84
+
85
+ assert_snapshot!(rendered, @r"
86
+ Apr May
87
+ Su □ □ □ □ □ □ □ □ □
88
+ Mo □ □ □ □ □ □ □ □ ■
89
+ Tu □ □ □ □ □ □ □ □ □
90
+ We □ □ □ □ □ □ □ □ □
91
+ Th □ □ □ □ □ □ □ □ □
92
+ Fr □ □ □ □ □ □ □ □ ■
93
+ Sa □ □ □ □ □ □ □ □
94
+
95
+ Less □ ■ ■ ■ ■ More
96
+ ");
97
+ }
98
+
99
+ #[test]
100
+ fn daily_graph_snapshot_stays_left_aligned_in_wide_terminal() {
101
+ assert_eq!(graph_width(/*width*/ 160), 107);
102
+ assert_eq!(graph_width(/*width*/ u16::MAX), u16::MAX);
103
+
104
+ let today =
105
+ NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
106
+ let lines = chart_lines(TokenActivityView::Daily, &[], today, /*width*/ 160);
107
+ let rendered = [&lines[0], &lines[1], lines.last().expect("legend line")]
108
+ .into_iter()
109
+ .map(|line| line.to_string().trim_end().to_string())
110
+ .collect::<Vec<_>>()
111
+ .join("\n");
112
+
113
+ assert_snapshot!(rendered, @"
114
+ Jun Jul Aug Sep Oct Nov Dec Jan Feb Mar Apr May
115
+ Su □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □
116
+ Less □ ■ ■ ■ ■ More
117
+ ");
118
+ }
119
+
120
+ #[test]
121
+ fn weekly_graph_snapshot_renders_bar_chart_and_caption() {
122
+ let today =
123
+ NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
124
+ let buckets = vec![
125
+ AccountTokenUsageDailyBucket {
126
+ start_date: "2026-05-11".to_string(),
127
+ tokens: 3,
128
+ },
129
+ AccountTokenUsageDailyBucket {
130
+ start_date: "2026-05-18".to_string(),
131
+ tokens: 6,
132
+ },
133
+ AccountTokenUsageDailyBucket {
134
+ start_date: "2026-05-25".to_string(),
135
+ tokens: 9,
136
+ },
137
+ ];
138
+
139
+ let rendered = chart_lines(
140
+ TokenActivityView::Weekly,
141
+ &buckets,
142
+ today,
143
+ /*width*/ 22,
144
+ )
145
+ .into_iter()
146
+ .map(|line| line.to_string().trim_end().to_string())
147
+ .collect::<Vec<_>>()
148
+ .join("\n");
149
+
150
+ assert_snapshot!(rendered, @"
151
+ Apr May
152
+ max █
153
+ █
154
+ █ █
155
+ █ █
156
+ █ █ █
157
+ █ █ █
158
+ 0 █ █ █
159
+
160
+ Each column = 1 week · tallest 9
161
+ ");
162
+ }
163
+
164
+ #[test]
165
+ fn cumulative_graph_snapshot_renders_running_total_bar_chart_and_caption() {
166
+ let today =
167
+ NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 5, /*day*/ 29).expect("valid date");
168
+ let buckets = vec![
169
+ AccountTokenUsageDailyBucket {
170
+ start_date: "2026-05-11".to_string(),
171
+ tokens: 3,
172
+ },
173
+ AccountTokenUsageDailyBucket {
174
+ start_date: "2026-05-18".to_string(),
175
+ tokens: 6,
176
+ },
177
+ AccountTokenUsageDailyBucket {
178
+ start_date: "2026-05-25".to_string(),
179
+ tokens: 9,
180
+ },
181
+ ];
182
+
183
+ let rendered = chart_lines(
184
+ TokenActivityView::Cumulative,
185
+ &buckets,
186
+ today,
187
+ /*width*/ 22,
188
+ )
189
+ .into_iter()
190
+ .map(|line| line.to_string().trim_end().to_string())
191
+ .collect::<Vec<_>>()
192
+ .join("\n");
193
+
194
+ assert_snapshot!(rendered, @"
195
+ Apr May
196
+ max █
197
+ █
198
+ █
199
+ █ █
200
+ █ █
201
+ █ █ █
202
+ 0 █ █ █
203
+
204
+ Running total · top 18
205
+ ");
206
+ }
207
+
208
+ #[test]
209
+ fn year_window_ignores_other_dates_and_saturates_extreme_totals() {
210
+ let today: NaiveDate = "2026-09-09".parse().unwrap();
211
+ let buckets = [
212
+ ("2025-01-01", 100),
213
+ ("2026-09-10", 100),
214
+ ("invalid", 100),
215
+ ("2026-09-08", i64::MAX),
216
+ ("2026-09-08", i64::MAX),
217
+ ("2026-09-09", i64::MAX),
218
+ ]
219
+ .map(|(date, tokens)| AccountTokenUsageDailyBucket {
220
+ start_date: date.into(),
221
+ tokens,
222
+ });
223
+ let values = daily_values(&buckets, today);
224
+ let mut expected = vec![0; CELL_COUNT];
225
+ let index = (today - chart_start(today)).num_days() as usize;
226
+ expected[index - 1] = i64::MAX;
227
+ expected[index] = i64::MAX;
228
+ assert_eq!(values, expected);
229
+ for view in [
230
+ TokenActivityView::Daily,
231
+ TokenActivityView::Weekly,
232
+ TokenActivityView::Cumulative,
233
+ ] {
234
+ let levels = levels_for_view(&values, view);
235
+ assert!(levels.iter().all(|level| *level <= 4));
236
+ assert!(levels.contains(&4));
237
+ assert!(!chart_lines(view, &buckets, today, /*width*/ 110).is_empty());
238
+ }
239
+ }
codex-rs/tui/src/analytics/chart.rs ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Daily history with a stable chart and a compact, separated breakdown table.
2
+ //! Chart series rank by the full range; table rows rank by the selected day and retain series colors.
3
+ //! Missing and zero days retain the same row count; only viewport changes and explicit expansion change height.
4
+
5
+ use super::AnalyticsView;
6
+ use super::data;
7
+ use super::plot;
8
+ use super::render::categories;
9
+ use super::render::columns;
10
+ use super::styles::number;
11
+ use super::styles::secondary_style;
12
+ use super::styles::series_colors;
13
+ use crate::analytics::models::AccountAnalyticsUnit;
14
+ use crate::analytics::models::AccountAnalyticsValue;
15
+ use crate::analytics::sections::Section;
16
+ use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
17
+ use ratatui::style::Styled;
18
+ use ratatui::style::Stylize;
19
+ use ratatui::text::Line;
20
+
21
+ impl AnalyticsView {
22
+ pub(super) fn day_has_details(&self, section: Section, day_index: usize) -> bool {
23
+ let Some(history) = self.sections[section].history.ready() else {
24
+ return false;
25
+ };
26
+ let date = self
27
+ .section_date_range(section)
28
+ .start()
29
+ .iter_days()
30
+ .nth(day_index);
31
+ history.data.iter().any(|day| {
32
+ date.is_some_and(|date| day.date == date)
33
+ && day.values.iter().any(|value| value.value != 0.0)
34
+ })
35
+ }
36
+
37
+ pub(super) fn history_lines(
38
+ &self,
39
+ section: Section,
40
+ width: usize,
41
+ height: usize,
42
+ ) -> plot::Chart {
43
+ let state = &self.sections[section].history;
44
+ let Some(history) = state.ready() else {
45
+ return plot::Chart {
46
+ lines: state
47
+ .message()
48
+ .into_iter()
49
+ .map(|message| message.to_string().set_style(secondary_style()).into())
50
+ .collect(),
51
+ bands: 0,
52
+ };
53
+ };
54
+ if history.data.is_empty() {
55
+ return plot::Chart {
56
+ lines: vec![
57
+ "No data reported for this range."
58
+ .set_style(secondary_style())
59
+ .into(),
60
+ ],
61
+ bands: 0,
62
+ };
63
+ }
64
+ let range = self.section_date_range(section);
65
+ // At most 30 daily slots; index by date if report ranges grow.
66
+ let days = range
67
+ .start()
68
+ .iter_days()
69
+ .take_while(|date| date <= range.end())
70
+ .map(|date| {
71
+ let day = history.data.iter().find(|day| day.date == date);
72
+ (date, day)
73
+ })
74
+ .collect::<Vec<_>>();
75
+ let cursor = self.sections[section].cursor.min(days.len() - 1);
76
+ let selected_date = days[cursor].0.format("%b %-d").to_string();
77
+ let day = days[cursor].1;
78
+ let expanded = self.zoomed && self.sections[section].detail.is_some();
79
+ let colors = series_colors();
80
+ let mut categories = categories(history);
81
+ if matches!(section, Section::Plugins | Section::Skills) {
82
+ for category in &mut categories {
83
+ category.label = super::tool_panel::tool_name(&category.label);
84
+ }
85
+ }
86
+ if self.sections[section].group == 2 {
87
+ for category in &mut categories {
88
+ category.label = self.model_name(&category.label).to_string();
89
+ }
90
+ }
91
+ let unit = match section {
92
+ Section::Usage if history.unit == AccountAnalyticsUnit::Tokens => "tokens",
93
+ Section::Summary => "tokens",
94
+ Section::Activity => "messages",
95
+ Section::Plugins => "calls",
96
+ Section::Skills => "uses",
97
+ Section::Usage | Section::Credits | Section::Chats | Section::Plan => "credits",
98
+ };
99
+ let empty_breakdown = matches!(
100
+ section,
101
+ Section::Plugins | Section::Activity | Section::Skills
102
+ ) && day.is_none_or(|day| {
103
+ day.total == 0.0 && day.values.iter().all(|value| value.value == 0.0)
104
+ });
105
+ let relative = history.unit == AccountAnalyticsUnit::RelativeUsage;
106
+ let format_amount = if history.unit == AccountAnalyticsUnit::Credits {
107
+ data::credit_amount
108
+ } else {
109
+ data::amount
110
+ };
111
+ let total: f64 = history.data.iter().map(|day| day.total).sum();
112
+ let formatted_total = if total.abs() >= 1_000.0 {
113
+ data::compact_amount(total)
114
+ } else {
115
+ format_amount(total)
116
+ };
117
+ let mut lines = vec![if relative {
118
+ "Total usage · relative units"
119
+ .set_style(secondary_style())
120
+ .into()
121
+ } else {
122
+ number(format!(
123
+ "{} {}{unit}",
124
+ formatted_total,
125
+ if section == Section::Activity {
126
+ "reported "
127
+ } else {
128
+ ""
129
+ }
130
+ ))
131
+ .into()
132
+ }];
133
+ if section == Section::Usage && !self.business() && self.zoomed {
134
+ lines.push(
135
+ "Approximate · may be delayed up to 6 hours"
136
+ .set_style(secondary_style())
137
+ .into(),
138
+ );
139
+ }
140
+ if self.sections[section].group == 3 {
141
+ lines.push(
142
+ "All usage by how each turn started"
143
+ .set_style(secondary_style())
144
+ .into(),
145
+ );
146
+ }
147
+ let has_activity = history
148
+ .data
149
+ .iter()
150
+ .any(|day| day.total != 0.0 || day.values.iter().any(|value| value.value != 0.0));
151
+ let mut bands = 0;
152
+ if has_activity {
153
+ let chart = plot::chart(&days, &categories, cursor, width, height, history.unit);
154
+ bands = chart.bands;
155
+ lines.extend(chart.lines);
156
+ } else {
157
+ lines.push(
158
+ if matches!(
159
+ section,
160
+ Section::Plugins | Section::Activity | Section::Skills
161
+ ) {
162
+ format!("No {unit} reported in this range.")
163
+ .set_style(secondary_style())
164
+ .into()
165
+ } else {
166
+ "No activity reported in this range."
167
+ .set_style(secondary_style())
168
+ .into()
169
+ },
170
+ );
171
+ }
172
+
173
+ let table_width = width.min(/*other*/ 56);
174
+ lines.push(Line::default());
175
+ if self.zoomed {
176
+ lines.push("─".repeat(table_width).dim().into());
177
+ }
178
+ let table_start = lines.len();
179
+ let summary = match day {
180
+ None => " · Not reported".set_style(secondary_style()),
181
+ Some(day) if day.total == 0.0 && day.values.iter().all(|value| value.value == 0.0) => {
182
+ " · No activity (0)".set_style(secondary_style())
183
+ }
184
+ Some(day) if relative => number(format!(" · {} usage units", data::amount(day.total))),
185
+ Some(day) => number(format!(
186
+ " · {} {unit}",
187
+ if expanded {
188
+ day.total.to_string()
189
+ } else {
190
+ format_amount(day.total)
191
+ }
192
+ )),
193
+ };
194
+ lines.push(truncate_line_with_ellipsis_if_overflow(
195
+ vec![selected_date.clone().bold(), summary].into(),
196
+ width,
197
+ ));
198
+ if self.zoomed {
199
+ lines.push(columns(
200
+ match section {
201
+ Section::Plugins => "Plugin",
202
+ Section::Skills => "Skill",
203
+ Section::Chats => "Chat",
204
+ Section::Summary
205
+ | Section::Usage
206
+ | Section::Credits
207
+ | Section::Activity
208
+ | Section::Plan => self.group_label(section, self.sections[section].group),
209
+ }
210
+ .bold()
211
+ .into(),
212
+ if history.unit == AccountAnalyticsUnit::Tokens {
213
+ "Tokens"
214
+ } else if section == Section::Usage {
215
+ "Share"
216
+ } else if section == Section::Activity {
217
+ "Messages"
218
+ } else if section == Section::Plugins {
219
+ "Calls"
220
+ } else if section == Section::Skills {
221
+ "Uses"
222
+ } else {
223
+ "Credits"
224
+ }
225
+ .bold()
226
+ .into(),
227
+ table_width,
228
+ ));
229
+ }
230
+ let mut values = categories
231
+ .iter()
232
+ .enumerate()
233
+ .map(|(index, category)| {
234
+ (
235
+ index,
236
+ AccountAnalyticsValue {
237
+ value: day
238
+ .and_then(|day| {
239
+ day.values.iter().find(|value| value.key == category.key)
240
+ })
241
+ .map_or(/*default*/ 0.0, |value| value.value),
242
+ ..category.clone()
243
+ },
244
+ )
245
+ })
246
+ .collect::<Vec<_>>();
247
+ values.sort_by(|(_, a), (_, b)| b.value.abs().total_cmp(&a.value.abs()));
248
+ let row_budget = if self.zoomed {
249
+ (self.viewport_height / 4).max(/*other*/ 4)
250
+ } else {
251
+ 4
252
+ };
253
+ let remainder = if !expanded && values.len() > row_budget {
254
+ let remaining = values.split_off(row_budget - 1);
255
+ Some((
256
+ format!("{} more", remaining.len()),
257
+ remaining.iter().map(|(_, value)| value.value).sum::<f64>(),
258
+ ))
259
+ } else {
260
+ None
261
+ };
262
+ let rows = values
263
+ .iter()
264
+ .map(|(index, value)| {
265
+ (
266
+ Line::from(vec![
267
+ "● ".fg(colors[(*index).min(/*other*/ 3)]),
268
+ value.label.clone().into(),
269
+ ]),
270
+ value.value,
271
+ )
272
+ })
273
+ .chain(
274
+ remainder.map(|(label, value)| (Line::from(vec!["… ".dim(), label.dim()]), value)),
275
+ );
276
+ for (label, value) in rows.take(if self.zoomed { usize::MAX } else { 2 }) {
277
+ let amount = match day {
278
+ None => "—".into(),
279
+ Some(day)
280
+ if section == Section::Usage
281
+ && history.unit != AccountAnalyticsUnit::Tokens =>
282
+ {
283
+ format!(
284
+ "{:.1}%",
285
+ if day.total == 0.0 {
286
+ 0.0
287
+ } else {
288
+ value / day.total * 100.0
289
+ }
290
+ )
291
+ }
292
+ Some(_) if expanded => value.to_string(),
293
+ Some(_) => format_amount(value),
294
+ };
295
+ lines.push(columns(
296
+ truncate_line_with_ellipsis_if_overflow(
297
+ label,
298
+ table_width.saturating_sub(amount.len() + 2),
299
+ ),
300
+ if day.is_none() || value == 0.0 {
301
+ amount.dim()
302
+ } else {
303
+ amount.into()
304
+ }
305
+ .into(),
306
+ table_width,
307
+ ));
308
+ }
309
+ if empty_breakdown {
310
+ // Preserve the measured table allocation when the selected day has no breakdown.
311
+ let table_end = lines.len();
312
+ lines.truncate(table_start);
313
+ lines.push(truncate_line_with_ellipsis_if_overflow(
314
+ if day.is_none() {
315
+ format!("Data not reported for {selected_date}.")
316
+ } else {
317
+ format!("No {unit} reported for {selected_date}.")
318
+ }
319
+ .set_style(secondary_style())
320
+ .into(),
321
+ width,
322
+ ));
323
+ lines.resize(table_end, Line::default());
324
+ }
325
+ if !self.zoomed
326
+ && let Some(updated) = history
327
+ .updated_at
328
+ .and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0))
329
+ {
330
+ lines.push(
331
+ format!("Updated {} UTC", updated.format("%b %-d %H:%M"))
332
+ .set_style(secondary_style())
333
+ .into(),
334
+ );
335
+ }
336
+ plot::Chart { lines, bands }
337
+ }
338
+ }
codex-rs/tui/src/analytics/chat_panel.rs ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Bounded chat tables with inline credit breakdowns and a viewport-sized row window.
2
+ //! Zero-credit groups are optional; missing dimensions and signed adjustments stay distinct.
3
+
4
+ use super::AnalyticsView;
5
+ use super::data;
6
+ use super::render::columns;
7
+ use super::styles::number;
8
+ use super::styles::secondary_style;
9
+ use crate::analytics::sections::Section;
10
+ use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
11
+ use crate::style::accent_style;
12
+ use crate::wrapping::RtOptions;
13
+ use crate::wrapping::word_wrap_lines;
14
+ use ratatui::style::Styled;
15
+ use ratatui::style::Stylize;
16
+ use ratatui::text::Line;
17
+ use std::ops::Range;
18
+
19
+ impl AnalyticsView {
20
+ pub(super) fn chat_lines(&self, width: usize) -> (Vec<Line<'static>>, Range<usize>) {
21
+ let width = width.clamp(/*min*/ 1, /*max*/ 96);
22
+ let wrap = |lines: Vec<Line<'static>>| word_wrap_lines(lines, RtOptions::new(width));
23
+ let mut lines = wrap(vec![
24
+ "Active in past 30 days · sorted by lifetime credits"
25
+ .set_style(secondary_style())
26
+ .into(),
27
+ ]);
28
+ if let Some(message) = self.chats.message() {
29
+ lines.extend(wrap(vec![
30
+ message.to_string().set_style(secondary_style()).into(),
31
+ ]));
32
+ }
33
+ let Some(chats) = self.chats.ready() else {
34
+ return (lines, 0..1);
35
+ };
36
+ if chats.rows.is_empty() {
37
+ lines.extend(wrap(vec![
38
+ "No recent local chats.".set_style(secondary_style()).into(),
39
+ ]));
40
+ return (lines, 0..1);
41
+ }
42
+ let missing = chats.rows.iter().filter(|row| row.usage.is_none()).count();
43
+ if missing > 0 {
44
+ lines.extend(wrap(vec![
45
+ format!(
46
+ "{missing} chat estimate{} unavailable",
47
+ if missing == 1 { "" } else { "s" }
48
+ )
49
+ .set_style(secondary_style())
50
+ .into(),
51
+ ]));
52
+ }
53
+ lines.push(Line::default());
54
+ let show_usd = chats.rows.iter().any(|chat| {
55
+ chat.usage
56
+ .as_ref()
57
+ .is_some_and(|usage| usage.estimated_usage_usd_micros.is_some())
58
+ });
59
+ let stacked_amounts = show_usd && width < 70;
60
+ lines.extend(wrap(vec![columns(
61
+ " Chat".bold().into(),
62
+ if stacked_amounts {
63
+ "Est. $ / credits"
64
+ } else if show_usd {
65
+ "Est. $ spent Lifetime credits"
66
+ } else {
67
+ "Lifetime credits"
68
+ }
69
+ .bold()
70
+ .into(),
71
+ width,
72
+ )]));
73
+ let rows = chats
74
+ .rows
75
+ .iter()
76
+ .enumerate()
77
+ .map(|(index, chat)| {
78
+ let selected =
79
+ self.section == Section::Chats && index == self.sections[Section::Chats].cursor;
80
+ let amount = chat.usage.as_ref().map_or_else(
81
+ || "—".into(),
82
+ |usage| data::credits(usage.estimated_usage_credits_micros),
83
+ );
84
+ let amount = if show_usd {
85
+ let usd = chat
86
+ .usage
87
+ .as_ref()
88
+ .and_then(|usage| usage.estimated_usage_usd_micros)
89
+ .map(|micros| format!("${:.2}", micros as f64 / 1_000_000.0))
90
+ .unwrap_or_else(|| "—".into());
91
+ format!("{usd:>12} {amount:>16}")
92
+ } else {
93
+ amount
94
+ };
95
+ let label = format!(
96
+ "{} {}",
97
+ if selected { "›" } else { " " },
98
+ chat.display_title()
99
+ );
100
+ let label: Line<'static> = if selected {
101
+ label.set_style(accent_style()).into()
102
+ } else {
103
+ label.into()
104
+ };
105
+ let label_width = if stacked_amounts {
106
+ width
107
+ } else {
108
+ width.saturating_sub(amount.len() + 1)
109
+ };
110
+ let truncated = label.width() > label_width;
111
+ let mut row = if stacked_amounts {
112
+ vec![
113
+ truncate_line_with_ellipsis_if_overflow(label, label_width),
114
+ format!(" {}", amount.trim()).into(),
115
+ ]
116
+ } else {
117
+ vec![columns(
118
+ truncate_line_with_ellipsis_if_overflow(label, label_width),
119
+ if chat.usage.is_none() {
120
+ amount.set_style(secondary_style()).into()
121
+ } else if selected {
122
+ number(amount).into()
123
+ } else {
124
+ amount.into()
125
+ },
126
+ width,
127
+ )]
128
+ };
129
+ if self.sections[Section::Chats].detail == Some(index)
130
+ && let Some(usage) = &chat.usage
131
+ {
132
+ row.push(
133
+ format!(" {}", "─".repeat(width.saturating_sub(/*rhs*/ 2)))
134
+ .dim()
135
+ .into(),
136
+ );
137
+ if truncated {
138
+ row.extend(word_wrap_lines(
139
+ [chat.display_title().to_string().bold()],
140
+ RtOptions::new(width)
141
+ .initial_indent(" ".into())
142
+ .subsequent_indent(" ".into()),
143
+ ));
144
+ }
145
+ let zeros = usage
146
+ .groups
147
+ .iter()
148
+ .filter(|group| group.estimated_usage_credits_micros == 0)
149
+ .count();
150
+ let mut groups = usage
151
+ .groups
152
+ .iter()
153
+ .filter(|group| {
154
+ self.show_zero_credit_groups
155
+ || group.estimated_usage_credits_micros != 0
156
+ })
157
+ .collect::<Vec<_>>();
158
+ groups.sort_by_key(|group| {
159
+ std::cmp::Reverse(group.estimated_usage_credits_micros)
160
+ });
161
+ // On narrow terminals, retain every dimension on a second line.
162
+ let table = width >= 70;
163
+ if !groups.is_empty() {
164
+ row.push(columns(
165
+ if table {
166
+ format!(
167
+ " {:<model_width$} {:<12} {:<12}",
168
+ "Model",
169
+ "Effort",
170
+ "Speed",
171
+ model_width = width - 46
172
+ )
173
+ .set_style(secondary_style())
174
+ .into()
175
+ } else {
176
+ " Model / effort / speed"
177
+ .set_style(secondary_style())
178
+ .into()
179
+ },
180
+ "Credits".bold().into(),
181
+ width,
182
+ ));
183
+ }
184
+ for group in groups {
185
+ let model =
186
+ self.model_name(group.model.as_deref().unwrap_or("Not reported"));
187
+ let effort = group.reasoning_effort.as_deref().unwrap_or("Not reported");
188
+ let speed = group.speed.as_deref().unwrap_or("Not reported");
189
+ let amount = data::credits(group.estimated_usage_credits_micros);
190
+ let label = if table {
191
+ // Wrap long model names instead of hiding distinctions between groups.
192
+ let model_width = width - 46;
193
+ let models = textwrap::wrap(model, model_width);
194
+ for model in models.iter().take(models.len().saturating_sub(/*rhs*/ 1))
195
+ {
196
+ row.push(format!(" {model}").bold().into());
197
+ }
198
+ vec![
199
+ format!(
200
+ " {:<model_width$}",
201
+ models
202
+ .last()
203
+ .map(std::convert::AsRef::as_ref)
204
+ .unwrap_or_default()
205
+ )
206
+ .bold(),
207
+ format!(" {effort:<12} {speed:<12}").set_style(secondary_style()),
208
+ ]
209
+ .into()
210
+ } else {
211
+ let label = Line::from(format!(" {model}").bold());
212
+ if label.width() + amount.len() + 1 > width {
213
+ row.push(label);
214
+ " ".into()
215
+ } else {
216
+ label
217
+ }
218
+ };
219
+ row.push(columns(label, amount.into(), width));
220
+ if !table {
221
+ row.push(
222
+ format!(" {effort} · {speed}")
223
+ .set_style(secondary_style())
224
+ .into(),
225
+ );
226
+ }
227
+ }
228
+ if usage.groups.is_empty() {
229
+ row.push(
230
+ " Breakdown unavailable"
231
+ .set_style(secondary_style())
232
+ .into(),
233
+ );
234
+ }
235
+ if zeros > 0 {
236
+ row.push(
237
+ format!(
238
+ " {zeros} zero-credit {}{} · a {}",
239
+ if zeros == 1 { "group" } else { "groups" },
240
+ if self.show_zero_credit_groups {
241
+ ""
242
+ } else {
243
+ " hidden"
244
+ },
245
+ if self.show_zero_credit_groups {
246
+ "hide zeros"
247
+ } else {
248
+ "show all"
249
+ },
250
+ )
251
+ .set_style(secondary_style())
252
+ .into(),
253
+ );
254
+ }
255
+ row.push(Line::default());
256
+ }
257
+ wrap(row)
258
+ })
259
+ .collect::<Vec<_>>();
260
+ let coverage = wrap(vec![
261
+ "Local chats · excludes archived chats and subagent usage"
262
+ .set_style(secondary_style())
263
+ .into(),
264
+ ]);
265
+ self.chat_window(lines, rows, coverage, width)
266
+ }
267
+
268
+ pub(super) fn chat_window(
269
+ &self,
270
+ mut lines: Vec<Line<'static>>,
271
+ rows: Vec<Vec<Line<'static>>>,
272
+ coverage: Vec<Line<'static>>,
273
+ width: usize,
274
+ ) -> (Vec<Line<'static>>, Range<usize>) {
275
+ let wrap = |lines: Vec<Line<'static>>| word_wrap_lines(lines, RtOptions::new(width));
276
+ let count = rows.len();
277
+ let range_height = wrap(vec![
278
+ format!("Showing {count}–{count} of {count} chats").into(),
279
+ ])
280
+ .len();
281
+ // Measure wrapped details before choosing neighbors so the range describes the rendered rows.
282
+ let budget = if self.zoomed {
283
+ self.viewport_height
284
+ .saturating_sub(lines.len() + coverage.len() + range_height + 4)
285
+ } else {
286
+ usize::MAX
287
+ };
288
+ let row_limit = if self.zoomed { count } else { 5 };
289
+ let cursor = self.sections[Section::Chats].cursor.min(count - 1);
290
+ let mut first = cursor;
291
+ let mut end = cursor + 1;
292
+ let mut used = rows[cursor].len();
293
+ while first > 0 && end - first < row_limit && used + rows[first - 1].len() <= budget {
294
+ first -= 1;
295
+ used += rows[first].len();
296
+ }
297
+ while end < count && end - first < row_limit && used + rows[end].len() <= budget {
298
+ used += rows[end].len();
299
+ end += 1;
300
+ }
301
+ let mut selection = 0..1;
302
+ for (index, row) in rows.into_iter().enumerate().take(end).skip(first) {
303
+ if index == cursor {
304
+ selection = lines.len()..lines.len() + row.len();
305
+ }
306
+ lines.extend(row);
307
+ }
308
+ lines.push(Line::default());
309
+ lines.extend(wrap(vec![
310
+ format!("Showing {}–{end} of {count} chats", first + 1)
311
+ .set_style(secondary_style())
312
+ .into(),
313
+ ]));
314
+ lines.extend(coverage);
315
+ (lines, selection)
316
+ }
317
+ }
codex-rs/tui/src/analytics/chat_panel_tests.rs ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Chat list coverage, responsive expansion, and controls for fixed-period data.
2
+
3
+ use super::*;
4
+ use pretty_assertions::assert_eq;
5
+
6
+ #[test]
7
+ fn chats_range_controls_and_visible_rows() {
8
+ let mut view = fixture::view(models::AccountKind::Enterprise);
9
+ let mut chats = fixture::chats();
10
+ let usage = chats.rows[0].usage.clone();
11
+ chats.rows = (1..=30)
12
+ .map(|index| chats::Chat {
13
+ title: format!("Chat {index:02}"),
14
+ usage: usage.clone(),
15
+ })
16
+ .collect();
17
+ view.chats = Load::Ready(chats);
18
+ press(&mut view, KeyCode::Char('6'));
19
+ let before = (
20
+ view.ranges,
21
+ view.sections.0.each_ref().map(|state| state.cursor),
22
+ view.sections.0.each_ref().map(|state| state.detail),
23
+ );
24
+ press(&mut view, KeyCode::Char('r'));
25
+ assert_eq!(
26
+ (
27
+ view.ranges,
28
+ view.sections.0.each_ref().map(|state| state.cursor),
29
+ view.sections.0.each_ref().map(|state| state.detail)
30
+ ),
31
+ before
32
+ );
33
+ let mut screens = Vec::new();
34
+ for key in [KeyCode::Home, KeyCode::End, KeyCode::Enter] {
35
+ press(&mut view, key);
36
+ let output = screen(&mut view, /*width*/ 110, /*height*/ 24);
37
+ assert!(!output.contains("7d") && !output.contains("r 7/30d"));
38
+ let visible = (1..=30)
39
+ .filter(|index| output.contains(&format!("Chat {index:02}")))
40
+ .collect::<Vec<_>>();
41
+ assert!(output.contains(&format!(
42
+ "Showing {}–{} of 30 chats",
43
+ visible.first().unwrap(),
44
+ visible.last().unwrap()
45
+ )));
46
+ screens.push(output);
47
+ }
48
+ press(&mut view, KeyCode::Char('z'));
49
+ press(&mut view, KeyCode::Char('r'));
50
+ assert_eq!(view.ranges, [0; 3]);
51
+ press(&mut view, KeyCode::Char('2'));
52
+ press(&mut view, KeyCode::Char('r'));
53
+ press(&mut view, KeyCode::Char('z'));
54
+ assert!(screen(&mut view, /*width*/ 110, /*height*/ 24).contains("[30d]"));
55
+ insta::assert_snapshot!(screens.join("\n"));
56
+ }
57
+
58
+ #[test]
59
+ fn chats_breakdown_ranks_nonzero_groups_and_reflows() {
60
+ let mut view = fixture::view(models::AccountKind::Enterprise);
61
+ let mut chats = fixture::chats();
62
+ let usage = chats.rows[0].usage.as_mut().unwrap();
63
+ let seed = usage.groups[0].clone();
64
+ usage.groups = [
65
+ ("Zero model", 0),
66
+ ("Small model", 215_610_920),
67
+ ("Top model", 19_134_430_635),
68
+ ("Refund model", -4),
69
+ ]
70
+ .map(
71
+ |(model, credits)| codex_backend_client::ThreadUsageBreakdownGroup {
72
+ model: Some(model.into()),
73
+ speed: (credits != 19_134_430_635).then(|| "standard".into()),
74
+ estimated_usage_credits_micros: credits,
75
+ ..seed.clone()
76
+ },
77
+ )
78
+ .to_vec();
79
+ usage.estimated_usage_credits_micros = usage
80
+ .groups
81
+ .iter()
82
+ .map(|group| group.estimated_usage_credits_micros)
83
+ .sum();
84
+ view.chats = Load::Ready(chats);
85
+ press(&mut view, KeyCode::Char('6'));
86
+ press(&mut view, KeyCode::Enter);
87
+ let mut screens = Vec::new();
88
+ for width in [110, 58] {
89
+ view.follow_selection = true;
90
+ let output = screen(&mut view, width, /*height*/ 32);
91
+ assert!(output.find("Top model").unwrap() < output.find("Small model").unwrap());
92
+ assert!(output.find("Small model").unwrap() < output.find("Refund model").unwrap());
93
+ assert!(!output.contains("Zero model"));
94
+ assert!(output.contains("-0.000004") && output.contains("Not reported"));
95
+ assert!(output.contains("19,350.04") && output.contains("19,134.43"));
96
+ assert!(output.contains("1 zero-credit group hidden · a show all"));
97
+ assert!(output.contains("Refactor billing usage"));
98
+ screens.push(output);
99
+ }
100
+ press(&mut view, KeyCode::Char('a'));
101
+ let expanded = screen(&mut view, /*width*/ 110, /*height*/ 32);
102
+ assert!(expanded.contains("Zero model") && expanded.contains("a hide zeros"));
103
+ screens.push(expanded);
104
+ press(&mut view, KeyCode::Char('a'));
105
+ assert!(!view.show_zero_credit_groups);
106
+ let wide = view.chat_lines(/*width*/ 220).0;
107
+ assert!(wide.iter().all(|line| line.width() <= 96));
108
+ insta::assert_snapshot!(screens.join("\n"));
109
+ }
110
+
111
+ #[test]
112
+ fn chats_long_title_keeps_detail_indent_and_counts_missing_estimates() {
113
+ let mut view = fixture::view(models::AccountKind::Enterprise);
114
+ let mut chats = fixture::chats();
115
+ 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();
116
+ chats.rows[1].usage = None;
117
+ chats.rows[2].usage = None;
118
+ view.chats = Load::Ready(chats);
119
+ press(&mut view, KeyCode::Char('6'));
120
+ press(&mut view, KeyCode::Enter);
121
+ let output = screen(&mut view, /*width*/ 110, /*height*/ 30);
122
+ assert!(output.contains("2 chat estimates unavailable"));
123
+ insta::assert_snapshot!(output);
124
+ }
125
+
126
+ #[tokio::test]
127
+ async fn unavailable_chats_do_not_open_invisible_details() {
128
+ for key in [KeyCode::Enter, KeyCode::Right] {
129
+ for state in 0..5 {
130
+ let mut view = fixture::view(models::AccountKind::Enterprise);
131
+ view.section = Section::Chats;
132
+ view.chats = match state {
133
+ 0 => Load::Unavailable,
134
+ 1 => Load::Error("Temporary failure".into()),
135
+ 2 => Load::Ready(chats::Chats::default()),
136
+ 3 => Load::Ready(chats::Chats {
137
+ rows: vec![chats::Chat {
138
+ title: "Another workspace's private title".into(),
139
+ usage: None,
140
+ }],
141
+ }),
142
+ _ => Load::start(std::future::pending(), FrameRequester::test_dummy()),
143
+ };
144
+ press(&mut view, key);
145
+ assert_eq!(view.sections[Section::Chats].detail, None);
146
+ press(&mut view, KeyCode::Esc);
147
+ assert!(view.is_done);
148
+ }
149
+ }
150
+ }
151
+
152
+ #[test]
153
+ fn unavailable_chat_titles_are_hidden_in_panel_and_overview() {
154
+ let mut view = fixture::view(models::AccountKind::Enterprise);
155
+ view.section = Section::Chats;
156
+ view.chats = Load::Ready(chats::Chats {
157
+ rows: vec![chats::Chat {
158
+ title: "Another workspace's private title".into(),
159
+ usage: None,
160
+ }],
161
+ });
162
+ let mut screens = Vec::new();
163
+ for zoomed in [true, false] {
164
+ view.zoomed = zoomed;
165
+ view.follow_selection = true;
166
+ let output = screen(&mut view, /*width*/ 110, /*height*/ 28);
167
+ assert!(!output.contains("Another workspace's private title"));
168
+ assert!(output.contains("Chat usage unavailable"));
169
+ screens.push(output);
170
+ }
171
+ insta::assert_snapshot!(screens.join("\n"));
172
+ }
173
+
174
+ #[test]
175
+ fn refreshing_chats_clears_details_before_replacing_the_ranked_rows() {
176
+ for unavailable in [true, false] {
177
+ let mut view = fixture::view(models::AccountKind::Enterprise);
178
+ view.section = Section::Chats;
179
+ press(&mut view, KeyCode::Enter);
180
+ assert_eq!(view.sections[Section::Chats].detail, Some(0));
181
+ press(&mut view, KeyCode::Char('R'));
182
+ assert_eq!(view.sections[Section::Chats].detail, None);
183
+ view.account = Load::Ready(codex_protocol::account::PlanType::EnterpriseCbpUsageBased);
184
+ view.chats = if unavailable {
185
+ Load::Error("Temporarily unavailable".into())
186
+ } else {
187
+ Load::Ready(chats::Chats::default())
188
+ };
189
+ press(&mut view, KeyCode::Esc);
190
+ assert!(view.is_done);
191
+ }
192
+ }
193
+
194
+ #[test]
195
+ fn moving_chat_selection_collapses_details_before_they_leave_the_viewport() {
196
+ for (start, key) in [
197
+ (0, KeyCode::Down),
198
+ (1, KeyCode::Up),
199
+ (0, KeyCode::End),
200
+ (2, KeyCode::Home),
201
+ ] {
202
+ let mut view = fixture::view(models::AccountKind::Enterprise);
203
+ view.section = Section::Chats;
204
+ view.sections[Section::Chats].cursor = start;
205
+ press(&mut view, KeyCode::Enter);
206
+ assert_eq!(view.sections[Section::Chats].detail, Some(start));
207
+ press(&mut view, key);
208
+ assert_eq!(view.sections[Section::Chats].detail, None);
209
+ if key == KeyCode::End {
210
+ insta::assert_snapshot!(screen(&mut view, /*width*/ 110, /*height*/ 24));
211
+ }
212
+ press(&mut view, KeyCode::Esc);
213
+ assert!(view.is_done);
214
+ }
215
+ }
codex-rs/tui/src/analytics/chats.rs ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Discover recent local roots and load business chat credit estimates.
2
+
3
+ use super::client::Live;
4
+ use super::client::Session;
5
+ use super::data;
6
+ use codex_app_server_client::AppServerRequestHandle;
7
+ use codex_app_server_protocol::ClientRequest;
8
+ use codex_app_server_protocol::RequestId;
9
+ use codex_app_server_protocol::SortDirection;
10
+ use codex_app_server_protocol::ThreadListParams;
11
+ use codex_app_server_protocol::ThreadListResponse;
12
+ use codex_app_server_protocol::ThreadSortKey;
13
+ use codex_backend_client::ThreadUsage;
14
+ use std::collections::HashMap;
15
+ use std::collections::HashSet;
16
+ use std::sync::Arc;
17
+
18
+ pub(super) struct Chat {
19
+ pub title: String,
20
+ pub usage: Option<ThreadUsage>,
21
+ }
22
+
23
+ impl Chat {
24
+ /// Local history can span accounts; expose titles only with a returned account estimate.
25
+ pub(super) fn display_title(&self) -> &str {
26
+ if self.usage.is_some() {
27
+ &self.title
28
+ } else {
29
+ "Chat usage unavailable"
30
+ }
31
+ }
32
+ }
33
+
34
+ #[derive(Default)]
35
+ pub(super) struct Chats {
36
+ pub rows: Vec<Chat>,
37
+ }
38
+
39
+ pub(super) async fn read(
40
+ handle: AppServerRequestHandle,
41
+ live: Arc<Live>,
42
+ ) -> Result<Option<Chats>, String> {
43
+ let session = live.session().await?;
44
+ if !super::models::thread_usage_supported(session.backend.account().plan_type) {
45
+ return Ok(None);
46
+ }
47
+ session.backend.ensure_identity().await?;
48
+ let threads = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 60), roots(&handle))
49
+ .await
50
+ .map_err(|_| "Chat listing timed out. Press R to retry.".to_string())??;
51
+ session.backend.ensure_identity().await?;
52
+ if threads.is_empty() {
53
+ return Ok(Some(Chats { rows: Vec::new() }));
54
+ }
55
+ let ids = threads
56
+ .iter()
57
+ .map(|thread| thread.id.as_str())
58
+ .collect::<Vec<_>>();
59
+ // Repairing local history has its own budget; it must not consume estimate time.
60
+ let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 25);
61
+ let mut data = Vec::new();
62
+ for pair in ids.chunks(/*chunk_size*/ 200) {
63
+ let midpoint = pair.len().min(/*other*/ 100);
64
+ let (first, second) = tokio::join!(
65
+ tokio::time::timeout_at(deadline, estimates(session, &pair[..midpoint])),
66
+ tokio::time::timeout_at(deadline, estimates(session, &pair[midpoint..])),
67
+ );
68
+ for batch in [first, second].into_iter().flatten() {
69
+ data.extend(batch?);
70
+ }
71
+ if tokio::time::Instant::now() >= deadline {
72
+ break;
73
+ }
74
+ }
75
+ session.backend.ensure_identity().await?;
76
+ let mut usage: HashMap<_, _> = data
77
+ .into_iter()
78
+ .map(|row| (row.thread_id.clone(), row))
79
+ .collect();
80
+ let mut rows = threads
81
+ .into_iter()
82
+ .map(|thread| Chat {
83
+ usage: usage.remove(&thread.id),
84
+ title: thread
85
+ .name
86
+ .filter(|name| !name.trim().is_empty())
87
+ .unwrap_or_else(|| {
88
+ if thread.preview.trim().is_empty() {
89
+ "Untitled chat".into()
90
+ } else {
91
+ thread.preview
92
+ }
93
+ }),
94
+ })
95
+ .collect::<Vec<_>>();
96
+ rows.sort_by_key(|row| {
97
+ std::cmp::Reverse(
98
+ row.usage
99
+ .as_ref()
100
+ .map(|usage| usage.estimated_usage_credits_micros),
101
+ )
102
+ });
103
+ Ok(Some(Chats { rows }))
104
+ }
105
+
106
+ /// Recent local roots are shared by consumer task and business credit reporting.
107
+ pub(super) async fn roots(
108
+ handle: &AppServerRequestHandle,
109
+ ) -> Result<Vec<codex_app_server_protocol::Thread>, String> {
110
+ let cutoff = chrono::Utc::now().timestamp() - 30 * 24 * 60 * 60;
111
+ let mut threads = Vec::new();
112
+ let mut cursor = None;
113
+ let mut seen_cursors = HashSet::new();
114
+ loop {
115
+ let page: ThreadListResponse = handle
116
+ .request_typed(ClientRequest::ThreadList {
117
+ request_id: RequestId::String(uuid::Uuid::new_v4().to_string()),
118
+ params: ThreadListParams {
119
+ cursor,
120
+ ..list_params()
121
+ },
122
+ })
123
+ .await
124
+ .map_err(data::error)?;
125
+ let reached_cutoff = page.data.iter().any(|thread| thread.updated_at < cutoff);
126
+ threads.extend(page.data.into_iter().filter(|thread| {
127
+ thread.updated_at >= cutoff && !thread.ephemeral && thread.parent_thread_id.is_none()
128
+ }));
129
+ match page.next_cursor {
130
+ Some(next) if !seen_cursors.insert(next.clone()) => {
131
+ return Err("Chat listing repeated a cursor. Press R to retry.".into());
132
+ }
133
+ Some(next) if !reached_cutoff => cursor = Some(next),
134
+ _ => break,
135
+ }
136
+ }
137
+ // Keep repeated rows from overlapping pages out of the backend's distinct-ID batches.
138
+ let mut seen_ids = HashSet::new();
139
+ threads.retain(|thread| seen_ids.insert(thread.id.clone()));
140
+ Ok(threads)
141
+ }
142
+
143
+ pub(super) fn list_params() -> ThreadListParams {
144
+ ThreadListParams {
145
+ limit: Some(100),
146
+ sort_key: Some(ThreadSortKey::UpdatedAt),
147
+ sort_direction: Some(SortDirection::Desc),
148
+ model_providers: Some(Vec::new()),
149
+ source_kinds: None,
150
+ originators: None,
151
+ archived: Some(false),
152
+ cursor: None,
153
+ section_id: None,
154
+ project_id: None,
155
+ cwd: None,
156
+ use_state_db_only: false,
157
+ search_term: None,
158
+ parent_thread_id: None,
159
+ ancestor_thread_id: None,
160
+ }
161
+ }
162
+
163
+ /// Isolate unavailable estimates within the existing concurrency slot; keep healthy rows.
164
+ async fn estimates(session: &Session, ids: &[&str]) -> Result<Vec<ThreadUsage>, String> {
165
+ let mut pending = if ids.is_empty() {
166
+ Vec::new()
167
+ } else {
168
+ vec![ids]
169
+ };
170
+ let mut rows = Vec::new();
171
+ // At most 15 requests per batch; leave remaining estimates unavailable during an outage.
172
+ for _ in 0..15 {
173
+ let Some(ids) = pending.pop() else { break };
174
+ match session
175
+ .backend
176
+ .request(|client| async move { client.get_threads_usage(ids).await })
177
+ .await
178
+ {
179
+ Ok(batch) => rows.extend(batch),
180
+ Err(error) if error.status().is_some_and(|status| status.as_u16() == 503) => {
181
+ if ids.len() > 1 {
182
+ let midpoint = ids.len().div_ceil(/*rhs*/ 2);
183
+ pending.push(&ids[midpoint..]);
184
+ pending.push(&ids[..midpoint]);
185
+ }
186
+ }
187
+ Err(error) => return Err(super::client::request_error(error)),
188
+ }
189
+ }
190
+ Ok(rows)
191
+ }
192
+
193
+ #[cfg(test)]
194
+ #[path = "chats_tests.rs"]
195
+ mod tests;
codex-rs/tui/src/analytics/chats_tests.rs ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Verify bounded chat discovery, account eligibility, and partial estimate handling.
2
+
3
+ use super::super::client::tests::live;
4
+ use super::*;
5
+ use pretty_assertions::assert_eq;
6
+ use serde_json::json;
7
+ use wiremock::Mock;
8
+ use wiremock::MockServer;
9
+ use wiremock::ResponseTemplate;
10
+ use wiremock::matchers::method;
11
+ use wiremock::matchers::path;
12
+
13
+ #[tokio::test]
14
+ async fn incomplete_chat_batches_retain_available_estimates_without_inventing_zeroes() {
15
+ let server = MockServer::start().await;
16
+ let (_home, live) = live(&server, "enterprise_cbp_usage_based").await;
17
+ Mock::given(method("POST")).and(path("/backend-api/wham/usage/thread_usage/query"))
18
+ .respond_with(|request: &wiremock::Request| {
19
+ let body: serde_json::Value = request.body_json().unwrap();
20
+ let ids = body["thread_ids"].as_array().unwrap();
21
+ if ids.len() > 1 || ids[0] == "incomplete" {
22
+ ResponseTemplate::new(/*s*/ 503).set_body_json(json!({"detail": "Thread usage is temporarily unavailable."}))
23
+ } else {
24
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"threads": [{
25
+ "thread_id": ids[0], "estimated_usage_credits_micros": if ids[0] == "missing" { None } else { Some(1250000) },
26
+ "estimated_usage_usd_micros": null, "groups": null,
27
+ }]}))
28
+ }
29
+ }).expect(/*r*/ 5).mount(&server).await;
30
+ let session = live.session().await.unwrap();
31
+ assert_eq!(
32
+ estimates(session, &["available", "incomplete", "missing"])
33
+ .await
34
+ .unwrap(),
35
+ vec![ThreadUsage {
36
+ thread_id: "available".into(),
37
+ estimated_usage_credits_micros: 1250000,
38
+ estimated_usage_usd_micros: None,
39
+ groups: Vec::new(),
40
+ }]
41
+ );
42
+ }
43
+
44
+ #[tokio::test]
45
+ async fn slow_repairing_chat_listing_keeps_estimate_budget_and_ranks_across_pages() {
46
+ use codex_app_server_client::RemoteAppServerClient;
47
+ use codex_app_server_client::RemoteAppServerConnectArgs;
48
+ use codex_app_server_client::RemoteAppServerEndpoint;
49
+ use futures::SinkExt;
50
+ use futures::StreamExt;
51
+ use tokio_tungstenite::tungstenite::Message;
52
+
53
+ let server = MockServer::start().await;
54
+ let (_home, live) = live(&server, "enterprise_cbp_usage_based").await;
55
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
56
+ let websocket_url = format!("ws://{}", listener.local_addr().unwrap());
57
+ let rpc = tokio::spawn(async move {
58
+ let (stream, _) = listener.accept().await.unwrap();
59
+ let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
60
+ let mut pages = Vec::new();
61
+ while let Some(Ok(Message::Text(text))) = socket.next().await {
62
+ let request: serde_json::Value = serde_json::from_str(&text).unwrap();
63
+ let result = match request["method"].as_str() {
64
+ Some("initialize") => {
65
+ json!({"userAgent": "analytics-test", "codexHome": "/unused"})
66
+ }
67
+ Some("thread/list") => {
68
+ let params = &request["params"];
69
+ pages.push(json!([
70
+ params["cursor"],
71
+ params["limit"],
72
+ params["useStateDbOnly"].as_bool().unwrap_or_default(),
73
+ params["modelProviders"]
74
+ ]));
75
+ if pages.len() == 1 {
76
+ // Simulate repair exceeding the HTTP estimate budget without a wall-clock wait.
77
+ tokio::time::pause();
78
+ tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 26)).await;
79
+ tokio::time::resume();
80
+ }
81
+ let range = if pages.len() == 1 { 0..100 } else { 100..101 };
82
+ let data = range.map(|index| json!({
83
+ "id": format!("chat-{index}"), "sessionId": format!("chat-{index}"),
84
+ "preview": "", "name": format!("Chat {index}"), "ephemeral": false,
85
+ "modelProvider": "openai", "createdAt": 0,
86
+ "updatedAt": chrono::Utc::now().timestamp(), "status": {"type": "idle"},
87
+ "cwd": std::env::temp_dir(), "cliVersion": "test", "source": "cli", "turns": [],
88
+ })).collect::<Vec<_>>();
89
+ json!({"data": data, "nextCursor": if pages.len() == 1 { Some("next") } else { None }})
90
+ }
91
+ _ => continue,
92
+ };
93
+ socket
94
+ .send(Message::Text(
95
+ json!({"jsonrpc": "2.0", "id": request["id"], "result": result})
96
+ .to_string()
97
+ .into(),
98
+ ))
99
+ .await
100
+ .unwrap();
101
+ if pages.len() == 2 {
102
+ break;
103
+ }
104
+ }
105
+ pages
106
+ });
107
+ let remote = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
108
+ endpoint: RemoteAppServerEndpoint::WebSocket {
109
+ websocket_url,
110
+ auth_token: None,
111
+ },
112
+ client_name: "analytics-test".into(),
113
+ client_version: "test".into(),
114
+ experimental_api: false,
115
+ mcp_server_openai_form_elicitation: false,
116
+ opt_out_notification_methods: Vec::new(),
117
+ channel_capacity: 16,
118
+ })
119
+ .await
120
+ .unwrap();
121
+ Mock::given(method("POST")).and(path("/backend-api/wham/usage/thread_usage/query"))
122
+ .respond_with(|request: &wiremock::Request| {
123
+ let body: serde_json::Value = request.body_json().unwrap();
124
+ let ids = body["thread_ids"].as_array().unwrap();
125
+ assert!(ids.len() <= 100);
126
+ if ids.iter().any(|id| id == "chat-35") {
127
+ return ResponseTemplate::new(/*s*/ 503)
128
+ .set_body_json(json!({"detail": "Thread usage is temporarily unavailable."}));
129
+ }
130
+ let threads = ids.iter().map(|id| json!({
131
+ "thread_id": id,
132
+ "estimated_usage_credits_micros": id.as_str().unwrap().strip_prefix("chat-").unwrap().parse::<i64>().unwrap(),
133
+ })).collect::<Vec<_>>();
134
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"threads": threads}))
135
+ }).expect(/*r*/ 2..=16).mount(&server).await;
136
+ let chats = read(
137
+ AppServerRequestHandle::Remote(remote.request_handle()),
138
+ Arc::new(live),
139
+ )
140
+ .await
141
+ .unwrap()
142
+ .unwrap();
143
+ assert_eq!(
144
+ (
145
+ chats.rows.len(),
146
+ chats.rows[0].title.as_str(),
147
+ chats.rows.last().unwrap().title.as_str(),
148
+ chats.rows.last().unwrap().usage.clone()
149
+ ),
150
+ (101, "Chat 100", "Chat 35", None)
151
+ );
152
+ assert_eq!(
153
+ rpc.await.unwrap(),
154
+ vec![
155
+ json!([null, 100, false, []]),
156
+ json!(["next", 100, false, []])
157
+ ]
158
+ );
159
+ }
160
+
161
+ #[tokio::test]
162
+ async fn unavailable_chat_batches_bound_requests_during_an_outage() {
163
+ let server = MockServer::start().await;
164
+ let (_home, live) = live(&server, "enterprise_cbp_usage_based").await;
165
+ Mock::given(method("POST"))
166
+ .respond_with(ResponseTemplate::new(/*s*/ 503))
167
+ .expect(/*r*/ 15)
168
+ .mount(&server)
169
+ .await;
170
+ let ids = (0..100).map(|i| format!("chat-{i}")).collect::<Vec<_>>();
171
+ let ids = ids.iter().map(String::as_str).collect::<Vec<_>>();
172
+ assert_eq!(
173
+ estimates(live.session().await.unwrap(), &ids)
174
+ .await
175
+ .unwrap(),
176
+ Vec::<ThreadUsage>::new()
177
+ );
178
+ }
codex-rs/tui/src/analytics/client.rs ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Local account report loading with backend-owned authentication and identity checks.
2
+ //! Cached responses belong to one local account and user, independently of the connected server.
3
+ //! Every report uses the view's captured end date until refresh replaces the session.
4
+
5
+ use super::models::AccountAnalyticsGrouping as Grouping;
6
+ use super::models::AccountAnalyticsReport as Report;
7
+ use super::models::AccountKind;
8
+ use super::report_data::AnalyticsData;
9
+ use crate::legacy_core::config::Config;
10
+ use codex_backend_client::AnalyticsReport;
11
+ use codex_backend_client::AnalyticsSession;
12
+ use codex_backend_client::RequestError;
13
+ use codex_protocol::account::PlanType;
14
+ use std::collections::HashMap;
15
+ use std::sync::Arc;
16
+ use tokio::sync::Mutex;
17
+ use tokio::sync::OnceCell;
18
+
19
+ pub(super) struct Live {
20
+ config: Arc<Config>,
21
+ end_date: chrono::NaiveDate,
22
+ session: OnceCell<Session>,
23
+ token_models: std::sync::RwLock<Vec<String>>,
24
+ attributed_usage: std::sync::atomic::AtomicBool,
25
+ }
26
+
27
+ pub(super) struct Session {
28
+ pub(super) kind: AccountKind,
29
+ pub(super) backend: AnalyticsSession,
30
+ credit_groups: Vec<usize>,
31
+ cache: Mutex<HashMap<(AnalyticsReport, String, String), AnalyticsData>>,
32
+ }
33
+
34
+ impl Live {
35
+ pub(super) fn new(config: Arc<Config>, end_date: chrono::NaiveDate) -> Self {
36
+ Self {
37
+ config,
38
+ end_date,
39
+ session: OnceCell::new(),
40
+ token_models: std::sync::RwLock::new(Vec::new()),
41
+ attributed_usage: std::sync::atomic::AtomicBool::new(/*v*/ true),
42
+ }
43
+ }
44
+
45
+ pub(super) fn account_label(&self) -> Option<String> {
46
+ let session = self.session.get()?;
47
+ session.backend.account().email.clone()
48
+ }
49
+
50
+ pub(super) fn credit_groups(&self) -> &[usize] {
51
+ self.session
52
+ .get()
53
+ .map_or(&[0], |session| &session.credit_groups)
54
+ }
55
+
56
+ pub(super) fn attributed_usage(&self) -> bool {
57
+ self.attributed_usage
58
+ .load(std::sync::atomic::Ordering::Relaxed)
59
+ }
60
+
61
+ pub(super) fn token_models(&self) -> Vec<String> {
62
+ self.token_models
63
+ .read()
64
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
65
+ .clone()
66
+ }
67
+
68
+ pub(super) async fn session(&self) -> Result<&Session, String> {
69
+ self.session
70
+ .get_or_try_init(|| async {
71
+ let session = AnalyticsSession::from_config(
72
+ self.config.as_ref(),
73
+ self.config.http_client_factory(),
74
+ )
75
+ .await?;
76
+ let credit_groups = Grouping::credit_groupings(session.account().plan_type)
77
+ .iter()
78
+ .filter_map(|group| {
79
+ super::data::GROUPINGS
80
+ .iter()
81
+ .position(|candidate| candidate == group)
82
+ })
83
+ .collect();
84
+ Ok(Session {
85
+ kind: AccountKind::from(session.account().plan_type),
86
+ credit_groups,
87
+ backend: session,
88
+ cache: Mutex::new(HashMap::new()),
89
+ })
90
+ })
91
+ .await
92
+ }
93
+
94
+ pub(super) async fn plan_history(&self) -> Result<Option<super::plan::Report>, String> {
95
+ let session = self.session().await?;
96
+ if session.kind != AccountKind::Consumer {
97
+ return Ok(None);
98
+ }
99
+ let history = session
100
+ .backend
101
+ .request(|client| async move { client.get_plan_limit_history().await })
102
+ .await;
103
+ session.backend.ensure_identity().await?;
104
+ history
105
+ .map_err(request_error)?
106
+ .map(super::plan::Report::parse)
107
+ .transpose()
108
+ .map(Option::flatten)
109
+ }
110
+
111
+ pub(super) async fn history(
112
+ &self,
113
+ report: Report,
114
+ days: u32,
115
+ grouping: Grouping,
116
+ ) -> Result<Option<super::models::AccountAnalyticsHistory>, String> {
117
+ self.filtered_history(report, days, grouping, /*model_filter*/ None)
118
+ .await
119
+ }
120
+
121
+ pub(super) async fn filtered_history(
122
+ &self,
123
+ report: Report,
124
+ days: u32,
125
+ grouping: Grouping,
126
+ model_filter: Option<&str>,
127
+ ) -> Result<Option<super::models::AccountAnalyticsHistory>, String> {
128
+ let end = self.end_date;
129
+ let start = days
130
+ .checked_sub(/*rhs*/ 1)
131
+ .and_then(|offset| end.checked_sub_days(chrono::Days::new(u64::from(offset))))
132
+ .ok_or("Invalid analytics date range.")?;
133
+ let session = self.session().await?;
134
+ session.backend.ensure_identity().await?;
135
+ let enterprise_tokens = report == Report::Usage
136
+ && matches!(
137
+ session.kind,
138
+ AccountKind::Enterprise | AccountKind::Business
139
+ );
140
+ let grouping =
141
+ if enterprise_tokens && !matches!(grouping, Grouping::Model | Grouping::TokenType) {
142
+ Grouping::TokenType
143
+ } else {
144
+ grouping
145
+ };
146
+ let route =
147
+ route(report, grouping, session.backend.account().plan_type).ok_or_else(|| {
148
+ "This credit breakdown is not supported for this account type.".to_string()
149
+ })?;
150
+ // Credit events have no range parameters. Other grouping changes reuse the same payload.
151
+ let key = if route == AnalyticsReport::Credits {
152
+ (route, String::new(), String::new())
153
+ } else {
154
+ (route, start.to_string(), end.to_string())
155
+ };
156
+ let cached = session.cache.lock().await.get(&key).cloned();
157
+ let response = if let Some(response) = cached {
158
+ Ok(response)
159
+ } else {
160
+ session
161
+ .backend
162
+ .request(|client| {
163
+ let (_, start, end) = key.clone();
164
+ async move { client.get_account_analytics(route, &start, &end).await }
165
+ })
166
+ .await
167
+ .map(AnalyticsData::from)
168
+ };
169
+ session.backend.ensure_identity().await?;
170
+ let response = response.map_err(request_error)?;
171
+ let history = (|| {
172
+ if enterprise_tokens {
173
+ let models = super::tokens::history(response.clone(), Grouping::Model, start, end)?;
174
+ *self
175
+ .token_models
176
+ .write()
177
+ .unwrap_or_else(std::sync::PoisonError::into_inner) =
178
+ super::render::categories(&models)
179
+ .into_iter()
180
+ .map(|model| model.key)
181
+ .collect();
182
+ super::tokens::filtered_history(
183
+ response.clone(),
184
+ grouping,
185
+ start,
186
+ end,
187
+ model_filter,
188
+ )
189
+ .map(Some)
190
+ } else {
191
+ let grouping = if report == Report::Usage {
192
+ let attributed =
193
+ super::normalize::has_complete_attribution(&response, start, end)?;
194
+ self.attributed_usage
195
+ .store(attributed, std::sync::atomic::Ordering::Relaxed);
196
+ if !attributed && matches!(grouping, Grouping::Feature | Grouping::TaskStart) {
197
+ Grouping::Surface
198
+ } else {
199
+ grouping
200
+ }
201
+ } else {
202
+ grouping
203
+ };
204
+ super::normalize::history(response.clone(), report, grouping, start, end)
205
+ }
206
+ })();
207
+ let mut cache = session.cache.lock().await;
208
+ if history.is_ok() {
209
+ cache.insert(key, response);
210
+ } else {
211
+ cache.remove(&key);
212
+ }
213
+ history
214
+ }
215
+ }
216
+
217
+ // Account type selects billing semantics, never activity-report eligibility.
218
+ fn route(report: Report, grouping: Grouping, plan: Option<PlanType>) -> Option<AnalyticsReport> {
219
+ let kind = AccountKind::from(plan);
220
+ let enterprise = kind == AccountKind::Enterprise;
221
+ Some(match report {
222
+ Report::Usage if enterprise => AnalyticsReport::EnterpriseTokens,
223
+ Report::Usage if kind == AccountKind::Business => AnalyticsReport::WorkspaceCredits,
224
+ Report::Usage => AnalyticsReport::Usage,
225
+ Report::Messages => AnalyticsReport::Messages,
226
+ Report::Credits if !Grouping::credit_groupings(plan).contains(&grouping) => return None,
227
+ Report::Credits if enterprise => AnalyticsReport::EnterpriseCredits {
228
+ breakdown: match grouping {
229
+ Grouping::Surface => "product",
230
+ Grouping::Model => "model",
231
+ Grouping::Speed => "speed",
232
+ Grouping::Reasoning => "reasoning_effort",
233
+ Grouping::Feature | Grouping::TaskStart | Grouping::TokenType => return None,
234
+ },
235
+ },
236
+ Report::Credits if kind == AccountKind::Business => AnalyticsReport::WorkspaceCredits,
237
+ Report::Credits => AnalyticsReport::Credits,
238
+ Report::Plugins => AnalyticsReport::Plugins {
239
+ limit: if enterprise { 8 } else { 10 },
240
+ },
241
+ Report::Skills => AnalyticsReport::Skills {
242
+ limit: if enterprise { 6 } else { 10 },
243
+ },
244
+ })
245
+ }
246
+
247
+ pub(super) fn request_error(error: RequestError) -> String {
248
+ match error.status().map(|status| status.as_u16()) {
249
+ Some(401) => "Sign in again to load this report.",
250
+ Some(403) => "Access denied for this report.",
251
+ Some(404) => "This report endpoint is unavailable. Press R to retry.",
252
+ _ => "Report request failed. Press R to retry.",
253
+ }
254
+ .into()
255
+ }
256
+
257
+ #[cfg(test)]
258
+ #[path = "client_tests.rs"]
259
+ pub(super) mod tests;
260
+
261
+ #[cfg(test)]
262
+ #[path = "connection_tests.rs"]
263
+ mod connection_tests;
264
+
265
+ #[cfg(test)]
266
+ #[path = "account_plan_tests.rs"]
267
+ mod account_plan_tests;
codex-rs/tui/src/analytics/client_tests.rs ADDED
@@ -0,0 +1,851 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Account-scoped authentication and request identity regression coverage.
2
+
3
+ use super::*;
4
+ use crate::analytics::sections::Section;
5
+ use crate::legacy_core::config::ConfigBuilder;
6
+ use base64::Engine;
7
+ use codex_config::LoaderOverrides;
8
+ use pretty_assertions::assert_eq;
9
+ use serde_json::json;
10
+ use wiremock::Mock;
11
+ use wiremock::MockServer;
12
+ use wiremock::ResponseTemplate;
13
+ use wiremock::matchers::header;
14
+ use wiremock::matchers::method;
15
+ use wiremock::matchers::path;
16
+
17
+ pub(super) fn sign_in(home: &std::path::Path, account: &str, user: &str, plan: &str) {
18
+ let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
19
+ json!({
20
+ "exp": 4102444800_i64, "email": "analytics@example.test",
21
+ "https://api.openai.com/auth": {
22
+ "chatgpt_account_id": account, "chatgpt_user_id": user, "chatgpt_plan_type": plan,
23
+ },
24
+ })
25
+ .to_string(),
26
+ );
27
+ let token = format!("e30.{claims}.test");
28
+ let auth = serde_json::from_value(json!({
29
+ "auth_mode": "chatgpt", "tokens": {"id_token": token, "access_token": token,
30
+ "refresh_token": "test-refresh", "account_id": account},
31
+ "last_refresh": chrono::Utc::now(),
32
+ }))
33
+ .unwrap();
34
+ codex_login::save_auth(
35
+ home,
36
+ &auth,
37
+ codex_login::AuthCredentialsStoreMode::File,
38
+ codex_login::AuthKeyringBackendKind::default(),
39
+ )
40
+ .unwrap();
41
+ }
42
+
43
+ pub(in crate::analytics) async fn live(
44
+ server: &MockServer,
45
+ plan: &str,
46
+ ) -> (tempfile::TempDir, Live) {
47
+ let home = tempfile::tempdir().unwrap();
48
+ let mut config = ConfigBuilder::default()
49
+ .codex_home(home.path().to_path_buf())
50
+ .loader_overrides(LoaderOverrides::without_managed_config_for_tests())
51
+ .build()
52
+ .await
53
+ .unwrap();
54
+ config.chatgpt_base_url = format!("{}/backend-api", server.uri());
55
+ config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File;
56
+ sign_in(home.path(), "account-a", "user-a", plan);
57
+ Mock::given(method("GET"))
58
+ .and(path("/backend-api/wham/accounts/check"))
59
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
60
+ "accounts": [{"id": "account-a", "plan_type": plan}],
61
+ "account_ordering": ["account-a"]
62
+ })))
63
+ .with_priority(/*p*/ 2)
64
+ .mount(server)
65
+ .await;
66
+ (
67
+ home,
68
+ Live::new(Arc::new(config), "2026-01-15".parse().unwrap()),
69
+ )
70
+ }
71
+
72
+ async fn report_requests(server: &MockServer) -> Vec<wiremock::Request> {
73
+ server
74
+ .received_requests()
75
+ .await
76
+ .unwrap()
77
+ .into_iter()
78
+ .filter(|request| request.url.path() != "/backend-api/wham/accounts/check")
79
+ .collect()
80
+ }
81
+
82
+ #[tokio::test]
83
+ async fn analytics_rejects_identity_changes_during_requests() {
84
+ let server = MockServer::start().await;
85
+ let (home, live) = live(&server, "business").await;
86
+ let session = live.session().await.unwrap();
87
+ let authenticated = &session.backend;
88
+ let result = authenticated
89
+ .request(|_| async {
90
+ sign_in(home.path(), "account-b", "user-a", "business");
91
+ Ok(123)
92
+ })
93
+ .await;
94
+ assert_eq!(
95
+ result.unwrap_err().to_string(),
96
+ "Account changed. Press R to refresh Analytics."
97
+ );
98
+ assert!(report_requests(&server).await.is_empty());
99
+ }
100
+
101
+ #[tokio::test]
102
+ async fn analytics_rejects_account_and_user_changes_before_requests() {
103
+ for (account, user) in [("account-b", "user-a"), ("account-a", "user-b")] {
104
+ let server = MockServer::start().await;
105
+ let (home, live) = live(&server, "plus").await;
106
+ let session = live.session().await.unwrap();
107
+ let authenticated = &session.backend;
108
+ sign_in(home.path(), account, user, "plus");
109
+ let requested = std::cell::Cell::new(/*value*/ false);
110
+ let result = authenticated
111
+ .request(|_| async {
112
+ requested.set(/*val*/ true);
113
+ Ok(())
114
+ })
115
+ .await;
116
+ assert!(!requested.get());
117
+ assert_eq!(
118
+ result.unwrap_err().to_string(),
119
+ "Account changed. Press R to refresh Analytics."
120
+ );
121
+ }
122
+ }
123
+
124
+ #[tokio::test]
125
+ async fn analytics_requires_local_chatgpt_authentication() {
126
+ for auth in [None, Some(json!({"OPENAI_API_KEY": "sk-test-only"}))] {
127
+ let server = MockServer::start().await;
128
+ let (home, live) = live(&server, "plus").await;
129
+ let path = home.path().join("auth.json");
130
+ match auth {
131
+ Some(auth) => std::fs::write(path, serde_json::to_vec(&auth).unwrap()).unwrap(),
132
+ None => std::fs::remove_file(path).unwrap(),
133
+ }
134
+ assert_eq!(
135
+ live.session().await.err(),
136
+ Some("Sign in locally with ChatGPT to view Analytics.".to_string())
137
+ );
138
+ assert!(report_requests(&server).await.is_empty());
139
+ }
140
+ }
141
+
142
+ #[tokio::test]
143
+ async fn analytics_requests_use_reloaded_credentials_for_the_same_identity() {
144
+ let server = MockServer::start().await;
145
+ let (home, live) = live(&server, "plus").await;
146
+ let session = live.session().await.unwrap();
147
+ let authenticated = &session.backend;
148
+ assert_eq!(
149
+ live.account_label().as_deref(),
150
+ Some("analytics@example.test")
151
+ );
152
+ let auth_path = home.path().join("auth.json");
153
+ let mut auth: serde_json::Value =
154
+ serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap();
155
+ auth["tokens"]["access_token"] = json!("refreshed-access-token");
156
+ std::fs::write(auth_path, serde_json::to_vec(&auth).unwrap()).unwrap();
157
+ Mock::given(method("GET"))
158
+ .and(path("/backend-api/wham/usage/daily-token-usage-breakdown"))
159
+ .and(header("chatgpt-account-id", "account-a"))
160
+ .and(header("authorization", "Bearer refreshed-access-token"))
161
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []})))
162
+ .expect(/*r*/ 1)
163
+ .mount(&server)
164
+ .await;
165
+ let result = authenticated
166
+ .request(|client| async move {
167
+ client
168
+ .get_account_analytics(
169
+ codex_backend_client::AnalyticsReport::Usage,
170
+ "2026-09-01",
171
+ "2026-09-07",
172
+ )
173
+ .await
174
+ })
175
+ .await
176
+ .unwrap();
177
+ assert_eq!(
178
+ result,
179
+ codex_backend_client::AnalyticsResponse::Usage(
180
+ codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default()
181
+ )
182
+ );
183
+ server.verify().await;
184
+ }
185
+
186
+ #[tokio::test]
187
+ async fn analytics_retries_unauthorized_requests_after_credentials_reload() {
188
+ let server = MockServer::start().await;
189
+ let (home, live) = live(&server, "plus").await;
190
+ let session = live.session().await.unwrap();
191
+ let authenticated = &session.backend;
192
+ let auth_path = home.path().join("auth.json");
193
+ let mut auth: serde_json::Value =
194
+ serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap();
195
+ let original_token = auth["tokens"]["access_token"].as_str().unwrap().to_owned();
196
+ auth["tokens"]["access_token"] = json!("recovered-access-token");
197
+ let updated_auth = serde_json::to_vec(&auth).unwrap();
198
+ Mock::given(method("GET"))
199
+ .and(header("authorization", format!("Bearer {original_token}")))
200
+ .respond_with(move |_: &wiremock::Request| {
201
+ std::fs::write(&auth_path, &updated_auth).unwrap();
202
+ ResponseTemplate::new(/*s*/ 401)
203
+ })
204
+ .expect(/*r*/ 1)
205
+ .mount(&server)
206
+ .await;
207
+ Mock::given(method("GET"))
208
+ .and(header("authorization", "Bearer recovered-access-token"))
209
+ .and(header("chatgpt-account-id", "account-a"))
210
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []})))
211
+ .expect(/*r*/ 1)
212
+ .mount(&server)
213
+ .await;
214
+ let result = authenticated
215
+ .request(|client| async move {
216
+ client
217
+ .get_account_analytics(
218
+ codex_backend_client::AnalyticsReport::Usage,
219
+ "2026-09-01",
220
+ "2026-09-07",
221
+ )
222
+ .await
223
+ })
224
+ .await
225
+ .unwrap();
226
+ assert_eq!(
227
+ result,
228
+ codex_backend_client::AnalyticsResponse::Usage(
229
+ codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default()
230
+ )
231
+ );
232
+ server.verify().await;
233
+ }
234
+
235
+ #[tokio::test]
236
+ async fn analytics_retries_session_initialization_after_sign_in() {
237
+ let server = MockServer::start().await;
238
+ let (home, live) = live(&server, "plus").await;
239
+ std::fs::remove_file(home.path().join("auth.json")).unwrap();
240
+ assert!(live.session().await.is_err());
241
+ sign_in(home.path(), "account-a", "user-a", "plus");
242
+ assert_eq!(
243
+ live.session().await.unwrap().backend.account().id,
244
+ "account-a"
245
+ );
246
+ }
247
+
248
+ #[tokio::test]
249
+ async fn analytics_matches_app_account_routes_and_parameters() {
250
+ let server = MockServer::start().await;
251
+ for (plan, report, grouping, endpoint, extra) in [
252
+ (
253
+ "plus",
254
+ Report::Usage,
255
+ Grouping::Model,
256
+ "usage/daily-token-usage-breakdown",
257
+ vec![("group_by", "day")],
258
+ ),
259
+ (
260
+ "business",
261
+ Report::Usage,
262
+ Grouping::TokenType,
263
+ "usage/daily-workspace-user-token-usage-breakdown",
264
+ vec![
265
+ ("group_by", "day"),
266
+ ("breakdown_by", "model"),
267
+ ("modes", "codex"),
268
+ ("modes", "work"),
269
+ ],
270
+ ),
271
+ (
272
+ "business",
273
+ Report::Messages,
274
+ Grouping::Model,
275
+ "analytics/daily-workspace-usage-counts",
276
+ vec![("group_by", "day"), ("workspace_user", "true")],
277
+ ),
278
+ (
279
+ "unknown",
280
+ Report::Usage,
281
+ Grouping::Surface,
282
+ "usage/daily-token-usage-breakdown",
283
+ vec![("group_by", "day")],
284
+ ),
285
+ (
286
+ "unknown",
287
+ Report::Messages,
288
+ Grouping::Model,
289
+ "analytics/daily-workspace-usage-counts",
290
+ vec![("group_by", "day"), ("workspace_user", "true")],
291
+ ),
292
+ (
293
+ "unknown",
294
+ Report::Plugins,
295
+ Grouping::Feature,
296
+ "analytics/daily-plugin-usage-metrics",
297
+ vec![
298
+ ("group_by", "day"),
299
+ ("workspace_user", "true"),
300
+ ("top_plugin_limit", "10"),
301
+ ],
302
+ ),
303
+ (
304
+ "unknown",
305
+ Report::Skills,
306
+ Grouping::Feature,
307
+ "analytics/daily-skill-usage-metrics",
308
+ vec![
309
+ ("group_by", "day"),
310
+ ("workspace_user", "true"),
311
+ ("top_skill_limit", "10"),
312
+ ],
313
+ ),
314
+ (
315
+ "edu",
316
+ Report::Credits,
317
+ Grouping::Model,
318
+ "usage/daily-workspace-user-credit-usage",
319
+ vec![("breakdown", "model")],
320
+ ),
321
+ (
322
+ "plus",
323
+ Report::Credits,
324
+ Grouping::Surface,
325
+ "usage/credit-usage-events",
326
+ vec![],
327
+ ),
328
+ (
329
+ "team",
330
+ Report::Usage,
331
+ Grouping::Model,
332
+ "usage/daily-workspace-user-token-usage-breakdown",
333
+ vec![("group_by", "day")],
334
+ ),
335
+ (
336
+ "team",
337
+ Report::Credits,
338
+ Grouping::Speed,
339
+ "usage/daily-workspace-user-token-usage-breakdown",
340
+ vec![("group_by", "day")],
341
+ ),
342
+ (
343
+ "business",
344
+ Report::Credits,
345
+ Grouping::Reasoning,
346
+ "usage/daily-workspace-user-credit-usage",
347
+ vec![("breakdown", "reasoning_effort")],
348
+ ),
349
+ (
350
+ "plus",
351
+ Report::Messages,
352
+ Grouping::Model,
353
+ "analytics/daily-workspace-usage-counts",
354
+ vec![("group_by", "day"), ("workspace_user", "true")],
355
+ ),
356
+ (
357
+ "plus",
358
+ Report::Plugins,
359
+ Grouping::Feature,
360
+ "analytics/daily-plugin-usage-metrics",
361
+ vec![
362
+ ("group_by", "day"),
363
+ ("workspace_user", "true"),
364
+ ("top_plugin_limit", "10"),
365
+ ],
366
+ ),
367
+ (
368
+ "business",
369
+ Report::Plugins,
370
+ Grouping::Feature,
371
+ "analytics/daily-plugin-usage-metrics",
372
+ vec![
373
+ ("group_by", "day"),
374
+ ("workspace_user", "true"),
375
+ ("top_plugin_limit", "8"),
376
+ ],
377
+ ),
378
+ (
379
+ "business",
380
+ Report::Skills,
381
+ Grouping::Feature,
382
+ "analytics/daily-skill-usage-metrics",
383
+ vec![
384
+ ("group_by", "day"),
385
+ ("workspace_user", "true"),
386
+ ("top_skill_limit", "6"),
387
+ ],
388
+ ),
389
+ ] {
390
+ server.reset().await;
391
+ let (_home, live) = live(&server, plan).await;
392
+ let today = live.end_date;
393
+ let start = (today - chrono::Days::new(/*num*/ 6)).to_string();
394
+ let response = if endpoint == "usage/daily-workspace-user-credit-usage" {
395
+ let breakdown = extra.iter().find(|(key, _)| *key == "breakdown").unwrap().1;
396
+ json!({"data": [], "series": [], "breakdown": breakdown})
397
+ } else {
398
+ json!({"data": []})
399
+ };
400
+ Mock::given(method("GET"))
401
+ .and(path(format!("/backend-api/wham/{endpoint}")))
402
+ .and(header("chatgpt-account-id", "account-a"))
403
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(response))
404
+ .expect(/*r*/ 1)
405
+ .mount(&server)
406
+ .await;
407
+ assert!(
408
+ live.history(report, /*days*/ 7, grouping)
409
+ .await
410
+ .unwrap()
411
+ .is_some()
412
+ );
413
+ let requests = report_requests(&server).await;
414
+ let mut actual = requests[0]
415
+ .url
416
+ .query_pairs()
417
+ .map(|(key, value)| (key.into_owned(), value.into_owned()))
418
+ .collect::<Vec<_>>();
419
+ let mut expected = extra
420
+ .into_iter()
421
+ .map(|(key, value)| (key.to_string(), value.to_string()))
422
+ .collect::<Vec<_>>();
423
+ if endpoint != "usage/credit-usage-events" {
424
+ expected.push(("start_date".into(), start.clone()));
425
+ expected.push(("end_date".into(), today.to_string()));
426
+ }
427
+ actual.sort();
428
+ expected.sort();
429
+ assert_eq!(actual, expected);
430
+ assert!(requests[0].headers.get("authorization").is_some());
431
+ }
432
+ }
433
+
434
+ #[tokio::test]
435
+ async fn analytics_uses_api_response_for_usage_and_turns_availability() {
436
+ let server = MockServer::start().await;
437
+ for report in [Report::Usage, Report::Messages] {
438
+ for status in [403, 404] {
439
+ server.reset().await;
440
+ let (_home, live) = live(&server, "business").await;
441
+ Mock::given(method("GET"))
442
+ .respond_with(ResponseTemplate::new(status))
443
+ .expect(/*r*/ 1)
444
+ .mount(&server)
445
+ .await;
446
+ assert_eq!(
447
+ live.history(report, /*days*/ 7, Grouping::Surface)
448
+ .await
449
+ .unwrap_err(),
450
+ if status == 403 {
451
+ "Access denied for this report."
452
+ } else {
453
+ "This report endpoint is unavailable. Press R to retry."
454
+ }
455
+ );
456
+ server.verify().await;
457
+ }
458
+ }
459
+ }
460
+
461
+ #[tokio::test]
462
+ async fn analytics_reuses_payload_for_grouping_and_rejects_account_changes() {
463
+ let server = MockServer::start().await;
464
+ let (home, live) = live(&server, "team").await;
465
+ let today = live.end_date.to_string();
466
+ Mock::given(method("GET"))
467
+ .and(path(
468
+ "/backend-api/wham/usage/daily-workspace-user-token-usage-breakdown",
469
+ ))
470
+ .respond_with(
471
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [{
472
+ "date": today, "product_surface_usage_values": {}, "premium_usage_values": {
473
+ "credit_usage_credits": {"cli": 12.5}, "total_usage_credits": {},
474
+ "uncached_text_input_tokens_by_surface": {}, "cached_text_input_tokens_by_surface": {},
475
+ "text_output_tokens_by_surface": {}, "text_total_tokens_by_surface": {}
476
+ },
477
+ "models": [{"model": "model-a", "credits": 12.5, "speed": "fast", "on_demand_credits": 12.5}]
478
+ }]})),
479
+ )
480
+ .expect(/*r*/ 1)
481
+ .mount(&server)
482
+ .await;
483
+ let surface = live
484
+ .history(Report::Credits, /*days*/ 7, Grouping::Surface)
485
+ .await
486
+ .unwrap()
487
+ .unwrap();
488
+ let model = live
489
+ .history(Report::Credits, /*days*/ 7, Grouping::Model)
490
+ .await
491
+ .unwrap()
492
+ .unwrap();
493
+ assert_eq!(
494
+ (
495
+ surface.data.last().unwrap().total,
496
+ model.data.last().unwrap().total
497
+ ),
498
+ (12.5, 12.5)
499
+ );
500
+ sign_in(home.path(), "account-a", "user-b", "team");
501
+ assert_eq!(
502
+ live.history(Report::Credits, /*days*/ 7, Grouping::Model)
503
+ .await
504
+ .unwrap_err(),
505
+ "Account changed. Press R to refresh Analytics."
506
+ );
507
+ }
508
+
509
+ #[tokio::test]
510
+ async fn token_model_filter_reuses_the_account_scoped_response() {
511
+ let server = MockServer::start().await;
512
+ let (_home, live) = live(&server, "business").await;
513
+ let today = live.end_date.to_string();
514
+ Mock::given(method("GET"))
515
+ .and(path("/backend-api/wham/usage/daily-workspace-user-token-usage-breakdown"))
516
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data":[{
517
+ "date":today, "product_surface_usage_values": {}, "groups":[
518
+ {"dimensions":{"model":"alpha"}, "credits": 0, "uncached_text_input_tokens":10,"cached_text_input_tokens":20,"text_output_tokens":30},
519
+ {"dimensions":{"model":"beta"}, "credits": 0, "uncached_text_input_tokens":100,"cached_text_input_tokens":200,"text_output_tokens":300}
520
+ ]
521
+ }]})))
522
+ .expect(/*r*/ 1).mount(&server).await;
523
+ let all = live
524
+ .history(Report::Usage, /*days*/ 7, Grouping::TokenType)
525
+ .await
526
+ .unwrap()
527
+ .unwrap();
528
+ let alpha = live
529
+ .filtered_history(
530
+ Report::Usage,
531
+ /*days*/ 7,
532
+ Grouping::TokenType,
533
+ Some("alpha"),
534
+ )
535
+ .await
536
+ .unwrap()
537
+ .unwrap();
538
+ let beta = live
539
+ .filtered_history(
540
+ Report::Usage,
541
+ /*days*/ 7,
542
+ Grouping::TokenType,
543
+ Some("beta"),
544
+ )
545
+ .await
546
+ .unwrap()
547
+ .unwrap();
548
+ assert_eq!(
549
+ (all.data[0].total, alpha.data[0].total, beta.data[0].total),
550
+ (660.0, 60.0, 600.0)
551
+ );
552
+ assert_eq!(
553
+ live.token_models(),
554
+ vec!["beta".to_string(), "alpha".to_string()]
555
+ );
556
+ assert_eq!(report_requests(&server).await.len(), 1);
557
+ }
558
+
559
+ #[tokio::test]
560
+ async fn legacy_usage_falls_back_to_surface_for_attribution_only_groupings() {
561
+ let server = MockServer::start().await;
562
+ let (_home, live) = live(&server, "plus").await;
563
+ let today = live.end_date.to_string();
564
+ Mock::given(method("GET"))
565
+ .respond_with(
566
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [{
567
+ "date": today, "product_surface_usage_values": {"cli": 12.5}
568
+ }]})),
569
+ )
570
+ .expect(/*r*/ 1)
571
+ .mount(&server)
572
+ .await;
573
+ let surface = live
574
+ .history(Report::Usage, /*days*/ 7, Grouping::Surface)
575
+ .await
576
+ .unwrap();
577
+ for grouping in [Grouping::Feature, Grouping::TaskStart] {
578
+ let history = live
579
+ .history(Report::Usage, /*days*/ 7, grouping)
580
+ .await
581
+ .unwrap();
582
+ assert_eq!((live.attributed_usage(), history), (false, surface.clone()));
583
+ }
584
+ server.verify().await;
585
+ }
586
+
587
+ #[tokio::test]
588
+ async fn invalid_ranges_fail_without_requesting_reports() {
589
+ let server = MockServer::start().await;
590
+ let (_home, live) = live(&server, "plus").await;
591
+ for days in [0, u32::MAX] {
592
+ assert_eq!(
593
+ live.history(Report::Usage, days, Grouping::Surface)
594
+ .await
595
+ .unwrap_err(),
596
+ "Invalid analytics date range."
597
+ );
598
+ }
599
+ assert!(report_requests(&server).await.is_empty());
600
+ }
601
+
602
+ #[tokio::test]
603
+ async fn unknown_plan_exposes_no_credit_breakdowns() {
604
+ let server = MockServer::start().await;
605
+ let (_home, live) = live(&server, "unknown").await;
606
+ live.session().await.unwrap();
607
+ assert!(live.credit_groups().is_empty());
608
+ assert!(
609
+ live.history(Report::Credits, /*days*/ 7, Grouping::Surface)
610
+ .await
611
+ .is_err()
612
+ );
613
+ assert!(report_requests(&server).await.is_empty());
614
+ }
615
+
616
+ #[tokio::test]
617
+ async fn in_flight_account_switch_retains_refresh_guidance() {
618
+ let server = MockServer::start().await;
619
+ let (home, live) = live(&server, "plus").await;
620
+ let home_path = home.path().to_path_buf();
621
+ Mock::given(method("GET"))
622
+ .respond_with(move |_: &wiremock::Request| {
623
+ sign_in(&home_path, "account-b", "user-a", "plus");
624
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []}))
625
+ })
626
+ .expect(/*r*/ 1)
627
+ .mount(&server)
628
+ .await;
629
+ assert_eq!(
630
+ live.history(Report::Usage, /*days*/ 7, Grouping::Surface)
631
+ .await
632
+ .unwrap_err(),
633
+ "Account changed. Press R to refresh Analytics."
634
+ );
635
+ server.verify().await;
636
+ }
637
+
638
+ #[tokio::test]
639
+ async fn missing_breakdowns_remain_unavailable() {
640
+ let server = MockServer::start().await;
641
+ let (_home, live) = live(&server, "plus").await;
642
+ let today = live.end_date.to_string();
643
+ Mock::given(method("GET"))
644
+ .respond_with(
645
+ ResponseTemplate::new(/*s*/ 200).set_body_json(
646
+ json!({"data": [{"date": today, "product_surface_usage_values": {}}]}),
647
+ ),
648
+ )
649
+ .expect(/*r*/ 1)
650
+ .mount(&server)
651
+ .await;
652
+ assert_eq!(
653
+ live.history(Report::Usage, /*days*/ 7, Grouping::Model)
654
+ .await,
655
+ Ok(None)
656
+ );
657
+ server.verify().await;
658
+ }
659
+
660
+ #[tokio::test]
661
+ async fn out_of_range_legacy_rows_do_not_disable_attribution() {
662
+ let server = MockServer::start().await;
663
+ let (_home, live) = live(&server, "plus").await;
664
+ let today = live.end_date.to_string();
665
+ Mock::given(method("GET"))
666
+ .respond_with(
667
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [
668
+ {"date": "2000-01-01", "product_surface_usage_values": {}},
669
+ {"date": today, "product_surface_usage_values": {}, "attribution": [{"thread_source": "user", "turn_trigger": "composer", "model": "example", "surface": "cli", "value": 12.0}]}
670
+ ]})),
671
+ )
672
+ .expect(/*r*/ 1)
673
+ .mount(&server)
674
+ .await;
675
+ let history = live
676
+ .history(Report::Usage, /*days*/ 7, Grouping::Feature)
677
+ .await
678
+ .unwrap()
679
+ .unwrap();
680
+ assert_eq!(
681
+ history.data,
682
+ vec![super::super::models::AccountAnalyticsDay {
683
+ date: live.end_date,
684
+ total: 12.0,
685
+ values: vec![super::super::models::AccountAnalyticsValue {
686
+ key: "user".into(),
687
+ label: "Tasks".into(),
688
+ value: 12.0,
689
+ }],
690
+ }]
691
+ );
692
+ assert!(live.attributed_usage());
693
+ }
694
+
695
+ #[tokio::test]
696
+ async fn invalid_reports_can_retry_without_recreating_the_session() {
697
+ for plan in ["plus", "business"] {
698
+ let server = MockServer::start().await;
699
+ let (_home, live) = live(&server, plan).await;
700
+ let requests = Arc::new(std::sync::atomic::AtomicUsize::new(/*v*/ 0));
701
+ let count = Arc::clone(&requests);
702
+ Mock::given(method("GET"))
703
+ .respond_with(move |_: &wiremock::Request| {
704
+ let body = if count.fetch_add(/*val*/ 1, std::sync::atomic::Ordering::SeqCst) == 0 {
705
+ json!({"data": [{"date": "invalid"}]})
706
+ } else {
707
+ json!({"data": []})
708
+ };
709
+ ResponseTemplate::new(/*s*/ 200).set_body_json(body)
710
+ })
711
+ .expect(/*r*/ 2)
712
+ .mount(&server)
713
+ .await;
714
+ assert!(
715
+ live.history(Report::Usage, /*days*/ 7, Grouping::Surface)
716
+ .await
717
+ .is_err()
718
+ );
719
+ assert!(
720
+ live.history(Report::Usage, /*days*/ 7, Grouping::Surface)
721
+ .await
722
+ .is_ok()
723
+ );
724
+ assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 2);
725
+ server.verify().await;
726
+ }
727
+ }
728
+
729
+ #[tokio::test]
730
+ async fn invalid_cached_breakdowns_are_evicted_before_retry() {
731
+ let server = MockServer::start().await;
732
+ let (_home, live) = live(&server, "plus").await;
733
+ let today = live.end_date.to_string();
734
+ let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(/*v*/ 0));
735
+ let count = Arc::clone(&attempts);
736
+ Mock::given(method("GET"))
737
+ .respond_with(move |_: &wiremock::Request| {
738
+ let model = if count.fetch_add(/*val*/ 1, std::sync::atomic::Ordering::SeqCst) == 0 {
739
+ json!({"model": "alpha", "credits": -1})
740
+ } else {
741
+ json!({"model": "alpha", "credits": 12})
742
+ };
743
+ ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": [{
744
+ "date": today, "product_surface_usage_values": {"cli": 12}, "models": [model]
745
+ }]}))
746
+ })
747
+ .expect(/*r*/ 2)
748
+ .mount(&server)
749
+ .await;
750
+ live.history(Report::Usage, /*days*/ 7, Grouping::Surface)
751
+ .await
752
+ .unwrap();
753
+ assert!(
754
+ live.history(Report::Usage, /*days*/ 7, Grouping::Model)
755
+ .await
756
+ .is_err()
757
+ );
758
+ assert!(
759
+ live.history(Report::Usage, /*days*/ 7, Grouping::Model)
760
+ .await
761
+ .unwrap()
762
+ .is_some()
763
+ );
764
+ assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
765
+ server.verify().await;
766
+ }
767
+
768
+ pub(in crate::analytics) async fn connected_view(
769
+ server: &MockServer,
770
+ plan: &str,
771
+ ) -> (
772
+ tempfile::TempDir,
773
+ crate::AppServerSession,
774
+ crate::analytics::AnalyticsView,
775
+ ) {
776
+ let (home, live) = live(server, plan).await;
777
+ let config = Arc::clone(&live.config);
778
+ let app_server = crate::start_embedded_app_server_for_picker(&config)
779
+ .await
780
+ .unwrap();
781
+ let mut view =
782
+ crate::analytics::AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list);
783
+ view.open(
784
+ app_server.request_handle(),
785
+ crate::tui::FrameRequester::test_dummy(),
786
+ Vec::new(),
787
+ config,
788
+ );
789
+ (home, app_server, view)
790
+ }
791
+
792
+ #[tokio::test]
793
+ async fn live_account_identity_is_visible_and_closing_aborts_pending_loads() {
794
+ let server = MockServer::start().await;
795
+ let (_home, live) = live(&server, "plus").await;
796
+ live.session().await.unwrap();
797
+ let mut view =
798
+ crate::analytics::AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list);
799
+ view.live = Some(Arc::new(live));
800
+ view.sections[Section::Usage].history =
801
+ super::super::data::Load::Error("Couldn't load usage. Press R to retry.".into());
802
+ let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(
803
+ /*width*/ 100, /*height*/ 24,
804
+ ))
805
+ .unwrap();
806
+ terminal
807
+ .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
808
+ .unwrap();
809
+ insta::assert_snapshot!(terminal.backend().to_string());
810
+ let (draw_tx, _) = tokio::sync::broadcast::channel(/*capacity*/ 1);
811
+ let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel::<()>();
812
+ view.sections[Section::Usage].history = super::super::data::Load::start(
813
+ async move {
814
+ let _cancelled = cancelled_tx;
815
+ std::future::pending().await
816
+ },
817
+ crate::tui::FrameRequester::new(draw_tx),
818
+ );
819
+ view.cancel_loads();
820
+ assert!(cancelled_rx.await.is_err());
821
+ }
822
+
823
+ #[tokio::test]
824
+ async fn plan_history_rejects_account_change_during_response() {
825
+ let server = MockServer::start().await;
826
+ for status in [200, 404, 503] {
827
+ server.reset().await;
828
+ let (home, live) = live(&server, "plus").await;
829
+ let home_path = home.path().to_path_buf();
830
+ Mock::given(method("GET"))
831
+ .and(path("/backend-api/wham/usage/plan_limit_history"))
832
+ .and(header("chatgpt-account-id", "account-a"))
833
+ .respond_with(move |_: &wiremock::Request| {
834
+ sign_in(&home_path, "account-a", "user-b", "plus");
835
+ ResponseTemplate::new(status).set_body_json(json!({
836
+ "data_as_of":"2026-09-02T00:00:00Z", "coverage_start":null,
837
+ "coverage_complete":true, "periods":[]
838
+ }))
839
+ })
840
+ .expect(/*r*/ 1)
841
+ .mount(&server)
842
+ .await;
843
+ assert_eq!(
844
+ live.plan_history().await.err().unwrap(),
845
+ "Account changed. Press R to refresh Analytics."
846
+ );
847
+ }
848
+ }
849
+
850
+ #[path = "profile_identity_tests.rs"]
851
+ mod profile_identity;
codex-rs/tui/src/analytics/compact_tests.rs ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Large report totals and axes stay compact while selected-day details retain precision.
2
+ use super::*;
3
+
4
+ #[test]
5
+ fn report_headlines_and_axes_compact_large_values() {
6
+ let mut screenshots = Vec::new();
7
+ for (section, unit, total, headline) in [
8
+ (
9
+ Section::Usage,
10
+ models::AccountAnalyticsUnit::Tokens,
11
+ 12_280_365_226.0,
12
+ "12.3B tokens",
13
+ ),
14
+ (
15
+ Section::Plugins,
16
+ models::AccountAnalyticsUnit::Count,
17
+ 9_749.0,
18
+ "9.7K calls",
19
+ ),
20
+ (
21
+ Section::Skills,
22
+ models::AccountAnalyticsUnit::Count,
23
+ 2_901.0,
24
+ "2.9K uses",
25
+ ),
26
+ ] {
27
+ let mut view = fixture::view(models::AccountKind::Enterprise);
28
+ view.section = section;
29
+ view.sections[section].history = Load::Ready(models::AccountAnalyticsHistory {
30
+ unit,
31
+ updated_at: None,
32
+ data: vec![models::AccountAnalyticsDay {
33
+ date: view.end_date,
34
+ total,
35
+ values: vec![models::AccountAnalyticsValue {
36
+ key: "test".into(),
37
+ label: "Example".into(),
38
+ value: total,
39
+ }],
40
+ }],
41
+ });
42
+ let rendered = screen(&mut view, /*width*/ 100, /*height*/ 30);
43
+ assert!(rendered.contains(headline));
44
+ assert!(rendered.contains(&data::amount(total)));
45
+ screenshots.push(rendered);
46
+ }
47
+ insta::assert_snapshot!(screenshots.join("\n"));
48
+ }
codex-rs/tui/src/analytics/connection_tests.rs ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Exercise local Analytics plan discovery and report recovery while connected to an app-server.
2
+
3
+ use super::tests::live;
4
+ use codex_app_server_client::AppServerRequestHandle;
5
+ use codex_app_server_client::RemoteAppServerClient;
6
+ use codex_app_server_client::RemoteAppServerConnectArgs;
7
+ use codex_app_server_client::RemoteAppServerEndpoint;
8
+ use futures::SinkExt;
9
+ use futures::StreamExt;
10
+ use pretty_assertions::assert_eq;
11
+ use serde_json::Value;
12
+ use serde_json::json;
13
+ use tokio::net::TcpListener;
14
+ use tokio::net::TcpStream;
15
+ use tokio_tungstenite::WebSocketStream;
16
+ use tokio_tungstenite::tungstenite::Message;
17
+ use wiremock::Mock;
18
+ use wiremock::ResponseTemplate;
19
+ use wiremock::matchers::header;
20
+ use wiremock::matchers::method;
21
+
22
+ async fn read(socket: &mut WebSocketStream<TcpStream>) -> Value {
23
+ let frame = socket.next().await.unwrap().unwrap();
24
+ serde_json::from_str(frame.to_text().unwrap()).unwrap()
25
+ }
26
+
27
+ async fn remote() -> (RemoteAppServerClient, tokio::task::JoinHandle<()>) {
28
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
29
+ let address = listener.local_addr().unwrap();
30
+ let server = tokio::spawn(async move {
31
+ let (stream, _) = listener.accept().await.unwrap();
32
+ let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
33
+ let initialize = read(&mut socket).await;
34
+ assert_eq!(initialize["method"], "initialize");
35
+ socket
36
+ .send(Message::Text(
37
+ json!({"id": initialize["id"], "result": {
38
+ "userAgent": "analytics-test", "codexHome": "/server/.codex",
39
+ }})
40
+ .to_string()
41
+ .into(),
42
+ ))
43
+ .await
44
+ .unwrap();
45
+ assert_eq!(read(&mut socket).await["method"], "initialized");
46
+ // Analytics uses the local account without requesting connected-server account data.
47
+ assert!(socket.next().await.unwrap().unwrap().is_close());
48
+ });
49
+ let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
50
+ endpoint: RemoteAppServerEndpoint::WebSocket {
51
+ websocket_url: format!("ws://{address}"),
52
+ auth_token: None,
53
+ },
54
+ client_name: "analytics-test".into(),
55
+ client_version: "0.0.0-test".into(),
56
+ experimental_api: true,
57
+ mcp_server_openai_form_elicitation: false,
58
+ opt_out_notification_methods: Vec::new(),
59
+ channel_capacity: 8,
60
+ })
61
+ .await
62
+ .unwrap();
63
+ (client, server)
64
+ }
65
+
66
+ #[tokio::test]
67
+ async fn local_reports_share_an_account_and_recover_profile_errors() {
68
+ use crate::analytics::AnalyticsView;
69
+ use crate::analytics::data::Load;
70
+ use crate::analytics::sections::Section;
71
+
72
+ let http = crate::analytics::test_support::server().await;
73
+ let (_home, local) = live(&http, "enterprise").await;
74
+ let (client, server) = remote().await;
75
+ let failed_profile = Mock::given(method("GET"))
76
+ .and(wiremock::matchers::path("/backend-api/wham/profiles/me"))
77
+ .and(header("chatgpt-account-id", "account-a"))
78
+ .respond_with(ResponseTemplate::new(/*s*/ 503))
79
+ .expect(/*r*/ 1)
80
+ .mount_as_scoped(&http)
81
+ .await;
82
+ let mut view = AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list);
83
+ view.open(
84
+ AppServerRequestHandle::Remote(client.request_handle()),
85
+ crate::tui::FrameRequester::test_dummy(),
86
+ Vec::new(),
87
+ std::sync::Arc::clone(&local.config),
88
+ );
89
+ crate::analytics::test_support::settle(&mut view).await;
90
+ assert_eq!(
91
+ view.visible_sections(),
92
+ &[
93
+ Section::Summary,
94
+ Section::Credits,
95
+ Section::Usage,
96
+ Section::Plugins,
97
+ Section::Skills,
98
+ ]
99
+ );
100
+ assert!(matches!(view.profile, Load::Error(_)));
101
+ for section in view.visible_sections() {
102
+ if section.report().is_some() {
103
+ assert!(view.sections[*section].history.ready().is_some());
104
+ }
105
+ }
106
+ view.select_summary(/*view*/ None);
107
+ view.end_date = "2026-09-09".parse().unwrap();
108
+ let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(
109
+ /*width*/ 100, /*height*/ 48,
110
+ ))
111
+ .unwrap();
112
+ terminal
113
+ .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
114
+ .unwrap();
115
+ insta::assert_snapshot!("local_profile_error", terminal.backend().to_string());
116
+ drop(failed_profile);
117
+
118
+ let profile = json!({
119
+ "profile":{"display_name":"Local account","username":"local-user"},
120
+ "stats":{"lifetime_tokens":12345,"peak_daily_tokens":12345,
121
+ "daily_usage_buckets":[{"start_date":"2026-09-09","tokens":12345}]}
122
+ });
123
+ Mock::given(method("GET"))
124
+ .and(wiremock::matchers::path("/backend-api/wham/profiles/me"))
125
+ .and(header("chatgpt-account-id", "account-a"))
126
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(profile.clone()))
127
+ .expect(/*r*/ 1)
128
+ .mount(&http)
129
+ .await;
130
+ view.refresh();
131
+ crate::analytics::test_support::settle(&mut view).await;
132
+ assert_eq!(
133
+ view.profile.ready(),
134
+ Some(&serde_json::from_value(profile).unwrap())
135
+ );
136
+ view.end_date = "2026-09-09".parse().unwrap();
137
+ terminal
138
+ .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
139
+ .unwrap();
140
+ insta::assert_snapshot!("local_profile_recovered", terminal.backend().to_string());
141
+ for request in http.received_requests().await.unwrap() {
142
+ assert_eq!(
143
+ request.headers.get("chatgpt-account-id").unwrap(),
144
+ "account-a"
145
+ );
146
+ }
147
+ view.cancel_loads();
148
+ client.shutdown().await.unwrap();
149
+ server.await.unwrap();
150
+ }
151
+
152
+ #[tokio::test]
153
+ async fn account_lookup_failure_recovers_on_refresh_with_the_server_plan() {
154
+ use crate::analytics::AnalyticsView;
155
+ use crate::analytics::data::Load;
156
+ use crate::analytics::sections::Section;
157
+
158
+ let http = crate::analytics::test_support::server().await;
159
+ let (_home, local) = live(&http, "plus").await;
160
+ let (client, server) = remote().await;
161
+ Mock::given(method("GET"))
162
+ .and(wiremock::matchers::path("/backend-api/wham/accounts/check"))
163
+ .respond_with(ResponseTemplate::new(/*s*/ 503))
164
+ .with_priority(/*p*/ 1)
165
+ .expect(/*r*/ 1)
166
+ .up_to_n_times(/*n*/ 1)
167
+ .mount(&http)
168
+ .await;
169
+ let mut view = AnalyticsView::new(crate::keymap::RuntimeKeymap::defaults().list);
170
+ view.open(
171
+ AppServerRequestHandle::Remote(client.request_handle()),
172
+ crate::tui::FrameRequester::test_dummy(),
173
+ Vec::new(),
174
+ std::sync::Arc::clone(&local.config),
175
+ );
176
+ crate::analytics::test_support::settle(&mut view).await;
177
+ assert!(matches!(view.account, Load::Error(_)));
178
+ assert_eq!(
179
+ http.received_requests()
180
+ .await
181
+ .unwrap()
182
+ .iter()
183
+ .map(|request| request.url.path())
184
+ .collect::<Vec<_>>(),
185
+ ["/backend-api/wham/accounts/check"]
186
+ );
187
+ view.end_date = "2026-09-09".parse().unwrap();
188
+ let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(
189
+ /*width*/ 100, /*height*/ 40,
190
+ ))
191
+ .unwrap();
192
+ terminal
193
+ .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
194
+ .unwrap();
195
+ insta::assert_snapshot!("account_plan_error", terminal.backend().to_string());
196
+
197
+ Mock::given(method("GET"))
198
+ .and(wiremock::matchers::path("/backend-api/wham/accounts/check"))
199
+ .and(header("chatgpt-account-id", "account-a"))
200
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
201
+ "accounts": [{"id": "account-a", "plan_type": "enterprise"}]
202
+ })))
203
+ .with_priority(/*p*/ 1)
204
+ .expect(/*r*/ 1)
205
+ .mount(&http)
206
+ .await;
207
+ Mock::given(method("GET"))
208
+ .and(wiremock::matchers::path("/backend-api/wham/profiles/me"))
209
+ .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"stats": {}})))
210
+ .mount(&http)
211
+ .await;
212
+ view.refresh();
213
+ crate::analytics::test_support::settle(&mut view).await;
214
+ assert_eq!(
215
+ view.visible_sections(),
216
+ &[
217
+ Section::Summary,
218
+ Section::Credits,
219
+ Section::Usage,
220
+ Section::Plugins,
221
+ Section::Skills,
222
+ ]
223
+ );
224
+ assert_eq!(
225
+ view.account.ready(),
226
+ Some(&codex_protocol::account::PlanType::Enterprise)
227
+ );
228
+ for section in view.visible_sections() {
229
+ if section.report().is_some() {
230
+ assert!(view.sections[*section].history.ready().is_some());
231
+ }
232
+ }
233
+ http.verify().await;
234
+ view.cancel_loads();
235
+ client.shutdown().await.unwrap();
236
+ server.await.unwrap();
237
+ }
codex-rs/tui/src/analytics/consumer_refresh_tests.rs ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Consumer chat columns follow reported metrics without substituting billing estimates.
2
+ use super::*;
3
+ use crate::analytics::tasks::Chat;
4
+ use crate::analytics::tasks::Chats;
5
+ use pretty_assertions::assert_eq;
6
+ use serde_json::json;
7
+
8
+ #[test]
9
+ fn consumer_chats_keep_missing_rows_and_only_offer_known_metrics() {
10
+ let mut view = fixture::view(models::AccountKind::Consumer);
11
+ view.section = Section::Chats;
12
+ view.tasks = Load::Ready(Chats {
13
+ rows: vec![
14
+ Chat { title: "Private title from another account".into(), task: None },
15
+ Chat { title: "Unverified backend row".into(), task: Some(serde_json::from_value(json!({
16
+ "thread_id":"unavailable", "data_status":"unavailable", "usage_source":"unknown",
17
+ "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":null,"groups":[]
18
+ })).unwrap()) },
19
+ Chat { title: "Refunded task".into(), task: Some(serde_json::from_value(json!({
20
+ "thread_id":"refund", "data_status":"partial", "usage_source":"credits",
21
+ "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":"-0.000004","groups":[{
22
+ "product_experience":"codex", "model":"gpt-5.5", "reasoning_effort":"high", "speed":"fast",
23
+ "weekly_limit_percent":null,"five_hour_limit_percent":null,"balance_usage_credits":"-0.000004"
24
+ }]
25
+ })).unwrap()) },
26
+ ], ..Chats::default()
27
+ });
28
+ assert_eq!(view.task_metrics(), vec![2]);
29
+ assert_eq!(
30
+ view.task_rows()
31
+ .iter()
32
+ .map(|row| row.title.as_str())
33
+ .collect::<Vec<_>>(),
34
+ vec![
35
+ "Refunded task",
36
+ "Private title from another account",
37
+ "Unverified backend row"
38
+ ]
39
+ );
40
+ press(&mut view, KeyCode::Down);
41
+ press(&mut view, KeyCode::Enter);
42
+ assert_eq!(view.sections[Section::Chats].detail, None);
43
+ let hidden = screen(&mut view, /*width*/ 90, /*height*/ 30);
44
+ assert!(!hidden.contains("Private title") && !hidden.contains("Unverified backend row"));
45
+ press(&mut view, KeyCode::Up);
46
+ press(&mut view, KeyCode::Enter);
47
+ insta::assert_snapshot!(screen(&mut view, /*width*/ 90, /*height*/ 30));
48
+ let chats = match &mut view.tasks {
49
+ Load::Ready(chats) => chats,
50
+ _ => unreachable!(),
51
+ };
52
+ chats.rows[2]
53
+ .task
54
+ .as_mut()
55
+ .unwrap()
56
+ .amounts
57
+ .five_hour_limit_percent = Some(0.0);
58
+ chats.rows[2]
59
+ .task
60
+ .as_mut()
61
+ .unwrap()
62
+ .amounts
63
+ .weekly_limit_percent = Some(125.0);
64
+ assert_eq!(view.task_metrics(), vec![0, 1, 2]);
65
+ insta::assert_snapshot!(
66
+ "consumer_chat_all_metrics",
67
+ screen(&mut view, /*width*/ 110, /*height*/ 30)
68
+ );
69
+ press(&mut view, KeyCode::Char('s'));
70
+ insta::assert_snapshot!(
71
+ "consumer_chat_available_limits",
72
+ screen(&mut view, /*width*/ 58, /*height*/ 30)
73
+ );
74
+ }
75
+
76
+ #[test]
77
+ fn consumer_overview_shows_top_five_and_closes_with_retained_details() {
78
+ let mut view = fixture::view(models::AccountKind::Consumer);
79
+ view.section = Section::Chats;
80
+ view.tasks = Load::Ready(Chats {
81
+ rows: (0..6).map(|index| Chat {
82
+ title: format!("Task {index}"),
83
+ task: Some(serde_json::from_value(json!({
84
+ "thread_id":format!("task-{index}"), "data_status":"available", "usage_source":"credits",
85
+ "balance_usage_credits":index.to_string(), "groups":[]
86
+ })).unwrap()),
87
+ }).collect(),
88
+ ..Chats::default()
89
+ });
90
+ press(&mut view, KeyCode::End);
91
+ press(&mut view, KeyCode::Enter);
92
+ assert_eq!(view.sections[Section::Chats].detail, Some(5));
93
+ press(&mut view, KeyCode::Char('z'));
94
+ let content = view
95
+ .task_lines(/*width*/ 70)
96
+ .0
97
+ .iter()
98
+ .map(ToString::to_string)
99
+ .collect::<Vec<_>>()
100
+ .join("\n");
101
+ assert!(content.contains("Task 5") && !content.contains("Task 0"));
102
+ insta::assert_snapshot!(content);
103
+ press(&mut view, KeyCode::Esc);
104
+ assert!(view.is_done);
105
+ }
codex-rs/tui/src/analytics/dashboard.rs ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Bordered, aligned overview cards. Detailed reports remain in maximized mode.
2
+ //! Card heights depend on the viewport, never on the selected day or load state.
3
+
4
+ use super::AnalyticsView;
5
+ use super::data;
6
+ use super::render::columns;
7
+ use super::render::join_columns;
8
+ use super::styles::number;
9
+ use super::styles::secondary_style;
10
+ use crate::analytics::sections::Section;
11
+ use crate::line_truncation::truncate_line_with_ellipsis_if_overflow as truncate;
12
+ use crate::style::accent_style;
13
+ use crate::wrapping::RtOptions;
14
+ use crate::wrapping::word_wrap_lines;
15
+ use ratatui::style::Styled;
16
+ use ratatui::style::Stylize;
17
+ use ratatui::text::Line;
18
+ use std::ops::Range;
19
+
20
+ impl AnalyticsView {
21
+ pub(super) fn dashboard_lines(
22
+ &self,
23
+ width: usize,
24
+ height: usize,
25
+ ) -> (Vec<Line<'static>>, Range<usize>) {
26
+ let count = if width >= 104 { 2 } else { 1 };
27
+ let card_width = if count == 2 { (width - 4) / 2 } else { width };
28
+ let visible = self.visible_sections();
29
+ let rows = visible.len().div_ceil(count).max(/*other*/ 1);
30
+ let card_height =
31
+ (height.saturating_sub(/*rhs*/ 2) / rows).clamp(/*min*/ 16, /*max*/ 24);
32
+ let inner_width = card_width.saturating_sub(/*rhs*/ 4).max(/*other*/ 1);
33
+ let inner_height = card_height - 4;
34
+ let mut lines = Vec::new();
35
+ let mut selected = 0..0;
36
+ for (row, chunk) in visible.chunks(count).enumerate() {
37
+ let mut cards = Vec::new();
38
+ for (column, section) in chunk.iter().copied().enumerate() {
39
+ let focused = self.section == section;
40
+ let border = if focused {
41
+ accent_style()
42
+ } else {
43
+ secondary_style().dim()
44
+ };
45
+ let mut content = Vec::new();
46
+ if !matches!(section, Section::Chats | Section::Plan | Section::Summary) {
47
+ content.push(
48
+ format!(
49
+ "{}d{}",
50
+ if self.ranges[self.range_group(section) as usize] == 0 {
51
+ 7
52
+ } else {
53
+ 30
54
+ },
55
+ if section == Section::Usage && self.business() {
56
+ format!(
57
+ " · {}",
58
+ self.token_model.as_deref().unwrap_or("All models")
59
+ )
60
+ } else {
61
+ String::new()
62
+ }
63
+ )
64
+ .set_style(secondary_style())
65
+ .into(),
66
+ );
67
+ }
68
+ if matches!(
69
+ section,
70
+ Section::Usage | Section::Credits | Section::Activity | Section::Plan
71
+ ) {
72
+ content.push(
73
+ format!(
74
+ "By {}",
75
+ self.group_label(section, self.sections[section].group)
76
+ .to_lowercase()
77
+ )
78
+ .set_style(secondary_style())
79
+ .into(),
80
+ );
81
+ }
82
+ match section {
83
+ Section::Summary => content.extend(self.summary_lines(inner_width)),
84
+ Section::Usage
85
+ | Section::Plugins
86
+ | Section::Credits
87
+ | Section::Activity
88
+ | Section::Skills => content.extend(
89
+ self.history_lines(
90
+ section,
91
+ inner_width,
92
+ inner_height.saturating_sub(/*rhs*/ 13).max(/*other*/ 1),
93
+ )
94
+ .lines,
95
+ ),
96
+ Section::Plan => content.extend(self.plan_lines(inner_width).0),
97
+ Section::Chats if !self.business() => {
98
+ content.extend(self.task_lines(inner_width).0)
99
+ }
100
+ Section::Chats => {
101
+ content.push(
102
+ "30d active · lifetime credits"
103
+ .set_style(secondary_style())
104
+ .into(),
105
+ );
106
+ content.push(Line::default());
107
+ if let Some(chats) = self.chats.ready() {
108
+ if chats.rows.is_empty() {
109
+ content.push(
110
+ "No recent local chats.".set_style(secondary_style()).into(),
111
+ );
112
+ }
113
+ for chat in chats.rows.iter().take(/*n*/ 5) {
114
+ let amount = chat
115
+ .usage
116
+ .as_ref()
117
+ .map(|usage| {
118
+ data::credits(usage.estimated_usage_credits_micros)
119
+ })
120
+ .unwrap_or_else(|| "—".into());
121
+ content.push(columns(
122
+ truncate(
123
+ chat.display_title().to_string().into(),
124
+ inner_width.saturating_sub(amount.len() + 1),
125
+ ),
126
+ number(amount).into(),
127
+ inner_width,
128
+ ));
129
+ }
130
+ content.push(Line::default());
131
+ content.push(
132
+ "Local chats · excludes subagents"
133
+ .set_style(secondary_style())
134
+ .into(),
135
+ );
136
+ if chats.rows.iter().any(|chat| chat.usage.is_none()) {
137
+ content.push(
138
+ "Some estimates unavailable"
139
+ .set_style(secondary_style())
140
+ .into(),
141
+ );
142
+ }
143
+ } else if let Some(message) = self.chats.message() {
144
+ content.push(message.to_string().set_style(secondary_style()).into());
145
+ }
146
+ }
147
+ }
148
+ let mut content = word_wrap_lines(content, RtOptions::new(inner_width));
149
+ if content.len() > inner_height {
150
+ content.truncate(inner_height);
151
+ content[inner_height - 1] =
152
+ "… z to maximize".set_style(secondary_style()).into();
153
+ }
154
+ content.resize(inner_height, Line::default());
155
+ let label = format!(
156
+ " {} {} ",
157
+ row * count + column + 1,
158
+ self.section_title(section)
159
+ );
160
+ let title = truncate(
161
+ Line::from(vec![
162
+ " ".into(),
163
+ if focused {
164
+ "▸".set_style(accent_style())
165
+ } else {
166
+ " ".into()
167
+ },
168
+ if focused {
169
+ label.bold()
170
+ } else {
171
+ label.set_style(secondary_style())
172
+ },
173
+ ]),
174
+ card_width.saturating_sub(/*rhs*/ 3),
175
+ );
176
+ let mut top = vec!["╭─".set_style(border)];
177
+ top.extend(title.spans.iter().cloned());
178
+ top.push(
179
+ "─"
180
+ .repeat(card_width.saturating_sub(title.width() + 3))
181
+ .set_style(border),
182
+ );
183
+ top.push("╮".set_style(border));
184
+ let blank = columns(
185
+ "│".set_style(border).into(),
186
+ "│".set_style(border).into(),
187
+ card_width,
188
+ );
189
+ let mut card = vec![Line::from(top), blank.clone()];
190
+ card.extend(content.into_iter().map(|line| {
191
+ let line = truncate(line, inner_width);
192
+ let padding = card_width.saturating_sub(line.width() + 3);
193
+ let mut padded = vec!["│".set_style(border), " ".into()];
194
+ padded.extend(line.spans);
195
+ padded.push(" ".repeat(padding).into());
196
+ padded.push("│".set_style(border));
197
+ Line::from(padded)
198
+ }));
199
+ card.push(blank);
200
+ card.push(
201
+ format!("╰{}╯", "─".repeat(card_width.saturating_sub(/*rhs*/ 2)))
202
+ .set_style(border)
203
+ .into(),
204
+ );
205
+ cards.push(
206
+ card.into_iter()
207
+ .map(|line| truncate(line, card_width))
208
+ .collect::<Vec<_>>(),
209
+ );
210
+ }
211
+ if chunk.contains(&self.section) {
212
+ selected = lines.len()..lines.len() + card_height;
213
+ }
214
+ lines.extend(if cards.len() == 2 {
215
+ join_columns(&cards[0], &cards[1], card_width)
216
+ } else {
217
+ cards.remove(/*index*/ 0)
218
+ });
219
+ if (row + 1) * count < visible.len() {
220
+ lines.push(Line::default());
221
+ }
222
+ }
223
+ (lines, selected)
224
+ }
225
+ }
codex-rs/tui/src/analytics/dashboard_tests.rs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Overview cards retain alignment and geometry while selection and report states change.
2
+ use super::*;
3
+ use crate::style::accent_style;
4
+ use pretty_assertions::assert_eq;
5
+ use ratatui::style::Modifier;
6
+
7
+ use crate::analytics::sections::Section;
8
+ #[test]
9
+ fn dashboard_focus_uses_marker_and_default_foreground_title() {
10
+ let mut view = fixture::view(models::AccountKind::Consumer);
11
+ view.zoomed = false;
12
+ for section in [Section::Usage, Section::Activity] {
13
+ view.section = section;
14
+ let (lines, _) = view.dashboard_lines(/*width*/ 140, /*height*/ 56);
15
+ let title = lines
16
+ .iter()
17
+ .find(|line| line.spans.iter().any(|span| span.content == "▸"))
18
+ .unwrap();
19
+ let marker = title.spans.iter().find(|span| span.content == "▸").unwrap();
20
+ assert_eq!(marker.style, accent_style());
21
+ let focused_title = title
22
+ .spans
23
+ .iter()
24
+ .find(|span| span.content.contains(view.section_title(section)))
25
+ .unwrap();
26
+ assert_eq!(focused_title.style.fg, None);
27
+ assert!(focused_title.style.add_modifier.contains(Modifier::BOLD));
28
+ assert_eq!(title.to_string().matches('▸').count(), 1);
29
+ }
30
+ }
31
+
32
+ #[test]
33
+ fn dashboard_cards_align_and_keep_stable_summary_heights() {
34
+ let mut view = fixture::view(models::AccountKind::Consumer);
35
+ press(&mut view, KeyCode::Char('z'));
36
+ let wide = screen(&mut view, /*width*/ 144, /*height*/ 64);
37
+ for (left, right) in [
38
+ ("1 Summary", "2 Total usage history"),
39
+ ("3 Messages", "4 Plugins called"),
40
+ ("5 Skills used", "6 Top chats"),
41
+ ] {
42
+ assert!(
43
+ wide.lines()
44
+ .any(|line| line.contains(left) && line.contains(right))
45
+ );
46
+ }
47
+ assert!(!wide.contains("Coverage from"));
48
+ let before = view.dashboard_lines(/*width*/ 140, /*height*/ 56);
49
+ press(&mut view, KeyCode::Left);
50
+ view.sections[Section::Plugins].history = Load::Error("Access denied for this report.".into());
51
+ let after = view.dashboard_lines(/*width*/ 140, /*height*/ 56);
52
+ assert_eq!((before.0.len(), before.1), (after.0.len(), after.1));
53
+ let edges = |lines: &[ratatui::text::Line<'_>]| {
54
+ lines
55
+ .iter()
56
+ .enumerate()
57
+ .filter(|(_, line)| line.to_string().starts_with(['╭', '╰']))
58
+ .map(|(index, _)| index)
59
+ .collect::<Vec<_>>()
60
+ };
61
+ assert_eq!(edges(&before.0), edges(&after.0));
62
+ for width in [1, 4, 40, 100, 140] {
63
+ assert!(
64
+ view.dashboard_lines(width, /*height*/ 56)
65
+ .0
66
+ .iter()
67
+ .all(|line| line.width() <= width)
68
+ );
69
+ }
70
+ let narrow = screen(&mut view, /*width*/ 72, /*height*/ 48);
71
+ assert!(narrow.contains("1 Summary"));
72
+ let context = (
73
+ view.section,
74
+ view.sections.0.each_ref().map(|state| state.cursor),
75
+ view.sections.0.each_ref().map(|state| state.group),
76
+ );
77
+ press(&mut view, KeyCode::Enter);
78
+ assert!(view.zoomed);
79
+ assert_eq!(
80
+ context,
81
+ (
82
+ view.section,
83
+ view.sections.0.each_ref().map(|state| state.cursor),
84
+ view.sections.0.each_ref().map(|state| state.group)
85
+ )
86
+ );
87
+ insta::assert_snapshot!(format!("{wide}\n{narrow}"));
88
+ }
89
+
90
+ #[test]
91
+ fn overview_closes_on_escape_with_retained_day_details() {
92
+ let mut view = fixture::view(models::AccountKind::Consumer);
93
+ press(&mut view, KeyCode::Enter);
94
+ assert_eq!(view.sections[Section::Usage].detail, Some(6));
95
+ press(&mut view, KeyCode::Char('z'));
96
+ assert!(!view.zoomed);
97
+ press(&mut view, KeyCode::Esc);
98
+ assert!(view.is_done);
99
+ }
codex-rs/tui/src/analytics/data.rs ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Report loading and grouping metadata for the account dashboard.
2
+ //! Replacing a pending load aborts it; its response cannot populate a newer range or account.
3
+
4
+ use super::models::AccountAnalyticsGrouping as Grouping;
5
+ use crate::tui::FrameRequester;
6
+ use futures::FutureExt;
7
+ use std::panic::AssertUnwindSafe;
8
+ use tokio::sync::oneshot;
9
+ use tokio::task::JoinHandle;
10
+
11
+ pub(super) const GROUPINGS: [Grouping; 7] = [
12
+ Grouping::Surface,
13
+ Grouping::Feature,
14
+ Grouping::Model,
15
+ Grouping::TaskStart,
16
+ Grouping::Speed,
17
+ Grouping::Reasoning,
18
+ Grouping::TokenType,
19
+ ];
20
+
21
+ pub(super) const GROUP_LABELS: [&str; 7] = [
22
+ "Surface",
23
+ "Feature",
24
+ "Model",
25
+ "Turn start",
26
+ "Speed",
27
+ "Reasoning",
28
+ "Token type",
29
+ ];
30
+
31
+ pub(super) struct Pending<T> {
32
+ receiver: oneshot::Receiver<Result<Option<T>, String>>,
33
+ task: JoinHandle<()>,
34
+ }
35
+
36
+ impl<T> Drop for Pending<T> {
37
+ fn drop(&mut self) {
38
+ self.task.abort();
39
+ }
40
+ }
41
+
42
+ pub(super) enum Load<T> {
43
+ Loading(Pending<T>),
44
+ Ready(T),
45
+ Unavailable,
46
+ Error(String),
47
+ }
48
+
49
+ impl<T: Send + 'static> Load<T> {
50
+ pub(super) fn start(
51
+ future: impl std::future::Future<Output = Result<Option<T>, String>> + Send + 'static,
52
+ frame: FrameRequester,
53
+ ) -> Self {
54
+ Self::start_with_timeout(future, frame, std::time::Duration::from_secs(/*secs*/ 30))
55
+ }
56
+
57
+ pub(super) fn start_with_timeout(
58
+ future: impl std::future::Future<Output = Result<Option<T>, String>> + Send + 'static,
59
+ frame: FrameRequester,
60
+ timeout: std::time::Duration,
61
+ ) -> Self {
62
+ let (sender, receiver) = oneshot::channel();
63
+ let task =
64
+ tokio::spawn(async move {
65
+ let result =
66
+ match tokio::time::timeout(timeout, AssertUnwindSafe(future).catch_unwind())
67
+ .await
68
+ {
69
+ Ok(Ok(result)) => result,
70
+ Ok(Err(_)) => Err("Request interrupted. Press R to retry.".into()),
71
+ Err(_) => Err("Request timed out. Press R to retry.".into()),
72
+ };
73
+ let _ = sender.send(result);
74
+ frame.schedule_frame();
75
+ });
76
+ Self::Loading(Pending { receiver, task })
77
+ }
78
+
79
+ pub(super) fn poll(&mut self) {
80
+ if let Self::Loading(pending) = self {
81
+ *self = match pending.receiver.try_recv() {
82
+ Ok(Ok(Some(data))) => Self::Ready(data),
83
+ Ok(Ok(None)) => Self::Unavailable,
84
+ Ok(Err(message)) => Self::Error(message),
85
+ Err(oneshot::error::TryRecvError::Closed) => {
86
+ Self::Error("Request interrupted. Press R to retry.".into())
87
+ }
88
+ Err(oneshot::error::TryRecvError::Empty) => return,
89
+ };
90
+ }
91
+ }
92
+
93
+ pub(super) fn ready(&self) -> Option<&T> {
94
+ if let Self::Ready(value) = self {
95
+ Some(value)
96
+ } else {
97
+ None
98
+ }
99
+ }
100
+
101
+ pub(super) fn message(&self) -> Option<&str> {
102
+ match self {
103
+ Self::Ready(_) => None,
104
+ Self::Loading(_) => Some("Loading…"),
105
+ Self::Unavailable => Some("No history has been reported."),
106
+ Self::Error(message) => Some(message),
107
+ }
108
+ }
109
+ }
110
+
111
+ /// Keep untyped server and transport diagnostics out of account reports.
112
+ pub(super) fn error(_error: codex_app_server_client::TypedRequestError) -> String {
113
+ "Couldn't load analytics. Press R to retry.".into()
114
+ }
115
+
116
+ /// Keep tiny refunds visible while avoiding noise on ordinary credit amounts.
117
+ pub(super) fn amount(value: f64) -> String {
118
+ if value == 0.0 {
119
+ return "0".into();
120
+ }
121
+ if value.abs() < 0.000001 {
122
+ return format!("{value:.2e}");
123
+ }
124
+ let rounded = if value.abs() < 0.01 {
125
+ format!("{value:.6}")
126
+ } else {
127
+ format!("{value:.2}")
128
+ };
129
+ let (whole, fraction) = rounded.split_once('.').unwrap_or((&rounded, ""));
130
+ let mut grouped = String::new();
131
+ for (index, ch) in whole.chars().enumerate() {
132
+ if index > 0
133
+ && ch.is_ascii_digit()
134
+ && (whole.len() - index).is_multiple_of(/*rhs*/ 3)
135
+ && !grouped.ends_with('-')
136
+ {
137
+ grouped.push(',');
138
+ }
139
+ grouped.push(ch);
140
+ }
141
+ let fraction = fraction.trim_end_matches('0');
142
+ if !fraction.is_empty() {
143
+ grouped.push('.');
144
+ grouped.push_str(fraction);
145
+ }
146
+ grouped
147
+ }
148
+
149
+ /// Compact headline and axis labels to one decimal, preserving small signed adjustments.
150
+ pub(super) fn compact_amount(value: f64) -> String {
151
+ if value.abs() < 1_000.0 || !value.is_finite() {
152
+ return amount(value);
153
+ }
154
+ let units = ["K", "M", "B", "T"];
155
+ let mut scaled = value / 1_000.0;
156
+ let mut unit = 0;
157
+ // Promote before rounding would produce a label such as 1000K.
158
+ while scaled.abs() >= 999.95 && unit + 1 < units.len() {
159
+ scaled /= 1_000.0;
160
+ unit += 1;
161
+ }
162
+ let rounded = format!("{:.1}", (scaled * 10.0).round() / 10.0);
163
+ format!(
164
+ "{}{}",
165
+ rounded.trim_end_matches('0').trim_end_matches('.'),
166
+ units[unit]
167
+ )
168
+ }
169
+
170
+ /// Align ordinary credit amounts to two decimals without hiding tiny adjustments.
171
+ pub(super) fn credit_amount(value: f64) -> String {
172
+ let formatted = amount(value);
173
+ if value != 0.0 && value.abs() < 0.01 {
174
+ return formatted;
175
+ }
176
+ match formatted.split_once('.') {
177
+ None => format!("{formatted}.00"),
178
+ Some((_, fraction)) if fraction.len() == 1 => format!("{formatted}0"),
179
+ Some(_) => formatted,
180
+ }
181
+ }
182
+
183
+ pub(super) fn credits(micros: i64) -> String {
184
+ let magnitude = micros.unsigned_abs();
185
+ if magnitude > 0 && magnitude < 10_000 {
186
+ let fractional = format!("{magnitude:06}");
187
+ return format!(
188
+ "{}0.{}",
189
+ if micros < 0 { "-" } else { "" },
190
+ fractional.trim_end_matches('0')
191
+ );
192
+ }
193
+ let cents = (magnitude + 5_000) / 10_000;
194
+ let mut whole = (cents / 100).to_string();
195
+ let digits = whole.len();
196
+ for index in (1..digits).rev() {
197
+ if (digits - index).is_multiple_of(/*rhs*/ 3) {
198
+ whole.insert(index, ',');
199
+ }
200
+ }
201
+ format!(
202
+ "{}{whole}.{:02}",
203
+ if micros < 0 { "-" } else { "" },
204
+ cents % 100
205
+ )
206
+ }
207
+
208
+ pub(super) fn date(date: &str) -> String {
209
+ chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
210
+ .map(|date| date.format("%b %-d").to_string())
211
+ .unwrap_or_else(|_| date.to_string())
212
+ }
213
+
214
+ #[cfg(test)]
215
+ #[path = "data_tests.rs"]
216
+ mod tests;
codex-rs/tui/src/analytics/data_tests.rs ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Loading transitions and cancellation when a view replaces pending work.
2
+
3
+ use super::*;
4
+ use pretty_assertions::assert_eq;
5
+
6
+ #[tokio::test]
7
+ async fn completed_loads_publish_data_unavailability_and_errors() {
8
+ for (result, expected) in [
9
+ (Ok(Some(7)), (Some(7), None)),
10
+ (Ok(None), (None, Some("No history has been reported."))),
11
+ (
12
+ Err("temporary failure".to_string()),
13
+ (None, Some("temporary failure")),
14
+ ),
15
+ ] {
16
+ let mut load = Load::start(async move { result }, FrameRequester::test_dummy());
17
+ let Load::Loading(pending) = &mut load else {
18
+ unreachable!()
19
+ };
20
+ (&mut pending.task).await.unwrap();
21
+ load.poll();
22
+ assert_eq!((load.ready().copied(), load.message()), expected);
23
+ }
24
+ }
25
+
26
+ #[tokio::test]
27
+ async fn replacing_a_pending_load_cancels_its_request() {
28
+ let (cancelled_tx, cancelled_rx) = oneshot::channel::<()>();
29
+ let load = Load::<()>::start(
30
+ async move {
31
+ let _cancelled = cancelled_tx;
32
+ std::future::pending().await
33
+ },
34
+ FrameRequester::test_dummy(),
35
+ );
36
+ tokio::task::yield_now().await;
37
+ drop(load);
38
+ assert!(cancelled_rx.await.is_err());
39
+ }
40
+
41
+ #[tokio::test]
42
+ async fn panicking_loads_request_a_frame_and_report_interruption() {
43
+ let (draw, mut frames) = tokio::sync::broadcast::channel(/*capacity*/ 1);
44
+ let frame = FrameRequester::new(draw);
45
+ let mut load = Load::<()>::start(async { panic!("report task failed") }, frame.clone());
46
+ let Load::Loading(pending) = &mut load else {
47
+ unreachable!()
48
+ };
49
+ let _ = (&mut pending.task).await;
50
+ tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 1), frames.recv())
51
+ .await
52
+ .unwrap()
53
+ .unwrap();
54
+ load.poll();
55
+ assert_eq!(
56
+ load.message(),
57
+ Some("Request interrupted. Press R to retry.")
58
+ );
59
+ }
60
+
61
+ #[test]
62
+ fn rpc_errors_do_not_expose_server_diagnostics() {
63
+ let error = codex_app_server_client::TypedRequestError::Server {
64
+ method: "thread/list".into(),
65
+ source: codex_app_server_protocol::JSONRPCErrorError {
66
+ code: -32603,
67
+ message: "Failed opening /private/workspace: token=secret".into(),
68
+ data: Some(serde_json::json!({"credential": "secret"})),
69
+ },
70
+ };
71
+ assert_eq!(
72
+ super::error(error),
73
+ "Couldn't load analytics. Press R to retry."
74
+ );
75
+ }
76
+
77
+ #[tokio::test(start_paused = true)]
78
+ async fn extended_chat_load_budget_still_times_out_and_can_be_cancelled() {
79
+ let (cancelled_tx, cancelled_rx) = oneshot::channel::<()>();
80
+ let mut load = Load::<()>::start_with_timeout(
81
+ async move {
82
+ let _cancelled = cancelled_tx;
83
+ std::future::pending().await
84
+ },
85
+ FrameRequester::test_dummy(),
86
+ std::time::Duration::from_secs(/*secs*/ 90),
87
+ );
88
+ tokio::task::yield_now().await;
89
+ tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 31)).await;
90
+ load.poll();
91
+ assert!(matches!(load, Load::Loading(_)));
92
+ tokio::time::advance(std::time::Duration::from_secs(/*secs*/ 60)).await;
93
+ assert!(cancelled_rx.await.is_err());
94
+ load.poll();
95
+ assert_eq!(load.message(), Some("Request timed out. Press R to retry."));
96
+ }
97
+
98
+ #[test]
99
+ fn compact_counts_round_promote_units_and_preserve_small_adjustments() {
100
+ let values = [
101
+ 0.0,
102
+ 959.0,
103
+ 1_000.0,
104
+ 9_749.0,
105
+ 2_901.0,
106
+ 12_280_365_226.0,
107
+ 142_300_000_000.0,
108
+ 999_999.0,
109
+ 999_999_999.0,
110
+ -1_250.0,
111
+ -0.000004,
112
+ 1_200_000_000_000.0,
113
+ ];
114
+ assert_eq!(
115
+ values.map(compact_amount),
116
+ [
117
+ "0",
118
+ "959",
119
+ "1K",
120
+ "9.7K",
121
+ "2.9K",
122
+ "12.3B",
123
+ "142.3B",
124
+ "1M",
125
+ "1B",
126
+ "-1.3K",
127
+ "-0.000004",
128
+ "1.2T"
129
+ ]
130
+ .map(str::to_string)
131
+ );
132
+ }
codex-rs/tui/src/analytics/models.rs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Private analytics display types, independent of the app-server wire protocol.
2
+
3
+ use codex_protocol::account::PlanType;
4
+
5
+ /// Billing families match the App's consumer, business, and workspace scopes.
6
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
7
+ pub(super) enum AccountKind {
8
+ Consumer,
9
+ Business,
10
+ Enterprise,
11
+ Unknown,
12
+ }
13
+
14
+ impl From<Option<PlanType>> for AccountKind {
15
+ fn from(plan: Option<PlanType>) -> Self {
16
+ match plan {
17
+ None | Some(PlanType::Unknown) => Self::Unknown,
18
+ Some(plan) if plan.is_team_like() => Self::Business,
19
+ Some(plan) if plan.is_workspace_account() => Self::Enterprise,
20
+ Some(_) => Self::Consumer,
21
+ }
22
+ }
23
+ }
24
+
25
+ /// Match the existing CLI thread-usage capability without widening other workspace reports.
26
+ pub(super) fn thread_usage_supported(plan: Option<PlanType>) -> bool {
27
+ matches!(
28
+ plan,
29
+ Some(
30
+ PlanType::Business
31
+ | PlanType::EnterpriseCbpUsageBased
32
+ | PlanType::EnterpriseCbpAutomation
33
+ )
34
+ )
35
+ }
36
+
37
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+ pub(crate) enum AccountAnalyticsReport {
39
+ Usage,
40
+ Credits,
41
+ Messages,
42
+ Plugins,
43
+ Skills,
44
+ }
45
+
46
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
47
+ pub(crate) enum AccountAnalyticsGrouping {
48
+ Feature,
49
+ Model,
50
+ Surface,
51
+ TaskStart,
52
+ Speed,
53
+ Reasoning,
54
+ TokenType,
55
+ }
56
+
57
+ impl AccountAnalyticsGrouping {
58
+ /// Credit breakdowns supported by the account's billing report.
59
+ pub(crate) fn credit_groupings(plan: Option<PlanType>) -> &'static [Self] {
60
+ match AccountKind::from(plan) {
61
+ AccountKind::Business => &[Self::Surface, Self::Model, Self::Speed],
62
+ AccountKind::Enterprise => &[Self::Surface, Self::Model, Self::Speed, Self::Reasoning],
63
+ AccountKind::Consumer => &[Self::Surface],
64
+ AccountKind::Unknown => &[],
65
+ }
66
+ }
67
+ }
68
+
69
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
70
+ pub(crate) enum AccountAnalyticsUnit {
71
+ RelativeUsage,
72
+ Credits,
73
+ Count,
74
+ Tokens,
75
+ }
76
+
77
+ #[derive(Debug, Clone, PartialEq)]
78
+ pub(crate) struct AccountAnalyticsHistory {
79
+ pub(crate) unit: AccountAnalyticsUnit,
80
+ /// Source freshness in Unix seconds, when provided. Not a completeness watermark.
81
+ pub(crate) updated_at: Option<i64>,
82
+ pub(crate) data: Vec<AccountAnalyticsDay>,
83
+ }
84
+
85
+ #[derive(Debug, Clone, PartialEq)]
86
+ pub(crate) struct AccountAnalyticsDay {
87
+ /// Source reporting date; display formatting belongs to the UI.
88
+ pub(crate) date: chrono::NaiveDate,
89
+ /// Full daily denominator, including non-task usage when grouped by task start.
90
+ pub(crate) total: f64,
91
+ pub(crate) values: Vec<AccountAnalyticsValue>,
92
+ }
93
+
94
+ #[derive(Debug, Clone, PartialEq)]
95
+ pub(crate) struct AccountAnalyticsValue {
96
+ pub(crate) key: String,
97
+ pub(crate) label: String,
98
+ pub(crate) value: f64,
99
+ }
codex-rs/tui/src/analytics/navigation_tests.rs ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Vim navigation aliases share arrow behavior while preserving configured bindings.
2
+
3
+ use super::*;
4
+ use crate::analytics::models::AccountKind;
5
+ use codex_config::types::KeybindingSpec;
6
+ use codex_config::types::KeybindingsSpec;
7
+ use codex_config::types::TuiKeymap;
8
+ use pretty_assertions::assert_eq;
9
+ use serde_json::json;
10
+
11
+ fn navigate(views: &mut [AnalyticsView; 2], keys: &[(char, KeyCode)], size: (u16, u16)) -> String {
12
+ let mut output = String::new();
13
+ for &(alias, arrow) in keys {
14
+ press(&mut views[0], KeyCode::Char(alias));
15
+ press(&mut views[1], arrow);
16
+ output = screen(&mut views[0], size.0, size.1);
17
+ assert_eq!(output, screen(&mut views[1], size.0, size.1), "{alias}");
18
+ }
19
+ output
20
+ }
21
+
22
+ #[test]
23
+ fn vim_chart_navigation_matches_arrows_in_focused_and_dashboard_views() {
24
+ for (kind, section) in [
25
+ (AccountKind::Consumer, Section::Usage),
26
+ (AccountKind::Consumer, Section::Activity),
27
+ (AccountKind::Consumer, Section::Plugins),
28
+ (AccountKind::Consumer, Section::Skills),
29
+ (AccountKind::Business, Section::Usage),
30
+ (AccountKind::Business, Section::Credits),
31
+ ] {
32
+ for zoomed in [true, false] {
33
+ let mut views = [fixture::view(kind), fixture::view(kind)];
34
+ for view in &mut views {
35
+ view.section = section;
36
+ view.zoomed = zoomed;
37
+ view.sections[section].cursor = 3;
38
+ view.sections[section].detail = Some(3);
39
+ }
40
+ let output = navigate(
41
+ &mut views,
42
+ &[
43
+ ('h', KeyCode::Left),
44
+ ('h', KeyCode::Left),
45
+ ('l', KeyCode::Right),
46
+ ('j', KeyCode::Down),
47
+ ('k', KeyCode::Up),
48
+ ],
49
+ /*size*/ (96, 32),
50
+ );
51
+ assert_eq!(
52
+ (
53
+ views[0].section,
54
+ views[0].sections[section].cursor,
55
+ views[0].sections[section].detail
56
+ ),
57
+ (section, 2, Some(2))
58
+ );
59
+ if kind == AccountKind::Consumer && section == Section::Usage && zoomed {
60
+ insta::assert_snapshot!("vim_chart_navigation", output);
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ #[test]
67
+ fn vim_plan_navigation_preserves_independent_windows_and_collapses_old_details() {
68
+ let mut views = [
69
+ fixture::view(AccountKind::Consumer),
70
+ fixture::view(AccountKind::Consumer),
71
+ ];
72
+ for view in &mut views {
73
+ let as_of = "2026-09-02T12:00:00Z"
74
+ .parse::<chrono::DateTime<chrono::Utc>>()
75
+ .unwrap();
76
+ view.plan.enabled = true;
77
+ view.plan.report = Load::Ready(plan::Report {
78
+ as_of,
79
+ coverage_start: None,
80
+ coverage_complete: true,
81
+ approximate: false,
82
+ periods: std::array::from_fn(|window| {
83
+ let length = if window == 0 {
84
+ chrono::Duration::hours(/*hours*/ 5)
85
+ } else {
86
+ chrono::Duration::days(/*days*/ 7)
87
+ };
88
+ (0..2)
89
+ .map(|index| plan::Period {
90
+ id: format!("{window}-{index}"),
91
+ start: as_of - length * index,
92
+ end: as_of - length * index + length,
93
+ complete: true,
94
+ used: Some(2500.0),
95
+ breakdowns: None,
96
+ })
97
+ .collect()
98
+ }),
99
+ });
100
+ view.plan.poll();
101
+ view.plan.expanded = [Some("0-0".into()), Some("1-0".into())];
102
+ view.section = Section::Plan;
103
+ }
104
+ navigate(
105
+ &mut views,
106
+ &[
107
+ ('l', KeyCode::Right),
108
+ ('j', KeyCode::Down),
109
+ ('k', KeyCode::Up),
110
+ ('h', KeyCode::Left),
111
+ ('j', KeyCode::Down),
112
+ ],
113
+ /*size*/ (120, 30),
114
+ );
115
+ assert_eq!(
116
+ (
117
+ views[0].plan.window,
118
+ views[0].plan.cursor,
119
+ &views[0].plan.expanded
120
+ ),
121
+ (0, [1, 0], &[None, None])
122
+ );
123
+ }
124
+
125
+ #[test]
126
+ fn vim_summary_navigation_scrolls_like_arrows() {
127
+ let mut views = [
128
+ fixture::view(AccountKind::Consumer),
129
+ fixture::view(AccountKind::Consumer),
130
+ ];
131
+ for view in &mut views {
132
+ view.section = Section::Summary;
133
+ view.profile = Load::Ready(
134
+ serde_json::from_value(json!({
135
+ "stats": {"lifetime_tokens": 123, "daily_usage_buckets": [
136
+ {"start_date": "2026-09-02", "tokens": 123}
137
+ ]}
138
+ }))
139
+ .unwrap(),
140
+ );
141
+ assert!(screen(view, /*width*/ 64, /*height*/ 18).contains("scroll"));
142
+ }
143
+ navigate(
144
+ &mut views,
145
+ &[
146
+ ('h', KeyCode::Left),
147
+ ('l', KeyCode::Right),
148
+ ('j', KeyCode::Down),
149
+ ('j', KeyCode::Down),
150
+ ('k', KeyCode::Up),
151
+ ],
152
+ /*size*/ (64, 18),
153
+ );
154
+ assert_eq!(views[0].scroll_offset, 1);
155
+ }
156
+
157
+ #[test]
158
+ fn vim_chat_navigation_expands_collapses_and_moves_like_arrows() {
159
+ for kind in [AccountKind::Consumer, AccountKind::Business] {
160
+ let mut views = [fixture::view(kind), fixture::view(kind)];
161
+ for view in &mut views {
162
+ view.section = Section::Chats;
163
+ if kind == AccountKind::Consumer {
164
+ view.tasks = Load::Ready(tasks::Chats {
165
+ rows: (0..2)
166
+ .map(|index| tasks::Chat {
167
+ title: format!("Example chat {index}"),
168
+ task: Some(
169
+ serde_json::from_value(json!({
170
+ "thread_id": format!("chat-{index}"),
171
+ "data_status": "available", "usage_source": "accounting",
172
+ "weekly_limit_percent": 20 - index,
173
+ "five_hour_limit_percent": null, "balance_usage_credits": null,
174
+ "groups": []
175
+ }))
176
+ .unwrap(),
177
+ ),
178
+ })
179
+ .collect(),
180
+ ..tasks::Chats::default()
181
+ });
182
+ }
183
+ }
184
+ navigate(
185
+ &mut views,
186
+ &[
187
+ ('l', KeyCode::Right),
188
+ ('h', KeyCode::Left),
189
+ ('l', KeyCode::Right),
190
+ ('j', KeyCode::Down),
191
+ ('l', KeyCode::Right),
192
+ ('k', KeyCode::Up),
193
+ ],
194
+ /*size*/ (96, 32),
195
+ );
196
+ assert_eq!(
197
+ (
198
+ views[0].sections[Section::Chats].cursor,
199
+ views[0].sections[Section::Chats].detail
200
+ ),
201
+ (0, None)
202
+ );
203
+ }
204
+ }
205
+
206
+ #[test]
207
+ fn configured_vim_keys_win_over_horizontal_aliases() {
208
+ let mut config = TuiKeymap::default();
209
+ config.list.jump_top = Some(KeybindingsSpec::Many(vec![
210
+ KeybindingSpec("h".into()),
211
+ KeybindingSpec("l".into()),
212
+ ]));
213
+ let mut view = fixture::view(AccountKind::Consumer);
214
+ view.keymap = RuntimeKeymap::from_config(&config).unwrap().list;
215
+ for alias in ['h', 'l'] {
216
+ view.sections[Section::Usage].cursor = 3;
217
+ press(&mut view, KeyCode::Char(alias));
218
+ assert_eq!(view.sections[Section::Usage].cursor, 0);
219
+ }
220
+ }
221
+
222
+ #[test]
223
+ fn vim_aliases_respect_removed_and_remapped_arrows() {
224
+ let mut config = TuiKeymap::default();
225
+ config.list.move_left = Some(KeybindingsSpec::Many(Vec::new()));
226
+ config.list.move_right = Some(KeybindingsSpec::Many(Vec::new()));
227
+ config.list.move_up = Some(KeybindingsSpec::One(KeybindingSpec("up".into())));
228
+ config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("down".into())));
229
+ let mut view = fixture::view(AccountKind::Consumer);
230
+ view.keymap = RuntimeKeymap::from_config(&config).unwrap().list;
231
+ view.sections[Section::Usage].cursor = 3;
232
+ for alias in ['h', 'j', 'k', 'l'] {
233
+ press(&mut view, KeyCode::Char(alias));
234
+ assert_eq!(view.sections[Section::Usage].cursor, 3);
235
+ }
236
+
237
+ config.list.move_up = Some(KeybindingsSpec::One(KeybindingSpec("right".into())));
238
+ config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("left".into())));
239
+ view.keymap = RuntimeKeymap::from_config(&config).unwrap().list;
240
+ press(&mut view, KeyCode::Char('h'));
241
+ assert_eq!(view.sections[Section::Usage].cursor, 4);
242
+ press(&mut view, KeyCode::Char('l'));
243
+ assert_eq!(view.sections[Section::Usage].cursor, 3);
244
+ }
245
+
246
+ #[test]
247
+ fn horizontal_aliases_ignore_modifiers_and_release_but_accept_repeat() {
248
+ let mut view = fixture::view(AccountKind::Consumer);
249
+ view.sections[Section::Usage].cursor = 3;
250
+ for alias in ['h', 'l'] {
251
+ for modifiers in [
252
+ KeyModifiers::ALT,
253
+ KeyModifiers::SHIFT,
254
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
255
+ ] {
256
+ view.handle_key(KeyEvent::new(KeyCode::Char(alias), modifiers));
257
+ assert_eq!(view.sections[Section::Usage].cursor, 3);
258
+ }
259
+ view.handle_key(KeyEvent {
260
+ kind: KeyEventKind::Release,
261
+ ..KeyEvent::new(KeyCode::Char(alias), KeyModifiers::NONE)
262
+ });
263
+ assert_eq!(view.sections[Section::Usage].cursor, 3);
264
+ }
265
+ for (alias, cursor) in [('h', 2), ('l', 3)] {
266
+ view.handle_key(KeyEvent {
267
+ kind: KeyEventKind::Repeat,
268
+ ..KeyEvent::new(KeyCode::Char(alias), KeyModifiers::NONE)
269
+ });
270
+ assert_eq!(view.sections[Section::Usage].cursor, cursor);
271
+ }
272
+ }
codex-rs/tui/src/analytics/normalize.rs ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Converts backend reports into display data while preserving missing values and full denominators.
2
+
3
+ use super::models::AccountAnalyticsGrouping as Grouping;
4
+ use super::models::AccountAnalyticsReport as Report;
5
+ use super::models::*;
6
+ use super::report_data::AnalyticsData;
7
+ use chrono::DateTime;
8
+ use chrono::NaiveDate;
9
+ use std::collections::BTreeMap;
10
+
11
+ /// Out-of-range legacy rows do not change attribution semantics for the requested period.
12
+ pub(super) fn has_complete_attribution(
13
+ response: &AnalyticsData,
14
+ start: NaiveDate,
15
+ end: NaiveDate,
16
+ ) -> Result<bool, String> {
17
+ response
18
+ .data
19
+ .iter()
20
+ .try_fold(/*init*/ true, |complete, record| {
21
+ let date = NaiveDate::parse_from_str(
22
+ record.date.get(..10).unwrap_or(&record.date),
23
+ "%Y-%m-%d",
24
+ )
25
+ .map_err(|_| String::from("Analytics returned an invalid date."))?;
26
+ Ok(complete && (date < start || date > end || record.attribution.is_some()))
27
+ })
28
+ }
29
+
30
+ pub(super) fn history(
31
+ response: AnalyticsData,
32
+ report: Report,
33
+ grouping: Grouping,
34
+ start: NaiveDate,
35
+ end: NaiveDate,
36
+ ) -> Result<Option<AccountAnalyticsHistory>, String> {
37
+ if report == Report::Messages {
38
+ match grouping {
39
+ Grouping::Model | Grouping::Surface => {}
40
+ Grouping::Feature
41
+ | Grouping::TaskStart
42
+ | Grouping::Speed
43
+ | Grouping::Reasoning
44
+ | Grouping::TokenType => {
45
+ return Err(String::from("Unsupported message count grouping."));
46
+ }
47
+ }
48
+ }
49
+ let attributed = has_complete_attribution(&response, start, end)?;
50
+ let series = response.series.as_ref();
51
+ let mut days: BTreeMap<NaiveDate, (f64, BTreeMap<String, f64>)> = BTreeMap::new();
52
+ for record in response.data {
53
+ let date =
54
+ NaiveDate::parse_from_str(record.date.get(..10).unwrap_or(&record.date), "%Y-%m-%d")
55
+ .map_err(|_| String::from("Analytics returned an invalid date."))?;
56
+ if date < start || date > end {
57
+ continue;
58
+ }
59
+ let (total, values) = days.entry(date).or_default();
60
+ match report {
61
+ Report::Usage => {
62
+ let daily = if attributed {
63
+ None
64
+ } else {
65
+ match grouping {
66
+ Grouping::Surface => record.product_surface_usage_values,
67
+ Grouping::Model => record
68
+ .models
69
+ .map(|models| -> Result<_, String> {
70
+ let mut values = BTreeMap::new();
71
+ for model in models {
72
+ let amount = model
73
+ .credits
74
+ .ok_or("Plan usage amount was not reported.")?;
75
+ *values.entry(model.model).or_insert(/*default*/ 0.0) += amount;
76
+ }
77
+ Ok(values)
78
+ })
79
+ .transpose()?,
80
+ _ => None,
81
+ }
82
+ };
83
+ if let Some(daily) = daily {
84
+ for (key, amount) in daily {
85
+ if !amount.is_finite() || amount < 0.0 {
86
+ return Err(String::from("Analytics returned an invalid amount."));
87
+ }
88
+ let key = if grouping == Grouping::Surface {
89
+ if key.starts_with("work_") {
90
+ "work"
91
+ } else {
92
+ "codex"
93
+ }
94
+ .to_string()
95
+ } else if key.eq_ignore_ascii_case("other") {
96
+ "other".to_string()
97
+ } else {
98
+ key
99
+ };
100
+ *total += amount;
101
+ *values.entry(key).or_default() += amount;
102
+ }
103
+ } else if let Some(attribution) = record.attribution.filter(|_| attributed) {
104
+ for entry in attribution {
105
+ if !entry.value.is_finite() || entry.value < 0.0 {
106
+ return Err(String::from("Analytics returned an invalid amount."));
107
+ }
108
+ *total += entry.value;
109
+ let key = match grouping {
110
+ Grouping::Feature => entry.thread_source,
111
+ Grouping::Model => entry.model,
112
+ Grouping::Surface => entry.surface,
113
+ Grouping::TaskStart => {
114
+ entry.turn_trigger.map(|trigger| format!("start-{trigger}"))
115
+ }
116
+ Grouping::Speed | Grouping::Reasoning | Grouping::TokenType => {
117
+ return Err(String::from("unsupported analytics grouping"));
118
+ }
119
+ }
120
+ .unwrap_or_else(|| "unknown".to_string());
121
+ *values.entry(key).or_default() += entry.value;
122
+ }
123
+ } else {
124
+ return Ok(None);
125
+ }
126
+ }
127
+
128
+ Report::Credits => {
129
+ if series.is_some() {
130
+ let Some(credits) = record.values else {
131
+ return Ok(None);
132
+ };
133
+ for (key, amount) in credits {
134
+ *values.entry(key).or_default() += amount;
135
+ *total += amount;
136
+ }
137
+ } else if matches!(grouping, Grouping::Model | Grouping::Speed) {
138
+ let Some(models) = record.models else {
139
+ return Ok(None);
140
+ };
141
+ for model in models {
142
+ let amount = model.on_demand_credits.unwrap_or(/*default*/ 0.0);
143
+ let key = if grouping == Grouping::Speed {
144
+ model.speed.unwrap_or_else(|| "unknown".to_string())
145
+ } else {
146
+ model.model
147
+ };
148
+ *values.entry(key).or_default() += amount;
149
+ *total += amount;
150
+ }
151
+ } else if let Some(premium) = record.premium_usage_values {
152
+ for (surface, amount) in premium.credit_usage_credits {
153
+ if !amount.is_finite() {
154
+ return Err(String::from("Analytics returned an invalid amount."));
155
+ }
156
+ *values.entry(surface).or_default() += amount;
157
+ *total += amount;
158
+ }
159
+ } else if let (Some(amount), Some(surface)) =
160
+ (record.credit_amount, record.product_surface)
161
+ {
162
+ *values.entry(surface).or_default() += amount;
163
+ *total += amount;
164
+ } else {
165
+ return Ok(None);
166
+ }
167
+ }
168
+ Report::Messages => {
169
+ let authoritative = record
170
+ .totals
171
+ .ok_or_else(|| String::from("Message count is unavailable."))?
172
+ .turns;
173
+ if !authoritative.is_finite() || authoritative < 0.0 {
174
+ return Err(String::from("Analytics returned an invalid amount."));
175
+ }
176
+ let mut record_values = BTreeMap::<String, f64>::new();
177
+ if grouping == Grouping::Model {
178
+ for model in record.models.unwrap_or_default() {
179
+ let key = if model.model.eq_ignore_ascii_case("codex-auto-review")
180
+ || model.model.eq_ignore_ascii_case("other")
181
+ {
182
+ "other".to_string()
183
+ } else {
184
+ model.model
185
+ };
186
+ let count = model
187
+ .turns
188
+ .ok_or_else(|| String::from("Turn count is unavailable."))?;
189
+ if !count.is_finite() || count < 0.0 {
190
+ return Err(String::from("Analytics returned an invalid amount."));
191
+ }
192
+ *record_values.entry(key).or_default() += count;
193
+ }
194
+ } else {
195
+ for client in record.clients.unwrap_or_default() {
196
+ if !client.turns.is_finite() || client.turns < 0.0 {
197
+ return Err(String::from("Analytics returned an invalid amount."));
198
+ }
199
+ let key = match client.client_id.as_str() {
200
+ "CODEX_CLI" => "cli",
201
+ "CODEX_IDE_VSCODE" => "vscode",
202
+ "CODEX_WEB" => "web",
203
+ "CODEX_DESKTOP_APP" | "CODEX_WORK_DESKTOP" => "desktop_app",
204
+ "CODEX_WORK_WEB" => "work_web",
205
+ "CODEX_WORK_MOBILE" => "mobile",
206
+ "CODEX_GITHUB" => "github_code_review",
207
+ _ => "other",
208
+ };
209
+ *record_values.entry(key.to_string()).or_default() += client.turns;
210
+ }
211
+ }
212
+ let remainder =
213
+ (authoritative - record_values.values().sum::<f64>()).max(/*other*/ 0.0);
214
+ if remainder > 0.0 {
215
+ *record_values.entry("other".to_string()).or_default() += remainder;
216
+ }
217
+ for (key, count) in record_values {
218
+ *values.entry(key).or_default() += count;
219
+ }
220
+ *total += authoritative;
221
+ }
222
+ Report::Plugins | Report::Skills => {
223
+ let overviews = if report == Report::Skills {
224
+ record.skill_usage_overviews
225
+ } else {
226
+ record.plugin_usage_overviews
227
+ }
228
+ .ok_or_else(|| String::from("Tool activity is unavailable."))?;
229
+ for tool in overviews {
230
+ if !tool.invocation_counts.is_finite() || tool.invocation_counts < 0.0 {
231
+ return Err(String::from("Analytics returned an invalid amount."));
232
+ }
233
+ *values.entry(tool.display_name).or_default() += tool.invocation_counts;
234
+ *total += tool.invocation_counts;
235
+ }
236
+ }
237
+ }
238
+ if !total.is_finite()
239
+ || values
240
+ .values()
241
+ .any(|value| !value.is_finite() || (report != Report::Credits && *value < 0.0))
242
+ {
243
+ return Err(String::from("Analytics returned an invalid amount."));
244
+ }
245
+ }
246
+ if report == Report::Usage
247
+ && !attributed
248
+ && grouping == Grouping::Model
249
+ && response.units.as_deref() != Some("credits")
250
+ {
251
+ let mut totals = BTreeMap::<String, f64>::new();
252
+ for (_, values) in days.values() {
253
+ for (key, value) in values {
254
+ *totals.entry(key.clone()).or_default() += value;
255
+ }
256
+ }
257
+ let total: f64 = totals.values().sum();
258
+ for (_, values) in days.values_mut() {
259
+ let mut other = 0.0;
260
+ values.retain(|key, value| {
261
+ let amount = totals[key];
262
+ if key.eq_ignore_ascii_case("other") || (amount > 0.0 && amount < total * 0.01) {
263
+ other += *value;
264
+ false
265
+ } else {
266
+ true
267
+ }
268
+ });
269
+ if other > 0.0 {
270
+ values.insert("other".to_string(), other);
271
+ }
272
+ }
273
+ }
274
+ if report == Report::Credits {
275
+ for date in start.iter_days().take_while(|date| *date <= end) {
276
+ days.entry(date).or_default();
277
+ }
278
+ }
279
+ Ok(Some(AccountAnalyticsHistory {
280
+ unit: match report {
281
+ Report::Usage if !attributed && response.units.as_deref() == Some("credits") => {
282
+ AccountAnalyticsUnit::Credits
283
+ }
284
+ Report::Usage => AccountAnalyticsUnit::RelativeUsage,
285
+ Report::Credits => AccountAnalyticsUnit::Credits,
286
+ Report::Messages | Report::Plugins | Report::Skills => AccountAnalyticsUnit::Count,
287
+ },
288
+ updated_at: response
289
+ .data_freshness_ts
290
+ .as_deref()
291
+ .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok())
292
+ .map(|timestamp| timestamp.timestamp()),
293
+ data: days
294
+ .into_iter()
295
+ .map(|(date, (total, values))| AccountAnalyticsDay {
296
+ date,
297
+ total,
298
+ values: values
299
+ .into_iter()
300
+ .map(|(key, value)| AccountAnalyticsValue {
301
+ label: series
302
+ .and_then(|series| series.iter().find(|series| series.key == key))
303
+ .map(|series| series.label.clone())
304
+ .unwrap_or_else(|| label(&key).to_string()),
305
+ key,
306
+ value,
307
+ })
308
+ .collect(),
309
+ })
310
+ .collect(),
311
+ }))
312
+ }
313
+
314
+ pub(super) fn label(key: &str) -> &str {
315
+ match key {
316
+ "user" => "Tasks",
317
+ "subagent" => "Subagents",
318
+ "image_generation" => "Image generation",
319
+ "automation" => "Automations",
320
+ "guardian_review" => "Auto review",
321
+ "guardian_classifier" => "Auto review classifier",
322
+ "thread_title" => "Thread title",
323
+ "system" => "System",
324
+ "automated_review" => "Auto review",
325
+ "agent_identity" => "Workspace agents",
326
+ "memory_consolidation" => "Memory consolidation",
327
+ "cli" => "CLI",
328
+ "desktop_app" => "Desktop app",
329
+ "vscode" => "VS Code",
330
+ "web" => "Web",
331
+ "work_web" => "Work web",
332
+ "work_desktop" => "Work desktop",
333
+ "work_mobile" => "Work mobile",
334
+ "mobile" => "Mobile",
335
+ "slack" => "Slack",
336
+ "linear" => "Linear",
337
+ "jetbrains" => "JetBrains",
338
+ "sdk" => "SDK",
339
+ "exec" => "Exec",
340
+ "github" => "GitHub",
341
+ "codex" => "Codex",
342
+ "work" => "Work",
343
+ "code_review" | "github_code_review" => "Code review",
344
+ "start-user" | "start-composer" => "User messages",
345
+ "start-goal" => "Goals",
346
+ "start-composer_queue" => "Queued messages",
347
+ "start-composer_queue_run_now" => "Queued messages run now",
348
+ "start-automation_cron_scheduled" => "Scheduled automations",
349
+ "start-automation_cron_run_now" => "Automations run now",
350
+ "start-automation_heartbeat_scheduled" => "Scheduled follow-ups",
351
+ "start-automation_heartbeat_run_now" => "Follow-ups run now",
352
+ "start-app_tool_create_thread" => "Agent-created tasks",
353
+ "start-app_tool_send_message" => "Agent follow-ups",
354
+ "unknown" | "start-unknown" => "Unknown",
355
+ "fast" => "Fast",
356
+ "standard" => "Standard",
357
+ "other" | "start-other" => "Other",
358
+ _ => key,
359
+ }
360
+ }
361
+
362
+ #[cfg(test)]
363
+ #[path = "normalize_tests.rs"]
364
+ mod tests;
codex-rs/tui/src/analytics/normalize_tests.rs ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Regression coverage for daily report normalization and billing semantics.
2
+
3
+ use super::*;
4
+ use pretty_assertions::assert_eq;
5
+ use serde_json::json;
6
+
7
+ #[test]
8
+ fn turn_start_includes_all_features_and_missing_attribution_is_unavailable() {
9
+ let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
10
+ let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "attribution": [
11
+ {"thread_source": "user", "turn_trigger": "composer", "value": 30},
12
+ {"thread_source": "subagent", "value": 70}
13
+ ]}]}))
14
+ .unwrap();
15
+ assert_eq!(
16
+ history(response, Report::Usage, Grouping::TaskStart, date, date).unwrap(),
17
+ Some(AccountAnalyticsHistory {
18
+ unit: AccountAnalyticsUnit::RelativeUsage,
19
+ updated_at: None,
20
+ data: vec![AccountAnalyticsDay {
21
+ date: "2026-01-09".parse().unwrap(),
22
+ total: 100.0,
23
+ values: vec![
24
+ AccountAnalyticsValue {
25
+ key: "start-composer".to_string(),
26
+ label: "User messages".to_string(),
27
+ value: 30.0
28
+ },
29
+ AccountAnalyticsValue {
30
+ key: "unknown".to_string(),
31
+ label: "Unknown".to_string(),
32
+ value: 70.0
33
+ }
34
+ ],
35
+ }],
36
+ })
37
+ );
38
+ let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09"}]})).unwrap();
39
+ assert_eq!(
40
+ history(response, Report::Usage, Grouping::Feature, date, date).unwrap(),
41
+ None
42
+ );
43
+ }
44
+
45
+ #[test]
46
+ fn signed_credits_keep_daily_activity_when_the_period_nets_to_zero() {
47
+ let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap();
48
+ let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
49
+ let response = serde_json::from_value(json!({"data": [
50
+ {"date": "2026-01-08", "product_surface": "cli", "credit_amount": 0.004},
51
+ {"date": "2026-01-09", "product_surface": "desktop_app", "credit_amount": -0.004}
52
+ ]}))
53
+ .unwrap();
54
+ assert_eq!(
55
+ history(response, Report::Credits, Grouping::Surface, start, end).unwrap(),
56
+ Some(AccountAnalyticsHistory {
57
+ unit: AccountAnalyticsUnit::Credits,
58
+ updated_at: None,
59
+ data: [
60
+ ("2026-01-08", 0.004, "cli", "CLI"),
61
+ ("2026-01-09", -0.004, "desktop_app", "Desktop app")
62
+ ]
63
+ .into_iter()
64
+ .map(|(date, amount, key, label)| AccountAnalyticsDay {
65
+ date: date.parse().unwrap(),
66
+ total: amount,
67
+ values: vec![AccountAnalyticsValue {
68
+ key: key.to_string(),
69
+ label: label.to_string(),
70
+ value: amount
71
+ }],
72
+ })
73
+ .collect(),
74
+ })
75
+ );
76
+ }
77
+
78
+ #[test]
79
+ fn message_surfaces_include_both_desktop_clients_and_unmapped_counts() {
80
+ let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
81
+ let response = serde_json::from_value(
82
+ json!({"data": [{"date": "2026-01-09", "totals": {"turns": 10}, "clients": [
83
+ {"client_id": "CODEX_DESKTOP_APP", "turns": 2},
84
+ {"client_id": "CODEX_WORK_DESKTOP", "turns": 3},
85
+ {"client_id": "new_client", "turns": 1}
86
+ ]}]}),
87
+ )
88
+ .unwrap();
89
+ assert_eq!(
90
+ history(response, Report::Messages, Grouping::Surface, date, date)
91
+ .unwrap()
92
+ .unwrap()
93
+ .data,
94
+ vec![AccountAnalyticsDay {
95
+ date: "2026-01-09".parse().unwrap(),
96
+ total: 10.0,
97
+ values: vec![
98
+ AccountAnalyticsValue {
99
+ key: "desktop_app".to_string(),
100
+ label: "Desktop app".to_string(),
101
+ value: 5.0
102
+ },
103
+ AccountAnalyticsValue {
104
+ key: "other".to_string(),
105
+ label: "Other".to_string(),
106
+ value: 5.0
107
+ },
108
+ ],
109
+ }]
110
+ );
111
+ }
112
+
113
+ #[test]
114
+ fn enterprise_credit_series_keep_labels_signed_values_and_freshness() {
115
+ let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
116
+ let response = serde_json::from_value(json!({
117
+ "series": [{"key": "medium", "label": "Medium", "total": 12.5}, {"key": "high", "label": "High", "total": -0.004}],
118
+ "data_freshness_ts": "2026-01-09T10:00:00Z",
119
+ "data": [{"date": "2026-01-09", "values": {"medium": 12.5, "high": -0.004}}]
120
+ })).unwrap();
121
+ assert_eq!(
122
+ history(response, Report::Credits, Grouping::Reasoning, date, date).unwrap(),
123
+ Some(AccountAnalyticsHistory {
124
+ unit: AccountAnalyticsUnit::Credits,
125
+ updated_at: Some(1767952800),
126
+ data: vec![AccountAnalyticsDay {
127
+ date,
128
+ total: 12.496,
129
+ values: vec![
130
+ AccountAnalyticsValue {
131
+ key: "high".into(),
132
+ label: "High".into(),
133
+ value: -0.004
134
+ },
135
+ AccountAnalyticsValue {
136
+ key: "medium".into(),
137
+ label: "Medium".into(),
138
+ value: 12.5
139
+ }
140
+ ],
141
+ }],
142
+ })
143
+ );
144
+ }
145
+
146
+ #[test]
147
+ fn credit_timestamps_and_legacy_model_usage_follow_app_fallbacks() {
148
+ let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
149
+ let credits = serde_json::from_value(json!({"data": [{"date": "2026-01-09T05:00:00Z", "product_surface": "cli", "credit_amount": 12.5}]})).unwrap();
150
+ let usage = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "models": [{"model": "model-a", "credits": 12.5}]}]})).unwrap();
151
+ let credits = history(credits, Report::Credits, Grouping::Surface, date, date)
152
+ .unwrap()
153
+ .unwrap();
154
+ let usage = history(usage, Report::Usage, Grouping::Model, date, date)
155
+ .unwrap()
156
+ .unwrap();
157
+ assert_eq!(
158
+ (
159
+ credits.data[0].total,
160
+ usage.data[0].total,
161
+ usage.data[0].values[0].key.as_str()
162
+ ),
163
+ (12.5, 12.5, "model-a")
164
+ );
165
+ }
166
+
167
+ #[test]
168
+ fn count_reports_preserve_explicit_zero_and_omit_unreported_days() {
169
+ let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap();
170
+ let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 10).unwrap();
171
+ for (report, field) in [
172
+ (Report::Messages, "totals"),
173
+ (Report::Plugins, "plugin_usage_overviews"),
174
+ (Report::Skills, "skill_usage_overviews"),
175
+ ] {
176
+ let value = if report == Report::Messages {
177
+ json!({"turns": 0})
178
+ } else {
179
+ json!([])
180
+ };
181
+ let response =
182
+ serde_json::from_value(json!({"data": [{"date": "2026-01-08", (field): value}]}))
183
+ .unwrap();
184
+ assert_eq!(
185
+ history(response, report, Grouping::Surface, start, end).unwrap(),
186
+ Some(AccountAnalyticsHistory {
187
+ unit: AccountAnalyticsUnit::Count,
188
+ updated_at: None,
189
+ data: vec![AccountAnalyticsDay {
190
+ date: start,
191
+ total: 0.0,
192
+ values: Vec::new()
193
+ }],
194
+ })
195
+ );
196
+ }
197
+ }
198
+
199
+ #[test]
200
+ fn legacy_consumer_history_keeps_daily_products_and_models() {
201
+ let date = "2026-09-01".parse().unwrap();
202
+ let response: AnalyticsData = serde_json::from_value(json!({"units":"relative", "data":[{
203
+ "date":"2026-09-01",
204
+ "product_surface_usage_values":{"work_desktop":12,"work_web":8,"cli":30,"desktop_app":50},
205
+ "models":[{"model":"major","credits":98.5},{"model":"one-percent","credits":1},{"model":"tiny","credits":0.3},{"model":"OTHER","credits":0.2}]
206
+ }]})).unwrap();
207
+ for (grouping, expected) in [
208
+ (
209
+ Grouping::Surface,
210
+ vec![("codex", "Codex", 80.0), ("work", "Work", 20.0)],
211
+ ),
212
+ (
213
+ Grouping::Model,
214
+ vec![
215
+ ("major", "major", 98.5),
216
+ ("one-percent", "one-percent", 1.0),
217
+ ("other", "Other", 0.5),
218
+ ],
219
+ ),
220
+ ] {
221
+ assert_eq!(
222
+ history(response.clone(), Report::Usage, grouping, date, date).unwrap(),
223
+ Some(AccountAnalyticsHistory {
224
+ unit: AccountAnalyticsUnit::RelativeUsage,
225
+ updated_at: None,
226
+ data: vec![AccountAnalyticsDay {
227
+ date,
228
+ total: 100.0,
229
+ values: expected
230
+ .into_iter()
231
+ .map(|(key, label, value)| AccountAnalyticsValue {
232
+ key: key.into(),
233
+ label: label.into(),
234
+ value
235
+ })
236
+ .collect()
237
+ }],
238
+ })
239
+ );
240
+ }
241
+ let mut credits = response;
242
+ credits.units = Some("credits".into());
243
+ let model = history(credits, Report::Usage, Grouping::Model, date, date)
244
+ .unwrap()
245
+ .unwrap();
246
+ assert_eq!(
247
+ (model.unit, model.data[0].values.len()),
248
+ (AccountAnalyticsUnit::Credits, 4)
249
+ );
250
+ }
251
+
252
+ #[test]
253
+ fn complete_attribution_wins_consistently_and_incomplete_attribution_is_not_mixed() {
254
+ let date = "2026-09-01".parse().unwrap();
255
+ let response: AnalyticsData = serde_json::from_value(json!({"units":"credits", "data":[{
256
+ "date":"2026-09-01", "product_surface_usage_values":{"cli":999},
257
+ "models":[{"model":"legacy","credits":999}],
258
+ "attribution":[
259
+ {"thread_source":"user","turn_trigger":"composer","surface":"desktop_app","model":"alpha","value":30},
260
+ {"thread_source":"subagent","turn_trigger":"goal","surface":"cli","model":"tiny","value":0.1}
261
+ ]
262
+ }]})).unwrap();
263
+ for grouping in [
264
+ Grouping::Feature,
265
+ Grouping::Model,
266
+ Grouping::Surface,
267
+ Grouping::TaskStart,
268
+ ] {
269
+ let normalized = history(response.clone(), Report::Usage, grouping, date, date)
270
+ .unwrap()
271
+ .unwrap();
272
+ assert_eq!(
273
+ (
274
+ normalized.unit,
275
+ normalized.data[0].total,
276
+ normalized.data[0].values.len()
277
+ ),
278
+ (AccountAnalyticsUnit::RelativeUsage, 30.1, 2)
279
+ );
280
+ }
281
+ let incomplete = serde_json::from_value(json!({"data":[
282
+ {"date":"2026-09-01", "attribution":[]}, {"date":"2026-09-02"}
283
+ ]}))
284
+ .unwrap();
285
+ assert_eq!(
286
+ history(
287
+ incomplete,
288
+ Report::Usage,
289
+ Grouping::Feature,
290
+ date,
291
+ "2026-09-02".parse().unwrap()
292
+ )
293
+ .unwrap(),
294
+ None
295
+ );
296
+ }
297
+
298
+ #[test]
299
+ fn duplicate_message_dates_preserve_each_records_other_remainder() {
300
+ let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
301
+ for grouping in [Grouping::Model, Grouping::Surface] {
302
+ let response = serde_json::from_value(json!({"data": [
303
+ {"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]},
304
+ {"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}
305
+ ]})).unwrap();
306
+ let (key, label) = if grouping == Grouping::Model {
307
+ ("example", "example")
308
+ } else {
309
+ ("cli", "CLI")
310
+ };
311
+ assert_eq!(
312
+ history(response, Report::Messages, grouping, date, date).unwrap(),
313
+ Some(AccountAnalyticsHistory {
314
+ unit: AccountAnalyticsUnit::Count,
315
+ updated_at: None,
316
+ data: vec![AccountAnalyticsDay {
317
+ date,
318
+ total: 20.0,
319
+ values: vec![
320
+ AccountAnalyticsValue {
321
+ key: key.into(),
322
+ label: label.into(),
323
+ value: 16.0
324
+ },
325
+ AccountAnalyticsValue {
326
+ key: "other".into(),
327
+ label: "Other".into(),
328
+ value: 4.0
329
+ },
330
+ ]
331
+ }],
332
+ })
333
+ );
334
+ }
335
+ }
336
+
337
+ #[test]
338
+ fn unsupported_message_groupings_fail_even_without_records() {
339
+ let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
340
+ for grouping in [
341
+ Grouping::Feature,
342
+ Grouping::TaskStart,
343
+ Grouping::Speed,
344
+ Grouping::Reasoning,
345
+ Grouping::TokenType,
346
+ ] {
347
+ for data in [
348
+ json!([]),
349
+ json!([{"date": "2026-01-09", "totals": {"turns": 10}, "clients": []}]),
350
+ ] {
351
+ let response = serde_json::from_value(json!({"data": data})).unwrap();
352
+ assert_eq!(
353
+ history(response, Report::Messages, grouping, date, date),
354
+ Err("Unsupported message count grouping.".into())
355
+ );
356
+ }
357
+ }
358
+ }
codex-rs/tui/src/analytics/panels.rs ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Responsive panels with consistent category colors and signed daily amounts.
2
+
3
+ use super::AnalyticsView;
4
+ use super::render::columns;
5
+ use super::styles::secondary_style;
6
+ use crate::analytics::sections::Section;
7
+ use crate::style::accent_style;
8
+ use crate::wrapping::RtOptions;
9
+ use crate::wrapping::word_wrap_lines;
10
+ use ratatui::style::Styled;
11
+ use ratatui::style::Stylize;
12
+ use ratatui::text::Line;
13
+ use std::ops::Range;
14
+
15
+ /// Panel content and measured geometry shared by layout and scrolling.
16
+ pub(super) struct Panel {
17
+ pub(super) lines: Vec<Line<'static>>,
18
+ pub(super) selection: Range<usize>,
19
+ pub(super) chart_bands: usize,
20
+ }
21
+
22
+ impl AnalyticsView {
23
+ pub(super) fn panel(&self, section: Section, width: usize, chart_height: usize) -> Panel {
24
+ let focused = section == self.section;
25
+ let title = format!(
26
+ "{} {}",
27
+ if focused { "▎" } else { " " },
28
+ self.section_title(section)
29
+ );
30
+ let heading = if focused {
31
+ title.set_style(accent_style()).into()
32
+ } else {
33
+ title.bold().into()
34
+ };
35
+ let group = self.sections[section].group;
36
+ let heading = if matches!(
37
+ section,
38
+ Section::Usage | Section::Credits | Section::Activity | Section::Plan
39
+ ) {
40
+ columns(
41
+ heading,
42
+ format!("By {}", self.group_label(section, group).to_lowercase())
43
+ .set_style(secondary_style())
44
+ .into(),
45
+ width,
46
+ )
47
+ } else {
48
+ heading
49
+ };
50
+ let mut lines = vec![heading, "─".repeat(width).dim().into()];
51
+ if section == Section::Usage && self.business() {
52
+ lines.push(
53
+ format!(
54
+ "{} · m change model",
55
+ self.token_model.as_deref().unwrap_or("All models")
56
+ )
57
+ .set_style(secondary_style())
58
+ .into(),
59
+ );
60
+ }
61
+ let selection;
62
+ let mut chart_bands = 0;
63
+
64
+ if section == Section::Summary {
65
+ selection = 0..1;
66
+ lines.extend(self.summary_lines(width));
67
+ } else if section == Section::Plan {
68
+ let (plan, detail) = self.plan_lines(width);
69
+ selection = lines.len() + detail.start..lines.len() + detail.end;
70
+ lines.extend(plan);
71
+ } else if section == Section::Chats {
72
+ let (chats, detail) = if self.business() {
73
+ self.chat_lines(width)
74
+ } else {
75
+ self.task_lines(width)
76
+ };
77
+ selection = lines.len() + detail.start..lines.len() + detail.end;
78
+ lines.extend(chats);
79
+ } else {
80
+ let history = self.history_lines(section, width, chart_height);
81
+ chart_bands = history.bands;
82
+ lines.extend(history.lines);
83
+ selection = 0..lines.len();
84
+ }
85
+
86
+ let mut wrapped = Vec::new();
87
+ let mut wrapped_selection = 0..0;
88
+ for (index, line) in lines.into_iter().enumerate() {
89
+ if index == selection.start {
90
+ wrapped_selection.start = wrapped.len();
91
+ }
92
+ wrapped.extend(word_wrap_lines(
93
+ [line],
94
+ RtOptions::new(width.max(/*other*/ 1)),
95
+ ));
96
+ if index + 1 == selection.end {
97
+ wrapped_selection.end = wrapped.len();
98
+ }
99
+ }
100
+ Panel {
101
+ lines: wrapped,
102
+ selection: wrapped_selection,
103
+ chart_bands,
104
+ }
105
+ }
106
+ }
codex-rs/tui/src/analytics/plan.rs ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Historical allowance snapshots, separate from daily usage and current chat allowances.
2
+ //! Period IDs are snapshot-scoped; unknown accounting is never converted to zero.
3
+ use super::data::Load;
4
+ use chrono::DateTime;
5
+ use chrono::Utc;
6
+ use codex_backend_client::PlanLimitBreakdown;
7
+ use codex_backend_client::PlanLimitHistory;
8
+
9
+ pub(super) struct Period {
10
+ pub id: String,
11
+ pub start: DateTime<Utc>,
12
+ pub end: DateTime<Utc>,
13
+ pub complete: bool,
14
+ pub used: Option<f64>,
15
+ pub breakdowns: Option<Vec<PlanLimitBreakdown>>,
16
+ }
17
+
18
+ pub(super) struct Report {
19
+ pub as_of: DateTime<Utc>,
20
+ pub coverage_start: Option<DateTime<Utc>>,
21
+ pub coverage_complete: bool,
22
+ pub approximate: bool,
23
+ pub periods: [Vec<Period>; 2],
24
+ }
25
+
26
+ impl Report {
27
+ pub(super) fn parse(history: PlanLimitHistory) -> Result<Option<Self>, String> {
28
+ let parse = |value: &str| {
29
+ DateTime::parse_from_rfc3339(value)
30
+ .map(|date| date.with_timezone(&Utc))
31
+ .map_err(|_| "Invalid plan history timestamp.".to_string())
32
+ };
33
+ let Some(as_of) = history.data_as_of.as_deref().map(parse).transpose()? else {
34
+ return Ok(None);
35
+ };
36
+ let mut periods = [Vec::new(), Vec::new()];
37
+ let mut ids = std::collections::HashSet::new();
38
+ if history.periods.len() > 1_000 {
39
+ return Err("Plan history contains too many periods.".into());
40
+ }
41
+ for period in history.periods {
42
+ let window = match period.window_minutes {
43
+ 300 => 0,
44
+ 10080 => 1,
45
+ _ => return Err("Unsupported plan history window.".into()),
46
+ };
47
+ let start = parse(&period.starts_at)?;
48
+ let end = parse(&period.ends_at)?;
49
+ if start >= end
50
+ || !ids.insert(period.id.clone())
51
+ || period
52
+ .used_basis_points
53
+ .is_some_and(|amount| !amount.is_finite())
54
+ || period
55
+ .breakdowns
56
+ .iter()
57
+ .flatten()
58
+ .flat_map(|group| &group.rows)
59
+ .any(|row| !row.basis_points.is_finite())
60
+ {
61
+ return Err("Invalid plan history period.".into());
62
+ }
63
+ periods[window].push(Period {
64
+ id: period.id,
65
+ start,
66
+ end,
67
+ complete: period.accounting_complete,
68
+ used: period.used_basis_points,
69
+ breakdowns: period.breakdowns,
70
+ });
71
+ }
72
+ for window in &mut periods {
73
+ window.sort_by_key(|period| std::cmp::Reverse(period.start));
74
+ }
75
+ Ok(Some(Self {
76
+ as_of,
77
+ coverage_start: history.coverage_start.as_deref().map(parse).transpose()?,
78
+ coverage_complete: history.coverage_complete,
79
+ approximate: history.approximate,
80
+ periods,
81
+ }))
82
+ }
83
+ }
84
+
85
+ pub(super) struct State {
86
+ pub enabled: bool,
87
+ pub report: Load<Report>,
88
+ pub window: usize,
89
+ pub cursor: [usize; 2],
90
+ pub expanded: [Option<String>; 2],
91
+ snapshot: Option<DateTime<Utc>>,
92
+ }
93
+
94
+ impl Default for State {
95
+ fn default() -> Self {
96
+ Self {
97
+ enabled: false,
98
+ report: Load::Unavailable,
99
+ window: 0,
100
+ cursor: [0; 2],
101
+ expanded: [None, None],
102
+ snapshot: None,
103
+ }
104
+ }
105
+ }
106
+
107
+ impl State {
108
+ pub(super) fn poll(&mut self) {
109
+ self.report.poll();
110
+ let snapshot = self.report.ready().map(|report| report.as_of);
111
+ if snapshot != self.snapshot {
112
+ self.snapshot = snapshot;
113
+ self.cursor = [0; 2];
114
+ self.expanded = [None, None];
115
+ }
116
+ }
117
+ }
118
+
119
+ #[cfg(test)]
120
+ #[path = "plan_tests.rs"]
121
+ mod tests;
codex-rs/tui/src/analytics/plan_panel.rs ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Five-hour and weekly period tables share grouping, with independent row expansion.
2
+ use super::AnalyticsView;
3
+ use super::data;
4
+ use super::plan;
5
+ use super::render::columns;
6
+ use super::render::join_columns;
7
+ use super::sections::Section;
8
+ use super::styles::secondary_style;
9
+ use crate::keymap::ListAction;
10
+ use crate::style::accent_style;
11
+ use codex_backend_client::PlanLimitDimension;
12
+ use ratatui::style::Styled;
13
+ use ratatui::style::Stylize;
14
+ use ratatui::text::Line;
15
+ use std::ops::Range;
16
+
17
+ impl AnalyticsView {
18
+ pub(super) fn plan_action(&mut self, action: Option<ListAction>) {
19
+ let window = self.plan.window;
20
+ let periods = self
21
+ .plan
22
+ .report
23
+ .ready()
24
+ .map(|report| &report.periods[window]);
25
+ let count = periods.map_or(/*default*/ 0, Vec::len);
26
+ let cursor = &mut self.plan.cursor[window];
27
+ let previous_cursor = *cursor;
28
+ match action {
29
+ Some(ListAction::MoveLeft | ListAction::MoveRight) => self.plan.window ^= 1,
30
+ Some(ListAction::MoveUp) => *cursor = cursor.saturating_sub(/*rhs*/ 1),
31
+ Some(ListAction::MoveDown) => {
32
+ *cursor = (*cursor + 1).min(count.saturating_sub(/*rhs*/ 1))
33
+ }
34
+ Some(ListAction::JumpTop) => *cursor = 0,
35
+ Some(ListAction::JumpBottom) => *cursor = count.saturating_sub(/*rhs*/ 1),
36
+ Some(ListAction::Accept) => {
37
+ if let Some(period) = periods.and_then(|periods| periods.get(*cursor)) {
38
+ self.plan.expanded[window] =
39
+ if self.plan.expanded[window].as_ref() == Some(&period.id) {
40
+ None
41
+ } else {
42
+ Some(period.id.clone())
43
+ };
44
+ }
45
+ }
46
+ Some(ListAction::Cancel) => {
47
+ self.is_done |= !self.zoomed || self.plan.expanded[window].take().is_none();
48
+ }
49
+ Some(ListAction::PageDown) => {
50
+ self.scroll_offset += self.viewport_height;
51
+ self.follow_selection = false;
52
+ }
53
+ Some(ListAction::PageUp) => {
54
+ self.scroll_offset = self.scroll_offset.saturating_sub(self.viewport_height);
55
+ self.follow_selection = false;
56
+ }
57
+ None => {}
58
+ }
59
+ if *cursor != previous_cursor {
60
+ self.plan.expanded[window] = None;
61
+ }
62
+ }
63
+
64
+ pub(super) fn plan_lines(&self, width: usize) -> (Vec<Line<'static>>, Range<usize>) {
65
+ let side_by_side = width >= 100;
66
+ let table_width = if side_by_side { (width - 4) / 2 } else { width };
67
+ let group = self.sections[Section::Plan].group;
68
+ let dimension = match group {
69
+ 1 => PlanLimitDimension::ThreadSource,
70
+ 2 => PlanLimitDimension::Model,
71
+ 3 => PlanLimitDimension::TurnTrigger,
72
+ _ => PlanLimitDimension::Surface,
73
+ };
74
+ let mut tables = Vec::new();
75
+ let mut selections = Vec::new();
76
+ for (window, title) in ["5-hour limits", "Weekly limits"].into_iter().enumerate() {
77
+ let heading = format!(
78
+ "{} {title}",
79
+ if self.plan.window == window {
80
+ "›"
81
+ } else {
82
+ " "
83
+ }
84
+ );
85
+ let mut lines = vec![heading.bold().into(), Line::default()];
86
+ let mut selection = 0..2;
87
+ if let Some(report) = self.plan.report.ready() {
88
+ if report.periods[window].is_empty() {
89
+ lines.push(
90
+ if report.coverage_complete {
91
+ "No limit periods in this date range"
92
+ } else {
93
+ "Limit history isn't available yet"
94
+ }
95
+ .set_style(secondary_style())
96
+ .into(),
97
+ );
98
+ }
99
+ for (index, period) in report.periods[window]
100
+ .iter()
101
+ .take(if self.zoomed { usize::MAX } else { 1 })
102
+ .enumerate()
103
+ {
104
+ let start = lines.len();
105
+ let selected = self.plan.cursor[window] == index;
106
+ let label = if !self.zoomed {
107
+ format!("Latest period · {} total", report.periods[window].len())
108
+ } else {
109
+ format!(
110
+ "{} {} – {}{}",
111
+ if selected { "›" } else { " " },
112
+ period.start.format("%b %-d %H:%M"),
113
+ period.end.format("%b %-d %H:%M"),
114
+ if period.start <= report.as_of && period.end > report.as_of {
115
+ " *"
116
+ } else {
117
+ ""
118
+ }
119
+ )
120
+ };
121
+ let amount = period
122
+ .used
123
+ .map(|value| format!("{}%", data::amount(value / 100.0)))
124
+ .unwrap_or_else(|| "Not available".into());
125
+ let row = columns(label.into(), amount.into(), table_width);
126
+ lines.push(if selected && self.plan.window == window {
127
+ row.set_style(accent_style())
128
+ } else {
129
+ row
130
+ });
131
+ if self.zoomed && self.plan.expanded[window].as_ref() == Some(&period.id) {
132
+ if !period.complete {
133
+ lines.push(
134
+ " Some usage is unavailable; this period may be incomplete"
135
+ .dim()
136
+ .into(),
137
+ );
138
+ }
139
+ if let Some(breakdown) = period.breakdowns.as_ref().and_then(|groups| {
140
+ groups.iter().find(|group| group.dimension == dimension)
141
+ }) {
142
+ for row in &breakdown.rows {
143
+ let key = if dimension == PlanLimitDimension::TurnTrigger {
144
+ format!("start-{}", row.key)
145
+ } else {
146
+ row.key.clone()
147
+ };
148
+ let label = if dimension == PlanLimitDimension::Model {
149
+ self.model_name(&key)
150
+ } else {
151
+ super::normalize::label(&key)
152
+ };
153
+ lines.push(columns(
154
+ format!(" {label}").into(),
155
+ format!("{}%", data::amount(row.basis_points / 100.0)).into(),
156
+ table_width,
157
+ ));
158
+ }
159
+ } else {
160
+ lines.push(" Breakdown isn't available for this period".dim().into());
161
+ }
162
+ }
163
+ if selected {
164
+ selection = start..lines.len();
165
+ }
166
+ }
167
+ } else {
168
+ lines.push(
169
+ match &self.plan.report {
170
+ data::Load::Unavailable => "Limit history isn't available yet",
171
+ state => state
172
+ .message()
173
+ .unwrap_or("Limit history isn't available yet"),
174
+ }
175
+ .to_string()
176
+ .set_style(secondary_style())
177
+ .into(),
178
+ );
179
+ }
180
+ // Wrap each column before joining so long timestamps never collide with its neighbor.
181
+ let mut wrapped = Vec::new();
182
+ let mut wrapped_selection = 0..0;
183
+ for (index, line) in lines.into_iter().enumerate() {
184
+ if index == selection.start {
185
+ wrapped_selection.start = wrapped.len();
186
+ }
187
+ wrapped.extend(crate::wrapping::word_wrap_lines(
188
+ [line],
189
+ crate::wrapping::RtOptions::new(table_width.max(/*other*/ 1)),
190
+ ));
191
+ if index + 1 == selection.end {
192
+ wrapped_selection.end = wrapped.len();
193
+ }
194
+ }
195
+ tables.push(wrapped);
196
+ selections.push(wrapped_selection);
197
+ }
198
+ let mut selection = selections[self.plan.window].clone();
199
+ let mut lines = if side_by_side {
200
+ join_columns(&tables[0], &tables[1], table_width)
201
+ } else {
202
+ if self.plan.window == 1 {
203
+ selection =
204
+ selection.start + tables[0].len() + 1..selection.end + tables[0].len() + 1;
205
+ }
206
+ let mut lines = std::mem::take(&mut tables[0]);
207
+ lines.push(Line::default());
208
+ lines.append(&mut tables[1]);
209
+ lines
210
+ };
211
+ if group == 3 {
212
+ lines.push(
213
+ "By turn start includes Tasks only; percentages use the full period limit"
214
+ .dim()
215
+ .into(),
216
+ );
217
+ }
218
+ if let Some(plan::Report {
219
+ as_of,
220
+ coverage_start,
221
+ coverage_complete,
222
+ approximate,
223
+ ..
224
+ }) = self.plan.report.ready()
225
+ {
226
+ if !*coverage_complete {
227
+ lines.push("Some periods aren't available yet".dim().into());
228
+ }
229
+ lines.push(
230
+ format!(
231
+ "Usage as of {} UTC{}",
232
+ as_of.format("%b %-d %H:%M"),
233
+ if self.zoomed {
234
+ " · * current at last update"
235
+ } else {
236
+ ""
237
+ }
238
+ )
239
+ .dim()
240
+ .into(),
241
+ );
242
+ if let Some(start) = coverage_start {
243
+ lines.push(
244
+ format!("Available since {} UTC", start.format("%b %-d %H:%M"))
245
+ .dim()
246
+ .into(),
247
+ );
248
+ }
249
+ if *approximate {
250
+ lines.push(
251
+ "Amounts and period boundaries are approximate; recent activity may be delayed"
252
+ .dim()
253
+ .into(),
254
+ );
255
+ }
256
+ }
257
+ (lines, selection)
258
+ }
259
+ }
codex-rs/tui/src/analytics/plan_tests.rs ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Snapshot semantics and independent plan-period navigation without a live billing service.
2
+ use super::*;
3
+ use crate::analytics::fixture;
4
+ use crate::analytics::models::AccountKind;
5
+ use crate::analytics::sections::Section;
6
+ use crossterm::event::KeyCode;
7
+ use crossterm::event::KeyEvent;
8
+ use crossterm::event::KeyModifiers;
9
+ use pretty_assertions::assert_eq;
10
+ use serde_json::json;
11
+
12
+ fn response() -> PlanLimitHistory {
13
+ serde_json::from_value(json!({
14
+ "data_as_of":"2026-09-02T12:00:00Z", "coverage_start":"2026-09-01T00:00:00Z",
15
+ "coverage_complete":false, "boundary_tolerance_seconds":60,
16
+ "periods":[
17
+ {"id":"five", "window_minutes":300,"plan_type":"plus",
18
+ "starts_at":"2026-09-02T10:00:00Z","ends_at":"2026-09-02T15:00:00Z",
19
+ "accounting_complete":false,"used_basis_points":12500,
20
+ "breakdowns":[
21
+ {"dimension":"future_dimension","rows":[{"key":"future","basis_points":500}]},
22
+ {"dimension":"thread_source","rows":[{"key":"user","basis_points":12500}]}]},
23
+ {"id":"weekly", "window_minutes":10080,"plan_type":"plus",
24
+ "starts_at":"2026-09-01T00:00:00Z","ends_at":"2026-09-08T00:00:00Z",
25
+ "accounting_complete":true,"used_basis_points":null,"breakdowns":null}
26
+ ]
27
+ }))
28
+ .unwrap()
29
+ }
30
+
31
+ #[test]
32
+ fn period_navigation_preserves_unknowns_and_resets_on_new_snapshot() {
33
+ let mut view = fixture::view(AccountKind::Consumer);
34
+ view.plan.enabled = true;
35
+ view.plan.report = Load::Ready(Report::parse(response()).unwrap().unwrap());
36
+ view.plan.poll();
37
+ view.section = Section::Plan;
38
+ view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
39
+ view.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
40
+ view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
41
+ assert_eq!(
42
+ view.plan.expanded,
43
+ [Some("five".into()), Some("weekly".into())]
44
+ );
45
+ let mut output = Vec::new();
46
+ for width in [120, 58] {
47
+ let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(
48
+ width, /*height*/ 30,
49
+ ))
50
+ .unwrap();
51
+ terminal
52
+ .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
53
+ .unwrap();
54
+ output.push(terminal.backend().to_string());
55
+ }
56
+ insta::assert_snapshot!(output.join("\n"));
57
+ let mut next = response();
58
+ next.data_as_of = Some("2026-09-02T13:00:00Z".into());
59
+ view.plan.report = Load::Ready(Report::parse(next).unwrap().unwrap());
60
+ view.plan.poll();
61
+ assert_eq!(
62
+ (view.plan.cursor, view.plan.expanded),
63
+ ([0, 0], [None, None])
64
+ );
65
+ }
66
+
67
+ #[test]
68
+ fn unavailable_empty_and_zero_history_are_distinct() {
69
+ let mut view = fixture::view(AccountKind::Consumer);
70
+ view.plan.enabled = true;
71
+ view.section = Section::Plan;
72
+ let mut states = Vec::new();
73
+ states.push(
74
+ view.plan_lines(/*width*/ 100)
75
+ .0
76
+ .iter()
77
+ .map(ToString::to_string)
78
+ .collect::<Vec<_>>()
79
+ .join("\n"),
80
+ );
81
+ let mut history = response();
82
+ history.coverage_complete = true;
83
+ history.periods.clear();
84
+ view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap());
85
+ states.push(
86
+ view.plan_lines(/*width*/ 100)
87
+ .0
88
+ .iter()
89
+ .map(ToString::to_string)
90
+ .collect::<Vec<_>>()
91
+ .join("\n"),
92
+ );
93
+ let mut history = response();
94
+ history.periods[0].used_basis_points = Some(0.0);
95
+ view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap());
96
+ states.push(
97
+ view.plan_lines(/*width*/ 100)
98
+ .0
99
+ .iter()
100
+ .map(ToString::to_string)
101
+ .collect::<Vec<_>>()
102
+ .join("\n"),
103
+ );
104
+ let mut future = response();
105
+ future.periods[0].starts_at = "2026-09-02T13:00:00Z".into();
106
+ view.plan.report = Load::Ready(Report::parse(future).unwrap().unwrap());
107
+ states.push(
108
+ view.plan_lines(/*width*/ 100)
109
+ .0
110
+ .iter()
111
+ .map(ToString::to_string)
112
+ .collect::<Vec<_>>()
113
+ .join("\n"),
114
+ );
115
+ let snapshot = states.join("\n\n");
116
+ let snapshot = snapshot
117
+ .lines()
118
+ .map(str::trim_end)
119
+ .collect::<Vec<_>>()
120
+ .join("\n");
121
+ insta::assert_snapshot!(snapshot);
122
+ let mut missing = response();
123
+ missing.data_as_of = None;
124
+ assert!(Report::parse(missing).unwrap().is_none());
125
+ }
126
+
127
+ #[tokio::test]
128
+ async fn plan_gate_prevents_requests_and_unavailable_plan_does_not_block_other_reports() {
129
+ use crate::analytics::client;
130
+ use crate::analytics::test_support;
131
+ use wiremock::Mock;
132
+ use wiremock::ResponseTemplate;
133
+ use wiremock::matchers::path;
134
+ let server = test_support::server().await;
135
+ let (_home, _server, mut view) = client::tests::connected_view(&server, "plus").await;
136
+ test_support::settle(&mut view).await;
137
+ assert!(
138
+ !server
139
+ .received_requests()
140
+ .await
141
+ .unwrap()
142
+ .iter()
143
+ .any(|request| request.url.path().ends_with("plan_limit_history"))
144
+ );
145
+ assert!(!view.visible_sections().contains(&Section::Plan));
146
+ Mock::given(path("/backend-api/wham/usage/plan_limit_history"))
147
+ .respond_with(ResponseTemplate::new(/*s*/ 404))
148
+ .with_priority(/*p*/ 1)
149
+ .expect(/*r*/ 1)
150
+ .mount(&server)
151
+ .await;
152
+ let (mut config, handle, frame) = view.connection.clone().unwrap();
153
+ std::sync::Arc::make_mut(&mut config)
154
+ .features
155
+ .enable(codex_features::Feature::AnalyticsPlanHistory)
156
+ .unwrap();
157
+ view.open(handle, frame, Vec::new(), config);
158
+ test_support::settle(&mut view).await;
159
+ assert!(matches!(view.plan.report, Load::Unavailable));
160
+ assert!(view.sections[Section::Usage].history.ready().is_some());
161
+ assert!(view.visible_sections().contains(&Section::Plan));
162
+ // Closing cancels the pending plan task along with the other account-owned work.
163
+ let (sender, receiver) = tokio::sync::oneshot::channel::<()>();
164
+ view.plan.report = Load::start(
165
+ async move {
166
+ let _sender = sender;
167
+ std::future::pending().await
168
+ },
169
+ crate::tui::FrameRequester::test_dummy(),
170
+ );
171
+ view.cancel_loads();
172
+ assert!(receiver.await.is_err());
173
+ server.verify().await;
174
+ let server = test_support::server().await;
175
+ let (_home, _server, mut business) = client::tests::connected_view(&server, "business").await;
176
+ let (mut config, handle, frame) = business.connection.clone().unwrap();
177
+ std::sync::Arc::make_mut(&mut config)
178
+ .features
179
+ .enable(codex_features::Feature::AnalyticsPlanHistory)
180
+ .unwrap();
181
+ business.open(handle, frame, Vec::new(), config);
182
+ test_support::settle(&mut business).await;
183
+ assert!(!business.visible_sections().contains(&Section::Plan));
184
+ assert!(matches!(business.plan.report, Load::Unavailable));
185
+ server.verify().await;
186
+ }
187
+
188
+ #[test]
189
+ fn plan_overview_escape_closes_with_retained_expansion() {
190
+ let mut history = response();
191
+ for index in 1..12 {
192
+ let mut period = history.periods[0].clone();
193
+ period.id = format!("five-{index}");
194
+ history.periods.push(period);
195
+ }
196
+ let mut view = fixture::view(AccountKind::Consumer);
197
+ view.plan.enabled = true;
198
+ view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap());
199
+ view.plan.poll();
200
+ view.section = Section::Plan;
201
+ view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
202
+ view.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
203
+ let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(
204
+ /*width*/ 120, /*height*/ 60,
205
+ ))
206
+ .unwrap();
207
+ terminal
208
+ .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
209
+ .unwrap();
210
+ let output = terminal.backend().to_string();
211
+ assert!(output.contains("Latest period · 12 total") && output.contains("Weekly limits"));
212
+ insta::assert_snapshot!(output);
213
+ view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
214
+ assert!(view.is_done);
215
+ }
216
+
217
+ #[test]
218
+ fn moving_period_selection_collapses_its_previous_details() {
219
+ let mut history = response();
220
+ let mut older = history.periods[0].clone();
221
+ older.id = "older-five".into();
222
+ older.starts_at = "2026-09-02T05:00:00Z".into();
223
+ older.ends_at = "2026-09-02T10:00:00Z".into();
224
+ history.periods.push(older);
225
+ let mut view = fixture::view(AccountKind::Consumer);
226
+ view.plan.enabled = true;
227
+ view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap());
228
+ view.plan.poll();
229
+ view.section = Section::Plan;
230
+ view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
231
+ view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
232
+ assert_eq!(
233
+ (view.plan.cursor, view.plan.expanded),
234
+ ([1, 0], [None, None])
235
+ );
236
+ }
237
+
238
+ #[test]
239
+ fn invalid_history_returns_safe_errors() {
240
+ for invalid in 0..5 {
241
+ let mut history = response();
242
+ match invalid {
243
+ 0 => history.periods[0].starts_at = "private wire value".into(),
244
+ 1 => history.periods[0].ends_at = history.periods[0].starts_at.clone(),
245
+ 2 => history.periods.push(history.periods[0].clone()),
246
+ 3 => history.periods[0].window_minutes = 1,
247
+ _ => history.periods[0].used_basis_points = Some(f64::INFINITY),
248
+ }
249
+ let error = Report::parse(history).err().unwrap();
250
+ assert!(!error.contains("private wire value"));
251
+ }
252
+ }
codex-rs/tui/src/analytics/plot.rs ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Daily stacked bars with fixed calendar ticks, a selection cursor, and an anchored value.
2
+ //! Relative usage retains the server's scale; signed credits keep both bands.
3
+
4
+ mod annotations;
5
+ mod layout;
6
+ mod painting;
7
+
8
+ use self::layout::Layout;
9
+ use self::painting::Band;
10
+ use super::data;
11
+ use super::data::compact_amount as tick;
12
+ use super::render::parts;
13
+ use crate::analytics::models::AccountAnalyticsDay;
14
+ use crate::analytics::models::AccountAnalyticsUnit;
15
+ use crate::analytics::models::AccountAnalyticsValue;
16
+ use chrono::NaiveDate;
17
+ use ratatui::buffer::Buffer;
18
+ use ratatui::layout::Rect;
19
+ use ratatui::text::Line;
20
+ use ratatui::text::Span;
21
+ use unicode_width::UnicodeWidthStr;
22
+
23
+ /// Share the chart's scale with the selected-day summary, including signed values.
24
+ pub(super) fn peak(
25
+ days: &[(NaiveDate, Option<&AccountAnalyticsDay>)],
26
+ categories: &[AccountAnalyticsValue],
27
+ ) -> f64 {
28
+ days.iter()
29
+ .filter_map(|(_, day)| *day)
30
+ .map(|day| {
31
+ parts(&day.values, categories)
32
+ .iter()
33
+ .map(|value| value.abs())
34
+ .sum::<f64>()
35
+ .max(day.total.abs())
36
+ })
37
+ .fold(/*init*/ 0.0, f64::max)
38
+ }
39
+
40
+ /// Two readable intervals cover the range; counts never use fractional ticks.
41
+ pub(super) fn axis_max(peak: f64, unit: AccountAnalyticsUnit) -> f64 {
42
+ if peak == 0.0 {
43
+ return if matches!(
44
+ unit,
45
+ AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens
46
+ ) {
47
+ 2.0
48
+ } else {
49
+ 1.0
50
+ };
51
+ }
52
+ let step = if matches!(
53
+ unit,
54
+ AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens
55
+ ) {
56
+ (peak / 2.0).max(/*other*/ 1.0)
57
+ } else {
58
+ peak / 2.0
59
+ };
60
+ let magnitude = 10.0_f64.powf(step.log10().floor());
61
+ let normalized = step / magnitude;
62
+ let multiple = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.5, 10.0]
63
+ .into_iter()
64
+ .find(|multiple| {
65
+ *multiple >= normalized
66
+ && (!matches!(
67
+ unit,
68
+ AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens
69
+ ) || (multiple * magnitude).fract() == 0.0)
70
+ })
71
+ .unwrap_or(/*default*/ 10.0);
72
+ let ceiling = multiple * magnitude * 2.0;
73
+ if ceiling.is_finite() && ceiling >= peak {
74
+ ceiling
75
+ } else {
76
+ peak
77
+ }
78
+ }
79
+
80
+ /// Rendered chart geometry exposes its positive and optional negative value bands.
81
+ #[derive(Default)]
82
+ pub(super) struct Chart {
83
+ pub(super) lines: Vec<Line<'static>>,
84
+ pub(super) bands: usize,
85
+ }
86
+
87
+ /// Render a calendar selection; the cursor must index days and dimensions fit the terminal.
88
+ pub(super) fn chart(
89
+ days: &[(NaiveDate, Option<&AccountAnalyticsDay>)],
90
+ categories: &[AccountAnalyticsValue],
91
+ cursor: usize,
92
+ width: usize,
93
+ height: usize,
94
+ unit: AccountAnalyticsUnit,
95
+ ) -> Chart {
96
+ if days.is_empty() || width == 0 {
97
+ return Chart::default();
98
+ }
99
+ let values = days
100
+ .iter()
101
+ .map(|(_, day)| {
102
+ day.map_or(
103
+ /*default*/ [0.0; 4],
104
+ |day| parts(&day.values, categories),
105
+ )
106
+ })
107
+ .collect::<Vec<_>>();
108
+ let bands = if values.iter().flatten().any(|value| *value < 0.0) {
109
+ &[Band::Positive, Band::Negative][..]
110
+ } else {
111
+ &[Band::Positive][..]
112
+ };
113
+ let peak = axis_max(peak(days, categories), unit);
114
+ let label_width = tick(peak).width().max(tick(peak / 2.0).width()) + bands.len() - 1 + 2;
115
+ let layout = Layout::new(days.len(), cursor, width, height, label_width, bands);
116
+ let renderer = Renderer {
117
+ days,
118
+ values,
119
+ cursor,
120
+ peak,
121
+ unit,
122
+ bands,
123
+ layout,
124
+ };
125
+ let mut buf = Buffer::empty(Rect::new(
126
+ /*x*/ 0,
127
+ /*y*/ 0,
128
+ width as u16,
129
+ (layout.marker_row + 2) as u16,
130
+ ));
131
+ renderer.paint_bars(&mut buf);
132
+ renderer.annotate_missing(&mut buf);
133
+ renderer.paint_axis(&mut buf);
134
+ renderer.annotate_selection(&mut buf);
135
+ renderer.annotate_calendar(&mut buf);
136
+ let lines = buf
137
+ .content
138
+ .chunks(width)
139
+ .map(|row| {
140
+ Line::from(
141
+ row.iter()
142
+ .map(|cell| Span::styled(cell.symbol().to_owned(), cell.style()))
143
+ .collect::<Vec<_>>(),
144
+ )
145
+ })
146
+ .collect();
147
+ Chart {
148
+ lines,
149
+ bands: bands.len(),
150
+ }
151
+ }
152
+
153
+ /// Shared inputs for painting and annotations; all phases use the same calendar geometry.
154
+ struct Renderer<'a> {
155
+ days: &'a [(NaiveDate, Option<&'a AccountAnalyticsDay>)],
156
+ values: Vec<[f64; 4]>,
157
+ cursor: usize,
158
+ peak: f64,
159
+ unit: AccountAnalyticsUnit,
160
+ bands: &'static [Band],
161
+ layout: Layout,
162
+ }
163
+
164
+ fn amount(value: f64, unit: AccountAnalyticsUnit) -> String {
165
+ match unit {
166
+ AccountAnalyticsUnit::RelativeUsage => data::amount(value),
167
+ AccountAnalyticsUnit::Count | AccountAnalyticsUnit::Tokens => {
168
+ data::compact_amount(value.round())
169
+ }
170
+ AccountAnalyticsUnit::Credits => data::credit_amount(value),
171
+ }
172
+ }
173
+
174
+ #[cfg(test)]
175
+ #[path = "plot_tests.rs"]
176
+ mod tests;
codex-rs/tui/src/analytics/plot/annotations.rs ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Missing-day markers, daily-value callouts, and stable calendar labels.
2
+
3
+ use super::Renderer;
4
+ use super::amount;
5
+ use super::layout::Layout;
6
+ use super::painting::Band;
7
+ use crate::analytics::styles::secondary_style;
8
+ use crate::color::is_light;
9
+ use crate::style::accent_style;
10
+ use crate::terminal_palette::default_bg;
11
+ use ratatui::buffer::Buffer;
12
+ use ratatui::style::Style;
13
+ use ratatui::style::Styled;
14
+ use ratatui::style::Stylize;
15
+ use ratatui::text::Line;
16
+ use unicode_width::UnicodeWidthStr;
17
+
18
+ impl Renderer<'_> {
19
+ pub(super) fn annotate_missing(&self, buf: &mut Buffer) {
20
+ let Layout {
21
+ count,
22
+ height,
23
+ start,
24
+ ..
25
+ } = self.layout;
26
+ for (index, parts) in self.values[start..start + count].iter().enumerate() {
27
+ // The selected zero/missing value gets its own callout below; avoid repeating it.
28
+ if start + index != self.cursor {
29
+ let marker = match self.days[start + index].1 {
30
+ None => Some('—'),
31
+ Some(day) if day.total == 0.0 && parts.iter().all(|value| *value == 0.0) => {
32
+ Some('0')
33
+ }
34
+ Some(_) => None,
35
+ };
36
+ if let Some(marker) = marker {
37
+ buf[(self.layout.center(index) as u16, height as u16)]
38
+ .set_char(marker)
39
+ .set_style(Style::default().dim());
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ pub(super) fn annotate_selection(&self, buf: &mut Buffer) {
46
+ let Layout {
47
+ axis_width,
48
+ baseline,
49
+ height,
50
+ marker_row,
51
+ plot_width,
52
+ start,
53
+ width,
54
+ ..
55
+ } = self.layout;
56
+ // Give the selected value first choice of space; the other labels never overwrite it.
57
+ for index in
58
+ std::iter::once(self.cursor).chain((start..start + self.layout.count).filter(|index| {
59
+ self.days.len() <= 7
60
+ && *index != self.cursor
61
+ && self.days[*index].1.is_some_and(|day| {
62
+ day.total != 0.0 || self.values[*index].iter().any(|value| *value != 0.0)
63
+ })
64
+ }))
65
+ {
66
+ let total = self.days[index].1.map_or(/*default*/ 0.0, |day| day.total);
67
+ let label = self.days[index]
68
+ .1
69
+ .map_or_else(|| "—".into(), |_| amount(total, self.unit));
70
+ // A partial number can mean a different amount; leave the cursor when the value cannot fit.
71
+ if label.width() > plot_width {
72
+ continue;
73
+ }
74
+ let x = self.layout.center(index - start);
75
+ let label_width = label.width();
76
+ let label_x = x
77
+ .saturating_sub(label_width / 2)
78
+ .clamp(axis_width, width - label_width);
79
+ let occupied = label_x.saturating_sub(/*rhs*/ 1).max(axis_width)
80
+ ..(label_x + label_width + 1).min(width);
81
+ let below = total < 0.0;
82
+ let band = if below {
83
+ Band::Negative
84
+ } else {
85
+ Band::Positive
86
+ };
87
+ let extent = self.values[index]
88
+ .iter()
89
+ .map(|value| band.amount(*value))
90
+ .sum::<f64>();
91
+ let rows = ((extent * height as f64 / self.peak).ceil() as usize).min(height);
92
+ let cap = if below {
93
+ baseline + rows
94
+ } else {
95
+ baseline - rows
96
+ };
97
+ let mut label_y = if below {
98
+ cap + 1
99
+ } else {
100
+ cap.saturating_sub(/*rhs*/ 1)
101
+ };
102
+ // Keep the callout close without painting over a neighboring bar. A guide connects any gap.
103
+ while occupied
104
+ .clone()
105
+ .any(|col| buf[(col as u16, label_y as u16)].symbol() != " ")
106
+ {
107
+ if below && label_y + 1 < marker_row {
108
+ label_y += 1;
109
+ } else if !below && label_y > 0 {
110
+ label_y -= 1;
111
+ } else {
112
+ break;
113
+ }
114
+ }
115
+ if occupied
116
+ .clone()
117
+ .any(|col| buf[(col as u16, label_y as u16)].symbol() != " ")
118
+ {
119
+ continue;
120
+ }
121
+ // Use white on dark backgrounds and the terminal foreground on light backgrounds.
122
+ let style = if index == self.cursor {
123
+ let selected = Style::default().bold().not_dim();
124
+ if default_bg().is_some_and(is_light) {
125
+ selected
126
+ } else {
127
+ selected.white()
128
+ }
129
+ } else {
130
+ Style::default().dim()
131
+ };
132
+ let guide = if below {
133
+ cap + 1..label_y
134
+ } else {
135
+ label_y + 1..cap
136
+ };
137
+ for y in guide {
138
+ let cell = &mut buf[(x as u16, y as u16)];
139
+ if cell.symbol() == " " {
140
+ cell.set_char('┊').set_style(style);
141
+ }
142
+ }
143
+ buf.set_line(
144
+ label_x as u16,
145
+ label_y as u16,
146
+ &Line::from(label.set_style(style)),
147
+ label_width as u16,
148
+ );
149
+ }
150
+ let x = self.layout.center(self.cursor - start);
151
+ buf[(x as u16, marker_row as u16)]
152
+ .set_char('▲')
153
+ .set_style(accent_style());
154
+ }
155
+
156
+ pub(super) fn annotate_calendar(&self, buf: &mut Buffer) {
157
+ let Layout {
158
+ axis_width,
159
+ count,
160
+ marker_row,
161
+ plot_width,
162
+ start,
163
+ width,
164
+ ..
165
+ } = self.layout;
166
+ // Calendar ticks depend only on the range/width, never on the current selection.
167
+ let mut occupied = vec![false; width];
168
+ // Give the two range endpoints priority over intermediate weekly ticks.
169
+ for index in std::iter::once(/*value*/ 0).chain((1..count).rev()) {
170
+ if self.days.len() > 7
171
+ && !(start + index).is_multiple_of(/*rhs*/ 7)
172
+ && index + 1 != count
173
+ {
174
+ continue;
175
+ }
176
+ let format = if self.days.len() <= 7 && plot_width / count < 7 {
177
+ "%-d"
178
+ } else {
179
+ "%b %-d"
180
+ };
181
+ let mut label = self.days[start + index].0.format(format).to_string();
182
+ if label.width() > plot_width {
183
+ label = self.days[start + index].0.format("%-d").to_string();
184
+ }
185
+ if label.width() > plot_width {
186
+ continue;
187
+ }
188
+ let offset = self
189
+ .layout
190
+ .center(index)
191
+ .saturating_sub(label.len() / 2)
192
+ .clamp(axis_width, width - label.len());
193
+ if occupied[offset.saturating_sub(/*rhs*/ 1)..(offset + label.len() + 1).min(width)]
194
+ .iter()
195
+ .any(|used| *used)
196
+ {
197
+ continue;
198
+ }
199
+ occupied[offset..offset + label.len()].fill(/*value*/ true);
200
+ let label = if start + index == self.cursor {
201
+ label.set_style(accent_style()).underlined()
202
+ } else {
203
+ label.set_style(secondary_style())
204
+ };
205
+ buf.set_line(
206
+ offset as u16,
207
+ (marker_row + 1) as u16,
208
+ &Line::from(label),
209
+ (width - offset) as u16,
210
+ );
211
+ }
212
+ }
213
+ }
codex-rs/tui/src/analytics/plot/layout.rs ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Calendar viewport and shared coordinates for bars, axes, and annotations.
2
+
3
+ use super::painting::Band;
4
+
5
+ #[derive(Clone, Copy)]
6
+ pub(super) struct Layout {
7
+ pub(super) width: usize,
8
+ pub(super) height: usize,
9
+ pub(super) axis_width: usize,
10
+ pub(super) plot_width: usize,
11
+ pub(super) count: usize,
12
+ pub(super) start: usize,
13
+ pub(super) baseline: usize,
14
+ pub(super) marker_row: usize,
15
+ }
16
+
17
+ impl Layout {
18
+ pub(super) fn new(
19
+ day_count: usize,
20
+ cursor: usize,
21
+ width: usize,
22
+ height: usize,
23
+ label_width: usize,
24
+ bands: &[Band],
25
+ ) -> Self {
26
+ // Hide the axis when a complete label would crowd out the bars.
27
+ let axis_width = if width >= 30 && label_width <= width / 3 {
28
+ label_width
29
+ } else {
30
+ 0
31
+ };
32
+ let plot_width = width - axis_width;
33
+ let count = day_count.min(plot_width);
34
+ let start = cursor.saturating_sub(count - 1).min(day_count - count);
35
+ let baseline = height + 1;
36
+ let marker_row = baseline + if bands.len() == 2 { height + 2 } else { 1 };
37
+ Self {
38
+ width,
39
+ height,
40
+ axis_width,
41
+ plot_width,
42
+ count,
43
+ start,
44
+ baseline,
45
+ marker_row,
46
+ }
47
+ }
48
+
49
+ pub(super) fn center(self, index: usize) -> usize {
50
+ self.axis_width
51
+ + (index * self.plot_width / self.count + (index + 1) * self.plot_width / self.count
52
+ - 1)
53
+ / 2
54
+ }
55
+ }
codex-rs/tui/src/analytics/plot/painting.rs ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Paint signed bar cells and numeric axes using the shared layout and scale.
2
+
3
+ use super::Renderer;
4
+ use super::layout::Layout;
5
+ use super::tick;
6
+ use crate::analytics::styles::secondary_style;
7
+ use crate::analytics::styles::series_colors;
8
+ use ratatui::buffer::Buffer;
9
+ use ratatui::style::Style;
10
+ use ratatui::style::Styled;
11
+ use ratatui::text::Line;
12
+
13
+ /// A signed stack grows away from the baseline; amounts are nonnegative within each band.
14
+ #[derive(Clone, Copy)]
15
+ pub(super) enum Band {
16
+ Positive,
17
+ Negative,
18
+ }
19
+
20
+ impl Band {
21
+ pub(super) fn amount(self, value: f64) -> f64 {
22
+ match self {
23
+ Self::Positive => value,
24
+ Self::Negative => -value,
25
+ }
26
+ .max(/*other*/ 0.0)
27
+ }
28
+
29
+ fn row_y(self, baseline: usize, row: usize) -> usize {
30
+ match self {
31
+ Self::Positive => baseline - row - 1,
32
+ Self::Negative => baseline + row + 1,
33
+ }
34
+ }
35
+
36
+ fn partial_glyph(self, level: usize) -> char {
37
+ // Reversing the complementary lower block paints a top-anchored partial refund.
38
+ let index = match self {
39
+ Self::Negative if level < 8 => 8 - level,
40
+ Self::Positive | Self::Negative => level,
41
+ };
42
+ [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][index]
43
+ }
44
+ }
45
+
46
+ impl Renderer<'_> {
47
+ pub(super) fn paint_bars(&self, buf: &mut Buffer) {
48
+ let Layout {
49
+ axis_width,
50
+ baseline,
51
+ count,
52
+ height,
53
+ plot_width,
54
+ start,
55
+ ..
56
+ } = self.layout;
57
+ let colors = series_colors();
58
+ for (index, parts) in self.values[start..start + count].iter().enumerate() {
59
+ let left = axis_width + index * plot_width / count;
60
+ let step = (index + 1) * plot_width / count - index * plot_width / count;
61
+ let bar_width = (step * 2 / 3).clamp(/*min*/ 1, /*max*/ 12);
62
+ let x = left + (step - bar_width) / 2;
63
+ for band in self.bands {
64
+ let plotted = parts.iter().map(|value| band.amount(*value)).sum::<f64>();
65
+ let scaled = ((plotted * (height * 8) as f64 / self.peak) as usize)
66
+ .max(usize::from(plotted > 0.0));
67
+ for row in 0..height {
68
+ let level = scaled.saturating_sub(row * 8).min(/*other*/ 8);
69
+ if level == 0 {
70
+ continue;
71
+ }
72
+ let reverse = matches!(band, Band::Negative) && level < 8;
73
+ let glyph = band.partial_glyph(level);
74
+ let bottom = (row * 8) as f64 * self.peak / (height * 8) as f64;
75
+ let top =
76
+ ((row * 8 + level) as f64 * self.peak / (height * 8) as f64).min(plotted);
77
+ let sample = bottom + (top - bottom) / 2.0;
78
+ let mut cumulative = 0.0;
79
+ let color = parts
80
+ .iter()
81
+ .position(|value| {
82
+ cumulative += band.amount(*value);
83
+ sample < cumulative
84
+ })
85
+ .unwrap_or(/*default*/ 3);
86
+ let y = band.row_y(baseline, row);
87
+ for col in x..x + bar_width {
88
+ let cell = &mut buf[(col as u16, y as u16)];
89
+ cell.set_char(glyph).set_fg(colors[color]);
90
+ if reverse {
91
+ cell.set_style(Style::default().reversed());
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ pub(super) fn paint_axis(&self, buf: &mut Buffer) {
100
+ let Layout {
101
+ axis_width,
102
+ baseline,
103
+ height,
104
+ width,
105
+ ..
106
+ } = self.layout;
107
+ for x in axis_width..width {
108
+ buf[(x as u16, baseline as u16)]
109
+ .set_char('─')
110
+ .set_style(Style::default().dim());
111
+ }
112
+ if axis_width > 0 {
113
+ let mut ticks = vec![(1, self.peak), (baseline, 0.0)];
114
+ if height >= 4 {
115
+ ticks.push((1 + height / 2, self.peak / 2.0));
116
+ }
117
+ if self.bands.len() == 2 {
118
+ ticks.push((baseline + height, -self.peak));
119
+ }
120
+ for (y, value) in ticks {
121
+ let label = tick(value);
122
+ let x = axis_width.saturating_sub(label.len() + 1);
123
+ buf.set_line(
124
+ x as u16,
125
+ y as u16,
126
+ &Line::from(label.set_style(secondary_style())),
127
+ axis_width as u16,
128
+ );
129
+ }
130
+ }
131
+ }
132
+ }
codex-rs/tui/src/analytics/plot_tests.rs ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Chart scales, signed bands, missing days, narrow viewports, and terminal palettes.
2
+
3
+ use super::*;
4
+ use crate::analytics::models::AccountAnalyticsHistory;
5
+ use crate::analytics::render::categories;
6
+ use crate::analytics::styles::series_colors;
7
+ use crate::terminal_palette::with_test_default_colors;
8
+ use crate::terminal_probe::DefaultColors;
9
+ use pretty_assertions::assert_eq;
10
+
11
+ fn value(key: &str, value: f64) -> AccountAnalyticsValue {
12
+ AccountAnalyticsValue {
13
+ key: key.into(),
14
+ label: key.into(),
15
+ value,
16
+ }
17
+ }
18
+
19
+ fn display(chart: &Chart) -> String {
20
+ let text = chart
21
+ .lines
22
+ .iter()
23
+ .map(|line| line.to_string().trim_end().to_owned())
24
+ .collect::<Vec<_>>()
25
+ .join("\n");
26
+ let mut palette = Vec::new();
27
+ let mut rows = Vec::new();
28
+ for (row, line) in chart.lines.iter().enumerate() {
29
+ let mut previous = None;
30
+ let mut runs = Vec::new();
31
+ let mut column = 0;
32
+ for span in &line.spans {
33
+ let style = format!("{:?}", span.style);
34
+ let index = palette
35
+ .iter()
36
+ .position(|candidate| *candidate == style)
37
+ .unwrap_or_else(|| {
38
+ palette.push(style);
39
+ palette.len() - 1
40
+ });
41
+ if previous != Some(index) {
42
+ runs.push(format!("{column}:{index}"));
43
+ previous = Some(index);
44
+ }
45
+ column += span.width();
46
+ }
47
+ rows.push(format!("{row}: {}", runs.join(" ")));
48
+ }
49
+ let palette = palette
50
+ .iter()
51
+ .enumerate()
52
+ .map(|(index, style)| format!("{index}: {style}"))
53
+ .collect::<Vec<_>>()
54
+ .join("\n");
55
+ format!(
56
+ "{text}\n\nPalette:\n{palette}\nRows (column:palette):\n{}",
57
+ rows.join("\n")
58
+ )
59
+ }
60
+
61
+ #[test]
62
+ fn numeric_axes_use_readable_intervals() {
63
+ use AccountAnalyticsUnit::Count;
64
+ use AccountAnalyticsUnit::Credits;
65
+ use AccountAnalyticsUnit::RelativeUsage;
66
+ use AccountAnalyticsUnit::Tokens;
67
+ let cases = [
68
+ (82.0, Count, 100.0),
69
+ (4_400.0, Credits, 5_000.0),
70
+ (4_999.0, Credits, 5_000.0),
71
+ (5_001.0, Credits, 6_000.0),
72
+ (49.0, Count, 50.0),
73
+ (1.0, Count, 2.0),
74
+ (3.0, Count, 4.0),
75
+ (1_337.0, Credits, 1_500.0),
76
+ (0.07, Credits, 0.08),
77
+ (0.000004, Credits, 0.000004),
78
+ (82.0, RelativeUsage, 100.0),
79
+ (1.0, Tokens, 2.0),
80
+ ];
81
+ assert_eq!(
82
+ cases.map(|(peak, unit, _)| axis_max(peak, unit)),
83
+ cases.map(|(_, _, expected)| expected)
84
+ );
85
+ }
86
+
87
+ #[test]
88
+ fn stacked_parts_keep_the_remainder_and_full_denominator() {
89
+ let values = vec![
90
+ value("a", /*value*/ 4.0),
91
+ value("b", /*value*/ 2.0),
92
+ value("c", /*value*/ 1.0),
93
+ value("d", /*value*/ 6.0),
94
+ value("e", /*value*/ -1.0),
95
+ ];
96
+ let categories = vec![values[1].clone(), values[0].clone(), values[2].clone()];
97
+ assert_eq!(parts(&values, &categories), [2.0, 4.0, 1.0, 5.0]);
98
+ let day = AccountAnalyticsDay {
99
+ date: "2026-01-15".parse().unwrap(),
100
+ total: 20.0,
101
+ values,
102
+ };
103
+ let date = day.date;
104
+ assert_eq!(peak(&[(date, Some(&day))], &categories), 20.0);
105
+ let refund = AccountAnalyticsDay {
106
+ total: -1.0,
107
+ values: vec![value("a", /*value*/ 4.0), value("b", /*value*/ -5.0)],
108
+ ..day
109
+ };
110
+ assert_eq!(peak(&[(date, Some(&refund))], &categories), 9.0);
111
+ }
112
+
113
+ #[test]
114
+ fn signed_chart_on_light_terminal() {
115
+ signed_chart(
116
+ "light",
117
+ DefaultColors {
118
+ fg: (0, 0, 0),
119
+ bg: (255, 255, 255),
120
+ },
121
+ );
122
+ }
123
+
124
+ #[test]
125
+ fn signed_chart_on_dark_terminal() {
126
+ signed_chart(
127
+ "dark",
128
+ DefaultColors {
129
+ fg: (230, 230, 230),
130
+ bg: (16, 16, 16),
131
+ },
132
+ );
133
+ }
134
+
135
+ fn signed_chart(theme: &str, colors: DefaultColors) {
136
+ let history = AccountAnalyticsHistory {
137
+ unit: AccountAnalyticsUnit::Credits,
138
+ updated_at: None,
139
+ data: vec![
140
+ AccountAnalyticsDay {
141
+ date: "2026-01-13".parse().unwrap(),
142
+ total: 8.0,
143
+ values: vec![value("a", /*value*/ 10.0), value("b", /*value*/ -2.0)],
144
+ },
145
+ AccountAnalyticsDay {
146
+ date: "2026-01-14".parse().unwrap(),
147
+ total: -3.0,
148
+ values: vec![value("a", /*value*/ 1.0), value("b", /*value*/ -4.0)],
149
+ },
150
+ AccountAnalyticsDay {
151
+ date: "2026-01-15".parse().unwrap(),
152
+ total: 0.0,
153
+ values: vec![value("a", /*value*/ 3.0), value("b", /*value*/ -3.0)],
154
+ },
155
+ ],
156
+ };
157
+ let days = history
158
+ .data
159
+ .iter()
160
+ .map(|day| (day.date, Some(day)))
161
+ .collect::<Vec<_>>();
162
+ let categories = categories(&history);
163
+ with_test_default_colors(colors, || {
164
+ let rendered = chart(
165
+ &days,
166
+ &categories,
167
+ /*cursor*/ 1,
168
+ /*width*/ 44,
169
+ /*height*/ 4,
170
+ history.unit,
171
+ );
172
+ assert_eq!(rendered.bands, 2);
173
+ insta::assert_snapshot!(format!("signed_chart_{theme}"), display(&rendered));
174
+ });
175
+ }
176
+
177
+ #[test]
178
+ fn missing_zero_and_narrow_charts_keep_the_selection_visible() {
179
+ let zero = AccountAnalyticsDay {
180
+ date: "2026-01-14".parse().unwrap(),
181
+ total: 0.0,
182
+ values: vec![value("a", /*value*/ 0.0)],
183
+ };
184
+ let used = AccountAnalyticsDay {
185
+ date: "2026-01-15".parse().unwrap(),
186
+ total: 5.0,
187
+ values: vec![value("a", /*value*/ 5.0)],
188
+ };
189
+ let days = [
190
+ ("2026-01-13".parse().unwrap(), None),
191
+ (zero.date, Some(&zero)),
192
+ (used.date, Some(&used)),
193
+ ];
194
+ with_test_default_colors(
195
+ DefaultColors {
196
+ fg: (230, 230, 230),
197
+ bg: (16, 16, 16),
198
+ },
199
+ || {
200
+ let mut snapshots = Vec::new();
201
+ for (width, cursor) in [(36, 0), (36, 1), (8, 2), (1, 2)] {
202
+ let rendered = chart(
203
+ &days,
204
+ &used.values,
205
+ cursor,
206
+ width,
207
+ /*height*/ 3,
208
+ AccountAnalyticsUnit::Count,
209
+ );
210
+ assert_eq!(rendered.bands, 1);
211
+ assert!(rendered.lines.iter().all(|line| line.width() <= width));
212
+ assert_eq!(
213
+ rendered
214
+ .lines
215
+ .iter()
216
+ .flat_map(|line| &line.spans)
217
+ .filter(|span| span.content == "▲")
218
+ .count(),
219
+ 1
220
+ );
221
+ if cursor == 0 {
222
+ let dash = rendered
223
+ .lines
224
+ .iter()
225
+ .find_map(|line| line.to_string().find('—'));
226
+ let cursor = rendered
227
+ .lines
228
+ .iter()
229
+ .find_map(|line| line.to_string().find('▲'));
230
+ assert_eq!(dash, cursor);
231
+ }
232
+ snapshots.push(format!(
233
+ "width={width}, cursor={cursor}\n{}",
234
+ display(&rendered)
235
+ ));
236
+ }
237
+ insta::assert_snapshot!(snapshots.join("\n\n"));
238
+ },
239
+ );
240
+ }
241
+
242
+ #[test]
243
+ fn zero_credit_history_uses_a_readable_scale() {
244
+ let history = crate::analytics::normalize::history(
245
+ serde_json::from_str(r#"{"data":[]}"#).unwrap(),
246
+ crate::analytics::models::AccountAnalyticsReport::Credits,
247
+ crate::analytics::models::AccountAnalyticsGrouping::Surface,
248
+ "2026-01-09".parse().unwrap(),
249
+ "2026-01-15".parse().unwrap(),
250
+ )
251
+ .unwrap()
252
+ .unwrap();
253
+ let days = history
254
+ .data
255
+ .iter()
256
+ .map(|day| (day.date, Some(day)))
257
+ .collect::<Vec<_>>();
258
+ let rendered = chart(
259
+ &days,
260
+ &[],
261
+ /*cursor*/ 6,
262
+ /*width*/ 44,
263
+ /*height*/ 4,
264
+ history.unit,
265
+ );
266
+ assert_eq!(axis_max(peak(&days, &[]), history.unit), 1.0);
267
+ insta::assert_snapshot!(display(&rendered));
268
+ }
269
+
270
+ #[test]
271
+ fn partial_cells_use_the_midpoint_and_descend_from_the_baseline() {
272
+ let categories = vec![value("a", /*value*/ 0.8), value("b", /*value*/ 0.95)];
273
+ let mut snapshots = Vec::new();
274
+ for sign in [1.0, -1.0] {
275
+ let day = AccountAnalyticsDay {
276
+ date: "2026-01-14".parse().unwrap(),
277
+ total: 1.75 * sign,
278
+ values: categories
279
+ .iter()
280
+ .map(|value| AccountAnalyticsValue {
281
+ value: value.value * sign,
282
+ ..value.clone()
283
+ })
284
+ .collect(),
285
+ };
286
+ let days = [
287
+ (day.date, Some(&day)),
288
+ ("2026-01-15".parse().unwrap(), None),
289
+ ];
290
+ let rendered = chart(
291
+ &days,
292
+ &categories,
293
+ /*cursor*/ 1,
294
+ /*width*/ 12,
295
+ /*height*/ 1,
296
+ AccountAnalyticsUnit::Credits,
297
+ );
298
+ let cell = &rendered.lines[if sign > 0.0 { 1 } else { 3 }].spans[1];
299
+ assert_eq!(cell.style.fg, Some(series_colors()[1]));
300
+ assert_eq!(cell.content.as_ref(), if sign > 0.0 { "▇" } else { "▁" });
301
+ assert_eq!(
302
+ cell.style
303
+ .add_modifier
304
+ .contains(ratatui::style::Modifier::REVERSED),
305
+ sign < 0.0
306
+ );
307
+ snapshots.push(format!("sign={sign}\n{}", display(&rendered)));
308
+ }
309
+ insta::assert_snapshot!(snapshots.join("\n\n"));
310
+ }
311
+
312
+ #[test]
313
+ fn oversized_axes_are_hidden_instead_of_truncated() {
314
+ let mut snapshots = Vec::new();
315
+ for (width, total) in [
316
+ (8, 100_000_000.0),
317
+ (30, 100_000_000.0),
318
+ (35, 100_000_000.0),
319
+ (44, 100_000_000.0),
320
+ (30, 1e30),
321
+ ] {
322
+ let day = AccountAnalyticsDay {
323
+ date: "2026-01-15".parse().unwrap(),
324
+ total,
325
+ values: vec![value("a", total)],
326
+ };
327
+ let days = [(day.date, Some(&day))];
328
+
329
+ let rendered = chart(
330
+ &days,
331
+ &day.values,
332
+ /*cursor*/ 0,
333
+ width,
334
+ /*height*/ 4,
335
+ AccountAnalyticsUnit::Tokens,
336
+ );
337
+ let baseline = rendered.lines[5].to_string();
338
+ assert_eq!(baseline.starts_with('─'), width < 30 || total == 1e30);
339
+ if width == 8 {
340
+ assert!(rendered.lines[0].to_string().contains("100M"));
341
+ }
342
+ snapshots.push(format!("width={width}\n{}", display(&rendered)));
343
+ }
344
+ insta::assert_snapshot!(snapshots.join("\n\n"));
345
+ }
346
+
347
+ #[test]
348
+ fn selected_callout_keeps_the_authoritative_daily_total() {
349
+ let day = AccountAnalyticsDay {
350
+ date: "2026-01-15".parse().unwrap(),
351
+ total: 20.0,
352
+ values: vec![value("a", /*value*/ 8.0), value("b", /*value*/ 4.0)],
353
+ };
354
+ let days = [(day.date, Some(&day))];
355
+ let rendered = chart(
356
+ &days,
357
+ &day.values,
358
+ /*cursor*/ 0,
359
+ /*width*/ 44,
360
+ /*height*/ 4,
361
+ AccountAnalyticsUnit::Credits,
362
+ );
363
+ assert!(display(&rendered).contains("20.00"));
364
+ assert!(!display(&rendered).contains("12.00"));
365
+ insta::assert_snapshot!(display(&rendered));
366
+ }
367
+
368
+ #[test]
369
+ fn narrow_long_range_charts_never_truncate_dates() {
370
+ let start: NaiveDate = "2026-01-01".parse().unwrap();
371
+ let history = start
372
+ .iter_days()
373
+ .take(/*n*/ 30)
374
+ .map(|date| AccountAnalyticsDay {
375
+ date,
376
+ total: 1.0,
377
+ values: vec![value("a", /*value*/ 1.0)],
378
+ })
379
+ .collect::<Vec<_>>();
380
+ let days = history
381
+ .iter()
382
+ .map(|day| (day.date, Some(day)))
383
+ .collect::<Vec<_>>();
384
+ let mut snapshots = Vec::new();
385
+ for (width, expected) in [(1, ""), (2, "15"), (5, "15"), (6, "Jan 15")] {
386
+ let rendered = chart(
387
+ &days,
388
+ &history[0].values,
389
+ /*cursor*/ 14,
390
+ width,
391
+ /*height*/ 3,
392
+ AccountAnalyticsUnit::Count,
393
+ );
394
+ assert_eq!(rendered.lines.last().unwrap().to_string().trim(), expected);
395
+ snapshots.push(format!("width={width}\n{}", display(&rendered)));
396
+ }
397
+ insta::assert_snapshot!(snapshots.join("\n\n"));
398
+ }
399
+
400
+ #[test]
401
+ fn tiny_segments_keep_their_series_color_beside_a_large_day() {
402
+ let large = AccountAnalyticsDay {
403
+ date: "2026-01-14".parse().unwrap(),
404
+ total: 100.0,
405
+ values: vec![value("a", /*value*/ 100.0)],
406
+ };
407
+ let mut snapshots = Vec::new();
408
+ for sign in [1.0, -1.0] {
409
+ let tiny = AccountAnalyticsDay {
410
+ date: "2026-01-15".parse().unwrap(),
411
+ total: 0.01 * sign,
412
+ values: vec![value("a", 0.01 * sign)],
413
+ };
414
+ let days = [(large.date, Some(&large)), (tiny.date, Some(&tiny))];
415
+ let rendered = chart(
416
+ &days,
417
+ &large.values,
418
+ /*cursor*/ 0,
419
+ /*width*/ 44,
420
+ /*height*/ 4,
421
+ AccountAnalyticsUnit::Credits,
422
+ );
423
+ let glyph = if sign > 0.0 { "▁" } else { "▇" };
424
+ let cells = rendered
425
+ .lines
426
+ .iter()
427
+ .flat_map(|line| &line.spans)
428
+ .filter(|span| span.content == glyph)
429
+ .collect::<Vec<_>>();
430
+ assert!(!cells.is_empty());
431
+ assert!(
432
+ cells
433
+ .iter()
434
+ .all(|cell| cell.style.fg == Some(series_colors()[0]))
435
+ );
436
+ snapshots.push(format!("sign={sign}\n{}", display(&rendered)));
437
+ }
438
+ insta::assert_snapshot!(snapshots.join("\n\n"));
439
+ }
440
+
441
+ #[test]
442
+ fn duplicate_message_dates_render_the_full_other_remainder() {
443
+ use crate::analytics::models::AccountAnalyticsGrouping;
444
+ use crate::analytics::models::AccountAnalyticsReport;
445
+ let date = "2026-01-15".parse().unwrap();
446
+ let response = serde_json::from_value(serde_json::json!({"data": [
447
+ {"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]},
448
+ {"date": "2026-01-15", "totals": {"turns": 10}, "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}
449
+ ]})).unwrap();
450
+ let history = crate::analytics::normalize::history(
451
+ response,
452
+ AccountAnalyticsReport::Messages,
453
+ AccountAnalyticsGrouping::Surface,
454
+ date,
455
+ date,
456
+ )
457
+ .unwrap()
458
+ .unwrap();
459
+ let day = &history.data[0];
460
+ let rendered = chart(
461
+ &[(date, Some(day))],
462
+ &day.values,
463
+ /*cursor*/ 0,
464
+ /*width*/ 44,
465
+ /*height*/ 10,
466
+ AccountAnalyticsUnit::Count,
467
+ );
468
+ insta::assert_snapshot!(display(&rendered));
469
+ }
470
+
471
+ #[test]
472
+ fn seven_day_totals_render_without_selecting_each_bar() {
473
+ let start: NaiveDate = "2026-01-01".parse().unwrap();
474
+ let rows = (0..7)
475
+ .map(|index| AccountAnalyticsDay {
476
+ date: start + chrono::Duration::days(index),
477
+ total: 111.0 + index as f64 * 100.0,
478
+ values: vec![value("a", 111.0 + index as f64 * 100.0)],
479
+ })
480
+ .collect::<Vec<_>>();
481
+ let days = rows
482
+ .iter()
483
+ .map(|day| (day.date, Some(day)))
484
+ .collect::<Vec<_>>();
485
+ let rendered = chart(
486
+ &days,
487
+ &rows[0].values,
488
+ /*cursor*/ 3,
489
+ /*width*/ 100,
490
+ /*height*/ 8,
491
+ AccountAnalyticsUnit::Count,
492
+ );
493
+ let text = display(&rendered);
494
+ for day in &rows {
495
+ assert!(text.contains(&day.total.to_string()));
496
+ }
497
+ insta::assert_snapshot!("seven_day_totals", text);
498
+ }
499
+
500
+ #[test]
501
+ fn narrow_equal_height_totals_remain_separate_numbers() {
502
+ let start: NaiveDate = "2026-01-01".parse().unwrap();
503
+ let rows = (0..7)
504
+ .map(|index| AccountAnalyticsDay {
505
+ date: start + chrono::Duration::days(index),
506
+ total: 111.0,
507
+ values: vec![value("a", /*value*/ 111.0)],
508
+ })
509
+ .collect::<Vec<_>>();
510
+ let days = rows
511
+ .iter()
512
+ .map(|day| (day.date, Some(day)))
513
+ .collect::<Vec<_>>();
514
+ let mut screens = Vec::new();
515
+ for width in [21, 28, 35] {
516
+ let rendered = chart(
517
+ &days,
518
+ &rows[0].values,
519
+ /*cursor*/ 3,
520
+ width,
521
+ /*height*/ 4,
522
+ AccountAnalyticsUnit::Count,
523
+ );
524
+ let text = rendered
525
+ .lines
526
+ .iter()
527
+ .map(|line| line.to_string().trim_end().to_owned())
528
+ .collect::<Vec<_>>()
529
+ .join("\n");
530
+ assert!(text.contains("111"));
531
+ assert!(!text.contains("111111"));
532
+ screens.push(text);
533
+ }
534
+ insta::assert_snapshot!(screens.join("\n\n"));
535
+ }
codex-rs/tui/src/analytics/profile_identity_tests.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! In-flight profile responses cannot expose data after the active account changes.
2
+ use super::*;
3
+ use pretty_assertions::assert_eq;
4
+
5
+ #[tokio::test]
6
+ async fn profile_rejects_account_change_during_success_or_error_response() {
7
+ let server = MockServer::start().await;
8
+ for status in [200, 404, 503] {
9
+ server.reset().await;
10
+ let (home, live) = live(&server, "plus").await;
11
+ let home_path = home.path().to_path_buf();
12
+ Mock::given(method("GET"))
13
+ .and(path("/backend-api/wham/profiles/me"))
14
+ .and(header("chatgpt-account-id", "account-a"))
15
+ .respond_with(move |_: &wiremock::Request| {
16
+ sign_in(&home_path, "account-a", "user-b", "plus");
17
+ ResponseTemplate::new(status)
18
+ .set_body_json(json!({"stats":{"lifetime_tokens":123}}))
19
+ })
20
+ .expect(/*r*/ 1)
21
+ .mount(&server)
22
+ .await;
23
+ assert_eq!(
24
+ live.profile().await.err().unwrap(),
25
+ "Account changed. Press R to refresh Analytics."
26
+ );
27
+ }
28
+ }
codex-rs/tui/src/analytics/render.rs ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Maximized sections and a responsive overview share the same panels and scrolling behavior.
2
+ //! Zoom, section changes, and reflow preserve report selections and inline details.
3
+
4
+ use super::AnalyticsView;
5
+ use super::data;
6
+ use super::styles::secondary_style;
7
+ use crate::analytics::models::AccountAnalyticsHistory;
8
+ use crate::analytics::models::AccountAnalyticsValue;
9
+ use crate::analytics::sections::Section;
10
+ use crate::keymap::ListAction;
11
+ use crate::style::accent_style;
12
+ use crate::style::footer_hint_key_style;
13
+ use crate::wrapping::RtOptions;
14
+ use crate::wrapping::word_wrap_lines;
15
+ use ratatui::buffer::Buffer;
16
+ use ratatui::layout::Constraint;
17
+ use ratatui::layout::Layout;
18
+ use ratatui::layout::Rect;
19
+ use ratatui::style::Color;
20
+ use ratatui::style::Styled;
21
+ use ratatui::style::Stylize;
22
+ use ratatui::text::Line;
23
+ use ratatui::widgets::Paragraph;
24
+ use ratatui::widgets::Widget;
25
+ use std::collections::BTreeMap;
26
+
27
+ pub(super) fn columns(left: Line<'static>, right: Line<'static>, width: usize) -> Line<'static> {
28
+ let padding = width
29
+ .saturating_sub(left.width() + right.width())
30
+ .max(/*other*/ 1);
31
+ let mut spans = left.spans;
32
+ spans.push(" ".repeat(padding).into());
33
+ spans.extend(right.spans);
34
+ Line::from(spans)
35
+ }
36
+
37
+ /// Join independently stacked panels without leaving holes below shorter sections.
38
+ pub(super) fn join_columns(
39
+ left: &[Line<'static>],
40
+ right: &[Line<'static>],
41
+ column_width: usize,
42
+ ) -> Vec<Line<'static>> {
43
+ (0..left.len().max(right.len()))
44
+ .map(|row| {
45
+ let left = left.get(row).cloned().unwrap_or_default();
46
+ let mut line = columns(left, Line::default(), column_width + 4);
47
+ if let Some(right) = right.get(row) {
48
+ line.spans.extend(right.spans.iter().cloned());
49
+ }
50
+ line
51
+ })
52
+ .collect()
53
+ }
54
+
55
+ pub(super) fn categories(history: &AccountAnalyticsHistory) -> Vec<AccountAnalyticsValue> {
56
+ let mut totals = BTreeMap::<String, AccountAnalyticsValue>::new();
57
+ for value in history.data.iter().flat_map(|day| &day.values) {
58
+ totals
59
+ .entry(value.key.clone())
60
+ .or_insert_with(|| AccountAnalyticsValue {
61
+ value: 0.0,
62
+ ..value.clone()
63
+ })
64
+ .value += value.value;
65
+ }
66
+ let mut values = totals.into_values().collect::<Vec<_>>();
67
+ values.sort_by(|a, b| {
68
+ b.value
69
+ .abs()
70
+ .total_cmp(&a.value.abs())
71
+ .then_with(|| a.key.cmp(&b.key))
72
+ });
73
+ values
74
+ }
75
+
76
+ /// The last plotted category collects all remaining values without renormalizing them.
77
+ pub(super) fn parts(
78
+ values: &[AccountAnalyticsValue],
79
+ categories: &[AccountAnalyticsValue],
80
+ ) -> [f64; 4] {
81
+ let mut parts = [0.0; 4];
82
+ for value in values {
83
+ let index = categories
84
+ .iter()
85
+ .take(/*n*/ 3)
86
+ .position(|category| category.key == value.key)
87
+ .unwrap_or(/*default*/ 3);
88
+ parts[index] += value.value;
89
+ }
90
+ parts
91
+ }
92
+
93
+ impl AnalyticsView {
94
+ pub(super) fn render(&mut self, area: Rect, buf: &mut Buffer) {
95
+ self.poll_reports();
96
+ let width = usize::from(area.width.saturating_sub(/*rhs*/ 4)).max(/*other*/ 1);
97
+ let show_range = !matches!(
98
+ self.section,
99
+ Section::Chats | Section::Plan | Section::Summary
100
+ ) && !self.visible_sections().is_empty();
101
+ let hint = |action| {
102
+ self.keymap
103
+ .primary_hint(action)
104
+ .map(crate::key_hint::ShortcutHint::display_label)
105
+ .unwrap_or_default()
106
+ };
107
+ let mut controls = if self.visible_sections().is_empty() {
108
+ format!("R refresh · {} back · q close", hint(ListAction::Cancel))
109
+ } else {
110
+ let navigation = if !self.zoomed {
111
+ format!("{}/z maximize", hint(ListAction::Accept))
112
+ } else if self.section == Section::Summary {
113
+ format!(
114
+ "{}/{} scroll",
115
+ hint(ListAction::MoveUp),
116
+ hint(ListAction::MoveDown)
117
+ )
118
+ } else if self.section == Section::Plan {
119
+ format!(
120
+ "{}/{} window · {}/{} period · {} details",
121
+ hint(ListAction::MoveLeft),
122
+ hint(ListAction::MoveRight),
123
+ hint(ListAction::MoveUp),
124
+ hint(ListAction::MoveDown),
125
+ hint(ListAction::Accept)
126
+ )
127
+ } else if self.section != Section::Chats {
128
+ format!(
129
+ "{}/{} day · {} details",
130
+ hint(ListAction::MoveLeft),
131
+ hint(ListAction::MoveRight),
132
+ hint(ListAction::Accept)
133
+ )
134
+ } else {
135
+ format!(
136
+ "{}/{} row · {} details",
137
+ hint(ListAction::MoveUp),
138
+ hint(ListAction::MoveDown),
139
+ hint(ListAction::Accept)
140
+ )
141
+ };
142
+ format!(
143
+ "tab/1–{} section · {}{navigation}\n{}{}R refresh · {}/{} scroll · {} back · q close",
144
+ self.visible_sections().len(),
145
+ if self.section == Section::Chats && !self.business() && !self.zoomed {
146
+ "s sort · "
147
+ } else if self.section == Section::Chats && !self.business() {
148
+ "s sort · z dashboard · "
149
+ } else if self.zoomed {
150
+ "z dashboard · "
151
+ } else {
152
+ ""
153
+ },
154
+ if show_range { "r 7/30d · " } else { "" },
155
+ if self.group_options().len() < 2 {
156
+ ""
157
+ } else if self.section == Section::Summary {
158
+ "g view · "
159
+ } else {
160
+ "g group · "
161
+ },
162
+ hint(ListAction::PageUp),
163
+ hint(ListAction::PageDown),
164
+ hint(ListAction::Cancel)
165
+ )
166
+ };
167
+ let control_line = |line: &str| {
168
+ let mut spans = Vec::new();
169
+ for (index, control) in line.split(" · ").enumerate() {
170
+ if index > 0 {
171
+ spans.push(" · ".into());
172
+ }
173
+ let (keys, description) = control.rsplit_once(' ').unwrap_or((control, ""));
174
+ spans.push(
175
+ keys.to_owned()
176
+ .fg(Color::Reset)
177
+ .patch_style(footer_hint_key_style().bold().not_dim()),
178
+ );
179
+ if !description.is_empty() {
180
+ spans.push(format!(" {description}").into());
181
+ }
182
+ }
183
+ Line::from(spans)
184
+ };
185
+ let updated = self
186
+ .zoomed
187
+ .then(|| self.sections[self.section].history.ready())
188
+ .flatten()
189
+ .and_then(|history| history.updated_at)
190
+ .and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0))
191
+ .map(|updated| format!("Updated {} UTC", updated.format("%b %-d %H:%M")));
192
+ let add_updated = |footer: &mut Vec<Line<'static>>| {
193
+ if let Some(updated) = &updated {
194
+ let timestamp = Line::from(updated.clone().set_style(secondary_style()));
195
+ if let Some(line) = footer
196
+ .last_mut()
197
+ .filter(|line| line.width() + timestamp.width() + 3 <= width)
198
+ {
199
+ *line = columns(line.clone(), timestamp, width);
200
+ } else {
201
+ footer.extend(word_wrap_lines(
202
+ [columns(Line::default(), timestamp, width)],
203
+ RtOptions::new(width),
204
+ ));
205
+ }
206
+ }
207
+ };
208
+ let mut footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width));
209
+ if matches!(
210
+ self.section,
211
+ Section::Plugins | Section::Activity | Section::Skills
212
+ ) && !self.day_has_details(self.section, self.sections[self.section].cursor)
213
+ {
214
+ let height = footer.len();
215
+ controls = controls.replace(&format!(" · {} details", hint(ListAction::Accept)), "");
216
+ footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width));
217
+ // Removing a contextual hint must not resize the chart on narrow terminals.
218
+ footer.resize(height, Line::default());
219
+ }
220
+ add_updated(&mut footer);
221
+ let sections = if self.zoomed {
222
+ let line = Line::from(
223
+ self.visible_sections()
224
+ .iter()
225
+ .copied()
226
+ .enumerate()
227
+ .map(|(index, section)| {
228
+ let label = self.section_title(section);
229
+ let label = if width >= 64 {
230
+ format!("{} {label}", index + 1)
231
+ } else {
232
+ label.to_string()
233
+ };
234
+ if section == self.section {
235
+ format!("[{label}] ").set_style(accent_style())
236
+ } else {
237
+ format!("{label} ").set_style(secondary_style())
238
+ }
239
+ })
240
+ .collect::<Vec<_>>(),
241
+ );
242
+ word_wrap_lines([line], RtOptions::new(width))
243
+ } else {
244
+ Vec::new()
245
+ };
246
+ let [title, range, tabs, body, hints] = Layout::vertical([
247
+ Constraint::Length(1),
248
+ Constraint::Length(2),
249
+ Constraint::Length(if sections.is_empty() {
250
+ 0
251
+ } else {
252
+ sections.len() as u16 + 1
253
+ }),
254
+ Constraint::Min(0),
255
+ Constraint::Length(footer.len() as u16),
256
+ ])
257
+ .areas(Rect {
258
+ x: area.x + area.width.min(/*other*/ 2),
259
+ width: area.width.saturating_sub(/*rhs*/ 4),
260
+ ..area
261
+ });
262
+ Line::from(vec![
263
+ "Analytics".bold(),
264
+ self.live
265
+ .as_ref()
266
+ .and_then(|live| live.account_label())
267
+ .map(|account| format!(" · Live account · {account}"))
268
+ .unwrap_or_else(|| " · Live account".into())
269
+ .set_style(secondary_style()),
270
+ ])
271
+ .render(title, buf);
272
+ if show_range {
273
+ let dates = self.date_range();
274
+ let (start, end) = (dates.start(), dates.end());
275
+ Line::from(vec![
276
+ if self.ranges[self.range_group(self.section) as usize] == 0 {
277
+ "[7d]".set_style(accent_style())
278
+ } else {
279
+ "7d".into()
280
+ },
281
+ " ".into(),
282
+ if self.ranges[self.range_group(self.section) as usize] == 1 {
283
+ "[30d]".set_style(accent_style())
284
+ } else {
285
+ "30d".into()
286
+ },
287
+ format!(
288
+ " · {}–{}",
289
+ data::date(&start.to_string()),
290
+ end.format("%b %-d, %Y")
291
+ )
292
+ .set_style(secondary_style()),
293
+ ])
294
+ .render(range, buf);
295
+ }
296
+ Paragraph::new(sections).render(tabs, buf);
297
+ self.viewport_height = usize::from(body.height).max(/*other*/ 1);
298
+ let (lines, selection) = if self.visible_sections().is_empty() {
299
+ let message = self
300
+ .account
301
+ .message()
302
+ .unwrap_or("Analytics is not available for this account type.");
303
+ let lines = textwrap::wrap(message, width)
304
+ .into_iter()
305
+ .map(|line| Line::from(line.into_owned()))
306
+ .collect::<Vec<_>>();
307
+ let selection = 0..lines.len();
308
+ (lines, selection)
309
+ } else if self.zoomed {
310
+ let chart_height =
311
+ (usize::from(body.height) / 5).clamp(/*min*/ 4, /*max*/ 10);
312
+ let mut panel = self.panel(self.section, width, chart_height);
313
+ if panel.chart_bands > 0 {
314
+ let overhead = panel.lines.len() - chart_height * panel.chart_bands;
315
+ let height =
316
+ usize::from(body.height).saturating_sub(overhead + 1) / panel.chart_bands;
317
+ let height = height.max(/*other*/ 2);
318
+ if height != chart_height {
319
+ panel = self.panel(self.section, width, height);
320
+ }
321
+ }
322
+ let center_summary = self.section == Section::Summary && self.profile.ready().is_some();
323
+ if center_summary {
324
+ // Keep the section heading and rule fixed; center the profile below them.
325
+ let padding = usize::from(body.height).saturating_sub(panel.lines.len()) / 2;
326
+ panel
327
+ .lines
328
+ .splice(2..2, std::iter::repeat_n(Line::default(), padding));
329
+ }
330
+ let selection = if panel.lines.len() <= usize::from(body.height) {
331
+ 0..panel.lines.len()
332
+ } else {
333
+ panel.selection
334
+ };
335
+ if !center_summary && panel.lines.len() > usize::from(body.height) {
336
+ panel.lines.push(Line::default());
337
+ }
338
+ (panel.lines, selection)
339
+ } else {
340
+ self.dashboard_lines(width, usize::from(body.height))
341
+ };
342
+ if lines.len() <= self.viewport_height {
343
+ let height = footer.len();
344
+ let controls = controls
345
+ .replace(
346
+ &format!(
347
+ " · {}/{} scroll",
348
+ hint(ListAction::PageUp),
349
+ hint(ListAction::PageDown)
350
+ ),
351
+ "",
352
+ )
353
+ .replace(
354
+ &format!(
355
+ " · {}/{} scroll",
356
+ hint(ListAction::MoveUp),
357
+ hint(ListAction::MoveDown)
358
+ ),
359
+ "",
360
+ );
361
+ footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width));
362
+ add_updated(&mut footer);
363
+ if updated.is_some() {
364
+ let padding = height.saturating_sub(footer.len());
365
+ footer.splice(0..0, std::iter::repeat_n(Line::default(), padding));
366
+ } else {
367
+ footer.resize(height, Line::default());
368
+ }
369
+ }
370
+ if self.follow_selection {
371
+ if selection.start < self.scroll_offset {
372
+ self.scroll_offset = selection.start;
373
+ } else {
374
+ let end = selection.start + selection.len().min(self.viewport_height);
375
+ if end > self.scroll_offset + self.viewport_height {
376
+ self.scroll_offset = end - self.viewport_height;
377
+ }
378
+ }
379
+ }
380
+ self.scroll_offset = self
381
+ .scroll_offset
382
+ .min(lines.len().saturating_sub(self.viewport_height));
383
+ self.follow_selection = false;
384
+ Paragraph::new(lines)
385
+ .scroll(/*offset*/ (self.scroll_offset as u16, 0))
386
+ .render(body, buf);
387
+ Paragraph::new(footer)
388
+ .set_style(secondary_style())
389
+ .render(hints, buf);
390
+ }
391
+ }
codex-rs/tui/src/analytics/report_data.rs ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Projects endpoint-specific backend models into inputs for display normalization.
2
+ //! This representation is never used to deserialize HTTP responses.
3
+ //! Optional fields select the inputs used by each report; missing backend values stay missing.
4
+
5
+ use codex_backend_client::AnalyticsResponse;
6
+ use codex_backend_client::analytics_models as backend;
7
+
8
+ /// Report values projected for normalization after endpoint-specific decoding.
9
+ #[derive(Clone, Debug, Default)]
10
+ #[cfg_attr(test, derive(serde::Deserialize))]
11
+ pub(super) struct AnalyticsData {
12
+ pub(super) data: Vec<AnalyticsRecord>,
13
+ pub(super) units: Option<String>,
14
+ pub(super) data_freshness_ts: Option<String>,
15
+ pub(super) series: Option<Vec<AnalyticsSeries>>,
16
+ }
17
+
18
+ #[derive(Clone, Debug, Default)]
19
+ #[cfg_attr(test, derive(serde::Deserialize))]
20
+ pub(super) struct AnalyticsSeries {
21
+ pub(super) key: String,
22
+ pub(super) label: String,
23
+ }
24
+
25
+ #[derive(Clone, Debug, Default)]
26
+ #[cfg_attr(test, derive(serde::Deserialize))]
27
+ pub(super) struct AnalyticsRecord {
28
+ pub(super) date: String,
29
+ pub(super) values: Option<std::collections::BTreeMap<String, f64>>,
30
+ pub(super) product_surface_usage_values: Option<std::collections::BTreeMap<String, f64>>,
31
+ pub(super) premium_usage_values: Option<PremiumUsage>,
32
+ pub(super) attribution: Option<Vec<UsageAttribution>>,
33
+ pub(super) credit_amount: Option<f64>,
34
+ pub(super) product_surface: Option<String>,
35
+ pub(super) totals: Option<AnalyticsCount>,
36
+ pub(super) models: Option<Vec<AnalyticsModel>>,
37
+ pub(super) groups: Option<Vec<AnalyticsTokenGroup>>,
38
+ pub(super) clients: Option<Vec<AnalyticsClient>>,
39
+ pub(super) plugin_usage_overviews: Option<Vec<AnalyticsTool>>,
40
+ pub(super) skill_usage_overviews: Option<Vec<AnalyticsTool>>,
41
+ }
42
+
43
+ #[derive(Clone, Debug, Default)]
44
+ #[cfg_attr(test, derive(serde::Deserialize))]
45
+ pub(super) struct PremiumUsage {
46
+ pub(super) credit_usage_credits: std::collections::BTreeMap<String, f64>,
47
+ }
48
+
49
+ #[derive(Clone, Debug, Default)]
50
+ #[cfg_attr(test, derive(serde::Deserialize))]
51
+ pub(super) struct UsageAttribution {
52
+ pub(super) thread_source: Option<String>,
53
+ pub(super) turn_trigger: Option<String>,
54
+ pub(super) model: Option<String>,
55
+ pub(super) surface: Option<String>,
56
+ pub(super) value: f64,
57
+ }
58
+
59
+ #[derive(Clone, Debug, Default)]
60
+ #[cfg_attr(test, derive(serde::Deserialize))]
61
+ pub(super) struct AnalyticsCount {
62
+ pub(super) turns: f64,
63
+ }
64
+
65
+ #[derive(Clone, Debug, Default)]
66
+ #[cfg_attr(test, derive(serde::Deserialize))]
67
+ pub(super) struct AnalyticsModel {
68
+ pub(super) model: String,
69
+ pub(super) credits: Option<f64>,
70
+ pub(super) uncached_text_input_tokens: Option<f64>,
71
+ pub(super) cached_text_input_tokens: Option<f64>,
72
+ pub(super) text_output_tokens: Option<f64>,
73
+ pub(super) text_total_tokens: Option<f64>,
74
+ pub(super) turns: Option<f64>,
75
+ pub(super) on_demand_credits: Option<f64>,
76
+ pub(super) speed: Option<String>,
77
+ }
78
+
79
+ /// Enterprise model groups carry text token counts independently of billing amounts.
80
+ #[derive(Clone, Debug, Default)]
81
+ #[cfg_attr(test, derive(serde::Deserialize))]
82
+ pub(super) struct AnalyticsTokenGroup {
83
+ pub(super) dimensions: std::collections::BTreeMap<String, String>,
84
+ #[cfg_attr(test, serde(default))]
85
+ pub(super) is_other: bool,
86
+ pub(super) uncached_text_input_tokens: Option<f64>,
87
+ pub(super) cached_text_input_tokens: Option<f64>,
88
+ pub(super) text_output_tokens: Option<f64>,
89
+ }
90
+
91
+ #[derive(Clone, Debug, Default)]
92
+ #[cfg_attr(test, derive(serde::Deserialize))]
93
+ pub(super) struct AnalyticsClient {
94
+ pub(super) client_id: String,
95
+ pub(super) turns: f64,
96
+ }
97
+
98
+ #[derive(Clone, Debug, Default)]
99
+ #[cfg_attr(test, derive(serde::Deserialize))]
100
+ pub(super) struct AnalyticsTool {
101
+ pub(super) display_name: String,
102
+ pub(super) invocation_counts: f64,
103
+ }
104
+
105
+ impl From<AnalyticsResponse> for AnalyticsData {
106
+ fn from(response: AnalyticsResponse) -> Self {
107
+ match response {
108
+ AnalyticsResponse::Usage(response) => Self {
109
+ units: response.units,
110
+ data_freshness_ts: response.data_freshness_ts,
111
+ data: response
112
+ .data
113
+ .into_iter()
114
+ .map(|record| AnalyticsRecord {
115
+ date: record.date,
116
+ product_surface_usage_values: Some(record.product_surface_usage_values),
117
+ premium_usage_values: record.premium_usage_values.map(|value| {
118
+ PremiumUsage {
119
+ credit_usage_credits: value.credit_usage_credits,
120
+ }
121
+ }),
122
+ attribution: record.attribution.map(|values| {
123
+ values
124
+ .into_iter()
125
+ .map(|value| UsageAttribution {
126
+ thread_source: Some(value.thread_source),
127
+ turn_trigger: Some(value.turn_trigger),
128
+ model: Some(value.model),
129
+ surface: Some(value.surface),
130
+ value: value.value,
131
+ })
132
+ .collect()
133
+ }),
134
+ models: record
135
+ .models
136
+ .map(|values| values.into_iter().map(AnalyticsModel::from).collect()),
137
+ groups: record.groups.map(|values| {
138
+ values
139
+ .into_iter()
140
+ .map(|value| AnalyticsTokenGroup {
141
+ dimensions: value.dimensions,
142
+ is_other: value.is_other.unwrap_or(/*default*/ false),
143
+ uncached_text_input_tokens: value
144
+ .uncached_text_input_tokens
145
+ .map(|count| count as f64),
146
+ cached_text_input_tokens: value
147
+ .cached_text_input_tokens
148
+ .map(|count| count as f64),
149
+ text_output_tokens: value
150
+ .text_output_tokens
151
+ .map(|count| count as f64),
152
+ })
153
+ .collect()
154
+ }),
155
+ ..Default::default()
156
+ })
157
+ .collect(),
158
+ ..Default::default()
159
+ },
160
+ AnalyticsResponse::Credits(response) => Self {
161
+ data: response
162
+ .data
163
+ .into_iter()
164
+ .map(|record| AnalyticsRecord {
165
+ date: record.date,
166
+ credit_amount: Some(record.credit_amount),
167
+ product_surface: Some(record.product_surface),
168
+ ..Default::default()
169
+ })
170
+ .collect(),
171
+ ..Default::default()
172
+ },
173
+ AnalyticsResponse::EnterpriseCredits(response) => Self {
174
+ data_freshness_ts: response.data_freshness_ts,
175
+ series: Some(
176
+ response
177
+ .series
178
+ .into_iter()
179
+ .map(|value| AnalyticsSeries {
180
+ key: value.key,
181
+ label: value.label,
182
+ })
183
+ .collect(),
184
+ ),
185
+ data: response
186
+ .data
187
+ .into_iter()
188
+ .map(|record| AnalyticsRecord {
189
+ date: record.date,
190
+ values: Some(record.values),
191
+ ..Default::default()
192
+ })
193
+ .collect(),
194
+ ..Default::default()
195
+ },
196
+ AnalyticsResponse::Messages(response) => Self {
197
+ data: response
198
+ .data
199
+ .into_iter()
200
+ .map(|record| AnalyticsRecord {
201
+ date: record.date,
202
+ totals: Some(AnalyticsCount {
203
+ turns: record.totals.turns as f64,
204
+ }),
205
+ clients: Some(
206
+ record
207
+ .clients
208
+ .into_iter()
209
+ .map(|value| AnalyticsClient {
210
+ client_id: value.client_id,
211
+ turns: value.turns as f64,
212
+ })
213
+ .collect(),
214
+ ),
215
+ models: record
216
+ .models
217
+ .map(|values| values.into_iter().map(AnalyticsModel::from).collect()),
218
+ ..Default::default()
219
+ })
220
+ .collect(),
221
+ ..Default::default()
222
+ },
223
+ AnalyticsResponse::Plugins(response) => Self {
224
+ data_freshness_ts: response.data_freshness_ts,
225
+ data: response
226
+ .data
227
+ .into_iter()
228
+ .map(|record| AnalyticsRecord {
229
+ date: record.date,
230
+ plugin_usage_overviews: Some(
231
+ record
232
+ .plugin_usage_overviews
233
+ .into_iter()
234
+ .map(|value| AnalyticsTool {
235
+ display_name: value.display_name,
236
+ invocation_counts: value.invocation_counts as f64,
237
+ })
238
+ .collect(),
239
+ ),
240
+ ..Default::default()
241
+ })
242
+ .collect(),
243
+ ..Default::default()
244
+ },
245
+ AnalyticsResponse::Skills(response) => Self {
246
+ data_freshness_ts: response.data_freshness_ts,
247
+ data: response
248
+ .data
249
+ .into_iter()
250
+ .map(|record| AnalyticsRecord {
251
+ date: record.date,
252
+ skill_usage_overviews: Some(
253
+ record
254
+ .skill_usage_overviews
255
+ .into_iter()
256
+ .map(|value| AnalyticsTool {
257
+ display_name: value.display_name,
258
+ invocation_counts: value.invocation_counts as f64,
259
+ })
260
+ .collect(),
261
+ ),
262
+ ..Default::default()
263
+ })
264
+ .collect(),
265
+ ..Default::default()
266
+ },
267
+ }
268
+ }
269
+ }
270
+
271
+ impl From<backend::ModelUsage> for AnalyticsModel {
272
+ fn from(value: backend::ModelUsage) -> Self {
273
+ Self {
274
+ model: value.model,
275
+ credits: Some(value.credits),
276
+ uncached_text_input_tokens: value.uncached_text_input_tokens.map(|count| count as f64),
277
+ cached_text_input_tokens: value.cached_text_input_tokens.map(|count| count as f64),
278
+ text_output_tokens: value.text_output_tokens.map(|count| count as f64),
279
+ text_total_tokens: value.text_total_tokens.map(|count| count as f64),
280
+ turns: value.turns.map(|count| count as f64),
281
+ on_demand_credits: value.on_demand_credits,
282
+ speed: value.speed,
283
+ }
284
+ }
285
+ }
286
+
287
+ #[cfg(test)]
288
+ #[path = "report_data_tests.rs"]
289
+ mod tests;
codex-rs/tui/src/analytics/report_data_tests.rs ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use pretty_assertions::assert_eq;
3
+
4
+ #[test]
5
+ fn typed_usage_preserves_missing_counts_and_supplied_zero() {
6
+ let input = backend::DailyProductSurfaceUsageResponse {
7
+ data: vec![backend::DailyProductSurfaceUsage {
8
+ date: "2026-01-09".into(),
9
+ models: Some(vec![backend::ModelUsage {
10
+ model: "example".into(),
11
+ credits: 1.5,
12
+ cached_text_input_tokens: Some(0),
13
+ text_output_tokens: Some(42),
14
+ ..Default::default()
15
+ }]),
16
+ ..Default::default()
17
+ }],
18
+ ..Default::default()
19
+ };
20
+ let actual = AnalyticsData::from(AnalyticsResponse::Usage(input));
21
+ let model = &actual.data[0].models.as_ref().unwrap()[0];
22
+ assert_eq!(
23
+ (
24
+ model.credits,
25
+ model.uncached_text_input_tokens,
26
+ model.cached_text_input_tokens,
27
+ model.text_output_tokens
28
+ ),
29
+ (Some(1.5), None, Some(0.0), Some(42.0))
30
+ );
31
+ }
32
+
33
+ #[test]
34
+ fn typed_message_records_keep_independent_daily_remainders() {
35
+ use super::super::models::*;
36
+ let date = "2026-01-09".parse().unwrap();
37
+ let row = backend::DailyWorkspaceUsageCount {
38
+ date: "2026-01-09".into(),
39
+ totals: backend::WorkspaceUsageCount {
40
+ turns: 10,
41
+ ..Default::default()
42
+ },
43
+ clients: vec![backend::ClientWorkspaceUsageCount {
44
+ client_id: "CODEX_CLI".into(),
45
+ turns: 8,
46
+ ..Default::default()
47
+ }],
48
+ ..Default::default()
49
+ };
50
+ let response = AnalyticsResponse::Messages(backend::DailyWorkspaceUsageCountResponse {
51
+ data: vec![row.clone(), row],
52
+ ..Default::default()
53
+ });
54
+ assert_eq!(
55
+ super::super::normalize::history(
56
+ response.into(),
57
+ AccountAnalyticsReport::Messages,
58
+ AccountAnalyticsGrouping::Surface,
59
+ date,
60
+ date
61
+ )
62
+ .unwrap(),
63
+ Some(AccountAnalyticsHistory {
64
+ unit: AccountAnalyticsUnit::Count,
65
+ updated_at: None,
66
+ data: vec![AccountAnalyticsDay {
67
+ date,
68
+ total: 20.0,
69
+ values: vec![
70
+ AccountAnalyticsValue {
71
+ key: "cli".into(),
72
+ label: "CLI".into(),
73
+ value: 16.0
74
+ },
75
+ AccountAnalyticsValue {
76
+ key: "other".into(),
77
+ label: "Other".into(),
78
+ value: 4.0
79
+ },
80
+ ]
81
+ }],
82
+ })
83
+ );
84
+ }
codex-rs/tui/src/analytics/sections.rs ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Account-specific section order, request eligibility, and independent report ranges.
2
+ //! Resolving the account precedes report loading; refreshing drops all account-owned data.
3
+
4
+ use super::AnalyticsView;
5
+ use super::chats;
6
+ use super::data::Load;
7
+ use super::models::AccountKind;
8
+
9
+ /// Stable identities preserve selections while account layouts change their order.
10
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11
+ pub(super) enum Section {
12
+ Usage,
13
+ Plugins,
14
+ Credits,
15
+ Chats,
16
+ Activity,
17
+ Skills,
18
+ Plan,
19
+ Summary,
20
+ }
21
+
22
+ impl Section {
23
+ pub(super) fn report(self) -> Option<super::models::AccountAnalyticsReport> {
24
+ use super::models::AccountAnalyticsReport as Report;
25
+ match self {
26
+ Self::Usage => Some(Report::Usage),
27
+ Self::Plugins => Some(Report::Plugins),
28
+ Self::Credits => Some(Report::Credits),
29
+ Self::Chats | Self::Plan | Self::Summary => None,
30
+ Self::Activity => Some(Report::Messages),
31
+ Self::Skills => Some(Report::Skills),
32
+ }
33
+ }
34
+ }
35
+
36
+ /// Section-only indexing keeps report identity separate from array positions.
37
+ pub(super) struct SectionStates(pub(super) [SectionState; 8]);
38
+
39
+ impl std::ops::Index<Section> for SectionStates {
40
+ type Output = SectionState;
41
+ fn index(&self, section: Section) -> &Self::Output {
42
+ &self.0[section as usize]
43
+ }
44
+ }
45
+
46
+ impl std::ops::IndexMut<Section> for SectionStates {
47
+ fn index_mut(&mut self, section: Section) -> &mut Self::Output {
48
+ &mut self.0[section as usize]
49
+ }
50
+ }
51
+
52
+ /// A report and its selection are replaced independently of the other sections.
53
+ pub(super) struct SectionState {
54
+ pub(super) history: Load<super::models::AccountAnalyticsHistory>,
55
+ pub(super) cursor: usize,
56
+ pub(super) detail: Option<usize>,
57
+ pub(super) group: usize,
58
+ }
59
+
60
+ impl Default for SectionState {
61
+ fn default() -> Self {
62
+ Self {
63
+ history: Load::Unavailable,
64
+ cursor: 6,
65
+ detail: None,
66
+ group: 0,
67
+ }
68
+ }
69
+ }
70
+
71
+ /// Usage, product activity, and tools each share one range across their related sections.
72
+ #[derive(Clone, Copy, PartialEq, Eq)]
73
+ pub(super) enum RangeGroup {
74
+ Usage,
75
+ Activity,
76
+ Tools,
77
+ }
78
+
79
+ impl AnalyticsView {
80
+ pub(super) fn poll_reports(&mut self) {
81
+ for section in &mut self.sections.0 {
82
+ section.history.poll();
83
+ }
84
+ self.chats.poll();
85
+ self.tasks.poll();
86
+ self.profile.poll();
87
+ self.plan.poll();
88
+ self.account.poll();
89
+ self.start_reports();
90
+ if !self.business()
91
+ && !self.consumer_attribution()
92
+ && ![0, 2].contains(&self.sections[Section::Usage].group)
93
+ {
94
+ self.sections[Section::Usage].group = 0;
95
+ }
96
+ if let Some(chats) = self.tasks.ready() {
97
+ self.sections[Section::Chats].cursor = self.sections[Section::Chats]
98
+ .cursor
99
+ .min(chats.rows.len().saturating_sub(/*rhs*/ 1));
100
+ }
101
+ if let Some(chats) = self.chats.ready() {
102
+ self.sections[Section::Chats].cursor = self.sections[Section::Chats]
103
+ .cursor
104
+ .min(chats.rows.len().saturating_sub(/*rhs*/ 1));
105
+ }
106
+ }
107
+
108
+ pub(super) fn range_group(&self, section: Section) -> RangeGroup {
109
+ match section {
110
+ Section::Usage if self.business() => RangeGroup::Activity,
111
+ Section::Usage
112
+ | Section::Credits
113
+ | Section::Chats
114
+ | Section::Plan
115
+ | Section::Summary => RangeGroup::Usage,
116
+ Section::Activity => RangeGroup::Activity,
117
+ Section::Plugins | Section::Skills => RangeGroup::Tools,
118
+ }
119
+ }
120
+
121
+ pub(super) fn group_label(&self, section: Section, group: usize) -> &'static str {
122
+ if section == Section::Summary {
123
+ return super::summary::VIEWS[group].label();
124
+ }
125
+ if group == 0
126
+ && (section == Section::Credits
127
+ || (section == Section::Usage && !self.consumer_attribution()))
128
+ {
129
+ "Product"
130
+ } else {
131
+ super::data::GROUP_LABELS[group]
132
+ }
133
+ }
134
+
135
+ fn account_kind(&self) -> Option<AccountKind> {
136
+ self.account
137
+ .ready()
138
+ .copied()
139
+ .map(|plan| AccountKind::from(Some(plan)))
140
+ }
141
+
142
+ pub(super) fn business(&self) -> bool {
143
+ matches!(
144
+ self.account_kind(),
145
+ Some(AccountKind::Business | AccountKind::Enterprise)
146
+ )
147
+ }
148
+
149
+ pub(super) fn visible_sections(&self) -> &'static [Section] {
150
+ match self.account_kind() {
151
+ Some(AccountKind::Consumer) if self.plan.enabled => &[
152
+ Section::Summary,
153
+ Section::Usage,
154
+ Section::Plan,
155
+ Section::Activity,
156
+ Section::Plugins,
157
+ Section::Skills,
158
+ Section::Chats,
159
+ ],
160
+ Some(AccountKind::Consumer) => &[
161
+ Section::Summary,
162
+ Section::Usage,
163
+ Section::Activity,
164
+ Section::Plugins,
165
+ Section::Skills,
166
+ Section::Chats,
167
+ ],
168
+ Some(AccountKind::Business | AccountKind::Enterprise)
169
+ if super::models::thread_usage_supported(self.account.ready().copied()) =>
170
+ {
171
+ &[
172
+ Section::Summary,
173
+ Section::Credits,
174
+ Section::Usage,
175
+ Section::Plugins,
176
+ Section::Skills,
177
+ Section::Chats,
178
+ ]
179
+ }
180
+ Some(AccountKind::Business | AccountKind::Enterprise) => &[
181
+ Section::Summary,
182
+ Section::Credits,
183
+ Section::Usage,
184
+ Section::Plugins,
185
+ Section::Skills,
186
+ ],
187
+ Some(AccountKind::Unknown) => &[Section::Summary],
188
+ None => &[],
189
+ }
190
+ }
191
+
192
+ pub(super) fn start_reports(&mut self) {
193
+ if self.reports_started || self.account.ready().is_none() {
194
+ return;
195
+ }
196
+ self.reports_started = true;
197
+ let visible = self.visible_sections();
198
+ let Some(first) = visible.first() else {
199
+ return;
200
+ };
201
+ if !visible.contains(&self.section) {
202
+ self.section = *first;
203
+ }
204
+ let usage_groups: &[usize] = if self.business() {
205
+ &[6, 2]
206
+ } else {
207
+ &[1, 2, 0, 3]
208
+ };
209
+ if !usage_groups.contains(&self.sections[Section::Usage].group) {
210
+ self.sections[Section::Usage].group =
211
+ if matches!(self.account_kind(), Some(AccountKind::Business)) {
212
+ 2
213
+ } else {
214
+ usage_groups[0]
215
+ };
216
+ }
217
+ if visible.contains(&Section::Credits)
218
+ && let Some(live) = &self.live
219
+ && !live
220
+ .credit_groups()
221
+ .contains(&self.sections[Section::Credits].group)
222
+ {
223
+ self.sections[Section::Credits].group = live.credit_groups()[0];
224
+ }
225
+ self.load_summary();
226
+ for section in visible {
227
+ if section.report().is_some() {
228
+ self.load_report(*section);
229
+ }
230
+ }
231
+ if visible.contains(&Section::Plan)
232
+ && let (Some((_, _, frame)), Some(live)) = (&self.connection, &self.live)
233
+ {
234
+ let live = std::sync::Arc::clone(live);
235
+ self.plan.report = Load::start(async move { live.plan_history().await }, frame.clone());
236
+ }
237
+ if visible.contains(&Section::Chats) && !self.business() {
238
+ if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) {
239
+ self.tasks = Load::start_with_timeout(
240
+ super::tasks::read(handle.clone(), std::sync::Arc::clone(live)),
241
+ frame.clone(),
242
+ std::time::Duration::from_secs(/*secs*/ 120),
243
+ );
244
+ }
245
+ } else if visible.contains(&Section::Chats) {
246
+ self.chats =
247
+ if let (Some((_, handle, frame)), Some(live)) = (&self.connection, &self.live) {
248
+ Load::start_with_timeout(
249
+ chats::read(handle.clone(), std::sync::Arc::clone(live)),
250
+ frame.clone(),
251
+ std::time::Duration::from_secs(/*secs*/ 90),
252
+ )
253
+ } else {
254
+ Load::Unavailable
255
+ };
256
+ }
257
+ }
258
+
259
+ pub(super) fn section_date_range(
260
+ &self,
261
+ section: Section,
262
+ ) -> std::ops::RangeInclusive<chrono::NaiveDate> {
263
+ let end = self.end_date;
264
+ let range = self.ranges[self.range_group(section) as usize];
265
+ (end - chrono::Days::new(if range == 0 { 6 } else { 29 }))..=end
266
+ }
267
+
268
+ pub(super) fn change_range(&mut self) {
269
+ let index = self.range_group(self.section);
270
+ self.ranges[index as usize] ^= 1;
271
+ for section in self.visible_sections() {
272
+ if matches!(section, Section::Chats | Section::Plan | Section::Summary)
273
+ || self.range_group(*section) != index
274
+ {
275
+ continue;
276
+ }
277
+ self.sections[*section].cursor = if self.ranges[index as usize] == 1 {
278
+ self.sections[*section].cursor + 23
279
+ } else {
280
+ self.sections[*section].cursor.saturating_sub(/*rhs*/ 23)
281
+ };
282
+ self.sections[*section].detail = self.sections[*section].detail.and_then(|day| {
283
+ if self.ranges[index as usize] == 1 {
284
+ Some(day + 23)
285
+ } else {
286
+ day.checked_sub(/*rhs*/ 23)
287
+ }
288
+ });
289
+ if section.report().is_some() {
290
+ self.load_report(*section);
291
+ }
292
+ }
293
+ }
294
+ }
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__account_plan_error.snap ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/connection_tests.rs
3
+ expression: terminal.backend().to_string()
4
+ ---
5
+ " Analytics · Live account "
6
+ " "
7
+ " "
8
+ " "
9
+ " "
10
+ " Couldn't load account plan. Press R to retry Analytics. "
11
+ " "
12
+ " "
13
+ " "
14
+ " "
15
+ " "
16
+ " "
17
+ " "
18
+ " "
19
+ " "
20
+ " "
21
+ " "
22
+ " "
23
+ " "
24
+ " "
25
+ " "
26
+ " "
27
+ " "
28
+ " "
29
+ " "
30
+ " "
31
+ " "
32
+ " "
33
+ " "
34
+ " "
35
+ " "
36
+ " "
37
+ " "
38
+ " "
39
+ " "
40
+ " "
41
+ " "
42
+ " "
43
+ " "
44
+ " R refresh · esc back · q close "
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_error.snap ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/connection_tests.rs
3
+ expression: terminal.backend().to_string()
4
+ ---
5
+ " Analytics · Live account · analytics@example.test "
6
+ " "
7
+ " "
8
+ " [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used "
9
+ " "
10
+ " ▎ Summary "
11
+ " ──────────────────────────────────────────────────────────────────────────────────────────────── "
12
+ " Report request failed. Press R to retry. "
13
+ " "
14
+ " "
15
+ " "
16
+ " "
17
+ " "
18
+ " "
19
+ " "
20
+ " "
21
+ " "
22
+ " "
23
+ " "
24
+ " "
25
+ " "
26
+ " "
27
+ " "
28
+ " "
29
+ " "
30
+ " "
31
+ " "
32
+ " "
33
+ " "
34
+ " "
35
+ " "
36
+ " "
37
+ " "
38
+ " "
39
+ " "
40
+ " "
41
+ " "
42
+ " "
43
+ " "
44
+ " "
45
+ " "
46
+ " "
47
+ " "
48
+ " "
49
+ " "
50
+ " "
51
+ " tab/1–5 section · z dashboard "
52
+ " g view · R refresh · esc back · q close "
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_recovered.snap ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/connection_tests.rs
3
+ expression: terminal.backend().to_string()
4
+ ---
5
+ " Analytics · Live account · analytics@example.test "
6
+ " "
7
+ " "
8
+ " [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used "
9
+ " "
10
+ " ▎ Summary "
11
+ " ──────────────────────────────────────────────────────────────────────────────────────────────── "
12
+ " "
13
+ " "
14
+ " "
15
+ " "
16
+ " "
17
+ " Local account "
18
+ " @local-user "
19
+ " Enterprise "
20
+ " "
21
+ " 12.3K 12.3K — — — "
22
+ " Lifetime tokens Peak tokens Longest chat Current streak Longest streak "
23
+ " "
24
+ " Token activity Daily "
25
+ " Last 12 months "
26
+ " "
27
+ " Nov Dec Jan Feb Mar Apr May Jun Jul Aug "
28
+ " Su □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ "
29
+ " Mo □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ "
30
+ " Tu □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ "
31
+ " We □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ ■ "
32
+ " Th □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ "
33
+ " Fr □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ "
34
+ " Sa □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ "
35
+ " "
36
+ " Less □ ■ ■ ■ ■ More "
37
+ " "
38
+ " Activity insights Most used plugins and skills "
39
+ " "
40
+ " Fast Mode — Plugin and skill usage unavailable. "
41
+ " Most used reasoning — "
42
+ " Skills explored — "
43
+ " Total skills used — "
44
+ " Total chats — "
45
+ " "
46
+ " "
47
+ " "
48
+ " "
49
+ " "
50
+ " "
51
+ " tab/1–5 section · z dashboard "
52
+ " g view · R refresh · esc back · q close "
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/client_tests.rs
3
+ expression: terminal.backend().to_string()
4
+ ---
5
+ " Analytics · Live account · analytics@example.test "
6
+ " "
7
+ " "
8
+ " "
9
+ " "
10
+ " No history has been reported. "
11
+ " "
12
+ " "
13
+ " "
14
+ " "
15
+ " "
16
+ " "
17
+ " "
18
+ " "
19
+ " "
20
+ " "
21
+ " "
22
+ " "
23
+ " "
24
+ " "
25
+ " "
26
+ " "
27
+ " "
28
+ " R refresh · esc back · q close "
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plan_tests.rs
3
+ expression: "output.join(\"\\n\")"
4
+ ---
5
+ " Analytics · Live account "
6
+ " "
7
+ " "
8
+ " 1 Summary 2 Total usage history [3 Plan usage history] 4 Messages 5 Plugins called 6 Skills used 7 Top chats "
9
+ " "
10
+ " ▎ Plan usage history By feature "
11
+ " ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── "
12
+ " 5-hour limits › Weekly limits "
13
+ " "
14
+ " › Sep 2 10:00 – Sep 2 15:00 * 125% › Sep 1 00:00 – Sep 8 00:00 * Not available "
15
+ " Some usage is unavailable; this period may be Breakdown isn't available for this period "
16
+ " incomplete "
17
+ " Tasks 125% "
18
+ " Some periods aren't available yet "
19
+ " Usage as of Sep 2 12:00 UTC · * current at last update "
20
+ " Available since Sep 1 00:00 UTC "
21
+ " Amounts and period boundaries are approximate; recent activity may be delayed "
22
+ " "
23
+ " "
24
+ " "
25
+ " "
26
+ " "
27
+ " "
28
+ " "
29
+ " "
30
+ " "
31
+ " "
32
+ " "
33
+ " tab/1–7 section · z dashboard · ←/→ window · ↑/↓ period · enter details "
34
+ " g group · R refresh · esc back · q close "
35
+
36
+ " Analytics · Live account "
37
+ " "
38
+ " "
39
+ " Summary Total usage history [Plan usage history] "
40
+ " Messages Plugins called Skills used Top chats "
41
+ " "
42
+ " ▎ Plan usage history By feature "
43
+ " ────────────────────────────────────────────────────── "
44
+ " 5-hour limits "
45
+ " "
46
+ " › Sep 2 10:00 – Sep 2 15:00 * 125% "
47
+ " Some usage is unavailable; this period may be "
48
+ " incomplete "
49
+ " Tasks 125% "
50
+ " "
51
+ " › Weekly limits "
52
+ " "
53
+ " › Sep 1 00:00 – Sep 8 00:00 * Not available "
54
+ " Breakdown isn't available for this period "
55
+ " Some periods aren't available yet "
56
+ " Usage as of Sep 2 12:00 UTC · * current at last update "
57
+ " Available since Sep 1 00:00 UTC "
58
+ " Amounts and period boundaries are approximate; recent "
59
+ " activity may be delayed "
60
+ " "
61
+ " "
62
+ " tab/1–7 section · z dashboard · ←/→ window · ↑/↓ "
63
+ " period · enter details "
64
+ " g group · R refresh · esc back · q close "
65
+ " "
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plan_tests.rs
3
+ expression: output
4
+ ---
5
+ " Analytics · Live account "
6
+ " "
7
+ " "
8
+ " ╭─ 1 Summary ────────────────────────────────────────╮ ╭─ 2 Total usage history ────────────────────────────╮ "
9
+ " │ │ │ │ "
10
+ " │ No history has been reported. │ │ 7d │ "
11
+ " │ │ │ By feature │ "
12
+ " │ │ │ Total usage · relative units │ "
13
+ " │ │ │ 20 73 64 91 81 100 86 │ "
14
+ " │ │ │ 100 ▁▁▁▁ ▅▅▅▅ ▅▅▅▅ ▇▇▇▇ ▆▆▆▆ ████ ▆▆▆▆ │ "
15
+ " │ │ │ 0 ─────────────────────────────────────────────── │ "
16
+ " │ │ │ ▲ │ "
17
+ " │ │ │ 27 28 29 30 31 1 2 │ "
18
+ " │ │ │ │ "
19
+ " │ │ │ Sep 2 · 86 usage units │ "
20
+ " │ │ │ ● Tasks 64.0% │ "
21
+ " │ │ │ ● Code review 14.0% │ "
22
+ " │ │ │ │ "
23
+ " ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ "
24
+ " "
25
+ " ╭─ ▸ 3 Plan usage history ─────────────────────────────╮ ╭─ 4 Messages ───────────────────────────────────────╮ "
26
+ " │ │ │ │ "
27
+ " │ By feature │ │ 7d │ "
28
+ " │ › 5-hour limits │ │ By model │ "
29
+ " │ │ │ 574 reported messages │ "
30
+ " │ Latest period · 12 total 125% │ │ 62 83 107 75 59 85 103 │ "
31
+ " │ │ │ 150 ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ ▄▄▄▄ ▃▃▃▃ ▄▄▄▄ ▅▅▅▅ │ "
32
+ " │ Weekly limits │ │ 0 ─────────────────────────────────────────────── │ "
33
+ " │ │ │ ▲ │ "
34
+ " │ Latest period · 1 total Not available │ │ 27 28 29 30 31 1 2 │ "
35
+ " │ Some periods aren't available yet │ │ │ "
36
+ " │ Usage as of Sep 2 12:00 UTC │ │ Sep 2 · 103 messages │ "
37
+ " │ Available since Sep 1 00:00 UTC │ │ ● GPT-5.6-Sol 61 │ "
38
+ " │ … z to maximize │ │ ● GPT-5.6-Terra 25 │ "
39
+ " │ │ │ │ "
40
+ " ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ "
41
+ " "
42
+ " ╭─ 5 Plugins called ─────────────────────────────────╮ ╭─ 6 Skills used ────────────────────────────────────╮ "
43
+ " │ │ │ │ "
44
+ " │ 7d │ │ 7d │ "
45
+ " │ 186 calls │ │ 54 uses │ "
46
+ " │ 34 23 29 26 22 23 29 │ │ 8 6 9 8 7 5 11 │ "
47
+ " │ 40 ▆▆▆▆ ▄▄▄▄ ▅▅▅▅ ▅▅▅▅ ▄▄▄▄ ▄▄▄▄ ▅▅▅▅ │ │ 20 ▃▃▃▃ ▂▂▂▂ ▃▃▃▃ ▃▃▃▃ ▂▂▂▂ ▂▂▂▂ ▄▄▄▄ │ "
48
+ " │ 0 ──────────────────────────────────────────────── │ │ 0 ──────────────────────────────────────────────── │ "
49
+ " │ ▲ │ │ ▲ │ "
50
+ " │ 27 28 29 30 31 1 2 │ │ 27 28 29 30 31 1 2 │ "
51
+ " │ │ │ │ "
52
+ " │ Sep 2 · 29 calls │ │ Sep 2 · 11 uses │ "
53
+ " │ ● Slack 10 │ │ ● Testing 4 │ "
54
+ " │ ● Figma 10 │ │ ● Code review 3 │ "
55
+ " │ │ │ │ "
56
+ " │ │ │ │ "
57
+ " ╰──────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ "
58
+ " "
59
+ " ╭─ 7 Top chats ──────────────────────────────────────╮ "
60
+ " │ │ "
61
+ " │ No history has been reported. │ "
62
+ " │ │ "
63
+ " tab/1–7 section · enter/z maximize "
64
+ " g group · R refresh · pgup/pgdn scroll · esc back · q close "
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plan_tests.rs
3
+ assertion_line: 105
4
+ expression: snapshot
5
+ ---
6
+ › 5-hour limits Weekly limits
7
+
8
+ Limit history isn't available yet Limit history isn't available yet
9
+
10
+ › 5-hour limits Weekly limits
11
+
12
+ No limit periods in this date range No limit periods in this date range
13
+ Usage as of Sep 2 12:00 UTC · * current at last update
14
+ Available since Sep 1 00:00 UTC
15
+ Amounts and period boundaries are approximate; recent activity may be delayed
16
+
17
+ › 5-hour limits Weekly limits
18
+
19
+ › Sep 2 10:00 – Sep 2 15:00 * 0% › Sep 1 00:00 – Sep 8 00:00 * Not available
20
+ Some periods aren't available yet
21
+ Usage as of Sep 2 12:00 UTC · * current at last update
22
+ Available since Sep 1 00:00 UTC
23
+ Amounts and period boundaries are approximate; recent activity may be delayed
24
+
25
+ › 5-hour limits Weekly limits
26
+
27
+ › Sep 2 13:00 – Sep 2 15:00 125% › Sep 1 00:00 – Sep 8 00:00 * Not available
28
+ Some periods aren't available yet
29
+ Usage as of Sep 2 12:00 UTC · * current at last update
30
+ Available since Sep 1 00:00 UTC
31
+ Amounts and period boundaries are approximate; recent activity may be delayed
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plot_tests.rs
3
+ expression: display(&rendered)
4
+ ---
5
+ 20
6
+ 20 ████████████
7
+ ████████████
8
+ ████████████
9
+ ████████████
10
+ ████████████
11
+ 10 ████████████
12
+ ████████████
13
+ ████████████
14
+ ████████████
15
+ ████████████
16
+ 0 ────────────────────────────────────────
17
+ ▲
18
+ Jan 15
19
+
20
+ Palette:
21
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
22
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
23
+ 2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
24
+ 3: Style::new().magenta().bg(Color::Reset).underline_color(Color::Reset)
25
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
26
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
27
+ 6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
28
+ Rows (column:palette):
29
+ 0: 0:0 22:1 24:0
30
+ 1: 0:0 1:2 3:0 18:3 30:0
31
+ 2: 0:0 18:3 30:0
32
+ 3: 0:0 18:4 30:0
33
+ 4: 0:0 18:4 30:0
34
+ 5: 0:0 18:4 30:0
35
+ 6: 0:0 1:2 3:0 18:4 30:0
36
+ 7: 0:0 18:4 30:0
37
+ 8: 0:0 18:4 30:0
38
+ 9: 0:0 18:4 30:0
39
+ 10: 0:0 18:4 30:0
40
+ 11: 0:0 2:2 3:0 4:2
41
+ 12: 0:0 23:5 24:0
42
+ 13: 0:0 20:6 26:0
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plot_tests.rs
3
+ expression: "snapshots.join(\"\\n\\n\")"
4
+ ---
5
+ width=36, cursor=0
6
+ 5
7
+ 6 ▄▄▄▄▄▄▄
8
+ ███████
9
+ — 0 ███████
10
+ 0 ─────────────────────────────────
11
+ ▲
12
+ Jan 13 Jan 14 Jan 15
13
+
14
+ Palette:
15
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
16
+ 1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
17
+ 2: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
18
+ 3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
19
+ 4: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
20
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
21
+ 6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
22
+ Rows (column:palette):
23
+ 0: 0:0 30:1 31:0
24
+ 1: 0:0 1:2 2:0 27:3 34:0
25
+ 2: 0:0 27:3 34:0
26
+ 3: 0:0 8:4 9:0 19:1 20:0 27:3 34:0
27
+ 4: 0:0 1:2 2:0 3:1
28
+ 5: 0:0 8:5 9:0
29
+ 6: 0:0 5:6 11:0 16:2 22:0 27:2 33:0
30
+
31
+ width=36, cursor=1
32
+ 5
33
+ 6 ▄▄▄▄▄▄▄
34
+ ███████
35
+ — 0 ███████
36
+ 0 ─────────────────────────────────
37
+ ▲
38
+ Jan 13 Jan 14 Jan 15
39
+
40
+ Palette:
41
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
42
+ 1: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
43
+ 2: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
44
+ 3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
45
+ 4: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
46
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
47
+ 6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
48
+ Rows (column:palette):
49
+ 0: 0:0 30:1 31:0
50
+ 1: 0:0 1:2 2:0 27:3 34:0
51
+ 2: 0:0 27:3 34:0
52
+ 3: 0:0 8:1 9:0 19:4 20:0 27:3 34:0
53
+ 4: 0:0 1:2 2:0 3:1
54
+ 5: 0:0 19:5 20:0
55
+ 6: 0:0 5:2 11:0 16:6 22:0 27:2 33:0
56
+
57
+ width=8, cursor=2
58
+ 5
59
+ ▄▄
60
+ ██
61
+ — 0 ██
62
+ ────────
63
+ ▲
64
+ 13 15
65
+
66
+ Palette:
67
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
68
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
69
+ 2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
70
+ 3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
71
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
72
+ 5: Style::new().fg(Color::Rgb(165, 165, 165)).bg(Color::Reset).underline_color(Color::Reset)
73
+ 6: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
74
+ Rows (column:palette):
75
+ 0: 0:0 6:1 7:0
76
+ 1: 0:0 5:2 7:0
77
+ 2: 0:0 5:2 7:0
78
+ 3: 0:3 1:0 3:3 4:0 5:2 7:0
79
+ 4: 0:3
80
+ 5: 0:0 6:4 7:0
81
+ 6: 0:5 2:0 5:6 7:0
82
+
83
+ width=1, cursor=2
84
+ 5
85
+ ▄
86
+ █
87
+ █
88
+ ─
89
+ ▲
90
+
91
+
92
+ Palette:
93
+ 0: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
94
+ 1: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
95
+ 2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
96
+ 3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
97
+ 4: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
98
+ Rows (column:palette):
99
+ 0: 0:0
100
+ 1: 0:1
101
+ 2: 0:1
102
+ 3: 0:1
103
+ 4: 0:2
104
+ 5: 0:3
105
+ 6: 0:4
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_equal_height_totals_remain_separate_numbers.snap ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plot_tests.rs
3
+ expression: "screens.join(\"\\n\\n\")"
4
+ ---
5
+ 111 111 111
6
+ 111 ┊ 111 ┊ 111 ┊
7
+ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇
8
+ ██ ██ ██ ██ ██ ██ ██
9
+ ██ ██ ██ ██ ██ ██ ██
10
+ ─────────────────────
11
+ ▲
12
+ 1 2 3 4 5 6 7
13
+
14
+
15
+ 111 111 111 111 111 111 111
16
+ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇ ▇▇
17
+ ██ ██ ██ ██ ██ ██ ██
18
+ ██ ██ ██ ██ ██ ██ ██
19
+ ────────────────────────────
20
+ ▲
21
+ 1 2 3 4 5 6 7
22
+
23
+
24
+ 150 111 111 111 111 111 111 111
25
+ ▇▇ ▇▇ ▇▇ ▇▇▇ ▇▇ ▇▇ ▇▇▇
26
+ 75 ██ ██ ██ ███ ██ ██ ███
27
+ ██ ██ ██ ███ ██ ██ ███
28
+ 0 ──────────────────────────────
29
+ ▲
30
+ 1 2 3 4 5 6 7
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plot_tests.rs
3
+ expression: "snapshots.join(\"\\n\\n\")"
4
+ ---
5
+ width=1
6
+
7
+ 1
8
+ ▄
9
+ █
10
+ ─
11
+ ▲
12
+
13
+
14
+ Palette:
15
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
16
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
17
+ 2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
18
+ 3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
19
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
20
+ Rows (column:palette):
21
+ 0: 0:0
22
+ 1: 0:1
23
+ 2: 0:2
24
+ 3: 0:2
25
+ 4: 0:3
26
+ 5: 0:4
27
+ 6: 0:0
28
+
29
+ width=2
30
+
31
+ 1
32
+ ▄▄
33
+ ██
34
+ ──
35
+ ▲
36
+ 15
37
+
38
+ Palette:
39
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
40
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
41
+ 2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
42
+ 3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
43
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
44
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
45
+ Rows (column:palette):
46
+ 0: 0:0
47
+ 1: 0:0 1:1
48
+ 2: 0:2
49
+ 3: 0:2
50
+ 4: 0:3
51
+ 5: 0:0 1:4
52
+ 6: 0:5
53
+
54
+ width=5
55
+
56
+ 1
57
+ ▄▄▄▄▄
58
+ █████
59
+ ─────
60
+ ▲
61
+ 15
62
+
63
+ Palette:
64
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
65
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
66
+ 2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
67
+ 3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
68
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
69
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
70
+ Rows (column:palette):
71
+ 0: 0:0
72
+ 1: 0:0 4:1
73
+ 2: 0:2
74
+ 3: 0:2
75
+ 4: 0:3
76
+ 5: 0:0 4:4
77
+ 6: 0:0 3:5
78
+
79
+ width=6
80
+
81
+ 1
82
+ ▄▄▄▄▄▄
83
+ ██████
84
+ ──────
85
+ ▲
86
+ Jan 15
87
+
88
+ Palette:
89
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
90
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
91
+ 2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
92
+ 3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
93
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
94
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
95
+ Rows (column:palette):
96
+ 0: 0:0
97
+ 1: 0:0 5:1
98
+ 2: 0:2
99
+ 3: 0:2
100
+ 4: 0:3
101
+ 5: 0:0 5:4
102
+ 6: 0:5
codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__oversized_axes_are_hidden_instead_of_truncated.snap ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ source: tui/src/analytics/plot_tests.rs
3
+ expression: "snapshots.join(\"\\n\\n\")"
4
+ ---
5
+ width=8
6
+ 100M
7
+ █████
8
+ █████
9
+ █████
10
+ █████
11
+ ────────
12
+ ▲
13
+ Jan 15
14
+
15
+ Palette:
16
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
17
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
18
+ 2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
19
+ 3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
20
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
21
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
22
+ Rows (column:palette):
23
+ 0: 0:0 1:1 5:0
24
+ 1: 0:0 1:2 6:0
25
+ 2: 0:0 1:2 6:0
26
+ 3: 0:0 1:2 6:0
27
+ 4: 0:0 1:2 6:0
28
+ 5: 0:3
29
+ 6: 0:0 3:4 4:0
30
+ 7: 0:5 6:0
31
+
32
+ width=30
33
+ 100M
34
+ 100M ████████████
35
+ ████████████
36
+ 50M ████████████
37
+ ████████████
38
+ 0 ────────────────────────
39
+ ▲
40
+ Jan 15
41
+
42
+ Palette:
43
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
44
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
45
+ 2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
46
+ 3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
47
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
48
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
49
+ Rows (column:palette):
50
+ 0: 0:0 15:1 19:0
51
+ 1: 0:0 1:2 5:0 12:3 24:0
52
+ 2: 0:0 12:3 24:0
53
+ 3: 0:0 2:2 5:0 12:3 24:0
54
+ 4: 0:0 12:3 24:0
55
+ 5: 0:0 4:2 5:0 6:2
56
+ 6: 0:0 17:4 18:0
57
+ 7: 0:0 14:5 20:0
58
+
59
+ width=35
60
+ 100M
61
+ 100M ████████████
62
+ ████████████
63
+ 50M ████████████
64
+ ████████████
65
+ 0 ─────────────────────────────
66
+ ▲
67
+ Jan 15
68
+
69
+ Palette:
70
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
71
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
72
+ 2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
73
+ 3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
74
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
75
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
76
+ Rows (column:palette):
77
+ 0: 0:0 18:1 22:0
78
+ 1: 0:0 1:2 5:0 14:3 26:0
79
+ 2: 0:0 14:3 26:0
80
+ 3: 0:0 2:2 5:0 14:3 26:0
81
+ 4: 0:0 14:3 26:0
82
+ 5: 0:0 4:2 5:0 6:2
83
+ 6: 0:0 20:4 21:0
84
+ 7: 0:0 17:5 23:0
85
+
86
+ width=44
87
+ 100M
88
+ 100M ████████████
89
+ ████████████
90
+ 50M ████████████
91
+ ████████████
92
+ 0 ──────────────────────────────────────
93
+ ▲
94
+ Jan 15
95
+
96
+ Palette:
97
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
98
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
99
+ 2: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
100
+ 3: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
101
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
102
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
103
+ Rows (column:palette):
104
+ 0: 0:0 22:1 26:0
105
+ 1: 0:0 1:2 5:0 19:3 31:0
106
+ 2: 0:0 19:3 31:0
107
+ 3: 0:0 2:2 5:0 19:3 31:0
108
+ 4: 0:0 19:3 31:0
109
+ 5: 0:0 4:2 5:0 6:2
110
+ 6: 0:0 24:4 25:0
111
+ 7: 0:0 21:5 27:0
112
+
113
+ width=30
114
+
115
+ 1000000000000000000T
116
+ ▅▅▅▅▅▅▅▅▅▅▅▅
117
+ ████████████
118
+ ████████████
119
+ ──────────────────────────────
120
+ ▲
121
+ Jan 15
122
+
123
+ Palette:
124
+ 0: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
125
+ 1: Style::new().white().bg(Color::Reset).underline_color(Color::Reset).bold()
126
+ 2: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset)
127
+ 3: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim()
128
+ 4: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold()
129
+ 5: Style::new().cyan().bg(Color::Reset).underline_color(Color::Reset).bold().underlined()
130
+ Rows (column:palette):
131
+ 0: 0:0
132
+ 1: 0:0 4:1 24:0
133
+ 2: 0:0 9:2 21:0
134
+ 3: 0:0 9:2 21:0
135
+ 4: 0:0 9:2 21:0
136
+ 5: 0:3
137
+ 6: 0:0 14:4 15:0
138
+ 7: 0:0 11:5 17:0