Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- codex-rs/tui/src/analytics/account_layout_tests.rs +491 -0
- codex-rs/tui/src/analytics/account_plan_tests.rs +167 -0
- codex-rs/tui/src/analytics/activity_chart.rs +319 -0
- codex-rs/tui/src/analytics/activity_chart/palette.rs +151 -0
- codex-rs/tui/src/analytics/activity_chart/palette_tests.rs +115 -0
- codex-rs/tui/src/analytics/activity_chart_tests.rs +239 -0
- codex-rs/tui/src/analytics/chart.rs +338 -0
- codex-rs/tui/src/analytics/chat_panel.rs +317 -0
- codex-rs/tui/src/analytics/chat_panel_tests.rs +215 -0
- codex-rs/tui/src/analytics/chats.rs +195 -0
- codex-rs/tui/src/analytics/chats_tests.rs +178 -0
- codex-rs/tui/src/analytics/client.rs +267 -0
- codex-rs/tui/src/analytics/client_tests.rs +851 -0
- codex-rs/tui/src/analytics/compact_tests.rs +48 -0
- codex-rs/tui/src/analytics/connection_tests.rs +237 -0
- codex-rs/tui/src/analytics/consumer_refresh_tests.rs +105 -0
- codex-rs/tui/src/analytics/dashboard.rs +225 -0
- codex-rs/tui/src/analytics/dashboard_tests.rs +99 -0
- codex-rs/tui/src/analytics/data.rs +216 -0
- codex-rs/tui/src/analytics/data_tests.rs +132 -0
- codex-rs/tui/src/analytics/models.rs +99 -0
- codex-rs/tui/src/analytics/navigation_tests.rs +272 -0
- codex-rs/tui/src/analytics/normalize.rs +364 -0
- codex-rs/tui/src/analytics/normalize_tests.rs +358 -0
- codex-rs/tui/src/analytics/panels.rs +106 -0
- codex-rs/tui/src/analytics/plan.rs +121 -0
- codex-rs/tui/src/analytics/plan_panel.rs +259 -0
- codex-rs/tui/src/analytics/plan_tests.rs +252 -0
- codex-rs/tui/src/analytics/plot.rs +176 -0
- codex-rs/tui/src/analytics/plot/annotations.rs +213 -0
- codex-rs/tui/src/analytics/plot/layout.rs +55 -0
- codex-rs/tui/src/analytics/plot/painting.rs +132 -0
- codex-rs/tui/src/analytics/plot_tests.rs +535 -0
- codex-rs/tui/src/analytics/profile_identity_tests.rs +28 -0
- codex-rs/tui/src/analytics/render.rs +391 -0
- codex-rs/tui/src/analytics/report_data.rs +289 -0
- codex-rs/tui/src/analytics/report_data_tests.rs +84 -0
- codex-rs/tui/src/analytics/sections.rs +294 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__account_plan_error.snap +44 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_error.snap +52 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__connection_tests__local_profile_recovered.snap +52 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap +28 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap +65 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__plan_overview_escape_closes_with_retained_expansion.snap +64 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__unavailable_empty_and_zero_history_are_distinct.snap +31 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__duplicate_message_dates_render_the_full_other_remainder.snap +42 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__missing_zero_and_narrow_charts_keep_the_selection_visible.snap +105 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_equal_height_totals_remain_separate_numbers.snap +30 -0
- codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plot__tests__narrow_long_range_charts_never_truncate_dates.snap +102 -0
- 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
|