@@ -24,6 +24,15 @@ const MIN_WIDTH: u16 = 60;
2424/// Minimum terminal height for proper display
2525const 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 ) ]
2938pub 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
0 commit comments