From e36e12a6edce7525b152af356be4c0aba2985d6e Mon Sep 17 00:00:00 2001 From: jimyag Date: Tue, 22 Sep 2026 23:10:39 +0800 Subject: [PATCH 1/2] feat(tui): show total tokens for each period Show cached, input, and output tokens combined in each aggregate period and its footer. Keep the total visible and update rendering coverage for the wider table. Signed-off-by: jimyag --- src/tui.rs | 49 +++++++++++++++++++++++++- src/tui/tests.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 8657d15..0421490 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1018,7 +1018,7 @@ pub(crate) fn build_display_stats( display_stats } -/// Column width for all token count columns (Cached, Input, Output, Reasoning). +/// Column width for all token count columns (Cached, Input, Output, Total, Reasoning). /// /// Width of 12 accommodates: /// - All u32 per-day values without commas (max 10 digits: "4294967295") @@ -2660,6 +2660,8 @@ fn draw_aggregate_stats_table( let mut best_input_tokens_period = None; let mut best_output_tokens: u64 = 0; let mut best_output_tokens_period = None; + let mut best_total_tokens: u64 = 0; + let mut best_total_tokens_period = None; let mut best_reasoning_tokens: u64 = 0; let mut best_reasoning_tokens_period = None; let mut best_conversations = 0; @@ -2689,6 +2691,13 @@ fn draw_aggregate_stats_table( best_output_tokens = period_stats.stats.output_tokens; best_output_tokens_period = Some(period.as_str()); } + let total_tokens = period_stats.stats.cached_tokens + + period_stats.stats.input_tokens + + period_stats.stats.output_tokens; + if best_total_tokens_period.is_none() || total_tokens > best_total_tokens { + best_total_tokens = total_tokens; + best_total_tokens_period = Some(period.as_str()); + } if best_reasoning_tokens_period.is_none() || period_stats.stats.reasoning_tokens > best_reasoning_tokens { @@ -2761,6 +2770,7 @@ fn draw_aggregate_stats_table( ("cached", TOKEN_COL_WIDTH), ("input", TOKEN_COL_WIDTH), ("output", TOKEN_COL_WIDTH), + ("total", TOKEN_COL_WIDTH), ("reason", TOKEN_COL_WIDTH), ("convs", COUNT_COL_WIDTH), ("tools", COUNT_COL_WIDTH), @@ -2930,6 +2940,28 @@ fn draw_aggregate_stats_table( } .right_aligned(); + let total_tokens = period_stats.stats.cached_tokens + + period_stats.stats.input_tokens + + period_stats.stats.output_tokens; + let total_cell = if is_empty_row { + Line::from(Span::styled( + format_number_fit(total_tokens, format_options, tw), + Style::default().add_modifier(Modifier::DIM), + )) + } else if best_total_tokens_period == Some(period.as_str()) { + Line::from(Span::styled( + format_number_fit(total_tokens, format_options, tw), + Style::default().fg(Color::Red), + )) + } else { + Line::from(Span::raw(format_number_fit( + total_tokens, + format_options, + tw, + ))) + } + .right_aligned(); + let reasoning_cell = if is_empty_row { Line::from(Span::styled( format_number_fit(period_stats.stats.reasoning_tokens, format_options, tw), @@ -3021,6 +3053,7 @@ fn draw_aggregate_stats_table( if show("output") { row_cells.push(Cell::new(output_cell)); } + row_cells.push(Cell::new(total_cell)); if show("reason") { row_cells.push(Cell::new(reasoning_cell)); } @@ -3062,6 +3095,7 @@ fn draw_aggregate_stats_table( if show("output") { header_cells.push(Cell::new(Text::from("Outp Tks").right_aligned())); } + header_cells.push(Cell::new(Text::from("Total Tks").right_aligned())); if show("reason") { header_cells.push(Cell::new(Text::from("Reason Tks").right_aligned())); } @@ -3112,6 +3146,7 @@ fn draw_aggregate_stats_table( if show("output") { sep_cells.push(dim(token_sep.clone())); } + sep_cells.push(dim(token_sep.clone())); if show("reason") { sep_cells.push(dim(token_sep.clone())); } @@ -3196,6 +3231,17 @@ fn draw_aggregate_stats_table( .right_aligned(), )); } + totals_cells.push(Cell::new( + Line::from(Span::styled( + format_number_fit( + total_cached + total_input + total_output, + format_options, + tw, + ), + Style::default().add_modifier(Modifier::BOLD), + )) + .right_aligned(), + )); if show("reason") { totals_cells.push(Cell::new( Line::from(Span::styled( @@ -3262,6 +3308,7 @@ fn draw_aggregate_stats_table( if show("output") { widths.push(Constraint::Length(TOKEN_COL_WIDTH)); } + widths.push(Constraint::Length(TOKEN_COL_WIDTH)); if show("reason") { widths.push(Constraint::Length(TOKEN_COL_WIDTH)); } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 9491209..6c3f1b5 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -278,6 +278,89 @@ fn test_update_window_offsets_and_period_filters_resize() { assert_eq!(filters, vec![Some(PeriodFilter::Day(day))]); } +#[test] +fn aggregate_table_shows_total_tokens_for_each_period_and_footer() { + let date = "2025-01-01"; + let view = AnalyzerStatsView { + daily_stats: BTreeMap::from([( + date.to_string(), + DailyStats { + date: CompactDate::from_str(date).unwrap(), + stats: TuiStats { + cached_tokens: 100, + input_tokens: 200, + output_tokens: 30, + reasoning_tokens: 10, + ..TuiStats::default() + }, + ..DailyStats::default() + }, + )]), + session_aggregates: Vec::new(), + num_conversations: 0, + analyzer_name: Arc::from("Test"), + }; + let format_options = crate::utils::NumberFormatOptions { + use_comma: false, + use_human: false, + locale: "en".to_string(), + currency_symbol: "$".to_string(), + cost_decimal_places: 2, + decimal_places: 2, + }; + let width = 160; + let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap(); + let mut table_state = TableState::default(); + + terminal + .draw(|frame| { + draw_aggregate_stats_table( + frame, + Rect::new(0, 0, width, 8), + &view, + &format_options, + &mut table_state, + AggregateViewMode::Daily, + "", + false, + false, + Color::Cyan, + &HashSet::new(), + false, + ModelUsageShareMetric::Tokens, + ); + }) + .unwrap(); + + let rows: Vec = terminal + .backend() + .buffer() + .content + .chunks(width as usize) + .map(|row| row.iter().map(|cell| cell.symbol()).collect()) + .collect(); + let header = rows.iter().find(|row| row.contains("Total Tks")).unwrap(); + let total_column = header.find("Total Tks").unwrap(); + let period = rows.iter().find(|row| row.contains("1/1/2025")).unwrap(); + let footer = rows.iter().find(|row| row.contains("Total (1d)")).unwrap(); + assert!( + period + .chars() + .skip(total_column) + .take(9) + .collect::() + .contains("330") + ); + assert!( + footer + .chars() + .skip(total_column) + .take(9) + .collect::() + .contains("330") + ); +} + #[test] fn aggregate_table_preserves_leading_digit_in_large_tool_total() { let mut daily_stats = BTreeMap::new(); @@ -369,7 +452,7 @@ fn aggregate_table_highlights_best_value_when_sort_is_reversed() { cost_decimal_places: 2, decimal_places: 2, }; - let width = 120; + let width = 150; let backend = TestBackend::new(width, 8); let mut terminal = Terminal::new(backend).unwrap(); let mut table_state = TableState::default(); @@ -1155,7 +1238,7 @@ fn aggregate_table_wraps_model_column_on_narrow_terminal() { cost_decimal_places: 2, decimal_places: 2, }; - let backend = TestBackend::new(115, 12); + let backend = TestBackend::new(145, 12); let mut terminal = Terminal::new(backend).unwrap(); let mut table_state = TableState::default(); @@ -1163,7 +1246,7 @@ fn aggregate_table_wraps_model_column_on_narrow_terminal() { .draw(|frame| { draw_aggregate_stats_table( frame, - Rect::new(0, 0, 115, 12), + Rect::new(0, 0, 145, 12), &view, &format_options, &mut table_state, From 3ee6865c4c42954bc6141e2b85b8df24fb62758f Mon Sep 17 00:00:00 2001 From: jimyag Date: Tue, 22 Sep 2026 23:12:37 +0800 Subject: [PATCH 2/2] fix(tui): reserve width for total token column The total column is always visible, so count it unconditionally when sizing the aggregate table. Signed-off-by: jimyag --- src/tui.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 0421490..cbe1ee7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2764,13 +2764,12 @@ fn draw_aggregate_stats_table( .max(terminal_text_width(&width_all_models_text)) .clamp(MODELS_COL_MIN_WIDTH, MODELS_COL_MAX_WIDTH); - let mut fixed_width = 1usize + period_width as usize + 10; - let mut column_count = 3usize; + let mut fixed_width = 1usize + period_width as usize + 10 + TOKEN_COL_WIDTH as usize; + let mut column_count = 4usize; for (column, width) in [ ("cached", TOKEN_COL_WIDTH), ("input", TOKEN_COL_WIDTH), ("output", TOKEN_COL_WIDTH), - ("total", TOKEN_COL_WIDTH), ("reason", TOKEN_COL_WIDTH), ("convs", COUNT_COL_WIDTH), ("tools", COUNT_COL_WIDTH),