@@ -32,8 +32,8 @@ pub struct AppState {
3232 pub proxy_health : Option < ProxyHealth > ,
3333 pub lhm_data : Option < LhmData > ,
3434 pub disk_active : std:: collections:: HashMap < String , f64 > ,
35- // Pace tracking: last 3 utilization readings
36- pub pace_history : Vec < f64 > ,
35+ // Pace tracking: last 3 (epoch_secs, pct) readings
36+ pub pace_history : Vec < ( f64 , f64 ) > ,
3737 // GPU temp tracking (session max/avg since LHM only gives current)
3838 pub gpu_temp_max : f32 ,
3939 pub gpu_temp_samples : Vec < f32 > ,
@@ -85,9 +85,12 @@ pub fn run(config: &Config, usage_rx: Option<tokio::sync::mpsc::UnboundedReceive
8585 // Check for new usage data from background
8686 if let Some ( ref mut rx) = usage_rx {
8787 while let Ok ( data) = rx. try_recv ( ) {
88- // Track pace
89- let pct = data. five_hour . utilization * 100.0 ;
90- state. pace_history . push ( pct) ;
88+ // Track pace with timestamp (normalize to 0-100)
89+ let u = data. five_hour . utilization ;
90+ let pct = if u > 1.0 { u } else { u * 100.0 } ;
91+ let now = std:: time:: SystemTime :: now ( )
92+ . duration_since ( std:: time:: UNIX_EPOCH ) . unwrap ( ) . as_secs_f64 ( ) ;
93+ state. pace_history . push ( ( now, pct) ) ;
9194 if state. pace_history . len ( ) > 3 {
9295 state. pace_history . remove ( 0 ) ;
9396 }
@@ -125,9 +128,11 @@ pub fn run(config: &Config, usage_rx: Option<tokio::sync::mpsc::UnboundedReceive
125128 if let Some ( entry) = state. rate_limit_collector . collect ( ) {
126129 if state. rate_limit_collector . is_fresh ( ) && state. usage . source != "api" {
127130 if let Some ( util) = entry. five_h_utilization {
128- // Track pace from proxy data too
129- let pct = util * 100.0 ;
130- state. pace_history . push ( pct) ;
131+ // Track pace from proxy data too (normalize to 0-100)
132+ let pct = if util > 1.0 { util } else { util * 100.0 } ;
133+ let now = std:: time:: SystemTime :: now ( )
134+ . duration_since ( std:: time:: UNIX_EPOCH ) . unwrap ( ) . as_secs_f64 ( ) ;
135+ state. pace_history . push ( ( now, pct) ) ;
131136 if state. pace_history . len ( ) > 3 {
132137 state. pace_history . remove ( 0 ) ;
133138 }
@@ -182,14 +187,14 @@ pub fn run(config: &Config, usage_rx: Option<tokio::sync::mpsc::UnboundedReceive
182187 Ok ( ( ) )
183188}
184189
185- /// Compute pace string from last 3 utilization readings.
186- fn compute_pace ( history : & [ f64 ] ) -> & ' static str {
190+ /// Compute pace string from last 3 (epoch, pct) readings.
191+ fn compute_pace ( history : & [ ( f64 , f64 ) ] ) -> & ' static str {
187192 if history. len ( ) < 2 {
188193 return "" ;
189194 }
190- let last = * history. last ( ) . unwrap ( ) ;
191- let prev = history[ history. len ( ) - 2 ] ;
192- let delta = last - prev ;
195+ let ( _ , pct_last ) = history[ history . len ( ) - 1 ] ;
196+ let ( _ , pct_prev ) = history[ history. len ( ) - 2 ] ;
197+ let delta = pct_last - pct_prev ;
193198 if delta > 2.0 {
194199 "↑ rising"
195200 } else if delta < -2.0 {
@@ -199,15 +204,78 @@ fn compute_pace(history: &[f64]) -> &'static str {
199204 }
200205}
201206
207+ /// Compute ETA to cap from current utilization and window reset time.
208+ /// Uses: rate = current_pct / elapsed_in_window, then extrapolates to 100%.
209+ fn compute_eta ( current_pct : f64 , resets_at : & str ) -> String {
210+ if current_pct < 0.1 {
211+ return String :: new ( ) ; // nothing to extrapolate
212+ }
213+
214+ // Parse resets_at to get seconds until reset
215+ let secs_until_reset = parse_reset_secs ( resets_at) ;
216+ if secs_until_reset <= 0.0 {
217+ return String :: new ( ) ;
218+ }
219+
220+ let window_total: f64 = 5.0 * 3600.0 ; // 5h window in seconds
221+ let elapsed = window_total - secs_until_reset;
222+ if elapsed < 60.0 {
223+ return String :: new ( ) ; // window just started, too noisy
224+ }
225+
226+ // rate in %/sec based on average consumption so far
227+ let rate = current_pct / elapsed;
228+ let remaining_pct = 100.0 - current_pct;
229+ let eta_secs = remaining_pct / rate;
230+
231+ if eta_secs <= 0.0 {
232+ return "at cap" . to_string ( ) ;
233+ }
234+
235+ let total_min = ( eta_secs / 60.0 ) as u64 ;
236+ if total_min >= 60 {
237+ format ! ( "ETA ~{}h {}m to cap" , total_min / 60 , total_min % 60 )
238+ } else if total_min > 0 {
239+ format ! ( "ETA ~{}m to cap" , total_min)
240+ } else {
241+ "ETA <1m to cap" . to_string ( )
242+ }
243+ }
244+
245+ /// Parse resets_at string into seconds remaining. Returns 0 on failure.
246+ fn parse_reset_secs ( resets_at : & str ) -> f64 {
247+ if resets_at. is_empty ( ) {
248+ return 0.0 ;
249+ }
250+ // Try epoch
251+ if let Ok ( epoch) = resets_at. parse :: < f64 > ( ) {
252+ if epoch > 1_000_000_000.0 {
253+ let now = std:: time:: SystemTime :: now ( )
254+ . duration_since ( std:: time:: UNIX_EPOCH ) . unwrap ( ) . as_secs_f64 ( ) ;
255+ return ( epoch - now) . max ( 0.0 ) ;
256+ }
257+ }
258+ // Try ISO 8601
259+ if let Ok ( dt) = chrono:: DateTime :: parse_from_rfc3339 ( resets_at) {
260+ let remaining = dt. signed_duration_since ( chrono:: Utc :: now ( ) ) ;
261+ return remaining. num_seconds ( ) . max ( 0 ) as f64 ;
262+ }
263+ // Try UTC without timezone
264+ if let Ok ( dt) = chrono:: NaiveDateTime :: parse_from_str ( resets_at, "%Y-%m-%dT%H:%M:%SZ" ) {
265+ let remaining = dt. signed_duration_since ( chrono:: Utc :: now ( ) . naive_utc ( ) ) ;
266+ return remaining. num_seconds ( ) . max ( 0 ) as f64 ;
267+ }
268+ 0.0
269+ }
270+
202271fn render ( frame : & mut ratatui:: Frame , area : Rect , state : & AppState , config : & Config ) {
203272 let mut constraints = Vec :: new ( ) ;
204273 let claude_enabled = config. claude_enabled ;
205274
206275 if claude_enabled {
207- constraints. push ( Constraint :: Length ( 8 ) ) ; // Claude Status
276+ constraints. push ( Constraint :: Length ( 8 ) ) ; // Claude Status (includes net line)
208277 constraints. push ( Constraint :: Length ( 4 ) ) ; // CPU + Temps (side by side, 2 border + 2 content)
209278 constraints. push ( Constraint :: Length ( 5 ) ) ; // Memory + GPU (GPU needs 3 content lines)
210- constraints. push ( Constraint :: Length ( 3 ) ) ; // Network (compact)
211279 constraints. push ( Constraint :: Length ( 6 ) ) ; // Disks
212280 constraints. push ( Constraint :: Min ( 5 ) ) ; // Processes
213281 } else {
@@ -221,16 +289,24 @@ fn render(frame: &mut ratatui::Frame, area: Rect, state: &AppState, config: &Con
221289 let chunks = Layout :: vertical ( constraints) . split ( area) ;
222290 let mut idx = 0 ;
223291
224- // Claude Status
292+ // Claude Status (includes network)
225293 if claude_enabled {
226294 let proxy_running = state. proxy_health . is_some ( ) ;
295+ let claude_reachable = state. usage . error . is_none ( ) && state. usage . fetched_at . is_some ( ) ;
296+ let util = state. usage . five_hour . utilization ;
297+ let current_pct = if util > 1.0 { util } else { util * 100.0 } ;
227298 let pace = compute_pace ( & state. pace_history ) ;
299+ let eta = compute_eta ( current_pct, & state. usage . five_hour . resets_at ) ;
300+ let ( rx_now, tx_now, rx_avg, tx_avg, _, _) = state. system . net_speeds ( ) ;
301+ let net = panels:: claude_status:: NetSpeeds { rx_now, tx_now, rx_avg, tx_avg } ;
228302 panels:: claude_status:: render (
229303 frame, chunks[ idx] ,
230304 & state. usage ,
231- state. proxy_health . as_ref ( ) ,
232305 proxy_running,
306+ claude_reachable,
233307 pace,
308+ & eta,
309+ & net,
234310 ) ;
235311 idx += 1 ;
236312 }
@@ -293,10 +369,12 @@ fn render(frame: &mut ratatui::Frame, area: Rect, state: &AppState, config: &Con
293369 panels:: memory:: render ( frame, mem_gpu_area, mem_used, mem_total, swap_used, swap_total) ;
294370 }
295371
296- // Network (compact)
297- let ( rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak) = state. system . net_speeds ( ) ;
298- panels:: network:: render ( frame, chunks[ idx] , rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak) ;
299- idx += 1 ;
372+ // Network (standalone only when Claude panel is disabled — otherwise embedded in Claude panel)
373+ if !claude_enabled {
374+ let ( rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak) = state. system . net_speeds ( ) ;
375+ panels:: network:: render ( frame, chunks[ idx] , rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak) ;
376+ idx += 1 ;
377+ }
300378
301379 // Disks
302380 let disk_io = state. system . disk_io ( & state. disk_active ) ;
0 commit comments