From 46f7d99d0e99365323e67eb7f1f39e72d24bd1a0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 20 Sep 2026 17:58:10 +0000 Subject: [PATCH] Stabilize usage dashboard navigation and add keyboard help (#46863) ## Why Wrapping navigation and contextual hints can shift report content as the displayed state changes, especially on narrow terminals. ## What changed - Use compact report tabs, fixed control rows, and two footer hint rows to keep report geometry stable. - Show the account plan in the `Usage` heading and move date ranges, grouping, model filters, sort controls, and update timestamps above reports. - Add scrollable `?` help with an independent scroll position, preserving report selection, expanded details, and reading position when dismissed. - Share control availability between keyboard actions and help, and preserve configured keybindings when they conflict with the help shortcut. ## Testing Add regression coverage for stable tabs after request failures, help state preservation, dashboard selection scrolling, date and update-time controls, and custom keybinding precedence. Update layout snapshots for the revised navigation and narrow help view. GitOrigin-RevId: ab89ed2fd346171362f487c64af7206d9123859a --- codex-rs/tui/src/analytics.rs | 156 ++++---- .../tui/src/analytics/account_layout_tests.rs | 13 +- .../tui/src/analytics/chat_panel_tests.rs | 31 +- codex-rs/tui/src/analytics/chrome.rs | 172 +++++++++ codex-rs/tui/src/analytics/controls.rs | 281 ++++++++++++++ codex-rs/tui/src/analytics/dashboard.rs | 6 +- codex-rs/tui/src/analytics/dashboard_tests.rs | 10 +- codex-rs/tui/src/analytics/data.rs | 6 - codex-rs/tui/src/analytics/hints.rs | 111 ++++++ .../tui/src/analytics/navigation_tests.rs | 57 +++ codex-rs/tui/src/analytics/panels.rs | 43 +-- codex-rs/tui/src/analytics/plan_tests.rs | 28 +- codex-rs/tui/src/analytics/recovery_tests.rs | 16 + codex-rs/tui/src/analytics/redesign_tests.rs | 69 ++++ codex-rs/tui/src/analytics/render.rs | 353 +++++------------- ...ible_and_closing_aborts_pending_loads.snap | 12 +- ...s_unknowns_and_resets_on_new_snapshot.snap | 84 ++--- ...down_ranks_nonzero_groups_and_reflows.snap | 140 +++---- ...l_indent_and_counts_missing_estimates.snap | 44 +-- ...er_refresh__consumer_chat_all_metrics.snap | 54 +-- ...align_and_keep_stable_summary_heights.snap | 212 +++++------ ...alytics__tests__redesign__narrow_help.snap | 20 + ...s__summary__summary_cumulative_narrow.snap | 64 ++-- ...s__tests__summary__summary_daily_wide.snap | 16 +- ...tics__tests__summary__summary_partial.snap | 38 +- codex-rs/tui/src/analytics/styles_tests.rs | 48 ++- codex-rs/tui/src/analytics/summary.rs | 9 +- codex-rs/tui/src/analytics/summary_panel.rs | 12 +- codex-rs/tui/src/analytics_tests.rs | 11 +- .../tui/src/app/tests/transcript_composer.rs | 28 +- ...nguish_zero_missing_and_exact_details.snap | 10 - ...rs_and_empty_reports_stay_independent.snap | 8 - ...zed_refunds_leave_room_for_both_bands.snap | 46 +-- ...denominators_and_signed_small_credits.snap | 4 - 34 files changed, 1390 insertions(+), 822 deletions(-) create mode 100644 codex-rs/tui/src/analytics/chrome.rs create mode 100644 codex-rs/tui/src/analytics/controls.rs create mode 100644 codex-rs/tui/src/analytics/hints.rs create mode 100644 codex-rs/tui/src/analytics/recovery_tests.rs create mode 100644 codex-rs/tui/src/analytics/redesign_tests.rs create mode 100644 codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__redesign__narrow_help.snap diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs index 84cec98f83..8ef53a3946 100644 --- a/codex-rs/tui/src/analytics.rs +++ b/codex-rs/tui/src/analytics.rs @@ -1,5 +1,5 @@ //! Authenticated account analytics dashboard. -//! A maximized section and an overview share bounded report loads, selection, and inline details. +//! Stable report tabs share bounded account loads, selection, and inline details. mod activity_chart; mod chart; @@ -8,12 +8,15 @@ mod summary_panel; pub(crate) use activity_chart::TokenActivityView; mod chat_panel; mod chats; +mod chrome; mod client; +mod controls; mod dashboard; mod data; #[cfg(test)] #[path = "analytics/test_fixtures.rs"] mod fixture; +mod hints; mod models; mod normalize; mod panels; @@ -70,8 +73,10 @@ pub(crate) struct AnalyticsView { live: Option>, pub(crate) keymap: ListKeymap, section: Section, + show_help: bool, zoomed: bool, scroll_offset: usize, + help_scroll: usize, follow_selection: bool, viewport_height: usize, ranges: [usize; 3], @@ -97,8 +102,10 @@ impl AnalyticsView { live: None, keymap, section: Section::Summary, + show_help: false, zoomed: true, scroll_offset: 0, + help_scroll: 0, follow_selection: true, viewport_height: 1, ranges: [0; 3], @@ -130,6 +137,7 @@ impl AnalyticsView { .enabled(codex_features::Feature::AnalyticsPlanHistory); self.connection = Some((config, handle, frame)); self.is_done = false; + self.show_help = false; self.refresh(); } @@ -241,25 +249,6 @@ impl AnalyticsView { .is_none_or(|live| live.attributed_usage()) } - fn section_title(&self, section: Section) -> &str { - match section { - Section::Usage if self.business() => "Token usage history", - Section::Usage if self.consumer_attribution() => "Total usage history", - Section::Usage => "Usage history", - Section::Plugins => "Plugins called", - Section::Credits => "Credits usage history", - Section::Activity => "Messages", - Section::Skills => "Skills used", - Section::Chats => "Top chats", - Section::Plan => "Plan usage history", - Section::Summary => "Summary", - } - } - - fn date_range(&self) -> std::ops::RangeInclusive { - self.section_date_range(self.section) - } - fn model_name<'a>(&'a self, model: &'a str) -> &'a str { self.model_names .get(model) @@ -311,65 +300,73 @@ impl AnalyticsView { }) }; - self.follow_selection = true; - if key_hint::plain(KeyCode::Char('z')).is_press(key) { - self.zoomed = !self.zoomed; - self.scroll_offset = 0; + self.follow_selection = matches!( + action, + Some( + ListAction::Accept + | ListAction::Cancel + | ListAction::MoveLeft + | ListAction::MoveRight + | ListAction::MoveUp + | ListAction::MoveDown + | ListAction::JumpTop + | ListAction::JumpBottom + ) + ); + if self.help_shortcut_available() + && (key_hint::plain(KeyCode::Char('?')).is_press(key) + || key_hint::shift(KeyCode::Char('?')).is_press(key)) + { + self.show_help = !self.show_help; + self.help_scroll = 0; + self.follow_selection = false; + return; + } + if self.show_help && action == Some(ListAction::Cancel) { + self.show_help = false; + self.follow_selection = false; + return; + } + if self.show_help { + self.summary_action(action); + return; + } + if key_hint::plain(KeyCode::Char('z')).is_press(key) + && self.control_available(controls::Control::Dashboard) + { + self.activate_control(controls::Control::Dashboard); return; } if !self.zoomed && action == Some(ListAction::Accept) { - self.zoomed = true; - self.scroll_offset = 0; + self.toggle_dashboard(); return; } if key_hint::plain(KeyCode::Char('R')).is_press(key) { self.refresh(); return; } - if self.section == Section::Chats - && self.sections[Section::Chats].detail.is_some() + if self.control_available(controls::Control::ZeroCreditGroups) && key_hint::plain(KeyCode::Char('a')).is_press(key) { - self.show_zero_credit_groups = !self.show_zero_credit_groups; + self.activate_control(controls::Control::ZeroCreditGroups); return; } - if self.section == Section::Usage - && self.business() + if self.control_available(controls::Control::Model) && key_hint::plain(KeyCode::Char('m')).is_press(key) { - let models = self - .live - .as_ref() - .map_or_else(Vec::new, |live| live.token_models()); - self.token_model = match self - .token_model - .as_ref() - .and_then(|model| models.iter().position(|candidate| candidate == model)) - { - Some(index) => models.get(index + 1).cloned(), - None => models.first().cloned(), - }; - self.load_report(Section::Usage); + self.activate_control(controls::Control::Model); return; } if key_hint::plain(KeyCode::Char('r')).is_press(key) - && !matches!( - self.section, - Section::Chats | Section::Plan | Section::Summary - ) - && !self.visible_sections().is_empty() + && self.control_available(controls::Control::Range) { - self.change_range(); + self.activate_control(controls::Control::Range); return; } - if key_hint::plain(KeyCode::Char('g')).is_press(key) && self.group_options().len() > 1 { - let options = self.group_options(); - let next = options - .iter() - .position(|group| *group == self.sections[self.section].group) - .map_or(/*default*/ 0, |index| (index + 1) % options.len()); - self.sections[self.section].group = options[next]; - self.load_report(self.section); + if key_hint::plain(KeyCode::Char('g')).is_press(key) + && self.control_available(controls::Control::Group) + { + self.activate_control(controls::Control::Group); return; } let visible = self.visible_sections(); @@ -390,10 +387,11 @@ impl AnalyticsView { _ => None, }; if let Some(section) = target { - if self.zoomed && section != self.section { - self.scroll_offset = 0; - } - self.section = section; + self.select_section(section); + return; + } + if !self.zoomed && action == Some(ListAction::Cancel) { + self.is_done = true; return; } if self.section == Section::Summary { @@ -404,17 +402,10 @@ impl AnalyticsView { self.plan_action(action); return; } - if self.section == Section::Chats - && !self.business() + if self.control_available(controls::Control::TaskMetric) && key_hint::plain(KeyCode::Char('s')).is_press(key) { - let metrics = self.task_metrics(); - let index = metrics - .iter() - .position(|metric| *metric == self.task_metric()) - .unwrap_or_default(); - self.chat_metric = metrics[(index + 1) % metrics.len()]; - self.sections[Section::Chats].detail = None; + self.activate_control(controls::Control::TaskMetric); return; } if matches!( @@ -427,23 +418,13 @@ impl AnalyticsView { } if self.section == Section::Chats && matches!(action, Some(ListAction::Accept | ListAction::MoveRight)) - && if self.business() { - self.chats - .ready() - .and_then(|chats| chats.rows.get(self.sections[Section::Chats].cursor)) - .and_then(|chat| chat.usage.as_ref()) - .is_none() - } else { - self.task_rows() - .get(self.sections[Section::Chats].cursor) - .and_then(|chat| task_panel::available(chat)) - .is_none() - } + && !self.chat_has_details() { return; } let chart = self.section != Section::Chats; let count = self.row_count().max(/*other*/ 1); + let mut scroll_offset = self.scroll_offset(); let SectionState { cursor, detail, .. } = &mut self.sections[self.section]; let previous_cursor = *cursor; match action { @@ -462,14 +443,14 @@ impl AnalyticsView { } Some(ListAction::MoveLeft) => *detail = None, Some(ListAction::Cancel) => { - self.is_done |= !self.zoomed || detail.take().is_none(); + self.is_done |= detail.take().is_none(); } Some(ListAction::PageDown) => { - self.scroll_offset += self.viewport_height; + scroll_offset += self.viewport_height; self.follow_selection = false; } Some(ListAction::PageUp) => { - self.scroll_offset = self.scroll_offset.saturating_sub(self.viewport_height); + scroll_offset = scroll_offset.saturating_sub(self.viewport_height); self.follow_selection = false; } None => {} @@ -479,6 +460,7 @@ impl AnalyticsView { } else if !chart && *cursor != previous_cursor { *detail = None; } + *self.scroll_offset_mut() = scroll_offset; } pub(crate) fn handle_event( @@ -505,7 +487,7 @@ impl AnalyticsView { tui.frame_requester().schedule_frame(); } TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { - if matches!(event, TuiEvent::Resize(_)) { + if matches!(event, TuiEvent::Resize(_)) && !self.show_help { self.follow_selection = true; } tui.draw(u16::MAX, |frame| self.render(frame.area(), frame.buffer))?; diff --git a/codex-rs/tui/src/analytics/account_layout_tests.rs b/codex-rs/tui/src/analytics/account_layout_tests.rs index 13d80a91c1..787052cc43 100644 --- a/codex-rs/tui/src/analytics/account_layout_tests.rs +++ b/codex-rs/tui/src/analytics/account_layout_tests.rs @@ -427,7 +427,7 @@ fn navigation_keeps_its_row_when_sections_change() { positions.push( rendered .lines() - .position(|line| line.contains("Summary") && line.contains("usage history")) + .position(|line| line.contains("Overview") && line.contains("Usage")) .unwrap(), ); } @@ -436,7 +436,7 @@ fn navigation_keeps_its_row_when_sections_change() { } #[test] -fn taller_report_reveals_more_legend_rows_and_keeps_timestamp_in_footer() { +fn taller_report_reveals_more_legend_rows_and_keeps_timestamp_in_controls() { let mut view = fixture::view(models::AccountKind::Consumer); view.section = Section::Usage; let mut history = fixture::history(/*report*/ 0, /*range*/ 0, /*group*/ 1); @@ -457,14 +457,9 @@ fn taller_report_reveals_more_legend_rows_and_keeps_timestamp_in_footer() { tall.lines().filter(|line| line.contains('●')).count() > short.lines().filter(|line| line.contains('●')).count() ); - assert!( - tall.lines() - .rev() - .take(3) - .any(|line| line.contains("Updated")) - ); + assert!(tall.lines().take(6).any(|line| line.contains("Updated"))); for width in [24, 40, 64] { let narrow = screen(&mut view, width, /*height*/ 60); - assert!(narrow.lines().last().unwrap().contains("UTC")); + assert!(narrow.lines().nth(/*n*/ 3).unwrap().contains("7d/30d")); } } diff --git a/codex-rs/tui/src/analytics/chat_panel_tests.rs b/codex-rs/tui/src/analytics/chat_panel_tests.rs index 91e8d22e78..e5b1de0be0 100644 --- a/codex-rs/tui/src/analytics/chat_panel_tests.rs +++ b/codex-rs/tui/src/analytics/chat_panel_tests.rs @@ -49,7 +49,8 @@ fn chats_range_controls_and_visible_rows() { press(&mut view, KeyCode::Char('2')); press(&mut view, KeyCode::Char('r')); press(&mut view, KeyCode::Char('z')); - assert!(screen(&mut view, /*width*/ 110, /*height*/ 24).contains("[30d]")); + assert_eq!(view.ranges[view.range_group(Section::Credits) as usize], 1); + assert!(screen(&mut view, /*width*/ 110, /*height*/ 24).contains("r 7d/30d")); } #[test] @@ -204,3 +205,31 @@ fn moving_chat_selection_collapses_details_before_they_leave_the_viewport() { assert!(view.is_done); } } + +#[test] +fn chat_help_and_zero_credit_action_follow_account_and_detail_state() { + for kind in [models::AccountKind::Consumer, models::AccountKind::Business] { + let mut view = fixture::view(kind); + fixture::seed_reports(&mut view); + view.section = Section::Chats; + view.sections[Section::Chats].detail = Some(0); + let help = view + .help_lines(/*width*/ 100) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + let business = kind == models::AccountKind::Business; + assert_eq!(help.contains("a · show zero-credit"), business); + assert_eq!(help.contains("s · change chat sort metric"), !business); + press(&mut view, KeyCode::Char('a')); + assert_eq!(view.show_zero_credit_groups, business); + view.sections[Section::Chats].detail = None; + assert!( + !view + .help_lines(/*width*/ 100) + .iter() + .any(|line| line.to_string().contains("a · show zero-credit")) + ); + } +} diff --git a/codex-rs/tui/src/analytics/chrome.rs b/codex-rs/tui/src/analytics/chrome.rs new file mode 100644 index 0000000000..ae87330700 --- /dev/null +++ b/codex-rs/tui/src/analytics/chrome.rs @@ -0,0 +1,172 @@ +//! Report navigation and keyboard help for the full-screen usage view. +//! Help has an independent scroll position and preserves report selection. + +use super::AnalyticsView; +use super::controls::Control; +use super::sections::Section; +use crate::keymap::ListAction; +use ratatui::text::Line; + +impl AnalyticsView { + pub(super) fn tab_label(&self, section: Section) -> &'static str { + match section { + Section::Summary => "Overview", + Section::Usage if self.business() => "Tokens", + Section::Usage => "Usage", + Section::Credits => "Credits", + Section::Activity => "Messages", + Section::Plugins => "Plugins", + Section::Skills => "Skills", + Section::Chats => "Chats", + Section::Plan => "Plan", + } + } + + pub(super) fn scroll_offset(&self) -> usize { + if self.show_help { + self.help_scroll + } else { + self.scroll_offset + } + } + + pub(super) fn scroll_offset_mut(&mut self) -> &mut usize { + if self.show_help { + &mut self.help_scroll + } else { + &mut self.scroll_offset + } + } + + pub(super) fn toggle_dashboard(&mut self) { + self.zoomed = !self.zoomed; + self.scroll_offset = 0; + self.follow_selection = true; + } + + pub(super) fn select_section(&mut self, section: Section) { + if self.zoomed && section != self.section { + self.scroll_offset = 0; + } + self.follow_selection = !self.zoomed || section != self.section; + self.section = section; + self.show_help = false; + } + + pub(super) fn chat_has_details(&self) -> bool { + let cursor = self.sections[Section::Chats].cursor; + if self.business() { + self.chats + .ready() + .and_then(|chats| chats.rows.get(cursor)) + .and_then(|chat| chat.usage.as_ref()) + .is_some() + } else { + self.task_rows() + .get(cursor) + .and_then(|chat| super::task_panel::available(chat)) + .is_some() + } + } + + pub(super) fn hint(&self, action: ListAction) -> String { + self.keymap + .primary_hint(action) + .map(crate::key_hint::ShortcutHint::display_label) + .unwrap_or_default() + } + + pub(super) fn help_shortcut_available(&self) -> bool { + [ + crossterm::event::KeyModifiers::NONE, + crossterm::event::KeyModifiers::SHIFT, + ] + .into_iter() + .all(|modifiers| { + self.keymap + .action_for(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('?'), + modifiers, + )) + .is_none() + }) + } + + pub(super) fn help_lines(&self, width: usize) -> Vec> { + let mut controls = vec![ + "Tab / Shift+Tab · next / previous report".to_owned(), + format!( + "1–{} · open a report directly", + self.visible_sections().len() + ), + format!( + "{} / {} · select chart day or plan window", + self.hint(ListAction::MoveLeft), + self.hint(ListAction::MoveRight) + ), + format!( + "{} / {} · select chat or plan period; scroll Overview", + self.hint(ListAction::MoveUp), + self.hint(ListAction::MoveDown) + ), + format!( + "{} · expand or collapse details", + self.hint(ListAction::Accept) + ), + format!( + "{} / {} · scroll report", + self.hint(ListAction::PageUp), + self.hint(ListAction::PageDown) + ), + ]; + if self.control_available(Control::Range) { + controls.push("r · switch between 7 and 30 days".into()); + } + if self.control_available(Control::Group) { + controls.push("g · change grouping / Overview aggregation".into()); + } + if self.control_available(Control::Model) { + controls.push("m · cycle model filter".into()); + } + if self.control_available(Control::TaskMetric) { + controls.push("s · change chat sort metric".into()); + } + if self.control_available(Control::ZeroCreditGroups) { + controls.push("a · show zero-credit groups in expanded details".into()); + } + controls.extend([ + "R · refresh all reports".into(), + format!( + "z · dashboard / focused report; {} · focus dashboard card", + self.hint(ListAction::Accept) + ), + format!( + "{} · back; q / ctrl+c · close usage", + self.hint(ListAction::Cancel) + ), + ]); + if self.help_shortcut_available() { + controls.insert(controls.len() - 1, "? · toggle this help".into()); + } + if let Some(updated) = self.sections[self.section] + .history + .ready() + .and_then(|report| report.updated_at) + .and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, /*nsecs*/ 0)) + { + controls.push(format!( + "Report updated {} UTC", + updated.format("%b %-d %H:%M") + )); + } + controls + .into_iter() + .flat_map(|line| { + textwrap::wrap(&line, width) + .into_iter() + .map(|line| Line::from(line.into_owned())) + .collect::>() + }) + .collect() + } +} diff --git a/codex-rs/tui/src/analytics/controls.rs b/codex-rs/tui/src/analytics/controls.rs new file mode 100644 index 0000000000..c846299334 --- /dev/null +++ b/codex-rs/tui/src/analytics/controls.rs @@ -0,0 +1,281 @@ +//! Visible report controls, update times, and shared keyboard actions. +//! Their reserved rows depend only on terminal width, so loading never shifts the report body. + +use super::AnalyticsView; +use super::sections::Section; +use super::styles::secondary_style; +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow as truncate; +use crate::style::accent_style; +use chrono::Datelike; +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Widget; + +/// Report actions shared by keyboard dispatch, visible controls, and help. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Control { + Range, + Group, + Model, + TaskMetric, + Dashboard, + PlanWindow, + ZeroCreditGroups, +} + +impl AnalyticsView { + /// Share action eligibility between painted controls, keyboard dispatch and help. + pub(super) fn control_available(&self, control: Control) -> bool { + if self.visible_sections().is_empty() { + return false; + } + match control { + Control::Range => self.section.report().is_some(), + Control::Group => self.group_options().len() > 1, + Control::Model => self.section == Section::Usage && self.business(), + Control::TaskMetric => self.section == Section::Chats && !self.business(), + Control::Dashboard => true, + Control::PlanWindow => self.section == Section::Plan, + Control::ZeroCreditGroups => { + self.section == Section::Chats + && self.business() + && self.sections[Section::Chats].detail.is_some() + } + } + } + + /// Apply a visible report action without synthesizing a remappable keyboard event. + pub(super) fn activate_control(&mut self, control: Control) { + if !self.control_available(control) { + return; + } + match control { + Control::ZeroCreditGroups => { + self.show_zero_credit_groups = !self.show_zero_credit_groups + } + Control::Range => self.change_range(), + Control::Dashboard => self.toggle_dashboard(), + Control::PlanWindow => { + self.plan_action(Some(crate::keymap::ListAction::MoveRight)); + self.follow_selection = true; + } + Control::Model => { + let models = self + .live + .as_ref() + .map_or_else(Vec::new, |live| live.token_models()); + self.token_model = match self + .token_model + .as_ref() + .and_then(|model| models.iter().position(|candidate| candidate == model)) + { + Some(index) => models.get(index + 1).cloned(), + None => models.first().cloned(), + }; + self.load_report(Section::Usage); + } + Control::Group => { + let options = self.group_options(); + let next = options + .iter() + .position(|group| *group == self.sections[self.section].group) + .map_or(/*default*/ 0, |index| (index + 1) % options.len()); + self.sections[self.section].group = options[next]; + self.load_report(self.section); + } + Control::TaskMetric => { + let metrics = self.task_metrics(); + let index = metrics + .iter() + .position(|metric| *metric == self.task_metric()) + .unwrap_or_default(); + self.chat_metric = metrics[(index + 1) % metrics.len()]; + self.sections[Section::Chats].detail = None; + } + } + } + + pub(super) fn render_controls(&mut self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + if self.show_help { + "Usage shortcuts".bold().render(area, buf); + return; + } + if self.visible_sections().is_empty() { + return; + } + let width = usize::from(area.width); + let mut rows = vec![Vec::new(), Vec::new()]; + let report = self.control_available(Control::Range); + if report { + let range = self.ranges[self.range_group(self.section) as usize]; + let dates = self.section_date_range(self.section); + let interval = if dates.start().year() != dates.end().year() && width < 50 { + format!( + "{}–{}", + dates.start().format("%m/%d/%Y"), + dates.end().format("%m/%d/%Y") + ) + } else if dates.start().year() == dates.end().year() { + format!( + "{}–{}", + dates.start().format("%b %-d"), + dates.end().format("%b %-d, %Y") + ) + } else { + format!( + "{}–{}", + dates.start().format("%b %-d, %Y"), + dates.end().format("%b %-d, %Y") + ) + }; + rows[0].push(( + Control::Range, + Line::from(vec![ + key_hint::plain(KeyCode::Char('r')).into(), + " ".dim(), + if range == 0 { + "7d".set_style(accent_style()).bold() + } else { + "7d".dim() + }, + "/".dim(), + if range == 1 { + "30d".set_style(accent_style()).bold() + } else { + "30d".dim() + }, + format!(" · {interval}").into(), + ]), + )); + } + let option_row = usize::from(width < 100 && report); + if self.control_available(Control::Group) { + rows[option_row].push(( + Control::Group, + Line::from(vec![ + key_hint::plain(KeyCode::Char('g')).into(), + " ".dim(), + self.group_label(self.section, self.sections[self.section].group) + .to_owned() + .into(), + ]), + )); + } + if self.control_available(Control::Model) { + rows[option_row].push(( + Control::Model, + Line::from(vec![ + key_hint::plain(KeyCode::Char('m')).into(), + " ".dim(), + self.token_model + .clone() + .unwrap_or_else(|| "All models".into()) + .into(), + ]), + )); + } + if self.control_available(Control::TaskMetric) { + rows[0].push(( + Control::TaskMetric, + Line::from(vec![ + key_hint::plain(KeyCode::Char('s')).into(), + " sort: ".dim(), + super::task_panel::METRICS[self.task_metric()].into(), + ]), + )); + } + if self.control_available(Control::PlanWindow) { + let row = usize::from(width < 100); + let hints = [ + crate::keymap::ListAction::MoveLeft, + crate::keymap::ListAction::MoveRight, + ] + .into_iter() + .filter_map(|action| self.keymap.primary_hint(action)) + .map(key_hint::ShortcutHint::display_label) + .collect::>() + .join("/"); + let mut plan_window = Line::default(); + if !hints.is_empty() { + plan_window.spans = key_hint::key_label_spans(&format!("{hints} ")); + } + plan_window.spans.extend([ + if self.plan.window == 0 { + "5-hour".set_style(accent_style()).bold() + } else { + "5-hour".dim() + }, + " / ".dim(), + if self.plan.window == 1 { + "Weekly".set_style(accent_style()).bold() + } else { + "Weekly".dim() + }, + ]); + rows[row].push((Control::PlanWindow, plan_window)); + } + if !self.zoomed { + let mut dashboard = Line::from("Dashboard".bold()); + if let Some(hint) = self.keymap.primary_hint(crate::keymap::ListAction::Accept) { + dashboard.spans.push(" · ".dim()); + dashboard.spans.extend(hint.spans()); + dashboard.spans.push(" focus".dim()); + } + rows[0].insert(/*index*/ 0, (Control::Dashboard, dashboard)); + } + for (row, controls) in rows + .into_iter() + .enumerate() + .take(usize::from(area.height.saturating_sub(/*rhs*/ 1))) + { + let mut x = area.x; + for (_, line) in controls { + if x > area.x { + " · ".dim().render( + Rect::new( + x, + area.y + row as u16, + area.right().saturating_sub(x).min(/*other*/ 3), + /*height*/ 1, + ), + buf, + ); + x = x.saturating_add(/*rhs*/ 3); + } + let remaining = area.right().saturating_sub(x); + if remaining == 0 { + break; + } + let line = truncate(line, usize::from(remaining)); + let hit = Rect::new( + x, + area.y + row as u16, + line.width() as u16, + /*height*/ 1, + ); + line.render(hit, buf); + x = hit.right(); + } + } + let updated = self.sections[self.section] + .history + .ready() + .and_then(|report| report.updated_at) + .and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0)); + let metadata = updated + .map(|updated| format!("Updated · {} UTC", updated.format("%b %-d %H:%M"))) + .unwrap_or_default(); + truncate(metadata.set_style(secondary_style()).into(), width).render( + Rect::new(area.x, area.bottom() - 1, area.width, /*height*/ 1), + buf, + ); + } +} diff --git a/codex-rs/tui/src/analytics/dashboard.rs b/codex-rs/tui/src/analytics/dashboard.rs index 57a58349a6..9420176515 100644 --- a/codex-rs/tui/src/analytics/dashboard.rs +++ b/codex-rs/tui/src/analytics/dashboard.rs @@ -152,11 +152,7 @@ impl AnalyticsView { "… z to maximize".set_style(secondary_style()).into(); } content.resize(inner_height, Line::default()); - let label = format!( - " {} {} ", - row * count + column + 1, - self.section_title(section) - ); + let label = format!(" {} {} ", row * count + column + 1, self.tab_label(section)); let title = truncate( Line::from(vec![ " ".into(), diff --git a/codex-rs/tui/src/analytics/dashboard_tests.rs b/codex-rs/tui/src/analytics/dashboard_tests.rs index 066facfbfb..fe0c47e697 100644 --- a/codex-rs/tui/src/analytics/dashboard_tests.rs +++ b/codex-rs/tui/src/analytics/dashboard_tests.rs @@ -21,7 +21,7 @@ fn dashboard_focus_uses_marker_and_default_foreground_title() { let focused_title = title .spans .iter() - .find(|span| span.content.contains(view.section_title(section))) + .find(|span| span.content.contains(view.tab_label(section))) .unwrap(); assert_eq!(focused_title.style.fg, None); assert!(focused_title.style.add_modifier.contains(Modifier::BOLD)); @@ -35,9 +35,9 @@ fn dashboard_cards_align_and_keep_stable_summary_heights() { press(&mut view, KeyCode::Char('z')); let wide = screen(&mut view, /*width*/ 144, /*height*/ 64); for (left, right) in [ - ("1 Summary", "2 Total usage history"), - ("3 Messages", "4 Plugins called"), - ("5 Skills used", "6 Top chats"), + ("1 Overview", "2 Usage"), + ("3 Messages", "4 Plugins"), + ("5 Skills", "6 Chats"), ] { assert!( wide.lines() @@ -68,7 +68,7 @@ fn dashboard_cards_align_and_keep_stable_summary_heights() { ); } let narrow = screen(&mut view, /*width*/ 72, /*height*/ 48); - assert!(narrow.contains("1 Summary")); + assert!(narrow.contains("1 Overview")); let context = ( view.section, view.sections.0.each_ref().map(|state| state.cursor), diff --git a/codex-rs/tui/src/analytics/data.rs b/codex-rs/tui/src/analytics/data.rs index f4421db2a8..76065d6070 100644 --- a/codex-rs/tui/src/analytics/data.rs +++ b/codex-rs/tui/src/analytics/data.rs @@ -205,12 +205,6 @@ pub(super) fn credits(micros: i64) -> String { ) } -pub(super) fn date(date: &str) -> String { - chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") - .map(|date| date.format("%b %-d").to_string()) - .unwrap_or_else(|_| date.to_string()) -} - #[cfg(test)] #[path = "data_tests.rs"] mod tests; diff --git a/codex-rs/tui/src/analytics/hints.rs b/codex-rs/tui/src/analytics/hints.rs new file mode 100644 index 0000000000..f84e4720f8 --- /dev/null +++ b/codex-rs/tui/src/analytics/hints.rs @@ -0,0 +1,111 @@ +//! Two fixed hint rows keep report actions visible without changing chart geometry. + +use super::AnalyticsView; +use super::sections::Section; +use crate::key_hint::key_label_spans; +use crate::keymap::ListAction; +use ratatui::style::Stylize; +use ratatui::text::Line; + +impl AnalyticsView { + pub(super) fn footer_lines(&self, width: usize, scrollable: bool) -> Vec> { + let pair = |a, b| format!("{}/{}", self.hint(a), self.hint(b)); + let mut actions = Vec::new(); + if self.show_help { + actions.push((self.hint(ListAction::Cancel), "back")); + } else if !self.zoomed { + actions.push((self.hint(ListAction::Accept), "focus")); + actions.push(("tab".into(), "card")); + } else { + match self.section { + Section::Summary => {} + Section::Plan => { + actions.push((pair(ListAction::MoveUp, ListAction::MoveDown), "period")); + } + Section::Chats => { + actions.push((pair(ListAction::MoveUp, ListAction::MoveDown), "row")); + } + Section::Usage + | Section::Credits + | Section::Activity + | Section::Plugins + | Section::Skills => { + actions.push((pair(ListAction::MoveLeft, ListAction::MoveRight), "day")); + } + } + let plan_details = self.section == Section::Plan + && self + .plan + .report + .ready() + .and_then(|report| { + report.periods[self.plan.window].get(self.plan.cursor[self.plan.window]) + }) + .is_some(); + if plan_details + || self.day_has_details(self.section, self.sections[self.section].cursor) + || (self.section == Section::Chats && self.chat_has_details()) + { + actions.push((self.hint(ListAction::Accept), "details")); + } + } + if scrollable { + actions.push((pair(ListAction::PageUp, ListAction::PageDown), "scroll")); + } + if !self.show_help { + actions.push((self.hint(ListAction::Cancel), "back")); + } + let mut navigation = if self.show_help { + vec![("?".into(), "close help")] + } else if self.visible_sections().is_empty() { + vec![("R".into(), "retry")] + } else { + vec![ + ( + if width >= 80 { + format!("tab/1–{}", self.visible_sections().len()) + } else { + "tab".into() + }, + "report", + ), + ( + "z".into(), + if !self.zoomed { + "focus" + } else if width < 50 { + "all" + } else { + "dashboard" + }, + ), + ("?".into(), if width < 50 { "" } else { "help" }), + ("R".into(), "refresh"), + ] + }; + if !self.help_shortcut_available() { + navigation.retain(|(key, _)| key != "?"); + } + [actions, navigation] + .into_iter() + .map(|hints| { + let mut line = Line::default(); + for (key, label) in hints { + if key.is_empty() { + continue; + } + let separator = if line.spans.is_empty() { "" } else { " · " }; + let mut part = Line::from(separator.dim()); + part.spans.extend(key_label_spans(&key)); + if !label.is_empty() { + part.spans.push(format!(" {label}").dim()); + } + if line.width() + part.width() <= width { + line.spans.extend(part.spans); + } + } + line + }) + .collect() + } +} diff --git a/codex-rs/tui/src/analytics/navigation_tests.rs b/codex-rs/tui/src/analytics/navigation_tests.rs index e600793421..98e4f88107 100644 --- a/codex-rs/tui/src/analytics/navigation_tests.rs +++ b/codex-rs/tui/src/analytics/navigation_tests.rs @@ -200,6 +200,63 @@ fn vim_chat_navigation_expands_collapses_and_moves_like_arrows() { } } +#[test] +fn configured_dashboard_accept_precedes_report_shortcuts() { + for binding in ["r", "g", "m", "R", "tab", "1"] { + let mut config = TuiKeymap::default(); + config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec(binding.into()))); + let mut view = fixture::view(AccountKind::Business); + view.keymap = RuntimeKeymap::from_config(&config).unwrap().list; + view.section = Section::Usage; + view.zoomed = false; + let previous = ( + view.section, + view.ranges, + view.sections[Section::Usage].group, + ); + let key = if binding == "tab" { + KeyCode::Tab + } else { + KeyCode::Char(binding.chars().next().unwrap()) + }; + press(&mut view, key); + assert_eq!( + ( + view.zoomed, + view.section, + view.ranges, + view.sections[Section::Usage].group + ), + (true, previous.0, previous.1, previous.2), + "{binding}" + ); + } +} + +#[test] +fn configured_question_mark_keeps_its_action_and_hides_help_hint() { + for (binding, modifiers) in [("?", KeyModifiers::NONE), ("shift-?", KeyModifiers::SHIFT)] { + let mut config = TuiKeymap::default(); + config.list.cancel = Some(KeybindingsSpec::One(KeybindingSpec(binding.into()))); + let mut view = fixture::view(AccountKind::Consumer); + view.keymap = RuntimeKeymap::from_config(&config).unwrap().list; + let footer = view.footer_lines(/*width*/ 120, /*scrollable*/ false); + assert!( + !footer + .iter() + .any(|line| line.to_string().contains("? help")) + ); + assert!( + !view + .help_lines(/*width*/ 120) + .iter() + .any(|line| line.to_string().contains("toggle this help")) + ); + view.handle_key(KeyEvent::new(KeyCode::Char('?'), modifiers)); + assert_eq!((view.is_done, view.show_help), (true, false)); + } +} + #[test] fn configured_vim_keys_win_over_horizontal_aliases() { let mut config = TuiKeymap::default(); diff --git a/codex-rs/tui/src/analytics/panels.rs b/codex-rs/tui/src/analytics/panels.rs index a30031ffd9..0be723e859 100644 --- a/codex-rs/tui/src/analytics/panels.rs +++ b/codex-rs/tui/src/analytics/panels.rs @@ -1,14 +1,9 @@ //! Responsive panels with consistent category colors and signed daily amounts. use super::AnalyticsView; -use super::render::columns; -use super::styles::secondary_style; use crate::analytics::sections::Section; -use crate::style::accent_style; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; -use ratatui::style::Styled; -use ratatui::style::Stylize; use ratatui::text::Line; use std::ops::Range; @@ -21,43 +16,7 @@ pub(super) struct Panel { impl AnalyticsView { pub(super) fn panel(&self, section: Section, width: usize, chart_height: usize) -> Panel { - let focused = section == self.section; - let title = format!( - "{} {}", - if focused { "▎" } else { " " }, - self.section_title(section) - ); - let heading = if focused { - title.set_style(accent_style()).into() - } else { - title.bold().into() - }; - let group = self.sections[section].group; - let heading = if matches!( - section, - Section::Usage | Section::Credits | Section::Activity | Section::Plan - ) { - columns( - heading, - format!("By {}", self.group_label(section, group).to_lowercase()) - .set_style(secondary_style()) - .into(), - width, - ) - } else { - heading - }; - let mut lines = vec![heading, "─".repeat(width).dim().into()]; - if section == Section::Usage && self.business() { - lines.push( - format!( - "{} · m change model", - self.token_model.as_deref().unwrap_or("All models") - ) - .set_style(secondary_style()) - .into(), - ); - } + let mut lines = Vec::new(); let selection; let mut chart_bands = 0; diff --git a/codex-rs/tui/src/analytics/plan_tests.rs b/codex-rs/tui/src/analytics/plan_tests.rs index 43ce3eb901..43561e18b7 100644 --- a/codex-rs/tui/src/analytics/plan_tests.rs +++ b/codex-rs/tui/src/analytics/plan_tests.rs @@ -217,10 +217,12 @@ fn plan_overview_escape_closes_with_retained_expansion() { fn moving_period_selection_collapses_its_previous_details() { let mut history = response(); let mut older = history.periods[0].clone(); - older.id = "older-five".into(); older.starts_at = "2026-09-02T05:00:00Z".into(); older.ends_at = "2026-09-02T10:00:00Z".into(); - history.periods.push(older); + for index in 0..13 { + older.id = format!("older-five-{index}"); + history.periods.push(older.clone()); + } let mut view = fixture::view(AccountKind::Consumer); view.plan.enabled = true; view.plan.report = Load::Ready(Report::parse(history).unwrap().unwrap()); @@ -229,9 +231,27 @@ fn moving_period_selection_collapses_its_previous_details() { view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); assert_eq!( - (view.plan.cursor, view.plan.expanded), - ([1, 0], [None, None]) + (&view.plan.cursor, &view.plan.expanded), + (&[1, 0], &[None, None]) ); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 80, /*height*/ 24, + )) + .unwrap(); + for code in [KeyCode::Right, KeyCode::Tab, KeyCode::BackTab] { + view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)); + terminal + .draw(|frame| view.render(frame.area(), frame.buffer_mut())) + .unwrap(); + if view.section == Section::Plan { + assert!( + terminal + .backend() + .to_string() + .contains("Sep 1 00:00 – Sep 8 00:00") + ); + } + } } #[test] diff --git a/codex-rs/tui/src/analytics/recovery_tests.rs b/codex-rs/tui/src/analytics/recovery_tests.rs new file mode 100644 index 0000000000..11cfdfccdc --- /dev/null +++ b/codex-rs/tui/src/analytics/recovery_tests.rs @@ -0,0 +1,16 @@ +//! Visible report controls and account-scoped dates. + +use super::*; + +#[test] +fn report_controls_show_dates_and_update_time() { + let mut view = fixture::view(models::AccountKind::Business); + view.section = Section::Usage; + view.sections[Section::Usage].group = 6; + fixture::seed_reports(&mut view); + view.end_date = "2027-01-02".parse().unwrap(); + let output = screen(&mut view, /*width*/ 40, /*height*/ 16); + assert!(output.contains("12/27/2026–01/02/2027")); + assert!(output.contains("g Token type · m All models")); + assert!(output.contains("Updated · Sep 2 16:00 UTC")); +} diff --git a/codex-rs/tui/src/analytics/redesign_tests.rs b/codex-rs/tui/src/analytics/redesign_tests.rs new file mode 100644 index 0000000000..a41002a867 --- /dev/null +++ b/codex-rs/tui/src/analytics/redesign_tests.rs @@ -0,0 +1,69 @@ +//! Fixed navigation and keyboard help around existing report bodies. +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn reports_keep_tabs_fixed_when_a_request_fails() { + for (width, height) in [(120, 36), (80, 24), (40, 16)] { + let mut view = fixture::view(models::AccountKind::Business); + view.section = Section::Usage; + let loaded = screen(&mut view, width, height); + view.sections[Section::Usage].history = Load::Error("Couldn't load tokens.".into()); + let failed = screen(&mut view, width, height); + assert_eq!( + loaded.lines().take(3).collect::>(), + failed.lines().take(3).collect::>() + ); + assert!(failed.contains("Couldn't load tokens.")); + } +} + +#[test] +fn keyboard_help_preserves_report_selection_and_reading_position() { + let mut view = fixture::view(models::AccountKind::Business); + view.section = Section::Usage; + view.sections[Section::Usage].detail = Some(6); + screen(&mut view, /*width*/ 80, /*height*/ 16); + press(&mut view, KeyCode::PageDown); + screen(&mut view, /*width*/ 80, /*height*/ 16); + let offset = view.scroll_offset(); + view.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); + let help = screen(&mut view, /*width*/ 40, /*height*/ 16); + assert!(help.contains("Usage shortcuts")); + assert!(help.lines().nth_back(/*n*/ 1).unwrap().contains("scroll")); + assert!(!help.lines().last().unwrap().contains("tab report")); + insta::assert_snapshot!("narrow_help", help); + press(&mut view, KeyCode::PageDown); + screen(&mut view, /*width*/ 40, /*height*/ 16); + assert!(view.scroll_offset() > 0); + press(&mut view, KeyCode::Esc); + assert_eq!( + ( + view.show_help, + view.is_done, + view.section, + view.sections[Section::Usage].detail, + view.scroll_offset() + ), + (false, false, Section::Usage, Some(6), offset) + ); + press(&mut view, KeyCode::Char('?')); + assert_eq!(view.scroll_offset(), 0); + press(&mut view, KeyCode::Char('?')); + assert_eq!(view.scroll_offset(), offset); +} + +#[test] +fn dashboard_navigation_scrolls_to_selected_card() { + let mut view = fixture::view(models::AccountKind::Consumer); + press(&mut view, KeyCode::Char('z')); + screen(&mut view, /*width*/ 80, /*height*/ 24); + press(&mut view, KeyCode::Char('6')); + let output = screen(&mut view, /*width*/ 80, /*height*/ 24); + assert!(view.scroll_offset() > 0); + assert!( + output + .lines() + .any(|line| line.contains('▸') && line.contains("Chats")) + ); +} diff --git a/codex-rs/tui/src/analytics/render.rs b/codex-rs/tui/src/analytics/render.rs index 595fbcea81..717eb40008 100644 --- a/codex-rs/tui/src/analytics/render.rs +++ b/codex-rs/tui/src/analytics/render.rs @@ -1,16 +1,10 @@ -//! Maximized sections and a responsive overview share the same panels and scrolling behavior. -//! Zoom, section changes, and reflow preserve report selections and inline details. +//! Fixed analytics navigation and keyboard help around existing report panels. +//! Tabs and hints never wrap or move when a report loads, changes range, or becomes empty. use super::AnalyticsView; -use super::data; use super::styles::secondary_style; use crate::analytics::models::AccountAnalyticsHistory; use crate::analytics::models::AccountAnalyticsValue; -use crate::analytics::sections::Section; -use crate::keymap::ListAction; -use crate::style::accent_style; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; @@ -91,216 +85,96 @@ pub(super) fn parts( impl AnalyticsView { pub(super) fn render(&mut self, area: Rect, buf: &mut Buffer) { self.poll_reports(); - let width = usize::from(area.width.saturating_sub(/*rhs*/ 4)).max(/*other*/ 1); - let show_range = !matches!( - self.section, - Section::Chats | Section::Plan | Section::Summary - ) && !self.visible_sections().is_empty(); - let hint = |action| { - self.keymap - .primary_hint(action) - .map(crate::key_hint::ShortcutHint::display_label) - .unwrap_or_default() - }; - let mut controls = if self.visible_sections().is_empty() { - format!("R refresh · {} back · q close", hint(ListAction::Cancel)) - } else { - let navigation = if !self.zoomed { - format!("{}/z maximize", hint(ListAction::Accept)) - } else if self.section == Section::Summary { - format!( - "{}/{} scroll", - hint(ListAction::MoveUp), - hint(ListAction::MoveDown) - ) - } else if self.section == Section::Plan { - format!( - "{}/{} window · {}/{} period · {} details", - hint(ListAction::MoveLeft), - hint(ListAction::MoveRight), - hint(ListAction::MoveUp), - hint(ListAction::MoveDown), - hint(ListAction::Accept) - ) - } else if self.section != Section::Chats { - format!( - "{}/{} day · {} details", - hint(ListAction::MoveLeft), - hint(ListAction::MoveRight), - hint(ListAction::Accept) - ) - } else { - format!( - "{}/{} row · {} details", - hint(ListAction::MoveUp), - hint(ListAction::MoveDown), - hint(ListAction::Accept) - ) - }; - format!( - "tab/1–{} section · {}{navigation}\n{}{}R refresh · {}/{} scroll · {} back · q close", - self.visible_sections().len(), - if self.section == Section::Chats && !self.business() && !self.zoomed { - "s sort · " - } else if self.section == Section::Chats && !self.business() { - "s sort · z dashboard · " - } else if self.zoomed { - "z dashboard · " - } else { - "" - }, - if show_range { "r 7/30d · " } else { "" }, - if self.group_options().len() < 2 { - "" - } else if self.section == Section::Summary { - "g view · " - } else { - "g group · " - }, - hint(ListAction::PageUp), - hint(ListAction::PageDown), - hint(ListAction::Cancel) - ) - }; - let control_line = |line: &str| { - let mut spans = Vec::new(); - for (index, control) in line.split(" · ").enumerate() { - if index > 0 { - spans.push(" · ".into()); - } - let (keys, description) = control.rsplit_once(' ').unwrap_or((control, "")); - spans.extend(crate::key_hint::key_label_spans(keys)); - if !description.is_empty() { - spans.push(format!(" {description}").into()); - } - } - Line::from(spans) - }; - let updated = self - .zoomed - .then(|| self.sections[self.section].history.ready()) - .flatten() - .and_then(|history| history.updated_at) - .and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0)) - .map(|updated| format!("Updated {} UTC", updated.format("%b %-d %H:%M"))); - let add_updated = |footer: &mut Vec>| { - if let Some(updated) = &updated { - let timestamp = Line::from(updated.clone().set_style(secondary_style())); - if let Some(line) = footer - .last_mut() - .filter(|line| line.width() + timestamp.width() + 3 <= width) - { - *line = columns(line.clone(), timestamp, width); - } else { - footer.extend(word_wrap_lines( - [columns(Line::default(), timestamp, width)], - RtOptions::new(width), - )); - } - } - }; - let mut footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width)); - if matches!( - self.section, - Section::Plugins | Section::Activity | Section::Skills - ) && !self.day_has_details(self.section, self.sections[self.section].cursor) - { - let height = footer.len(); - controls = controls.replace(&format!(" · {} details", hint(ListAction::Accept)), ""); - footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width)); - // Removing a contextual hint must not resize the chart on narrow terminals. - footer.resize(height, Line::default()); - } - add_updated(&mut footer); - let sections = if self.zoomed { - let line = Line::from( - self.visible_sections() - .iter() - .copied() - .enumerate() - .map(|(index, section)| { - let label = self.section_title(section); - let label = if width >= 64 { - format!("{} {label}", index + 1) - } else { - label.to_string() - }; - if section == self.section { - format!("[{label}] ").set_style(accent_style()) - } else { - format!("{label} ").set_style(secondary_style()) - } - }) - .collect::>(), - ); - word_wrap_lines([line], RtOptions::new(width)) - } else { - Vec::new() - }; - let [title, range, tabs, body, hints] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(2), - Constraint::Length(if sections.is_empty() { - 0 - } else { - sections.len() as u16 + 1 - }), - Constraint::Min(0), - Constraint::Length(footer.len() as u16), - ]) - .areas(Rect { - x: area.x + area.width.min(/*other*/ 2), - width: area.width.saturating_sub(/*rhs*/ 4), + let inset = u16::from(area.width > 2); + let inner = Rect { + x: area.x + inset, + width: area.width.saturating_sub(inset * 2), ..area - }); - Line::from(vec![ - "Analytics".bold(), - self.live - .as_ref() - .and_then(|live| live.account_label()) - .map(|account| format!(" · Live account · {account}")) - .unwrap_or_else(|| " · Live account".into()) - .set_style(secondary_style()), + }; + let width = usize::from(inner.width).max(/*other*/ 1); + let [title, tabs, rule, controls, body, hints] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(if width >= 100 { 2 } else { 3 }), + Constraint::Min(1), + Constraint::Length(2), ]) - .render(title, buf); - if show_range { - let dates = self.date_range(); - let (start, end) = (dates.start(), dates.end()); - Line::from(vec![ - if self.ranges[self.range_group(self.section) as usize] == 0 { - "[7d]".set_style(accent_style()) - } else { - "7d".into() - }, - " ".into(), - if self.ranges[self.range_group(self.section) as usize] == 1 { - "[30d]".set_style(accent_style()) - } else { - "30d".into() - }, - format!( - " · {}–{}", - data::date(&start.to_string()), - end.format("%b %-d, %Y") - ) + .areas(inner); + let account = self.live.as_ref().and_then(|live| live.account_label()); + let plan = self.account.ready().map(|plan| { + use codex_protocol::account::PlanType; + match plan { + PlanType::Free => "Free", + PlanType::Go => "Go", + PlanType::Plus => "Plus", + PlanType::Pro => "Pro", + PlanType::ProLite => "Pro Lite", + PlanType::Team + | PlanType::Business + | PlanType::SelfServeBusinessProLite + | PlanType::SelfServeBusinessUsageBased => "Business", + PlanType::Enterprise + | PlanType::Ent26 + | PlanType::EnterpriseCbpAutomation + | PlanType::EnterpriseCbpUsageBased => "Enterprise", + PlanType::Edu | PlanType::EduPlus | PlanType::EduPro => "Education", + PlanType::Unknown => "Account", + } + }); + let heading = Line::from(vec![ + plan.map(|plan| format!("Usage · {plan}")) + .unwrap_or_else(|| "Usage".into()) + .bold(), + account + .map(|account| format!(" · {account}")) + .unwrap_or_default() .set_style(secondary_style()), - ]) - .render(range, buf); - } - Paragraph::new(sections).render(tabs, buf); + ]); + crate::line_truncation::truncate_line_with_ellipsis_if_overflow(heading, width) + .render(title, buf); + let visible = self.visible_sections(); + let labels = visible + .iter() + .enumerate() + .map(|(index, section)| { + if width >= 80 { + format!("{} {}", index + 1, self.tab_label(*section)) + } else { + self.tab_label(*section).to_owned() + } + }) + .collect::>(); + let selected = visible + .iter() + .position(|section| *section == self.section) + .unwrap_or_default(); + crate::bottom_pane::render_filled_tab_bar( + &labels.iter().map(String::as_str).collect::>(), + selected, + tabs, + buf, + ); + Line::from("─".repeat(width)).dim().render(rule, buf); + self.render_controls(controls, buf); self.viewport_height = usize::from(body.height).max(/*other*/ 1); - let (lines, selection) = if self.visible_sections().is_empty() { + let (lines, selection) = if self.show_help { + (self.help_lines(width), 0..1) + } else if visible.is_empty() { let message = self .account .message() .unwrap_or("Analytics is not available for this account type."); - let lines = textwrap::wrap(message, width) - .into_iter() - .map(|line| Line::from(line.into_owned())) - .collect::>(); - let selection = 0..lines.len(); - (lines, selection) - } else if self.zoomed { + ( + textwrap::wrap(message, width) + .into_iter() + .map(|line| Line::from(line.into_owned())) + .collect(), + 0..1, + ) + } else if !self.zoomed { + self.dashboard_lines(width, self.viewport_height) + } else { let chart_height = (usize::from(body.height) / 5).clamp(/*min*/ 4, /*max*/ 10); let mut panel = self.panel(self.section, width, chart_height); @@ -313,13 +187,13 @@ impl AnalyticsView { panel = self.panel(self.section, width, height); } } - let center_summary = self.section == Section::Summary && self.profile.ready().is_some(); + let center_summary = + self.section == super::sections::Section::Summary && self.profile.ready().is_some(); if center_summary { - // Keep the section heading and rule fixed; center the profile below them. let padding = usize::from(body.height).saturating_sub(panel.lines.len()) / 2; panel .lines - .splice(2..2, std::iter::repeat_n(Line::default(), padding)); + .splice(0..0, std::iter::repeat_n(Line::default(), padding)); } let selection = if panel.lines.len() <= usize::from(body.height) { 0..panel.lines.len() @@ -330,56 +204,29 @@ impl AnalyticsView { panel.lines.push(Line::default()); } (panel.lines, selection) - } else { - self.dashboard_lines(width, usize::from(body.height)) }; - if lines.len() <= self.viewport_height { - let height = footer.len(); - let controls = controls - .replace( - &format!( - " · {}/{} scroll", - hint(ListAction::PageUp), - hint(ListAction::PageDown) - ), - "", - ) - .replace( - &format!( - " · {}/{} scroll", - hint(ListAction::MoveUp), - hint(ListAction::MoveDown) - ), - "", - ); - footer = word_wrap_lines(controls.lines().map(control_line), RtOptions::new(width)); - add_updated(&mut footer); - if updated.is_some() { - let padding = height.saturating_sub(footer.len()); - footer.splice(0..0, std::iter::repeat_n(Line::default(), padding)); - } else { - footer.resize(height, Line::default()); - } - } + let mut scroll_offset = self.scroll_offset(); if self.follow_selection { - if selection.start < self.scroll_offset { - self.scroll_offset = selection.start; + if selection.start < scroll_offset { + scroll_offset = selection.start; } else { let end = selection.start + selection.len().min(self.viewport_height); - if end > self.scroll_offset + self.viewport_height { - self.scroll_offset = end - self.viewport_height; + if end > scroll_offset + self.viewport_height { + scroll_offset = end - self.viewport_height; } } } - self.scroll_offset = self - .scroll_offset - .min(lines.len().saturating_sub(self.viewport_height)); + let scrollable = lines.len() > self.viewport_height; + scroll_offset = scroll_offset.min(lines.len().saturating_sub(self.viewport_height)); + *self.scroll_offset_mut() = scroll_offset; self.follow_selection = false; Paragraph::new(lines) - .scroll(/*offset*/ (self.scroll_offset as u16, 0)) + .scroll( + /*offset*/ (scroll_offset.min(usize::from(u16::MAX)) as u16, 0), + ) .render(body, buf); - Paragraph::new(footer) - .set_style(secondary_style()) + Paragraph::new(self.footer_lines(width, scrollable)) + .style(secondary_style()) .render(hints, buf); } } diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap index 8068475b9c..f50ca2a46c 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__client__tests__live_account_identity_is_visible_and_closing_aborts_pending_loads.snap @@ -2,12 +2,13 @@ source: tui/src/analytics/client_tests.rs expression: terminal.backend().to_string() --- -" Analytics · Live account · analytics@example.test " +" Usage · analytics@example.test " +" " +" ────────────────────────────────────────────────────────────────────────────────────────────────── " " " " " " " -" " -" No history has been reported. " +" No history has been reported. " " " " " " " @@ -23,6 +24,5 @@ expression: terminal.backend().to_string() " " " " " " -" " -" " -" R refresh · esc back · q close " +" esc back " +" R retry " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap index b84a18a7a7..381a05e7b5 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__plan__tests__period_navigation_preserves_unknowns_and_resets_on_new_snapshot.snap @@ -2,23 +2,21 @@ source: tui/src/analytics/plan_tests.rs expression: "output.join(\"\\n\")" --- -" Analytics · Live account " +" Usage · Plus " +" 1 Overview 2 Usage 3 Plan 4 Messages 5 Plugins 6 Skills 7 Chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" g Feature · ←/→ 5-hour / Weekly " " " +" 5-hour limits › Weekly limits " " " -" 1 Summary 2 Total usage history [3 Plan usage history] 4 Messages 5 Plugins called 6 Skills used 7 Top chats " -" " -" ▎ Plan usage history By feature " -" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " -" 5-hour limits › Weekly limits " -" " -" › Sep 2 10:00 – Sep 2 15:00 * 125% › Sep 1 00:00 – Sep 8 00:00 * Not available " -" Some usage is unavailable; this period may be Breakdown isn't available for this period " -" incomplete " -" Tasks 125% " -" Some periods aren't available yet " -" Usage as of Sep 2 12:00 UTC · * current at last update " -" Available since Sep 1 00:00 UTC " -" Amounts and period boundaries are approximate; recent activity may be delayed " +" › Sep 2 10:00 – Sep 2 15:00 * 125% › Sep 1 00:00 – Sep 8 00:00 * Not available " +" Some usage is unavailable; this period may be Breakdown isn't available for this period " +" incomplete " +" Tasks 125% " +" Some periods aren't available yet " +" Usage as of Sep 2 12:00 UTC · * current at last update " +" Available since Sep 1 00:00 UTC " +" Amounts and period boundaries are approximate; recent activity may be delayed " " " " " " " @@ -30,36 +28,38 @@ expression: "output.join(\"\\n\")" " " " " " " -" tab/1–7 section · z dashboard · ←/→ window · ↑/↓ period · enter details " -" g group · R refresh · esc back · q close " +" " +" " +" ↑/↓ period · enter details · esc back " +" tab/1–7 report · z dashboard · ? help · R refresh " -" Analytics · Live account " +" Usage · Plus " +" Overview Usage Plan Messages Plugins › " +" ──────────────────────────────────────────────────────── " +" g Feature " +" ←/→ 5-hour / Weekly " +" " +" 5-hour limits " +" " +" › Sep 2 10:00 – Sep 2 15:00 * 125% " +" Some usage is unavailable; this period may be " +" incomplete " +" Tasks 125% " +" " +" › Weekly limits " +" " +" › Sep 1 00:00 – Sep 8 00:00 * Not available " +" Breakdown isn't available for this period " +" Some periods aren't available yet " +" Usage as of Sep 2 12:00 UTC · * current at last update " +" Available since Sep 1 00:00 UTC " +" Amounts and period boundaries are approximate; recent " +" activity may be delayed " " " " " -" Summary Total usage history [Plan usage history] " -" Messages Plugins called Skills used Top chats " -" " -" ▎ Plan usage history By feature " -" ────────────────────────────────────────────────────── " -" 5-hour limits " -" " -" › Sep 2 10:00 – Sep 2 15:00 * 125% " -" Some usage is unavailable; this period may be " -" incomplete " -" Tasks 125% " -" " -" › Weekly limits " -" " -" › Sep 1 00:00 – Sep 8 00:00 * Not available " -" Breakdown isn't available for this period " -" Some periods aren't available yet " -" Usage as of Sep 2 12:00 UTC · * current at last update " -" Available since Sep 1 00:00 UTC " -" Amounts and period boundaries are approximate; recent " -" activity may be delayed " " " " " -" tab/1–7 section · z dashboard · ←/→ window · ↑/↓ " -" period · enter details " -" g group · R refresh · esc back · q close " " " +" " +" ↑/↓ period · enter details · esc back " +" tab report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_breakdown_ranks_nonzero_groups_and_reflows.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_breakdown_ranks_nonzero_groups_and_reflows.snap index c288c094b4..7bd27457c3 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_breakdown_ranks_nonzero_groups_and_reflows.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_breakdown_ranks_nonzero_groups_and_reflows.snap @@ -2,101 +2,101 @@ source: tui/src/analytics/chat_panel_tests.rs expression: "screens.join(\"\\n\")" --- -" Analytics · Live account " +" Usage · Enterprise " +" 1 Overview 2 Credits 3 Tokens 4 Plugins 5 Skills 6 Chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────── " " " " " -" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" Active in past 30 days · sorted by lifetime credits " " " -" ▎ Top chats " -" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " -" Active in past 30 days · sorted by lifetime credits " +" Chat Lifetime credits " +" › Q3 planning analysis 19,350.04 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Model Effort Speed Credits " +" Top model high Not reported 19,134.43 " +" Small model high standard 215.61 " +" Refund model high standard -0.000004 " +" 1 zero-credit group hidden · a show all " " " -" Chat Lifetime credits " -" › Q3 planning analysis 19,350.04 " -" ────────────────────────────────────────────────────────────────────────────────────────────── " -" Model Effort Speed Credits " -" Top model high Not reported 19,134.43 " -" Small model high standard 215.61 " -" Refund model high standard -0.000004 " -" 1 zero-credit group hidden · a show all " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " " " -" Refactor billing usage 113.00 " -" Other usage 109.00 " -" Launch readiness review 85.00 " -" Plan the week 0.00 " -" " -" Showing 1–5 of 5 chats " -" Local chats · excludes archived chats and subagent usage " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " " " " " " " " " " " -" tab/1–6 section · z dashboard · ↑/↓ row · enter details " -" R refresh · esc back · q close " +" " +" " +" ↑/↓ row · enter details · esc back " +" tab/1–6 report · z dashboard · ? help · R refresh " -" Analytics · Live account " +" Usage · Enterprise " +" Overview Credits Tokens Plugins Skills Chats " +" ──────────────────────────────────────────────────────── " " " " " -" Summary Credits usage history Token usage history " -" Plugins called Skills used [Top chats] " " " -" ▎ Top chats " -" ────────────────────────────────────────────────────── " -" Active in past 30 days · sorted by lifetime credits " +" Active in past 30 days · sorted by lifetime credits " " " -" Chat Lifetime credits " -" › Q3 planning analysis 19,350.04 " -" ──────────────────────────────────────────────────── " -" Model / effort / speed Credits " -" Top model 19,134.43 " -" high · Not reported " -" Small model 215.61 " -" high · standard " -" Refund model -0.000004 " -" high · standard " -" 1 zero-credit group hidden · a show all " +" Chat Lifetime credits " +" › Q3 planning analysis 19,350.04 " +" ────────────────────────────────────────────────────── " +" Model / effort / speed Credits " +" Top model 19,134.43 " +" high · Not reported " +" Small model 215.61 " +" high · standard " +" Refund model -0.000004 " +" high · standard " +" 1 zero-credit group hidden · a show all " " " -" Refactor billing usage 113.00 " -" Other usage 109.00 " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " " " -" Showing 1–3 of 5 chats " -" Local chats · excludes archived chats and subagent " -" usage " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " " " -" tab/1–6 section · z dashboard · ↑/↓ row · enter " -" details " -" R refresh · esc back · q close " +" " +" " +" ↑/↓ row · enter details · esc back " +" tab report · z dashboard · ? help · R refresh " -" Analytics · Live account " +" Usage · Enterprise " +" 1 Overview 2 Credits 3 Tokens 4 Plugins 5 Skills 6 Chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────── " " " " " -" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" Active in past 30 days · sorted by lifetime credits " " " -" ▎ Top chats " -" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " -" Active in past 30 days · sorted by lifetime credits " +" Chat Lifetime credits " +" › Q3 planning analysis 19,350.04 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Model Effort Speed Credits " +" Top model high Not reported 19,134.43 " +" Small model high standard 215.61 " +" Zero model high standard 0.00 " +" Refund model high standard -0.000004 " +" 1 zero-credit group · a hide zeros " " " -" Chat Lifetime credits " -" › Q3 planning analysis 19,350.04 " -" ────────────────────────────────────────────────────────────────────────────────────────────── " -" Model Effort Speed Credits " -" Top model high Not reported 19,134.43 " -" Small model high standard 215.61 " -" Zero model high standard 0.00 " -" Refund model high standard -0.000004 " -" 1 zero-credit group · a hide zeros " +" Refactor billing usage 113.00 " +" Other usage 109.00 " +" Launch readiness review 85.00 " +" Plan the week 0.00 " " " -" Refactor billing usage 113.00 " -" Other usage 109.00 " -" Launch readiness review 85.00 " -" Plan the week 0.00 " -" " -" Showing 1–5 of 5 chats " -" Local chats · excludes archived chats and subagent usage " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " " " " " " " " " -" tab/1–6 section · z dashboard · ↑/↓ row · enter details " -" R refresh · esc back · q close " +" " +" " +" ↑/↓ row · enter details · esc back " +" tab/1–6 report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_long_title_keeps_detail_indent_and_counts_missing_estimates.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_long_title_keeps_detail_indent_and_counts_missing_estimates.snap index e0cd2f1fce..ed139c2026 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_long_title_keeps_detail_indent_and_counts_missing_estimates.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__chats_table__chats_long_title_keeps_detail_indent_and_counts_missing_estimates.snap @@ -2,33 +2,33 @@ source: tui/src/analytics/chat_panel_tests.rs expression: output --- -" Analytics · Live account " +" Usage · Enterprise " +" 1 Overview 2 Credits 3 Tokens 4 Plugins 5 Skills 6 Chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────── " " " " " -" 1 Summary 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used [6 Top chats] " +" Active in past 30 days · sorted by lifetime credits " +" 2 chat estimates unavailable " " " -" ▎ Top chats " -" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " -" Active in past 30 days · sorted by lifetime credits " -" 2 chat estimates unavailable " +" Chat Lifetime credits " +" › Create a comprehensive list of areas to investigate across the runtime and describe ho… 120.00 " +" ────────────────────────────────────────────────────────────────────────────────────────────── " +" Create a comprehensive list of areas to investigate across the runtime and describe how each " +" area affects performance in a long conversation " +" Model Effort Speed Credits " +" GPT-5.6-Sol high standard 120.00 " " " -" Chat Lifetime credits " -" › Create a comprehensive list of areas to investigate across the runtime and describe ho… 120.00 " -" ────────────────────────────────────────────────────────────────────────────────────────────── " -" Create a comprehensive list of areas to investigate across the runtime and describe how each " -" area affects performance in a long conversation " -" Model Effort Speed Credits " -" GPT-5.6-Sol high standard 120.00 " +" Chat usage unavailable — " +" Chat usage unavailable — " +" Launch readiness review 85.00 " +" Plan the week 0.00 " " " -" Chat usage unavailable — " -" Chat usage unavailable — " -" Launch readiness review 85.00 " -" Plan the week 0.00 " -" " -" Showing 1–5 of 5 chats " -" Local chats · excludes archived chats and subagent usage " +" Showing 1–5 of 5 chats " +" Local chats · excludes archived chats and subagent usage " " " " " " " -" tab/1–6 section · z dashboard · ↑/↓ row · enter details " -" R refresh · esc back · q close " +" " +" " +" ↑/↓ row · enter details · esc back " +" tab/1–6 report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap index 67a6d16732..95a5b9c459 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__consumer_refresh__consumer_chat_all_metrics.snap @@ -2,33 +2,33 @@ source: tui/src/analytics/consumer_refresh_tests.rs expression: "screen(&mut view, 110, 30)" --- -" Analytics · Live account " +" Usage · Plus " +" 1 Overview 2 Usage 3 Messages 4 Plugins 5 Skills 6 Chats " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" s sort: Weekly % " +" " +" Usage estimates · active in past 30 days · sorted by Weekly % " +" Recorded usage / current full limit · may exceed 100% " +" Partial ranking · 1 partial · 2 unavailable " +" " +" Chat Weekly % 5-hour % Balance credits " +" › Refunded task · partial 125.0% 0.0% -0.000004 " +" ──────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Refunded task " +" Weekly % 125.0% " +" 5-hour % 0.0% " +" Balance credits -0.000004 " +" " +" Model / effort / speed Weekly % " +" gpt-5.5 — " +" high · fast · codex " +" " +" Chat usage unavailable — — — " +" " +" Showing 1–2 of 3 chats " +" Local chats · includes discovered descendants · excludes archived roots " " " " " -" 1 Summary 2 Total usage history 3 Messages 4 Plugins called 5 Skills used [6 Top chats] " " " -" ▎ Top chats " -" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " -" Usage estimates · active in past 30 days · sorted by Weekly % " -" Recorded usage / current full limit · may exceed 100% " -" Partial ranking · 1 partial · 2 unavailable " -" " -" Chat Weekly % 5-hour % Balance credits " -" › Refunded task · partial 125.0% 0.0% -0.000004 " -" ────────────────────────────────────────────────────────────────────────────────────────────────────────── " -" Refunded task " -" Weekly % 125.0% " -" 5-hour % 0.0% " -" Balance credits -0.000004 " -" " -" Model / effort / speed Weekly % " -" gpt-5.5 — " -" high · fast · codex " -" " -" Chat usage unavailable — — — " -" " -" Showing 1–2 of 3 chats " -" Local chats · includes discovered descendants · excludes archived roots " -" " -" tab/1–6 section · s sort · z dashboard · ↑/↓ row · enter details " -" R refresh · esc back · q close " +" ↑/↓ row · enter details · esc back " +" tab/1–6 report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap index dfc4c590ad..15642b7996 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__dashboard__dashboard_cards_align_and_keep_stable_summary_heights.snap @@ -2,116 +2,116 @@ source: tui/src/analytics/dashboard_tests.rs expression: "format!(\"{wide}\\n{narrow}\")" --- -" Analytics · Live account " -" [7d] 30d · Aug 27–Sep 2, 2026 " +" Usage · Plus " +" 1 Overview 2 Usage 3 Messages 4 Plugins 5 Skills 6 Chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" Dashboard · enter focus · r 7d/30d · Aug 27–Sep 2, 2026 · g Feature " " " -" ╭─ 1 Summary ────────────────────────────────────────────────────╮ ╭─ ▸ 2 Total usage history ────────────────────────────────────────╮ " -" │ │ │ │ " -" │ No history has been reported. │ │ 7d │ " -" │ │ │ By feature │ " -" │ │ │ Total usage · relative units │ " -" │ │ │ 73 64 91 81 100 86 │ " -" │ │ │ 100 20 ▃▃▃▃▃ ▂▂▂▂▂▂ ▆▆▆▆▆ ▄▄▄▄▄▄ █████ ▅▅▅▅▅▅ │ " -" │ │ │ ▃▃▃▃▃ █████ ██████ █████ ██████ █████ ██████ │ " -" │ │ │ 0 ─────────────────────────────────────────────────────────── │ " -" │ │ │ ▲ │ " -" │ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " -" │ │ │ │ " -" │ │ │ Sep 2 · 86 usage units │ " -" │ │ │ ● Tasks 64.0% │ " -" │ │ │ ● Code review 14.0% │ " -" │ │ │ │ " -" │ │ │ │ " -" │ │ │ │ " -" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" ╭─ 1 Overview ────────────────────────────────────────────────────╮ ╭─ ▸ 2 Usage ───────────────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ No history has been reported. │ │ 7d │ " +" │ │ │ By feature │ " +" │ │ │ Total usage · relative units │ " +" │ │ │ 20 73 64 91 81 100 86 │ " +" │ │ │ 100 ▁▁▁▁▁ ▅▅▅▅▅▅ ▅▅▅▅▅ ▇▇▇▇▇▇ ▆▆▆▆▆ ██████ ▆▆▆▆▆▆ │ " +" │ │ │ 0 ──────────────────────────────────────────────────────────── │ " +" │ │ │ ▲ │ " +" │ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ │ │ │ " +" │ │ │ Sep 2 · 86 usage units │ " +" │ │ │ ● Tasks 64.0% │ " +" │ │ │ ● Code review 14.0% │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰───────────────────────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────────────────────────╯ " " " -" ╭─ 3 Messages ───────────────────────────────────────────────────╮ ╭─ 4 Plugins called ─────────────────────────────────────────────╮ " -" │ │ │ │ " -" │ 7d │ │ 7d │ " -" │ By model │ │ 186 calls │ " -" │ 574 reported messages │ │ 34 23 29 26 22 23 29 │ " -" │ 83 107 85 103 │ │ 40 ▅▅▅▅▅ ▁▁▁▁▁▁ ▃▃▃▃▃ ▂▂▂▂▂▂ ▁▁▁▁▁▁ ▃▃▃▃▃▃ │ " -" │ 150 62 ▃▃▃▃▃▃ 75 59 ▁▁▁▁▁ ▂▂▂▂▂▂ │ │ █████ ██████ █████ ██████ █████ ██████ ██████ │ " -" │ ▆▆▆▆▆ █████ ██████ █████ ▆▆▆▆▆▆ █████ ██████ │ │ 0 ──────────────────────────────────────────────────────────── │ " -" │ 0 ─────────────────────────────────────────────────────────── │ │ ▲ │ " -" │ ▲ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " -" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " -" │ │ │ Sep 2 · 29 calls │ " -" │ Sep 2 · 103 messages │ │ ● Slack 10 │ " -" │ ● GPT-5.6-Sol 61 │ │ ● Figma 10 │ " -" │ ● GPT-5.6-Terra 25 │ │ │ " -" │ │ │ │ " -" │ │ │ │ " -" │ │ │ │ " -" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " +" ╭─ 3 Messages ────────────────────────────────────────────────────╮ ╭─ 4 Plugins ─────────────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ 7d │ " +" │ By model │ │ 186 calls │ " +" │ 574 reported messages │ │ 34 23 29 26 22 23 29 │ " +" │ 62 83 107 75 59 85 103 │ │ 40 ▆▆▆▆▆ ▄▄▄▄▄▄ ▅▅▅▅▅▅ ▅▅▅▅▅ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▅▅▅▅▅▅ │ " +" │ 150 ▃▃▃▃▃ ▄▄▄▄▄▄ ▅▅▅▅▅ ▄▄▄▄▄▄ ▃▃▃▃▃ ▄▄▄▄▄▄ ▅▅▅▅▅▅ │ │ 0 ───────────────────────────────────────────────────────────── │ " +" │ 0 ──────────────────────────────────────────────────────────── │ │ ▲ │ " +" │ ▲ │ │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ Sep 2 · 29 calls │ " +" │ Sep 2 · 103 messages │ │ ● Slack 10 │ " +" │ ● GPT-5.6-Sol 61 │ │ ● Figma 10 │ " +" │ ● GPT-5.6-Terra 25 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰───────────────────────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────────────────────────╯ " " " -" ╭─ 5 Skills used ────────────────────────────────────────────────╮ ╭─ 6 Top chats ──────────────────────────────────────────────────╮ " -" │ │ │ │ " -" │ 7d │ │ No history has been reported. │ " -" │ 54 uses │ │ │ " -" │ 11 │ │ │ " -" │ 20 8 6 9 8 7 5 │ │ │ " -" │ ▆▆▆▆▆ ▄▄▄▄▄▄ ▇▇▇▇▇ ▆▆▆▆▆▆ ▅▅▅▅▅ ▄▄▄▄▄▄ ██████ │ │ │ " -" │ 0 ──────────────────────────────────────────────────────────── │ │ │ " -" │ ▲ │ │ │ " -" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " -" │ │ │ │ " -" │ Sep 2 · 11 uses │ │ │ " -" │ ● Testing 4 │ │ │ " -" │ ● Code review 3 │ │ │ " -" │ │ │ │ " -" │ │ │ │ " -" │ │ │ │ " -" │ │ │ │ " -" ╰──────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────╯ " -" tab/1–6 section · enter/z maximize " -" r 7/30d · g group · R refresh · esc back · q close " +" ╭─ 5 Skills ──────────────────────────────────────────────────────╮ ╭─ 6 Chats ───────────────────────────────────────────────────────╮ " +" │ │ │ │ " +" │ 7d │ │ No history has been reported. │ " +" │ 54 uses │ │ │ " +" │ 8 6 9 8 7 5 11 │ │ │ " +" │ 20 ▃▃▃▃▃ ▂▂▂▂▂▂ ▃▃▃▃▃▃ ▃▃▃▃▃ ▂▂▂▂▂▂ ▂▂▂▂▂▂ ▄▄▄▄▄▄ │ │ │ " +" │ 0 ───────────────────────────────────────────────────────────── │ │ │ " +" │ ▲ │ │ │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ │ │ " +" │ │ │ │ " +" │ Sep 2 · 11 uses │ │ │ " +" │ ● Testing 4 │ │ │ " +" │ ● Code review 3 │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" │ │ │ │ " +" ╰───────────────────────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────────────────────────╯ " +" " +" enter focus · tab card · esc back " +" tab/1–6 report · z focus · ? help · R refresh " -" Analytics · Live account " -" [7d] 30d · Aug 27–Sep 2, 2026 " +" Usage · Plus " +" Overview Usage Messages Plugins Skills Chats " +" ────────────────────────────────────────────────────────────────────── " +" Dashboard · enter focus · r 7d/30d · Aug 27–Sep 2, 2026 " +" g Feature " " " -" ╭─ 1 Summary ────────────────────────────────────────────────────╮ " -" │ │ " -" │ No history has been reported. │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" │ │ " -" ╰──────────────────────────────────────────────────────────────────╯ " +" ╭─ 1 Overview ─────────────────────────────────────────────────────╮ " +" │ │ " +" │ No history has been reported. │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰────────────────────────────────────────────────────────────────────╯ " " " -" ╭─ ▸ 2 Total usage history ────────────────────────────────────────╮ " -" │ │ " -" │ 7d │ " -" │ By feature │ " -" │ Total usage · relative units │ " -" │ 20 73 64 91 81 100 86 │ " -" │ 100 ▁▁▁▁▁ ▅▅▅▅▅ ▅▅▅▅▅▅ ▇▇▇▇▇ ▆▆▆▆▆▆ █████ ▆▆▆▆▆▆ │ " -" │ 0 ─────────────────────────────────────────────────────────── │ " -" │ ▲ │ " -" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " -" │ │ " -" │ Sep 1 · 100 usage units │ " -" │ ● Tasks 65.0% │ " -" │ ● Code review 15.0% │ " -" │ │ " -" ╰──────────────────────────────────────────────────────────────────╯ " +" ╭─ ▸ 2 Usage ────────────────────────────────────────────────────────╮ " +" │ │ " +" │ 7d │ " +" │ By feature │ " +" │ Total usage · relative units │ " +" │ 20 73 64 91 81 100 86 │ " +" │ 100 ▁▁▁▁▁ ▅▅▅▅▅▅ ▅▅▅▅▅▅ ▇▇▇▇▇ ▆▆▆▆▆▆ ██████ ▆▆▆▆▆▆ │ " +" │ 0 ───────────────────────────────────────────────────────────── │ " +" │ ▲ │ " +" │ Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 │ " +" │ │ " +" │ Sep 1 · 100 usage units │ " +" │ ● Tasks 65.0% │ " +" │ ● Code review 15.0% │ " +" │ │ " +" ╰────────────────────────────────────────────────────────────────────╯ " " " -" ╭─ 3 Messages ───────────────────────────────────────────────────╮ " -" │ │ " -" │ 7d │ " -" │ By model │ " -" │ 574 reported messages │ " -" │ 62 83 107 75 59 85 103 │ " -" │ 150 ▃▃▃▃▃ ▄▄▄▄▄ ▅▅▅▅▅▅ ▄▄▄▄▄ ▃▃▃▃▃▃ ▄▄▄▄▄ ▅▅▅▅▅▅ │ " -" │ 0 ─────────────────────────────────────────────────────────── │ " -" tab/1–6 section · enter/z maximize " -" r 7/30d · g group · R refresh · pgup/pgdn scroll · esc back · q " -" close " +" ╭─ 3 Messages ─────────────────────────────────────────────────────╮ " +" │ │ " +" │ 7d │ " +" │ By model │ " +" │ 574 reported messages │ " +" │ 62 83 107 75 59 85 103 │ " +" enter focus · tab card · pgup/pgdn scroll · esc back " +" tab report · z focus · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__redesign__narrow_help.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__redesign__narrow_help.snap new file mode 100644 index 0000000000..444e31682f --- /dev/null +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__redesign__narrow_help.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/analytics/redesign_tests.rs +expression: help +--- +" Usage · Business " +" Overview Credits Tokens › " +" ────────────────────────────────────── " +" Usage shortcuts " +" " +" " +" Tab / Shift+Tab · next / previous " +" report " +" 1–5 · open a report directly " +" ← / → · select chart day or plan " +" window " +" ↑ / ↓ · select chat or plan period; " +" scroll Overview " +" enter · expand or collapse details " +" esc back · pgup/pgdn scroll " +" ? close help " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_cumulative_narrow.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_cumulative_narrow.snap index 7ac4ca264b..8f5e767b30 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_cumulative_narrow.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_cumulative_narrow.snap @@ -2,14 +2,13 @@ source: tui/src/analytics/summary_tests.rs expression: "screen(&mut view, 64, 60)" --- -" Analytics · Live account " +" Usage · Plus " +" Overview Usage Messages Plugins Skills Chats " +" ────────────────────────────────────────────────────────────── " +" g Cumulative " " " " " -" [Summary] Total usage history Messages Plugins called " -" Skills used Top chats " " " -" ▎ Summary " -" ──────────────────────────────────────────────────────────── " " " " " " " @@ -20,45 +19,46 @@ expression: "screen(&mut view, 64, 60)" " @example.user " " Plus " " " -" 142.3B 7B 44h 7m " -" Lifetime tokens Peak tokens Longest chat " +" 142.3B 7B 44h 7m " +" Lifetime tokens Peak tokens Longest chat " " " -" 158 days 160 days " -" Current streak Longest streak " +" 158 days 160 days " +" Current streak Longest streak " " " -" Token activity Cumulative " -" Last 12 months " +" Token activity " +" Last 12 months " " " -" Mar Apr May Jun Jul Aug " -" max █ " -" █ " -" █ " -" █ " -" █ " -" █ " -" 0 █ " +" Mar Apr May Jun Jul Aug " +" max █ " +" █ " +" █ " +" █ " +" █ " +" █ " +" 0 █ " " " -" Running total · top 300 " +" Running total · top 300 " " " -" Activity insights " +" Activity insights " " " -" Fast Mode 37% " -" Most used reasoning high · 26% " -" Skills explored 147 " -" Total skills used 13,190 " -" Total chats 21,482 " +" Fast Mode 37% " +" Most used reasoning high · 26% " +" Skills explored 147 " +" Total skills used 13,190 " +" Total chats 21,482 " " " -" Most used plugins and skills " +" Most used plugins and skills " " " -" $review 1,142 runs " -" @GitHub 1,059 runs " +" $review 1,142 runs " +" @GitHub 1,059 runs " " " -" Statistics as of Sep 9, 2026 " +" Statistics as of Sep 9, 2026 " " " " " " " " " " " " " -" tab/1–6 section · z dashboard " -" g view · R refresh · esc back · q close " +" " +" esc back " +" tab report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_daily_wide.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_daily_wide.snap index e9c3cb0deb..cff3b93ef5 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_daily_wide.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_daily_wide.snap @@ -2,13 +2,12 @@ source: tui/src/analytics/summary_tests.rs expression: "screen(&mut view, 140, 44)" --- -" Analytics · Live account " +" Usage · Plus " +" 1 Overview 2 Usage 3 Messages 4 Plugins 5 Skills 6 Chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " +" g Daily " " " " " -" [1 Summary] 2 Total usage history 3 Messages 4 Plugins called 5 Skills used 6 Top chats " -" " -" ▎ Summary " -" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── " " " " " " Example User " @@ -18,7 +17,7 @@ expression: "screen(&mut view, 140, 44)" " 142.3B 7B 44h 7m 158 days 160 days " " Lifetime tokens Peak tokens Longest chat Current streak Longest streak " " " -" Token activity Daily " +" Token activity " " Last 12 months " " " " Oct Nov Dec Jan Feb Mar Apr May Jun Jul Aug " @@ -44,5 +43,6 @@ expression: "screen(&mut view, 140, 44)" " " " " " " -" tab/1–6 section · z dashboard " -" g view · R refresh · esc back · q close " +" " +" esc back " +" tab/1–6 report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_partial.snap b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_partial.snap index a3d4e23815..bea6ed85f8 100644 --- a/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_partial.snap +++ b/codex-rs/tui/src/analytics/snapshots/codex_tui__analytics__tests__summary__summary_partial.snap @@ -2,13 +2,12 @@ source: tui/src/analytics/summary_tests.rs expression: rendered --- -" Analytics · Live account " +" Usage · Business " +" 1 Overview 2 Credits 3 Tokens 4 Plugins 5 Skills " +" ────────────────────────────────────────────────────────────────────────────────────────────────── " +" g Daily " " " " " -" [1 Summary] 2 Credits usage history 3 Token usage history 4 Plugins called 5 Skills used " -" " -" ▎ Summary " -" ──────────────────────────────────────────────────────────────────────────────────────────────── " " " " " " " @@ -17,28 +16,29 @@ expression: rendered " " " Business " " " -" 0 — — — — " -" Lifetime tokens Peak tokens Longest chat Current streak Longest streak " +" 0 — — — — " +" Lifetime tokens Peak tokens Longest chat Current streak Longest streak " " " -" Token activity Daily " -" Last 12 months " +" Token activity " +" Last 12 months " " " -" Token activity history unavailable " +" Token activity history unavailable " " " -" Activity insights Most used plugins and skills " +" Activity insights Most used plugins and skills " " " -" Fast Mode 0% No reported plugins or skills. " -" Most used reasoning — " -" Skills explored — " -" Total skills used — " -" Total chats 0 " +" Fast Mode 0% No reported plugins or skills. " +" Most used reasoning — " +" Skills explored — " +" Total skills used — " +" Total chats 0 " " " -" Some profile statistics are unavailable. " +" Some profile statistics are unavailable. " " " " " " " " " " " " " -" tab/1–5 section · z dashboard " -" g view · R refresh · esc back · q close " +" " +" esc back " +" tab/1–5 report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/analytics/styles_tests.rs b/codex-rs/tui/src/analytics/styles_tests.rs index b26795e7f1..6cd7c07479 100644 --- a/codex-rs/tui/src/analytics/styles_tests.rs +++ b/codex-rs/tui/src/analytics/styles_tests.rs @@ -56,12 +56,25 @@ fn analytics_sections_adapt_to_terminal_colors() { ); let mut buffer = Buffer::empty(area); view.render(area, &mut buffer); - let heading = buffer + let label = view.tab_label(section); + let tab = buffer .content - .iter() - .find(|cell| cell.symbol() == "▎") - .unwrap(); - assert_eq!(heading.fg, accent_style().fg.unwrap()); + .chunks(usize::from(area.width)) + .nth(/*n*/ 1) + .unwrap() + .windows(label.len()) + .find(|cells| { + cells + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + == label + }) + .expect("the report tab is visible"); + let heading = &tab[0]; + let active = crate::bottom_pane::active_tab_style(); + assert_eq!(heading.fg, active.fg.unwrap()); + assert_eq!(heading.bg, active.bg.unwrap()); assert!(heading.modifier.contains(Modifier::BOLD)); for marker in buffer.content.iter().filter(|cell| cell.symbol() == "▲") { assert_eq!(marker.fg, accent_style().fg.unwrap()); @@ -69,11 +82,20 @@ fn analytics_sections_adapt_to_terminal_colors() { assert!(marker.modifier.contains(Modifier::BOLD)); } if theme == "light" { - for cell in buffer + for (index, cell) in buffer .content .iter() - .filter(|cell| !cell.symbol().trim().is_empty()) + .enumerate() + .filter(|(_, cell)| !cell.symbol().trim().is_empty()) { + // Keyboard hints deliberately keep the requested white key style. + // Report text and chart colors still adapt to light backgrounds. + if cell.fg == Color::White { + let y = area.y + (index / usize::from(area.width)) as u16; + assert!(y >= area.bottom() - 2 || (3..5).contains(&y)); + assert_eq!(cell.modifier, Modifier::BOLD); + continue; + } let foreground = match cell.fg { Color::Rgb(r, g, b) => (r, g, b), Color::White => (255, 255, 255), @@ -81,8 +103,16 @@ fn analytics_sections_adapt_to_terminal_colors() { Color::Reset => colors.fg, _ => continue, }; - let contrast = - (luminance(colors.bg) + 0.05) / (luminance(foreground) + 0.05); + let background = match cell.bg { + Color::Rgb(r, g, b) => (r, g, b), + Color::White => (255, 255, 255), + Color::Black => (0, 0, 0), + Color::Reset => colors.bg, + _ => continue, + }; + let fg = luminance(foreground); + let bg = luminance(background); + let contrast = (fg.max(bg) + 0.05) / (fg.min(bg) + 0.05); assert!( contrast >= 4.5, "{}: {:?} has contrast {contrast}", diff --git a/codex-rs/tui/src/analytics/summary.rs b/codex-rs/tui/src/analytics/summary.rs index 42f569f0a2..7e55cae8cc 100644 --- a/codex-rs/tui/src/analytics/summary.rs +++ b/codex-rs/tui/src/analytics/summary.rs @@ -31,7 +31,8 @@ impl AnalyticsView { pub(crate) fn select_summary(&mut self, view: Option) { self.section = Section::Summary; self.zoomed = true; - self.scroll_offset = 0; + self.show_help = false; + *self.scroll_offset_mut() = 0; if let Some(view) = view { self.sections[Section::Summary].group = VIEWS .iter() @@ -54,11 +55,11 @@ impl AnalyticsView { Some(ListAction::PageUp) => Some(-(self.viewport_height as isize)), Some(ListAction::PageDown) => Some(self.viewport_height as isize), Some(ListAction::JumpTop) => { - self.scroll_offset = 0; + *self.scroll_offset_mut() = 0; Some(0) } Some(ListAction::JumpBottom) => { - self.scroll_offset = usize::MAX; + *self.scroll_offset_mut() = usize::MAX; Some(0) } Some(ListAction::Cancel) => { @@ -68,7 +69,7 @@ impl AnalyticsView { Some(ListAction::MoveLeft | ListAction::MoveRight | ListAction::Accept) | None => None, }; if let Some(step) = step { - self.scroll_offset = self.scroll_offset.saturating_add_signed(step); + *self.scroll_offset_mut() = self.scroll_offset().saturating_add_signed(step); self.follow_selection = false; } } diff --git a/codex-rs/tui/src/analytics/summary_panel.rs b/codex-rs/tui/src/analytics/summary_panel.rs index 6bcebfd5a9..121d383e87 100644 --- a/codex-rs/tui/src/analytics/summary_panel.rs +++ b/codex-rs/tui/src/analytics/summary_panel.rs @@ -156,17 +156,7 @@ impl AnalyticsView { lines.extend([values, labels, Line::default()]); } let selected = self.sections[Section::Summary].group; - let controls = Line::from(super::summary::VIEWS[selected].label()); - if inner_width >= 58 { - lines.push(columns( - "Token activity".bold().into(), - controls, - inner_width, - )); - } else { - lines.push("Token activity".bold().into()); - lines.extend(word_wrap_lines([controls], RtOptions::new(inner_width))); - } + lines.push("Token activity".bold().into()); lines.push("Last 12 months".set_style(secondary_style()).into()); lines.push(Line::default()); if let Some(buckets) = &tokens.daily_usage_buckets { diff --git a/codex-rs/tui/src/analytics_tests.rs b/codex-rs/tui/src/analytics_tests.rs index 86b45095ff..d0bf65e44f 100644 --- a/codex-rs/tui/src/analytics_tests.rs +++ b/codex-rs/tui/src/analytics_tests.rs @@ -3,8 +3,10 @@ use crate::analytics::sections::Section; #[path = "analytics/compact_tests.rs"] mod compact; -#[path = "analytics/dashboard_tests.rs"] -mod dashboard; +#[path = "analytics/recovery_tests.rs"] +mod recovery; +#[path = "analytics/redesign_tests.rs"] +mod redesign; #[path = "analytics/summary_tests.rs"] mod summary; @@ -335,7 +337,7 @@ fn analytics_model_labels_and_tool_remainders_are_unambiguous() { press(&mut view, KeyCode::Char('3')); let overview = screen(&mut view, /*width*/ 140, /*height*/ 64); assert!(overview.contains("Friendly model")); - assert!(overview.contains("Skills used")); + assert!(overview.contains("Skills")); press(&mut view, KeyCode::Char('5')); press(&mut view, KeyCode::Enter); let skills = screen(&mut view, /*width*/ 100, /*height*/ 32); @@ -663,3 +665,6 @@ fn analytics_details_reflow_and_keep_focus() { press(&mut view, KeyCode::Esc); assert!(view.is_done); } + +#[path = "analytics/dashboard_tests.rs"] +mod dashboard; diff --git a/codex-rs/tui/src/app/tests/transcript_composer.rs b/codex-rs/tui/src/app/tests/transcript_composer.rs index 38f100f44f..ecf63a6c21 100644 --- a/codex-rs/tui/src/app/tests/transcript_composer.rs +++ b/codex-rs/tui/src/app/tests/transcript_composer.rs @@ -154,15 +154,31 @@ async fn analytics_menu_reopen_preserves_navigation_and_explicit_view_selects_su } }) .await??; - let screen = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( - &tui.terminal, - )); - screens.push(screen); + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + if explicit.is_none() { + let plugins = buffer + .content() + .windows("Plugins".len()) + .find(|cells| { + cells + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + == "Plugins" + }) + .expect("Plugins tab is visible"); + let active = crate::bottom_pane::active_tab_style(); + assert_eq!( + (plugins[0].fg, plugins[0].bg), + (active.fg.unwrap(), active.bg.unwrap()), + "the initial and reopened report must select Plugins" + ); + } + screens.push(buffer_text(buffer)); press_key(&mut app, &mut tui, &mut app_server, KeyCode::Char('q')).await?; } assert_eq!(screens[0], screens[1]); - assert!(screens[1].contains("[4 Plugins called]")); - assert!(screens[2].contains("[1 Summary]")); + assert!(screens[2].contains("Lifetime tokens")); assert!(screens[2].contains("Weekly")); Ok(()) } diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_dates_distinguish_zero_missing_and_exact_details.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_dates_distinguish_zero_missing_and_exact_details.snap index 1bd8e2e004..d391919f49 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_dates_distinguish_zero_missing_and_exact_details.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_dates_distinguish_zero_missing_and_exact_details.snap @@ -2,8 +2,6 @@ source: tui/src/analytics_tests.rs expression: "screens.join(\"\\n\\n\")" --- -▎ Credits usage history By product -────────────────────────────────────────────────────── 13.1K credits 12,746.44 15K ▄▄▄▄ @@ -21,8 +19,6 @@ Product Credits ● IDE extension 0.00 ● Web 0.00 -▎ Credits usage history By product -────────────────────────────────────────────────────── 13.1K credits 12,746.44 15K ▄▄▄▄ @@ -40,8 +36,6 @@ Product Credits ● IDE extension 0 ● Web 0 -▎ Credits usage history By product -────────────────────────────────────────────────────── 13.1K credits 12,746.44 15K ▄▄▄▄ @@ -59,8 +53,6 @@ Product Credits ● IDE extension — ● Web — -▎ Credits usage history By product -────────────────────────────────────────────────────── 0.00 credits No activity reported in this range. @@ -68,6 +60,4 @@ No activity reported in this range. Aug 29 · Not reported Product Credits -▎ Credits usage history By product -────────────────────────────────────────────────────── No data reported for this range. diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_loading_errors_and_empty_reports_stay_independent.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_loading_errors_and_empty_reports_stay_independent.snap index 827070b4b4..b9da1ace38 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_loading_errors_and_empty_reports_stay_independent.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_loading_errors_and_empty_reports_stay_independent.snap @@ -2,18 +2,10 @@ source: tui/src/analytics_tests.rs expression: panels --- - Total usage history By feature -────────────────────────────────────────────────────── Loading… - Plugins called -────────────────────────────────────────────────────── No history has been reported. - Credits usage history By product -────────────────────────────────────────────────────── Couldn't load credits. Press R to retry. - Messages By model -────────────────────────────────────────────────────── No data reported for this range. diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_maximized_refunds_leave_room_for_both_bands.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_maximized_refunds_leave_room_for_both_bands.snap index dd14dd91d0..38a7178978 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_maximized_refunds_leave_room_for_both_bands.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_maximized_refunds_leave_room_for_both_bands.snap @@ -2,35 +2,35 @@ source: tui/src/analytics_tests.rs expression: output --- -" Analytics · Live account " -" [7d] 30d · Aug 27–Sep 2, 2026 " +" Usage · Enterprise " +" 1 Overview 2 Credits 3 Tokens 4 Plugins 5 Skills 6 Chats " +" ────────────────────────────────────────────────────────────────────────────────────────────────── " +" r 7d/30d · Aug 27–Sep 2, 2026 " " " -" 1 Summary [2 Credits usage history] 3 Token usage history 4 Plugins called 5 Skills used 6 " -" Top chats " " " -" ▎ Credits usage history By product " -" ──────────────────────────────────────────────────────────────────────────────────────────────── " -" 367.00 credits " -" 109.00 " -" 150 57.00 85.00 75.00 ▁▁▁▁▁▁▁▁ " -" 41.00 ▁▁▁▁▁▁▁▁ ▅▅▅▅▅▅▅▅ ▄▄▄▄▄▄▄▄ ████████ " -" 0 ▆▆▆▆▆▆▆▆ ████████ ████████ ████████ ████████ " -" 0 ────────────────────────────────────────────────────────────────────────────────────────── " -" ▇▇▇▇▇▇▇▇ " +" 367.00 credits " +" " +" 150 85.00 109.00 " +" 41.00 57.00 ▂▂▂▂▂▂▂▂ 75.00 ▇▇▇▇▇▇▇▇ " +" 75 ▄▄▄▄▄▄▄▄ ████████ ████████ ████████ " +" 0 ████████ ████████ ████████ ████████ ████████ " +" 0 ──────────────────────────────────────────────────────────────────────────────────────────── " +" ▇▇▇▇▇▇▇▇▇ " " -0.000004 " -" -150 " +" " +" -150 " " " " ▲ " " Aug 27 Aug 28 Aug 29 Aug 30 Aug 31 Sep 1 Sep 2 " " " -" ──────────────────────────────────────────────────────── " -" Sep 2 · -0.000004 credits " -" Product Credits " -" ● CLI -0.000004 " -" ● Desktop app 0.00 " -" ● IDE extension 0.00 " -" ● Web 0.00 " +" ──────────────────────────────────────────────────────── " +" Sep 2 · -0.000004 credits " +" Product Credits " +" ● CLI -0.000004 " +" ● Desktop app 0.00 " +" ● IDE extension 0.00 " +" ● Web 0.00 " " " " " -" tab/1–6 section · z dashboard · ←/→ day · enter details " -" r 7/30d · R refresh · esc back · q close " +" ←/→ day · enter details · esc back " +" tab/1–6 report · z dashboard · ? help · R refresh " diff --git a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_preserves_full_denominators_and_signed_small_credits.snap b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_preserves_full_denominators_and_signed_small_credits.snap index 2d969be4b6..380d495ef9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_preserves_full_denominators_and_signed_small_credits.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__analytics__tests__analytics_preserves_full_denominators_and_signed_small_credits.snap @@ -2,8 +2,6 @@ source: tui/src/analytics_tests.rs expression: "task.into_iter().chain(credits).map(|line|\nline.to_string()).collect::>().join(\"\\n\")" --- -▎ Total usage history By turn start -────────────────────────────────────────────────────── Total usage · relative units All usage by how each turn started @@ -15,8 +13,6 @@ All usage by how each turn started Sep 2 · 100 usage units ● Goals 30.0% - Credits usage history By product -────────────────────────────────────────────────────── -0.000004 credits 0.000004