1use 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#[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
90const 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), Color::Indexed(220), Color::Indexed(39), Color::Indexed(213), Color::Indexed(51), Color::Indexed(208), Color::Indexed(141), Color::Indexed(85), ];
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#[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#[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, Self::OneHour => 12, Self::SixHour => 12, Self::TwentyFourHour => 24, Self::ThreeDay => 18, Self::SevenDay => 14, }
221 }
222
223 fn bucket_ms(self) -> u64 {
224 self.ms() / self.bucket_count() as u64
225 }
226}
227
228#[derive(Debug, Clone)]
233enum FetchError {
234 NotRunning,
235 Other(String),
236}
237
238struct 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
261const 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); 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
285const 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); 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
309pub 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 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 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, ¤t_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 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 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 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 (KeyCode::Tab, _) => { focus = focus.next(); }
479 (KeyCode::BackTab, _) => { focus = focus.prev(); }
480
481 (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 (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
551const 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), Constraint::Min(0), Constraint::Length(1), ])
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
706fn 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
734fn 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 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
863fn 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
925fn draw_history_chart(f: &mut Frame, area: Rect, s: &StatusResponse, window: TimeWindow, focused: bool) {
930 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 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 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 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 let slot_w = (chart_w / n_buckets).max(1);
1000 let bar_w = slot_w.saturating_sub(1).max(1);
1001
1002 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 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 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 let label_row = build_x_labels(chart_w, n_buckets, slot_w, window);
1056 lines.push(label_row);
1057
1058 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 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
1107fn 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
1219fn 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
1267fn 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}