Skip to main content

shunt/
monitor.rs

1/// Live fullscreen TUI monitor for shunt.
2///
3/// Connects to the running proxy's /status endpoint and refreshes every second.
4/// Press 'q' or Esc to exit, 'u' to pick an account to pin, '?' for help.
5use anyhow::Result;
6use crossterm::{
7    event::{self, Event, KeyCode, KeyModifiers},
8    execute,
9    terminal,
10};
11use ratatui::{
12    backend::CrosstermBackend,
13    layout::{Alignment, Constraint, Direction, Layout, Rect},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table},
17    Frame, Terminal,
18};
19use serde::Deserialize;
20use std::{
21    io::stdout,
22    time::{Duration, Instant},
23};
24
25use crate::term::fmt_duration_ms;
26
27// ---------------------------------------------------------------------------
28// Status API response types
29// ---------------------------------------------------------------------------
30
31#[derive(Debug, Deserialize, Default)]
32struct StatusResponse {
33    #[serde(default)]
34    started_ms: Option<u64>,
35    #[serde(default)]
36    accounts: Vec<AccountStatus>,
37    #[serde(default)]
38    pinned_account: Option<String>,
39    #[serde(default)]
40    last_used_account: Option<String>,
41    #[serde(default)]
42    recent_requests: Vec<ReqLog>,
43    #[serde(default)]
44    #[allow(dead_code)]
45    savings: Option<SavingsInfo>,
46}
47
48#[derive(Debug, Deserialize, Default, Clone)]
49#[allow(dead_code)]
50struct SavingsInfo {
51    #[serde(default)] today_input: u64,
52    #[serde(default)] today_output: u64,
53    #[serde(default)] today_cost_usd: f64,
54    #[serde(default)] week_cost_usd: f64,
55    #[serde(default)] all_time_cost_usd: f64,
56}
57
58#[derive(Debug, Deserialize)]
59struct AccountStatus {
60    name: String,
61    #[serde(default)] email: Option<String>,
62    #[serde(default)] provider: String,
63    available: bool,
64    #[serde(default)] disabled: bool,
65    #[serde(default)] auth_failed: bool,
66    #[serde(default)] health_check_failed: bool,
67    #[serde(default)] utilization_5h: f64,
68    #[serde(default)] reset_5h: Option<u64>,
69    #[serde(default)] status_5h: Option<String>,
70    #[serde(default)] utilization_7d: f64,
71    #[serde(default)] reset_7d: Option<u64>,
72    #[serde(default)] status_7d: Option<String>,
73    #[serde(default)] cooldown_until_ms: u64,
74}
75
76#[derive(Debug, Deserialize, Clone)]
77struct ReqLog {
78    ts_ms: u64,
79    account: String,
80    model: String,
81    #[allow(dead_code)]
82    status: u16,
83    #[allow(dead_code)]
84    input_tokens: u64,
85    #[allow(dead_code)]
86    output_tokens: u64,
87    duration_ms: u64,
88}
89
90// ---------------------------------------------------------------------------
91// Colours
92// ---------------------------------------------------------------------------
93
94const GREEN:    Color = Color::Indexed(154);
95const DK_GREEN: Color = Color::Indexed(28);
96const BRAND:    Color = Color::Indexed(154);
97const DIM:      Color = Color::Indexed(240);
98const YELLOW:   Color = Color::Indexed(220);
99const RED:      Color = Color::Indexed(196);
100const WHITE:    Color = Color::Indexed(253);
101const CYAN:     Color = Color::Indexed(154);
102
103const ACCOUNT_COLORS: &[Color] = &[
104    Color::Indexed(154), // lime green (brand)
105    Color::Indexed(220), // bright yellow
106    Color::Indexed(39),  // dodger blue
107    Color::Indexed(213), // hot pink
108    Color::Indexed(51),  // aqua
109    Color::Indexed(208), // orange
110    Color::Indexed(141), // medium purple
111    Color::Indexed(85),  // sea green
112];
113
114fn style_brand()   -> Style { Style::default().fg(BRAND).add_modifier(Modifier::BOLD) }
115fn style_green()   -> Style { Style::default().fg(GREEN) }
116fn style_dkgreen() -> Style { Style::default().fg(DK_GREEN) }
117fn style_dim()     -> Style { Style::default().fg(DIM) }
118fn style_yellow()  -> Style { Style::default().fg(YELLOW) }
119fn style_red()     -> Style { Style::default().fg(RED) }
120fn style_white()   -> Style { Style::default().fg(WHITE) }
121fn style_cyan()    -> Style { Style::default().fg(CYAN) }
122#[allow(dead_code)]
123fn style_bold()    -> Style { Style::default().add_modifier(Modifier::BOLD) }
124
125// ---------------------------------------------------------------------------
126// Focus
127// ---------------------------------------------------------------------------
128
129#[derive(Debug, Clone, Copy, PartialEq)]
130enum Focus {
131    Accounts,
132    Requests,
133    History,
134}
135
136impl Focus {
137    fn next(self) -> Self {
138        match self {
139            Self::Accounts => Self::Requests,
140            Self::Requests => Self::History,
141            Self::History  => Self::Accounts,
142        }
143    }
144    fn prev(self) -> Self {
145        match self {
146            Self::Accounts => Self::History,
147            Self::Requests => Self::Accounts,
148            Self::History  => Self::Requests,
149        }
150    }
151}
152
153// ---------------------------------------------------------------------------
154// Time window
155// ---------------------------------------------------------------------------
156
157#[derive(Debug, Clone, Copy, PartialEq)]
158enum TimeWindow {
159    FifteenMin,
160    OneHour,
161    SixHour,
162    TwentyFourHour,
163    ThreeDay,
164    SevenDay,
165}
166
167impl TimeWindow {
168    fn ms(self) -> u64 {
169        match self {
170            Self::FifteenMin    => 15 * 60_000,
171            Self::OneHour       => 60 * 60_000,
172            Self::SixHour       => 6  * 60 * 60_000,
173            Self::TwentyFourHour=> 24 * 60 * 60_000,
174            Self::ThreeDay      => 3  * 24 * 60 * 60_000,
175            Self::SevenDay      => 7  * 24 * 60 * 60_000,
176        }
177    }
178
179    fn label(self) -> &'static str {
180        match self {
181            Self::FifteenMin     => "15m",
182            Self::OneHour        => "1h",
183            Self::SixHour        => "6h",
184            Self::TwentyFourHour => "24h",
185            Self::ThreeDay       => "3d",
186            Self::SevenDay       => "7d",
187        }
188    }
189
190    fn next(self) -> Self {
191        match self {
192            Self::FifteenMin     => Self::OneHour,
193            Self::OneHour        => Self::SixHour,
194            Self::SixHour        => Self::TwentyFourHour,
195            Self::TwentyFourHour => Self::ThreeDay,
196            Self::ThreeDay       => Self::SevenDay,
197            Self::SevenDay       => Self::FifteenMin,
198        }
199    }
200
201    fn prev(self) -> Self {
202        match self {
203            Self::FifteenMin     => Self::SevenDay,
204            Self::OneHour        => Self::FifteenMin,
205            Self::SixHour        => Self::OneHour,
206            Self::TwentyFourHour => Self::SixHour,
207            Self::ThreeDay       => Self::TwentyFourHour,
208            Self::SevenDay       => Self::ThreeDay,
209        }
210    }
211
212    fn bucket_count(self) -> usize {
213        match self {
214            Self::FifteenMin     => 15,  // 1 min each
215            Self::OneHour        => 12,  // 5 min each
216            Self::SixHour        => 12,  // 30 min each
217            Self::TwentyFourHour => 24,  // 1 h each
218            Self::ThreeDay       => 18,  // 4 h each
219            Self::SevenDay       => 14,  // 12 h each
220        }
221    }
222
223    fn bucket_ms(self) -> u64 {
224        self.ms() / self.bucket_count() as u64
225    }
226}
227
228// ---------------------------------------------------------------------------
229// Error classification
230// ---------------------------------------------------------------------------
231
232#[derive(Debug, Clone)]
233enum FetchError {
234    NotRunning,
235    Other(String),
236}
237
238// ---------------------------------------------------------------------------
239// Picker overlay
240// ---------------------------------------------------------------------------
241
242struct Picker {
243    items: Vec<String>,
244    cursor: usize,
245}
246
247impl Picker {
248    fn new(accounts: &[AccountStatus], pinned: Option<&str>) -> Self {
249        let mut items: Vec<String> = accounts.iter().map(|a| a.name.clone()).collect();
250        items.push("auto".to_owned());
251        let cursor = pinned
252            .and_then(|p| items.iter().position(|i| i == p))
253            .unwrap_or(items.len() - 1);
254        Self { items, cursor }
255    }
256    fn up(&mut self)   { self.cursor = if self.cursor == 0 { self.items.len() - 1 } else { self.cursor - 1 }; }
257    fn down(&mut self) { self.cursor = (self.cursor + 1) % self.items.len(); }
258    fn selected(&self) -> &str { &self.items[self.cursor] }
259}
260
261/// (display name, description, model id or "" for auto/clear)
262const MODEL_PRESETS: &[(&str, &str, &str)] = &[
263    ("Auto",     "Let the client choose the model",         ""),
264    ("Opus 4",   "Most capable · best for complex tasks",  "claude-opus-4-6"),
265    ("Sonnet 4", "Balanced · fast and smart",               "claude-sonnet-4-6"),
266    ("Haiku 4",  "Fastest · great for simple tasks",        "claude-haiku-4-5-20251001"),
267];
268
269struct ModelPicker {
270    cursor: usize,
271}
272
273impl ModelPicker {
274    fn new(current: Option<&str>) -> Self {
275        let cursor = current
276            .and_then(|m| MODEL_PRESETS.iter().position(|(_, _, id)| *id == m))
277            .unwrap_or(0); // default to "Auto"
278        Self { cursor }
279    }
280    fn up(&mut self)   { self.cursor = if self.cursor == 0 { MODEL_PRESETS.len() - 1 } else { self.cursor - 1 }; }
281    fn down(&mut self) { self.cursor = (self.cursor + 1) % MODEL_PRESETS.len(); }
282    fn selected_id(&self) -> &str { MODEL_PRESETS[self.cursor].2 }
283}
284
285/// (display name, description, strategy id, "" = auto/clear)
286const STRATEGY_PRESETS: &[(&str, &str, &str)] = &[
287    ("Maximus",  "Time-weighted dual-window scorer",       "maximus"),
288    ("Reaper",   "Use-it-or-lose-it · drain expiring first", "reaper"),
289    ("Carousel", "Fixed round-robin cycle",                "carousel"),
290    ("Cushion",  "Lowest utilization · softest landing",   "cushion"),
291];
292
293struct StrategyPicker {
294    cursor: usize,
295}
296
297impl StrategyPicker {
298    fn new(current: Option<&str>) -> Self {
299        let cursor = current
300            .and_then(|s| STRATEGY_PRESETS.iter().position(|(_, _, id)| *id == s))
301            .unwrap_or(0); // default to Maximus
302        Self { cursor }
303    }
304    fn up(&mut self)   { self.cursor = if self.cursor == 0 { STRATEGY_PRESETS.len() - 1 } else { self.cursor - 1 }; }
305    fn down(&mut self) { self.cursor = (self.cursor + 1) % STRATEGY_PRESETS.len(); }
306    fn selected_id(&self) -> &str { STRATEGY_PRESETS[self.cursor].2 }
307}
308
309// ---------------------------------------------------------------------------
310// Entry point
311// ---------------------------------------------------------------------------
312
313pub async fn run_monitor(base_url: &str) -> Result<()> {
314    let base = base_url.trim_end_matches('/');
315    let status_url = format!("{base}/status");
316    let use_url    = format!("{base}/use");
317    let model_url  = format!("{base}/model");
318    let strategy_url = format!("{base}/strategy");
319
320    let original_hook = std::panic::take_hook();
321    std::panic::set_hook(Box::new(move |info| {
322        let _ = terminal::disable_raw_mode();
323        let _ = crossterm::execute!(
324            std::io::stdout(),
325            terminal::LeaveAlternateScreen,
326            crossterm::cursor::Show
327        );
328        original_hook(info);
329    }));
330
331    terminal::enable_raw_mode()?;
332    let mut out = stdout();
333    execute!(out, terminal::EnterAlternateScreen, crossterm::cursor::Hide)?;
334    let backend = CrosstermBackend::new(out);
335    let mut terminal = Terminal::new(backend)?;
336
337    let mut state: Option<StatusResponse> = None;
338    let mut fetch_err: Option<FetchError> = None;
339    let mut last_fetch = Instant::now() - Duration::from_secs(10);
340    let mut accounts_scroll: usize = 0;
341    let mut requests_scroll: usize = 0;
342    let mut picker: Option<Picker> = None;
343    let mut model_picker: Option<ModelPicker> = None;
344    let mut model_override: Option<String> = None;
345    let mut strategy_picker: Option<StrategyPicker> = None;
346    let mut current_strategy: Option<String> = None;
347    let mut strategy_source: Option<String> = None;
348    let mut show_help = false;
349    let mut refresh_ms: u64 = 1_000;
350    let mut focus = Focus::Accounts;
351    let mut chart_window = TimeWindow::FifteenMin;
352    let start_time = Instant::now();
353
354    loop {
355        if last_fetch.elapsed() >= Duration::from_millis(refresh_ms) {
356            match fetch_status(&status_url).await {
357                Ok(s)  => { state = Some(s); fetch_err = None; }
358                Err(e) => { fetch_err = Some(e); state = None; }
359            }
360            // Fetch model override in parallel (ignore errors — proxy may not support it yet)
361            if let Ok(r) = reqwest::Client::new()
362                .get(&model_url)
363                .timeout(Duration::from_secs(2))
364                .send().await
365            {
366                if let Ok(v) = r.json::<serde_json::Value>().await {
367                    model_override = v["model"].as_str().map(|s| s.to_owned());
368                }
369            }
370            // Fetch current routing strategy
371            if let Ok(r) = reqwest::Client::new()
372                .get(&strategy_url)
373                .timeout(Duration::from_secs(2))
374                .send().await
375            {
376                if let Ok(v) = r.json::<serde_json::Value>().await {
377                    current_strategy = v["strategy"].as_str().map(|s| s.to_owned());
378                    strategy_source = v["source"].as_str().map(|s| s.to_owned());
379                }
380            }
381            last_fetch = Instant::now();
382        }
383
384        terminal.draw(|f| {
385            draw(f, &state, &fetch_err, accounts_scroll, requests_scroll,
386                 base_url, &picker, &model_picker, &model_override,
387                 &strategy_picker, &current_strategy, &strategy_source,
388                 show_help, refresh_ms, focus, chart_window, start_time)
389        })?;
390
391        if event::poll(Duration::from_millis(200))? {
392            if let Event::Key(key) = event::read()? {
393                if show_help {
394                    show_help = false;
395                    continue;
396                }
397
398                // Account picker overlay
399                if let Some(ref mut p) = picker {
400                    match key.code {
401                        KeyCode::Esc | KeyCode::Char('q') => { picker = None; }
402                        KeyCode::Up   | KeyCode::Char('k') => p.up(),
403                        KeyCode::Down | KeyCode::Char('j') => p.down(),
404                        KeyCode::Enter => {
405                            let chosen = p.selected().to_owned();
406                            picker = None;
407                            let _ = reqwest::Client::new()
408                                .post(&use_url)
409                                .json(&serde_json::json!({ "account": chosen }))
410                                .timeout(Duration::from_secs(3))
411                                .send()
412                                .await;
413                            last_fetch = Instant::now() - Duration::from_secs(10);
414                        }
415                        _ => {}
416                    }
417                    continue;
418                }
419
420                // Model picker overlay
421                if let Some(ref mut mp) = model_picker {
422                    match key.code {
423                        KeyCode::Esc | KeyCode::Char('q') => { model_picker = None; }
424                        KeyCode::Up   | KeyCode::Char('k') => mp.up(),
425                        KeyCode::Down | KeyCode::Char('j') => mp.down(),
426                        KeyCode::Enter => {
427                            let chosen_id = mp.selected_id().to_owned();
428                            model_picker = None;
429                            let client = reqwest::Client::new();
430                            if chosen_id.is_empty() {
431                                let _ = client.delete(&model_url)
432                                    .timeout(Duration::from_secs(3))
433                                    .send().await;
434                                model_override = None;
435                            } else {
436                                let _ = client.post(&model_url)
437                                    .json(&serde_json::json!({ "model": chosen_id }))
438                                    .timeout(Duration::from_secs(3))
439                                    .send().await;
440                                model_override = Some(chosen_id);
441                            }
442                            last_fetch = Instant::now() - Duration::from_secs(10);
443                        }
444                        _ => {}
445                    }
446                    continue;
447                }
448
449                // Strategy picker overlay
450                if let Some(ref mut sp) = strategy_picker {
451                    match key.code {
452                        KeyCode::Esc | KeyCode::Char('q') => { strategy_picker = None; }
453                        KeyCode::Up   | KeyCode::Char('k') => sp.up(),
454                        KeyCode::Down | KeyCode::Char('j') => sp.down(),
455                        KeyCode::Enter => {
456                            let chosen_id = sp.selected_id().to_owned();
457                            strategy_picker = None;
458                            let client = reqwest::Client::new();
459                            let _ = client.post(&strategy_url)
460                                .json(&serde_json::json!({ "strategy": chosen_id }))
461                                .timeout(Duration::from_secs(3))
462                                .send().await;
463                            current_strategy = Some(chosen_id);
464                            strategy_source = Some("override".to_owned());
465                            last_fetch = Instant::now() - Duration::from_secs(10);
466                        }
467                        _ => {}
468                    }
469                    continue;
470                }
471
472                match (key.code, key.modifiers) {
473                    (KeyCode::Char('q'), _)
474                    | (KeyCode::Esc, _)
475                    | (KeyCode::Char('c'), KeyModifiers::CONTROL) => break,
476
477                    // Tab / Shift+Tab — cycle focus
478                    (KeyCode::Tab, _) => { focus = focus.next(); }
479                    (KeyCode::BackTab, _) => { focus = focus.prev(); }
480
481                    // Scroll — routed to focused panel
482                    (KeyCode::Down, _) | (KeyCode::Char('j'), _) => match focus {
483                        Focus::Accounts => accounts_scroll = accounts_scroll.saturating_add(1),
484                        Focus::Requests => requests_scroll = requests_scroll.saturating_add(1),
485                        Focus::History  => chart_window = chart_window.next(),
486                    },
487                    (KeyCode::Up, _) | (KeyCode::Char('k'), _) => match focus {
488                        Focus::Accounts => accounts_scroll = accounts_scroll.saturating_sub(1),
489                        Focus::Requests => requests_scroll = requests_scroll.saturating_sub(1),
490                        Focus::History  => chart_window = chart_window.prev(),
491                    },
492
493                    // Time window (always works when history is visible)
494                    (KeyCode::Char('t'), _) | (KeyCode::Char(']'), _) => {
495                        chart_window = chart_window.next();
496                    }
497                    (KeyCode::Char('['), _) => {
498                        chart_window = chart_window.prev();
499                    }
500
501                    (KeyCode::Char('r'), _) => {
502                        last_fetch = Instant::now() - Duration::from_secs(10);
503                    }
504                    (KeyCode::Char('u'), _) => {
505                        if let Some(ref s) = state {
506                            picker = Some(Picker::new(&s.accounts, s.pinned_account.as_deref()));
507                        }
508                    }
509                    (KeyCode::Char('m'), _) => {
510                        model_picker = Some(ModelPicker::new(model_override.as_deref()));
511                    }
512                    (KeyCode::Char('s'), _) => {
513                        strategy_picker = Some(StrategyPicker::new(current_strategy.as_deref()));
514                    }
515                    (KeyCode::Char('?'), _) => { show_help = true; }
516                    (KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => {
517                        refresh_ms = (refresh_ms / 2).max(200);
518                    }
519                    (KeyCode::Char('-'), _) => {
520                        refresh_ms = (refresh_ms * 2).min(10_000);
521                    }
522                    _ => {}
523                }
524            }
525        }
526    }
527
528    execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen, crossterm::cursor::Show)?;
529    terminal::disable_raw_mode()?;
530    Ok(())
531}
532
533async fn fetch_status(url: &str) -> Result<StatusResponse, FetchError> {
534    let resp = reqwest::Client::new()
535        .get(url)
536        .timeout(Duration::from_secs(3))
537        .send()
538        .await
539        .map_err(|e| {
540            if e.is_connect() || e.is_timeout() { FetchError::NotRunning }
541            else { FetchError::Other(e.to_string()) }
542        })?
543        .error_for_status()
544        .map_err(|e| FetchError::Other(e.to_string()))?;
545
546    resp.json::<StatusResponse>()
547        .await
548        .map_err(|e| FetchError::Other(format!("bad response: {e}")))
549}
550
551// ---------------------------------------------------------------------------
552// Drawing
553// ---------------------------------------------------------------------------
554
555const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
556
557#[allow(clippy::too_many_arguments)]
558fn draw(
559    f: &mut Frame,
560    state: &Option<StatusResponse>,
561    error: &Option<FetchError>,
562    accounts_scroll: usize,
563    requests_scroll: usize,
564    base_url: &str,
565    picker: &Option<Picker>,
566    model_picker: &Option<ModelPicker>,
567    model_override: &Option<String>,
568    strategy_picker: &Option<StrategyPicker>,
569    current_strategy: &Option<String>,
570    strategy_source: &Option<String>,
571    show_help: bool,
572    refresh_ms: u64,
573    focus: Focus,
574    chart_window: TimeWindow,
575    start_time: Instant,
576) {
577    let area = f.area();
578
579    let chunks = Layout::default()
580        .direction(Direction::Vertical)
581        .constraints([
582            Constraint::Length(3), // header
583            Constraint::Min(0),    // body
584            Constraint::Length(1), // footer
585        ])
586        .split(area);
587
588    draw_header(f, chunks[0], state, model_override, current_strategy, strategy_source);
589
590    match state {
591        None    => draw_connecting(f, chunks[1], error, base_url, start_time),
592        Some(s) => draw_body(f, chunks[1], s, accounts_scroll, requests_scroll, focus, chart_window),
593    }
594
595    draw_footer(f, chunks[2], picker.is_some() || model_picker.is_some() || strategy_picker.is_some(), refresh_ms, focus);
596
597    if let Some(p) = picker { draw_picker(f, p, current_strategy.as_deref(), area); }
598    if let Some(mp) = model_picker { draw_model_picker(f, mp, model_override.as_deref(), area); }
599    if let Some(sp) = strategy_picker { draw_strategy_picker(f, sp, current_strategy.as_deref(), area); }
600    if show_help { draw_help_overlay(f, area); }
601}
602
603fn draw_header(f: &mut Frame, area: Rect, state: &Option<StatusResponse>, model_override: &Option<String>, current_strategy: &Option<String>, strategy_source: &Option<String>) {
604    let uptime_span = state
605        .as_ref()
606        .and_then(|s| s.started_ms)
607        .map(|ms| {
608            let now_ms = now_ms();
609            let elapsed = now_ms.saturating_sub(ms);
610            format!("  up {}", fmt_duration_ms(elapsed))
611        });
612
613    let mut spans = vec![
614        Span::styled(" ◆ ", style_brand()),
615        Span::styled("shunt", style_brand()),
616        Span::styled(format!(" v{}", env!("CARGO_PKG_VERSION")), style_dim()),
617        Span::styled("  monitor", style_dim()),
618        Span::styled("  ·  live", Style::default().fg(GREEN)),
619    ];
620    if let Some(ref u) = uptime_span {
621        spans.push(Span::styled(u.as_str(), style_dim()));
622    }
623    if let Some(ref m) = model_override {
624        spans.push(Span::styled("  ·  ", style_dim()));
625        spans.push(Span::styled("model ", style_dim()));
626        spans.push(Span::styled(shorten_model(m), style_yellow()));
627    }
628    if let Some(ref strat) = current_strategy {
629        let is_override = strategy_source.as_deref() == Some("override");
630        spans.push(Span::styled("  ·  ", style_dim()));
631        spans.push(Span::styled("strategy ", style_dim()));
632        spans.push(Span::styled(
633            strat.clone(),
634            if is_override { style_yellow() } else { style_dim() },
635        ));
636    }
637
638    let block = Block::default().borders(Borders::BOTTOM).border_style(style_dkgreen());
639    f.render_widget(Paragraph::new(Line::from(spans)).block(block).alignment(Alignment::Left), area);
640}
641
642fn sep() -> Span<'static> { Span::styled("  ·  ", Style::default().fg(DIM)) }
643
644fn draw_footer(f: &mut Frame, area: Rect, picker_open: bool, refresh_ms: u64, focus: Focus) {
645    let hint = if picker_open {
646        Line::from(vec![
647            Span::styled(" ↑↓ navigate", style_dim()), sep(),
648            Span::styled("enter", style_green()), Span::styled(" pin", style_dim()), sep(),
649            Span::styled("esc", style_green()), Span::styled(" cancel", style_dim()),
650        ])
651    } else {
652        let rate_str = if refresh_ms < 1_000 { format!("{}ms", refresh_ms) } else { format!("{}s", refresh_ms / 1_000) };
653        let scroll_hint = match focus {
654            Focus::Accounts | Focus::Requests => Span::styled(" scroll", style_dim()),
655            Focus::History  => Span::styled(" time", style_dim()),
656        };
657        Line::from(vec![
658            Span::styled(" q", style_green()), Span::styled(" quit", style_dim()), sep(),
659            Span::styled("tab", style_green()), Span::styled(" focus", style_dim()), sep(),
660            Span::styled("↑↓", style_green()), scroll_hint, sep(),
661            Span::styled("r", style_green()), Span::styled(" refresh", style_dim()), sep(),
662            Span::styled("u", style_green()), Span::styled(" pin", style_dim()), sep(),
663            Span::styled("m", style_green()), Span::styled(" model", style_dim()), sep(),
664            Span::styled("s", style_green()), Span::styled(" strategy", style_dim()), sep(),
665            Span::styled("+/-", style_green()), Span::styled(format!(" speed  {rate_str}"), style_dim()), sep(),
666            Span::styled("?", style_green()), Span::styled(" help", style_dim()),
667        ])
668    };
669    f.render_widget(Paragraph::new(hint), area);
670}
671
672fn is_remote_url(base_url: &str) -> bool {
673    !base_url.contains("127.0.0.1") && !base_url.contains("localhost")
674}
675
676fn draw_connecting(f: &mut Frame, area: Rect, error: &Option<FetchError>, base_url: &str, start_time: Instant) {
677    let remote = is_remote_url(base_url);
678    let lines: Vec<Line> = match error {
679        Some(FetchError::NotRunning) if remote => vec![
680            Line::from(vec![Span::styled("✗ ", style_red()), Span::styled("Lost connection to host", style_white())]),
681            Line::from(vec![Span::styled(format!("  {base_url}"), style_dim())]),
682            Line::from(vec![]),
683            Line::from(vec![Span::styled("  Is the host still running shunt?", style_dim())]),
684            Line::from(vec![
685                Span::styled("  Run ", style_dim()),
686                Span::styled("shunt connect <new-code>", style_cyan()),
687                Span::styled(" to reconnect.", style_dim()),
688            ]),
689        ],
690        Some(FetchError::NotRunning) => {
691            let frame = (start_time.elapsed().as_millis() / 120) as usize % SPINNER.len();
692            vec![Line::from(vec![
693                Span::styled(SPINNER[frame], style_dim()),
694                Span::styled("  waiting for proxy  ·  run shunt start", style_dim()),
695            ])]
696        }
697        Some(FetchError::Other(msg)) => vec![Line::from(vec![
698            Span::styled("✗ ", style_red()),
699            Span::styled(format!("cannot reach {base_url}  ·  {msg}"), style_dim()),
700        ])],
701        None => vec![Line::from(Span::styled("connecting…", style_dim()))],
702    };
703    f.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area);
704}
705
706// ---------------------------------------------------------------------------
707// Body — left: accounts, right: requests (top) + history chart (bottom)
708// ---------------------------------------------------------------------------
709
710fn draw_body(
711    f: &mut Frame,
712    area: Rect,
713    s: &StatusResponse,
714    accounts_scroll: usize,
715    requests_scroll: usize,
716    focus: Focus,
717    chart_window: TimeWindow,
718) {
719    let cols = Layout::default()
720        .direction(Direction::Horizontal)
721        .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
722        .split(area);
723
724    let rows = Layout::default()
725        .direction(Direction::Vertical)
726        .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
727        .split(cols[1]);
728
729    draw_accounts(f, cols[0], s, accounts_scroll, focus == Focus::Accounts);
730    draw_request_log(f, rows[0], s, requests_scroll, focus == Focus::Requests);
731    draw_history_chart(f, rows[1], s, chart_window, focus == Focus::History);
732}
733
734// ---------------------------------------------------------------------------
735// Panel: accounts
736// ---------------------------------------------------------------------------
737
738fn panel_border_style(focused: bool) -> Style {
739    if focused { style_green() } else { style_dkgreen() }
740}
741
742fn panel_title_style(focused: bool) -> Style {
743    if focused { style_green().add_modifier(Modifier::BOLD) } else { style_dim() }
744}
745
746fn draw_accounts(f: &mut Frame, area: Rect, s: &StatusResponse, scroll: usize, focused: bool) {
747    let title_span = Span::styled(" accounts", panel_title_style(focused));
748    let block = Block::default()
749        .title(Line::from(vec![title_span]))
750        .borders(Borders::RIGHT)
751        .border_style(panel_border_style(focused));
752
753    let inner = block.inner(area);
754    f.render_widget(block, area);
755
756    if s.accounts.is_empty() {
757        f.render_widget(Paragraph::new(Line::from(Span::styled("  no accounts configured", style_dim()))), inner);
758        return;
759    }
760
761    let pinned = s.pinned_account.as_deref().unwrap_or("");
762    let last   = s.last_used_account.as_deref().unwrap_or("");
763    let mut lines: Vec<Line> = Vec::new();
764
765    for acc in &s.accounts {
766        let routing_tag = if acc.name == pinned {
767            Span::styled("  pinned", style_yellow())
768        } else if acc.name == last {
769            Span::styled("  active", style_green())
770        } else {
771            Span::raw("")
772        };
773
774        let (status_sym, status_style) = if acc.disabled || acc.auth_failed {
775            ("✗", style_red())
776        } else if acc.health_check_failed {
777            ("!", style_yellow())
778        } else if !acc.available {
779            ("↺", style_yellow())
780        } else {
781            ("✓", style_green())
782        };
783
784        let provider_tag: Span<'static> = match acc.provider.as_str() {
785            "anthropic" | "" => Span::raw(""),
786            "openai" => Span::styled("  [chatgpt]".to_string(), Style::default().fg(YELLOW)),
787            other    => Span::styled(format!("  [{other}]"), Style::default().fg(CYAN)),
788        };
789
790        lines.push(Line::from(vec![
791            Span::styled(format!(" {status_sym} "), status_style),
792            Span::styled(acc.name.clone(), Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
793            routing_tag,
794            provider_tag,
795        ]));
796
797        if let Some(email) = &acc.email {
798            lines.push(Line::from(vec![
799                Span::styled("   ", style_dim()),
800                Span::styled(email.as_str(), style_dim()),
801            ]));
802        }
803
804        let now = now_ms();
805        if acc.cooldown_until_ms > now {
806            let rem = acc.cooldown_until_ms - now;
807            lines.push(Line::from(vec![
808                Span::styled("   ⏸ cooldown  ", style_yellow()),
809                Span::styled(format!("resumes in {}", fmt_duration_ms(rem)), style_yellow()),
810            ]));
811        }
812
813        if acc.provider == "anthropic" || acc.provider.is_empty() {
814            if acc.utilization_5h > 0.0 || acc.reset_5h.is_some() {
815                lines.push(util_bar_line("5h", acc.utilization_5h, acc.reset_5h, acc.status_5h.as_deref()));
816            }
817            if acc.utilization_7d > 0.0 || acc.reset_7d.is_some() {
818                lines.push(util_bar_line("7d", acc.utilization_7d, acc.reset_7d, acc.status_7d.as_deref()));
819            }
820        }
821
822        lines.push(Line::raw(""));
823    }
824
825    let visible = lines.into_iter().skip(scroll).collect::<Vec<_>>();
826    f.render_widget(Paragraph::new(visible), inner);
827}
828
829fn util_bar_line(label: &'static str, util: f64, reset: Option<u64>, wstatus: Option<&str>) -> Line<'static> {
830    let exhausted = wstatus == Some("exhausted");
831    let util = util.clamp(0.0, 1.0);
832    let bar_w = 20usize;
833    // Fill shows REMAINING capacity — matches `shunt status` convention.
834    let used  = (util * bar_w as f64).round() as usize;
835    let free  = bar_w.saturating_sub(used);
836    let bar_color = if exhausted || util >= 0.8 { RED } else if util >= 0.5 { YELLOW } else { GREEN };
837    let bar = format!("{}{}", "█".repeat(free), "░".repeat(used));
838    let rem_pct = ((1.0 - util) * 100.0).round() as u64;
839    let pct: String = if exhausted {
840        "exhausted".to_owned()
841    } else {
842        format!("{}% left", rem_pct)
843    };
844
845    let reset_str = reset.map(|reset_secs| {
846        let now_secs = std::time::SystemTime::now()
847            .duration_since(std::time::UNIX_EPOCH)
848            .unwrap_or_default()
849            .as_secs();
850        if reset_secs > now_secs {
851            format!("  resets {}", fmt_duration_ms((reset_secs - now_secs) * 1000))
852        } else { String::new() }
853    }).unwrap_or_default();
854
855    Line::from(vec![
856        Span::styled(format!("   {label} "), style_dim()),
857        Span::styled(bar, Style::default().fg(bar_color)),
858        Span::styled(format!(" {pct}"), Style::default().fg(bar_color)),
859        Span::styled(reset_str, style_dim()),
860    ])
861}
862
863// ---------------------------------------------------------------------------
864// Panel: request log
865// ---------------------------------------------------------------------------
866
867fn draw_request_log(f: &mut Frame, area: Rect, s: &StatusResponse, scroll: usize, focused: bool) {
868    let now = now_ms();
869    let req_per_min = s.recent_requests.iter()
870        .filter(|r| now.saturating_sub(r.ts_ms) < 60_000)
871        .count();
872    let rate_str = if req_per_min > 0 { format!("  {req_per_min}/min") } else { String::new() };
873
874    let block = Block::default()
875        .title(Line::from(vec![
876            Span::styled(" requests", panel_title_style(focused)),
877            Span::styled(rate_str, style_dim()),
878        ]))
879        .borders(Borders::BOTTOM)
880        .border_style(panel_border_style(focused));
881
882    let inner = block.inner(area);
883    f.render_widget(block, area);
884
885    if s.recent_requests.is_empty() {
886        f.render_widget(Paragraph::new(Line::from(Span::styled("  no requests yet", style_dim()))), inner);
887        return;
888    }
889
890    let header = Row::new(vec![
891        Cell::from(Span::styled("time", style_dim())),
892        Cell::from(Span::styled("account", style_dim())),
893        Cell::from(Span::styled("model", style_dim())),
894        Cell::from(Span::styled("dur", style_dim())),
895    ]).height(1);
896
897    let rows: Vec<Row> = s.recent_requests.iter().skip(scroll).map(|r| {
898        let age_ms = now.saturating_sub(r.ts_ms);
899        let time_str = if age_ms < 60_000 {
900            format!("{}s ago", age_ms / 1000)
901        } else {
902            format!("{} ago", fmt_duration_ms(age_ms))
903        };
904        Row::new(vec![
905            Cell::from(Span::styled(time_str, style_dim())),
906            Cell::from(Span::styled(&r.account, style_green())),
907            Cell::from(Span::styled(shorten_model(&r.model), style_cyan())),
908            Cell::from(Span::styled(fmt_dur_short(r.duration_ms), style_dim())),
909        ])
910    }).collect();
911
912    let widths = [
913        Constraint::Length(8),
914        Constraint::Length(12),
915        Constraint::Min(16),
916        Constraint::Length(7),
917    ];
918
919    f.render_widget(
920        Table::new(rows, widths).header(header).row_highlight_style(style_green()).column_spacing(1),
921        inner,
922    );
923}
924
925// ---------------------------------------------------------------------------
926// Panel: history chart (stacked bar)
927// ---------------------------------------------------------------------------
928
929fn draw_history_chart(f: &mut Frame, area: Rect, s: &StatusResponse, window: TimeWindow, focused: bool) {
930    // Title: time-window selector inline
931    let all_windows = [
932        TimeWindow::FifteenMin, TimeWindow::OneHour, TimeWindow::SixHour,
933        TimeWindow::TwentyFourHour, TimeWindow::ThreeDay, TimeWindow::SevenDay,
934    ];
935    let mut title_spans: Vec<Span> = vec![Span::styled(" history ", panel_title_style(focused))];
936    for w in all_windows {
937        if w == window {
938            title_spans.push(Span::styled(
939                format!("[{}]", w.label()),
940                Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
941            ));
942        } else {
943            title_spans.push(Span::styled(format!(" {} ", w.label()), style_dim()));
944        }
945    }
946
947    let block = Block::default()
948        .title(Line::from(title_spans))
949        .borders(Borders::NONE)
950        .border_style(panel_border_style(focused));
951
952    let inner = block.inner(area);
953    f.render_widget(block, area);
954
955    let chart_h = inner.height as usize;
956    let chart_w = inner.width as usize;
957    if chart_h < 3 || chart_w < 4 { return; }
958
959    // Reserve 1 row at bottom for x-axis time labels
960    let bar_h = chart_h.saturating_sub(1);
961
962    let now = now_ms();
963    let window_ms = window.ms();
964    let n_buckets = window.bucket_count();
965    let bucket_ms = window.bucket_ms();
966
967    let account_names: Vec<&str> = s.accounts.iter().map(|a| a.name.as_str()).collect();
968    let n_accounts = account_names.len();
969
970    // bucket_counts[bucket][account]
971    let mut bucket_counts: Vec<Vec<u32>> = vec![vec![0u32; n_accounts.max(1)]; n_buckets];
972
973    for req in &s.recent_requests {
974        let age_ms = now.saturating_sub(req.ts_ms);
975        if age_ms >= window_ms { continue; }
976        if let Some(idx) = account_names.iter().position(|&n| n == req.account) {
977            let b = (n_buckets - 1).saturating_sub((age_ms / bucket_ms) as usize);
978            bucket_counts[b][idx] += 1;
979        }
980    }
981
982    let max_total = bucket_counts.iter()
983        .map(|b| b.iter().sum::<u32>())
984        .max()
985        .unwrap_or(0);
986
987    // No data at all
988    if max_total == 0 {
989        f.render_widget(
990            Paragraph::new(Line::from(Span::styled(
991                format!("  no requests in the last {}", window.label()), style_dim(),
992            ))),
993            inner,
994        );
995        return;
996    }
997
998    // Slot width: divide available width across buckets
999    let slot_w = (chart_w / n_buckets).max(1);
1000    let bar_w  = slot_w.saturating_sub(1).max(1);
1001
1002    // Build grid[row][col] = Option<Color>
1003    let mut grid: Vec<Vec<Option<Color>>> = vec![vec![None; chart_w]; bar_h];
1004
1005    for (b, counts) in bucket_counts.iter().enumerate() {
1006        let x = b * slot_w;
1007        if x >= chart_w { break; }
1008        let x_end = (x + bar_w).min(chart_w);
1009
1010        let bucket_total: u32 = counts.iter().sum();
1011        if bucket_total == 0 { continue; }
1012
1013        let mut y_from_bottom: usize = 0;
1014        for (acc_idx, &count) in counts.iter().enumerate() {
1015            if count == 0 { continue; }
1016            // Height proportional to this account's share of the max bucket
1017            let seg_h = ((count as f64 / max_total as f64) * bar_h as f64).ceil() as usize;
1018            let seg_h = seg_h.max(1);
1019            let row_end   = bar_h.saturating_sub(y_from_bottom);
1020            let row_start = row_end.saturating_sub(seg_h);
1021            let color = ACCOUNT_COLORS[acc_idx % ACCOUNT_COLORS.len()];
1022            for row in row_start..row_end {
1023                for col in x..x_end {
1024                    grid[row][col] = Some(color);
1025                }
1026            }
1027            y_from_bottom += seg_h;
1028        }
1029    }
1030
1031    // Render grid as Lines
1032    let mut lines: Vec<Line> = grid.iter().map(|row| {
1033        let mut spans: Vec<Span> = Vec::new();
1034        let mut cur_color: Option<Color> = row.first().copied().flatten();
1035        let mut buf = String::new();
1036
1037        for &cell in row {
1038            if cell == cur_color {
1039                buf.push(if cell.is_some() { '█' } else { ' ' });
1040            } else {
1041                let style = cur_color.map(|c| Style::default().fg(c)).unwrap_or_default();
1042                spans.push(Span::styled(std::mem::take(&mut buf), style));
1043                cur_color = cell;
1044                buf.push(if cell.is_some() { '█' } else { ' ' });
1045            }
1046        }
1047        if !buf.is_empty() {
1048            let style = cur_color.map(|c| Style::default().fg(c)).unwrap_or_default();
1049            spans.push(Span::styled(buf, style));
1050        }
1051        Line::from(spans)
1052    }).collect();
1053
1054    // X-axis label row: show bucket timestamps at start / mid / end
1055    let label_row = build_x_labels(chart_w, n_buckets, slot_w, window);
1056    lines.push(label_row);
1057
1058    // Legend: one coloured dot per account that has data
1059    if n_accounts > 0 {
1060        let has_data: Vec<bool> = (0..n_accounts)
1061            .map(|i| bucket_counts.iter().any(|b| b[i] > 0))
1062            .collect();
1063        let mut legend_spans: Vec<Span> = vec![Span::styled(" ", style_dim())];
1064        for (i, name) in account_names.iter().enumerate() {
1065            if !has_data[i] { continue; }
1066            let color = ACCOUNT_COLORS[i % ACCOUNT_COLORS.len()];
1067            legend_spans.push(Span::styled("● ", Style::default().fg(color)));
1068            legend_spans.push(Span::styled(format!("{name}  "), style_dim()));
1069        }
1070        lines.push(Line::from(legend_spans));
1071    }
1072
1073    f.render_widget(Paragraph::new(lines), inner);
1074}
1075
1076fn build_x_labels(chart_w: usize, n_buckets: usize, slot_w: usize, window: TimeWindow) -> Line<'static> {
1077    // Place labels at left edge, middle bucket, and right edge
1078    let mut label_chars: Vec<char> = vec![' '; chart_w];
1079
1080    let place = |chars: &mut Vec<char>, pos: usize, label: &str| {
1081        for (i, ch) in label.chars().enumerate() {
1082            if pos + i < chars.len() { chars[pos + i] = ch; }
1083        }
1084    };
1085
1086    let left_label  = format!("-{}", window.label());
1087    let mid_label   = format!("-{}", fmt_secs_label(window.ms() as f64 / 2000.0));
1088    let right_label = "now";
1089
1090    place(&mut label_chars, 0, &left_label);
1091    let mid_pos = ((n_buckets / 2) * slot_w).saturating_sub(mid_label.len() / 2);
1092    place(&mut label_chars, mid_pos, &mid_label);
1093    let right_pos = chart_w.saturating_sub(right_label.len());
1094    place(&mut label_chars, right_pos, right_label);
1095
1096    let s: String = label_chars.into_iter().collect();
1097    Line::from(Span::styled(s, style_dim()))
1098}
1099
1100fn fmt_secs_label(secs: f64) -> String {
1101    if secs < 60.0 { format!("{:.0}s", secs) }
1102    else if secs < 3600.0 { format!("{:.0}m", secs / 60.0) }
1103    else if secs < 86400.0 { format!("{:.0}h", secs / 3600.0) }
1104    else { format!("{:.0}d", secs / 86400.0) }
1105}
1106
1107// ---------------------------------------------------------------------------
1108// Picker overlay
1109// ---------------------------------------------------------------------------
1110
1111fn draw_picker(f: &mut Frame, picker: &Picker, strategy: Option<&str>, area: Rect) {
1112    let h = (picker.items.len() + 4) as u16;
1113    let w = 36u16;
1114    let x = area.x + area.width.saturating_sub(w) / 2;
1115    let y = area.y + area.height.saturating_sub(h) / 2;
1116    let popup_area = Rect { x, y, width: w.min(area.width), height: h.min(area.height) };
1117
1118    f.render_widget(Clear, popup_area);
1119    let block = Block::default()
1120        .title(Line::from(Span::styled(" pin account ", style_dim())))
1121        .borders(Borders::ALL)
1122        .border_style(style_dkgreen());
1123    let inner = block.inner(popup_area);
1124    f.render_widget(block, popup_area);
1125
1126    let rows: Vec<Row> = picker.items.iter().enumerate().map(|(i, item)| {
1127        let is_sel = i == picker.cursor;
1128        let label = if item == "auto" {
1129            let strat = strategy.unwrap_or("auto");
1130            format!("  {} {} routing", if is_sel { "◆" } else { " " }, strat)
1131        } else {
1132            format!("  {} {}", if is_sel { "◆" } else { " " }, item)
1133        };
1134        let style = if is_sel { Style::default().fg(GREEN).add_modifier(Modifier::BOLD) } else { style_dim() };
1135        Row::new(vec![Cell::from(Span::styled(label, style))])
1136    }).collect();
1137
1138    f.render_widget(Table::new(rows, [Constraint::Min(0)]).column_spacing(0), inner);
1139}
1140
1141fn draw_model_picker(f: &mut Frame, mp: &ModelPicker, current: Option<&str>, area: Rect) {
1142    let h = (MODEL_PRESETS.len() + 4) as u16;
1143    let w = 52u16;
1144    let x = area.x + area.width.saturating_sub(w) / 2;
1145    let y = area.y + area.height.saturating_sub(h) / 2;
1146    let popup_area = Rect { x, y, width: w.min(area.width), height: h.min(area.height) };
1147
1148    f.render_widget(Clear, popup_area);
1149    let block = Block::default()
1150        .title(Line::from(Span::styled(" select model ", style_dim())))
1151        .borders(Borders::ALL)
1152        .border_style(style_dkgreen());
1153    let inner = block.inner(popup_area);
1154    f.render_widget(block, popup_area);
1155
1156    let rows: Vec<Row> = MODEL_PRESETS.iter().enumerate().map(|(i, &(name, desc, id))| {
1157        let is_sel = i == mp.cursor;
1158        let is_current = current == Some(id) || (id.is_empty() && current.is_none());
1159        let bullet = if is_sel { "◆" } else { " " };
1160        let check  = if is_current { " ✓" } else { "  " };
1161        let name_style = if is_sel {
1162            Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
1163        } else {
1164            style_white()
1165        };
1166        Row::new(vec![
1167            Cell::from(Span::styled(format!("  {bullet}"), style_dim())),
1168            Cell::from(Span::styled(format!("{name}{check}"), name_style)),
1169            Cell::from(Span::styled(desc, style_dim())),
1170        ])
1171    }).collect();
1172
1173    f.render_widget(
1174        Table::new(rows, [Constraint::Length(4), Constraint::Length(12), Constraint::Min(0)])
1175            .column_spacing(1),
1176        inner,
1177    );
1178}
1179
1180fn draw_strategy_picker(f: &mut Frame, sp: &StrategyPicker, current: Option<&str>, area: Rect) {
1181    let h = (STRATEGY_PRESETS.len() + 4) as u16;
1182    let w = 58u16;
1183    let x = area.x + area.width.saturating_sub(w) / 2;
1184    let y = area.y + area.height.saturating_sub(h) / 2;
1185    let popup_area = Rect { x, y, width: w.min(area.width), height: h.min(area.height) };
1186
1187    f.render_widget(Clear, popup_area);
1188    let block = Block::default()
1189        .title(Line::from(Span::styled(" select routing strategy ", style_dim())))
1190        .borders(Borders::ALL)
1191        .border_style(style_dkgreen());
1192    let inner = block.inner(popup_area);
1193    f.render_widget(block, popup_area);
1194
1195    let rows: Vec<Row> = STRATEGY_PRESETS.iter().enumerate().map(|(i, &(name, desc, id))| {
1196        let is_sel = i == sp.cursor;
1197        let is_current = current == Some(id);
1198        let bullet = if is_sel { "◆" } else { " " };
1199        let check  = if is_current { " ✓" } else { "  " };
1200        let name_style = if is_sel {
1201            Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
1202        } else {
1203            style_white()
1204        };
1205        Row::new(vec![
1206            Cell::from(Span::styled(format!("  {bullet}"), style_dim())),
1207            Cell::from(Span::styled(format!("{name}{check}"), name_style)),
1208            Cell::from(Span::styled(desc, style_dim())),
1209        ])
1210    }).collect();
1211
1212    f.render_widget(
1213        Table::new(rows, [Constraint::Length(4), Constraint::Length(14), Constraint::Min(0)])
1214            .column_spacing(1),
1215        inner,
1216    );
1217}
1218
1219// ---------------------------------------------------------------------------
1220// Help overlay
1221// ---------------------------------------------------------------------------
1222
1223fn draw_help_overlay(f: &mut Frame, area: Rect) {
1224    let lines: &[(&str, &str)] = &[
1225        ("q / Esc",  "quit"),
1226        ("tab",      "cycle panel focus"),
1227        ("↑ / k",   "scroll up / prev time"),
1228        ("↓ / j",   "scroll down / next time"),
1229        ("r",        "force refresh"),
1230        ("u",        "pin account"),
1231        ("m",        "override model"),
1232        ("s",        "switch routing strategy"),
1233        ("t / ]",   "next time window"),
1234        ("[",        "prev time window"),
1235        ("+  / =",  "faster refresh"),
1236        ("-",        "slower refresh"),
1237        ("?",        "close help"),
1238    ];
1239
1240    let h = (lines.len() + 4) as u16;
1241    let w = 42u16;
1242    let x = area.x + area.width.saturating_sub(w) / 2;
1243    let y = area.y + area.height.saturating_sub(h) / 2;
1244    let popup_area = Rect { x, y, width: w.min(area.width), height: h.min(area.height) };
1245
1246    f.render_widget(Clear, popup_area);
1247    let block = Block::default()
1248        .title(Line::from(Span::styled(" shortcuts ", style_dim())))
1249        .borders(Borders::ALL)
1250        .border_style(style_dkgreen());
1251    let inner = block.inner(popup_area);
1252    f.render_widget(block, popup_area);
1253
1254    let rows: Vec<Row> = lines.iter().map(|(key, desc)| {
1255        Row::new(vec![
1256            Cell::from(Span::styled(format!("  {key}"), style_green())),
1257            Cell::from(Span::styled(format!("  {desc}"), style_dim())),
1258        ])
1259    }).collect();
1260
1261    f.render_widget(
1262        Table::new(rows, [Constraint::Length(14), Constraint::Min(0)]).column_spacing(1),
1263        inner,
1264    );
1265}
1266
1267// ---------------------------------------------------------------------------
1268// Helpers
1269// ---------------------------------------------------------------------------
1270
1271fn now_ms() -> u64 {
1272    std::time::SystemTime::now()
1273        .duration_since(std::time::UNIX_EPOCH)
1274        .unwrap_or_default()
1275        .as_millis() as u64
1276}
1277
1278fn shorten_model(model: &str) -> String {
1279    let s = model.trim_start_matches("claude-");
1280    let s = if let Some(idx) = s.rfind('-') {
1281        let suffix = &s[idx + 1..];
1282        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) { &s[..idx] } else { s }
1283    } else { s };
1284    s.to_owned()
1285}
1286
1287fn fmt_dur_short(ms: u64) -> String {
1288    if ms < 1_000 { format!("{ms}ms") }
1289    else if ms < 60_000 { format!("{:.1}s", ms as f64 / 1_000.0) }
1290    else { format!("{}m", ms / 60_000) }
1291}