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
This commit is contained in:
Eric Traut
2026-09-20 18:18:19 +00:00
committed by copyberry
parent 5e5cadad6b
commit 46f7d99d0e
34 changed files with 1390 additions and 822 deletions
+69 -87
View File
@@ -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<std::sync::Arc<client::Live>>,
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<chrono::NaiveDate> {
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))?;
@@ -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"));
}
}
+30 -1
View File
@@ -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::<Vec<_>>()
.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"))
);
}
}
+172
View File
@@ -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<Line<'static>> {
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::<Vec<_>>()
})
.collect()
}
}
+281
View File
@@ -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::<Vec<_>>()
.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,
);
}
}
+1 -5
View File
@@ -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(),
@@ -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),
-6
View File
@@ -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;
+111
View File
@@ -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<Line<'static>> {
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()
}
}
@@ -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();
+1 -42
View File
@@ -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;
+24 -4
View File
@@ -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]
@@ -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"));
}
@@ -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::<Vec<_>>(),
failed.lines().take(3).collect::<Vec<_>>()
);
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"))
);
}
+100 -253
View File
@@ -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<Line<'static>>| {
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::<Vec<_>>(),
);
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::<Vec<_>>();
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::<Vec<_>>(),
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::<Vec<_>>();
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);
}
}
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
@@ -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 "
+39 -9
View File
@@ -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::<String>()
== 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}",
+5 -4
View File
@@ -31,7 +31,8 @@ impl AnalyticsView {
pub(crate) fn select_summary(&mut self, view: Option<TokenActivityView>) {
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;
}
}
+1 -11
View File
@@ -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 {
+8 -3
View File
@@ -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;
@@ -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::<String>()
== "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(())
}
@@ -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.
@@ -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.
@@ -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 "
@@ -2,8 +2,6 @@
source: tui/src/analytics_tests.rs
expression: "task.into_iter().chain(credits).map(|line|\nline.to_string()).collect::<Vec<_>>().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