mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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 "
|
||||
|
||||
+42
-42
@@ -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 "
|
||||
|
||||
+70
-70
@@ -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 "
|
||||
|
||||
+22
-22
@@ -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 "
|
||||
|
||||
+27
-27
@@ -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 "
|
||||
|
||||
+106
-106
@@ -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 "
|
||||
|
||||
+20
@@ -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 "
|
||||
+32
-32
@@ -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 "
|
||||
|
||||
+8
-8
@@ -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 "
|
||||
|
||||
+19
-19
@@ -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 "
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
-10
@@ -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.
|
||||
|
||||
-8
@@ -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.
|
||||
|
||||
+23
-23
@@ -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 "
|
||||
|
||||
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user