Skip to content

Commit 9c9e0ae

Browse files
committed
feat: animated count-up for token usage displays
1 parent 883e106 commit 9c9e0ae

File tree

4 files changed

+125
-25
lines changed

4 files changed

+125
-25
lines changed

src/stats.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -494,9 +494,21 @@ impl StatsSummary {
494494
mod tests {
495495
use super::*;
496496

497+
/// Create a fresh Stats without loading persistent data from disk.
498+
fn fresh_stats() -> Stats {
499+
Stats {
500+
requests: RwLock::new(HashMap::new()),
501+
start_time: Instant::now(),
502+
endpoint_requests: RwLock::new(HashMap::new()),
503+
rate_history: RwLock::new(RateHistory::new()),
504+
token_counters: RwLock::new(HashMap::new()),
505+
token_events: RwLock::new(VecDeque::with_capacity(MAX_TOKEN_EVENTS)),
506+
}
507+
}
508+
497509
#[test]
498510
fn test_stats_record_request() {
499-
let stats = Stats::new();
511+
let stats = fresh_stats();
500512
stats.record_request("claude-sonnet-4-5", "/v1/messages");
501513
stats.record_request("claude-sonnet-4-5", "/v1/messages");
502514
stats.record_request("claude-opus-4-5", "/v1/chat/completions");
@@ -509,14 +521,14 @@ mod tests {
509521

510522
#[test]
511523
fn test_stats_uptime() {
512-
let stats = Stats::new();
524+
let stats = fresh_stats();
513525
std::thread::sleep(std::time::Duration::from_millis(10));
514526
assert!(stats.uptime().as_millis() >= 10);
515527
}
516528

517529
#[test]
518530
fn test_stats_to_json() {
519-
let stats = Stats::new();
531+
let stats = fresh_stats();
520532
stats.record_request("test-model", "/v1/messages");
521533

522534
let summary = stats.summary();
@@ -528,7 +540,7 @@ mod tests {
528540

529541
#[test]
530542
fn test_stats_token_usage() {
531-
let stats = Stats::new();
543+
let stats = fresh_stats();
532544
stats.record_request("claude-sonnet-4-5", "/v1/messages");
533545
stats.record_token_usage("claude-sonnet-4-5", 100, 200, 50);
534546
stats.record_token_usage("claude-sonnet-4-5", 150, 300, 0);
@@ -560,7 +572,7 @@ mod tests {
560572

561573
#[test]
562574
fn test_stats_token_json() {
563-
let stats = Stats::new();
575+
let stats = fresh_stats();
564576
stats.record_request("test-model", "/v1/messages");
565577
stats.record_token_usage("test-model", 100, 200, 0);
566578

src/tui/app.rs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ const MIN_WIDTH: u16 = 60;
2424
/// Minimum terminal height for proper display
2525
const MIN_HEIGHT: u16 = 15;
2626

27+
/// Linearly interpolate between two u64 values.
28+
fn lerp_u64(from: u64, to: u64, t: f64) -> u64 {
29+
if to >= from {
30+
from + ((to - from) as f64 * t) as u64
31+
} else {
32+
from - ((from - to) as f64 * t) as u64
33+
}
34+
}
35+
2736
/// Available tabs in the TUI
2837
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2938
pub enum Tab {
@@ -271,6 +280,20 @@ pub struct App {
271280
pub cached_uptime: String,
272281
/// Cached token usage stats (fetched from /stats endpoint)
273282
pub cached_token_stats: Option<super::data::TokenStats>,
283+
/// Animated token display values (interpolated for count-up effect)
284+
pub animated_input_tokens: u64,
285+
pub animated_output_tokens: u64,
286+
pub animated_cache_read_tokens: u64,
287+
/// Previous token values (start of animation)
288+
token_anim_prev_input: u64,
289+
token_anim_prev_output: u64,
290+
token_anim_prev_cache_read: u64,
291+
/// Target token values (end of animation)
292+
token_anim_target_input: u64,
293+
token_anim_target_output: u64,
294+
token_anim_target_cache_read: u64,
295+
/// When the current animation started (in animation_time_ms)
296+
token_anim_start_ms: u64,
274297
/// Rolling time-series of token usage per model
275298
pub token_history: super::data::TokenHistory,
276299
/// Last time token stats were fetched
@@ -416,6 +439,16 @@ impl App {
416439
cached_requests_per_min: 0.0,
417440
cached_uptime: String::from("00:00:00"),
418441
cached_token_stats: None,
442+
animated_input_tokens: 0,
443+
animated_output_tokens: 0,
444+
animated_cache_read_tokens: 0,
445+
token_anim_prev_input: 0,
446+
token_anim_prev_output: 0,
447+
token_anim_prev_cache_read: 0,
448+
token_anim_target_input: 0,
449+
token_anim_target_output: 0,
450+
token_anim_target_cache_read: 0,
451+
token_anim_start_ms: 0,
419452
token_history: super::data::TokenHistory::load(),
420453
last_token_stats_refresh: Instant::now() - Duration::from_secs(10),
421454
last_token_history_save: Instant::now(),
@@ -517,7 +550,28 @@ impl App {
517550
return;
518551
}
519552
self.last_token_stats_refresh = Instant::now();
520-
self.cached_token_stats = super::data::DataProvider::fetch_token_stats();
553+
let new_stats = super::data::DataProvider::fetch_token_stats();
554+
555+
// Trigger count-up animation if values changed
556+
if let Some(ref stats) = new_stats {
557+
let changed = stats.total_input_tokens != self.token_anim_target_input
558+
|| stats.total_output_tokens != self.token_anim_target_output
559+
|| stats.total_cache_read_tokens != self.token_anim_target_cache_read;
560+
if changed {
561+
// Snapshot current displayed values as the animation start
562+
self.token_anim_prev_input = self.animated_input_tokens;
563+
self.token_anim_prev_output = self.animated_output_tokens;
564+
self.token_anim_prev_cache_read = self.animated_cache_read_tokens;
565+
// Set targets
566+
self.token_anim_target_input = stats.total_input_tokens;
567+
self.token_anim_target_output = stats.total_output_tokens;
568+
self.token_anim_target_cache_read = stats.total_cache_read_tokens;
569+
// Record animation start time
570+
self.token_anim_start_ms = self.animation_time_ms;
571+
}
572+
}
573+
574+
self.cached_token_stats = new_stats;
521575

522576
// Update quota period from quota data
523577
if self.token_history.period_start.is_none()
@@ -557,6 +611,42 @@ impl App {
557611
.cloned()
558612
}
559613

614+
/// Duration of the token count-up animation in milliseconds
615+
const TOKEN_ANIM_DURATION_MS: u64 = 400;
616+
617+
/// Update animated token values (called every frame).
618+
/// Interpolates from previous to target values with ease-out.
619+
pub fn update_token_animation(&mut self) {
620+
let elapsed = self
621+
.animation_time_ms
622+
.saturating_sub(self.token_anim_start_ms);
623+
if elapsed >= Self::TOKEN_ANIM_DURATION_MS {
624+
// Animation complete — snap to target
625+
self.animated_input_tokens = self.token_anim_target_input;
626+
self.animated_output_tokens = self.token_anim_target_output;
627+
self.animated_cache_read_tokens = self.token_anim_target_cache_read;
628+
} else {
629+
// Ease-out: t * (2 - t)
630+
let t = elapsed as f64 / Self::TOKEN_ANIM_DURATION_MS as f64;
631+
let eased = t * (2.0 - t);
632+
self.animated_input_tokens = lerp_u64(
633+
self.token_anim_prev_input,
634+
self.token_anim_target_input,
635+
eased,
636+
);
637+
self.animated_output_tokens = lerp_u64(
638+
self.token_anim_prev_output,
639+
self.token_anim_target_output,
640+
eased,
641+
);
642+
self.animated_cache_read_tokens = lerp_u64(
643+
self.token_anim_prev_cache_read,
644+
self.token_anim_target_cache_read,
645+
eased,
646+
);
647+
}
648+
}
649+
560650
/// Rebuild the filtered log indices based on current filter/search state
561651
pub fn refilter_logs(&mut self) {
562652
self.log_filtered_indices.clear();
@@ -2549,6 +2639,9 @@ fn render(frame: &mut Frame, app: &mut App, elapsed: Duration) {
25492639
.animation_time_ms
25502640
.wrapping_add(elapsed.as_millis() as u64);
25512641

2642+
// Update token count-up animation
2643+
app.update_token_animation();
2644+
25522645
// Main layout: Header | Tabs | Content | Footer
25532646
let chunks = Layout::vertical([
25542647
Constraint::Length(1), // Header

src/tui/views/overview.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
6262
frame.render_widget(status_panel, top_chunks[0]);
6363

6464
// Stats panel - comprehensive stats
65-
let (total_in, total_out) = app
66-
.cached_token_stats
67-
.as_ref()
68-
.map(|t| (t.total_input_tokens, t.total_output_tokens))
69-
.unwrap_or((0, 0));
65+
let (total_in, total_out) = (app.animated_input_tokens, app.animated_output_tokens);
7066
let stats_panel = StatsPanel::new(
7167
log_request_count,
7268
requests_per_min,

src/tui/views/usage.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
4242
])
4343
.split(area);
4444

45-
render_summary(frame, layout[0], stats);
45+
render_summary(frame, layout[0], app);
4646
render_cumulative_chart(frame, layout[1], app);
4747
}
4848

@@ -69,8 +69,8 @@ fn render_no_data(frame: &mut Frame, area: Rect) {
6969
frame.render_widget(msg, centered);
7070
}
7171

72-
/// Render the summary panel with total token counts
73-
fn render_summary(frame: &mut Frame, area: Rect, stats: &crate::tui::data::TokenStats) {
72+
/// Render the summary panel with total token counts (using animated values)
73+
fn render_summary(frame: &mut Frame, area: Rect, app: &App) {
7474
let block = Block::default()
7575
.title(" Token Usage Summary ")
7676
.title_style(theme::primary())
@@ -82,7 +82,11 @@ fn render_summary(frame: &mut Frame, area: Rect, stats: &crate::tui::data::Token
8282
let inner = block.inner(area);
8383
frame.render_widget(block, area);
8484

85-
let total = stats.total_input_tokens + stats.total_output_tokens;
85+
// Use animated values for the count-up effect
86+
let input = app.animated_input_tokens;
87+
let output = app.animated_output_tokens;
88+
let cache_read = app.animated_cache_read_tokens;
89+
let total = input + output;
8690

8791
let mut spans = vec![
8892
Span::styled(
@@ -91,24 +95,18 @@ fn render_summary(frame: &mut Frame, area: Rect, stats: &crate::tui::data::Token
9195
.fg(theme::TEXT)
9296
.add_modifier(Modifier::BOLD),
9397
),
94-
Span::styled(
95-
format_tokens(stats.total_input_tokens),
96-
Style::default().fg(theme::SECONDARY),
97-
),
98+
Span::styled(format_tokens(input), Style::default().fg(theme::SECONDARY)),
9899
Span::raw(" "),
99100
Span::styled(
100101
"Output: ",
101102
Style::default()
102103
.fg(theme::TEXT)
103104
.add_modifier(Modifier::BOLD),
104105
),
105-
Span::styled(
106-
format_tokens(stats.total_output_tokens),
107-
Style::default().fg(theme::PRIMARY),
108-
),
106+
Span::styled(format_tokens(output), Style::default().fg(theme::PRIMARY)),
109107
];
110108

111-
if stats.total_cache_read_tokens > 0 {
109+
if cache_read > 0 {
112110
spans.push(Span::raw(" "));
113111
spans.push(Span::styled(
114112
"Cached: ",
@@ -117,7 +115,7 @@ fn render_summary(frame: &mut Frame, area: Rect, stats: &crate::tui::data::Token
117115
.add_modifier(Modifier::BOLD),
118116
));
119117
spans.push(Span::styled(
120-
format_tokens(stats.total_cache_read_tokens),
118+
format_tokens(cache_read),
121119
Style::default().fg(theme::SUCCESS),
122120
));
123121
}
@@ -135,6 +133,7 @@ fn render_summary(frame: &mut Frame, area: Rect, stats: &crate::tui::data::Token
135133
));
136134

137135
// Second line: per-model totals with matching colors, sorted by usage
136+
let stats = app.cached_token_stats.as_ref().unwrap();
138137
let mut sorted_models: Vec<_> = stats.models.iter().collect();
139138
sorted_models.sort_by(|a, b| {
140139
let a_total = a.input_tokens + a.output_tokens;

0 commit comments

Comments
 (0)