1mod layout;
2
3use layout::{
4 CODE_WIDTH, COUNT_WIDTH, ColumnSpec, DURATION_WIDTH, EFFORT_WIDTH, ENDPOINT_WIDTH, ERROR_WIDTH,
5 ID_WIDTH, LayoutTier, MODEL_MEDIUM_WIDTH, MODEL_NARROW_WIDTH, MODEL_WIDE_WIDTH,
6 PROJECT_MEDIUM_WIDTH, PROJECT_WIDE_WIDTH, PROVIDER_WIDTH, RATE_WIDTH, STATUS_WIDTH, TIME_WIDTH,
7 TOKEN_WIDTH,
8};
9
10use std::{
11 collections::HashMap,
12 io::{self, Stdout},
13 sync::mpsc,
14 time::{Duration, SystemTime},
15};
16
17use crossterm::{
18 event::{self, Event, KeyCode, KeyModifiers},
19 execute,
20 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
21};
22use jiff::{Timestamp, Zoned, tz::TimeZone};
23use ratatui::{
24 Terminal,
25 backend::CrosstermBackend,
26 layout::{Alignment, Constraint, Direction, Layout, Rect},
27 style::{Color, Modifier, Style},
28 text::{Line, Span},
29 widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap},
30};
31use tokio::sync::oneshot;
32
33use crate::{
34 monitor::{
35 ActiveRequest, CompletedRequest, MockMonitor, MonitorHandle, MonitorState,
36 SESSION_TOKEN_BUCKET_SECS, SessionSummary,
37 },
38 paths,
39 registry::Registry,
40};
41
42const TEAL: Color = Color::Rgb(78, 201, 176);
43const WHITE: Color = Color::Rgb(240, 244, 248);
44const DIM_WHITE: Color = Color::Rgb(180, 190, 200);
45const SEPARATOR: Color = Color::Rgb(72, 74, 82);
46const BG: Color = Color::Rgb(18, 18, 22);
47const PANEL_BG: Color = Color::Rgb(22, 22, 27);
48const SELECTED_BG: Color = Color::Rgb(42, 45, 54);
49const GREEN: Color = Color::Rgb(120, 200, 120);
50const RED: Color = Color::Rgb(220, 120, 120);
51const YELLOW: Color = Color::Rgb(220, 200, 100);
52const BLUE: Color = Color::Rgb(120, 170, 230);
53const PURPLE: Color = Color::Rgb(190, 140, 240);
54const DIM: Color = Color::Rgb(100, 104, 114);
55const SESSION_SPARKLINE_MIN_WIDTH: u16 = 170;
56const SESSION_SPARKLINE_MAX_TOKENS: u64 = 4_000;
57
58pub struct MonitorUiConfig<'a> {
59 pub listen_url: String,
60 pub port: u16,
61 pub registry: &'a Registry,
62 pub shutdown: Option<oneshot::Sender<()>>,
63 pub shutdown_complete: Option<mpsc::Receiver<()>>,
64}
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum MonitorExit {
68 ShutdownComplete,
69 ForceQuit,
70}
71
72pub fn run_monitor(
73 handle: MonitorHandle,
74 config: MonitorUiConfig<'_>,
75) -> Result<MonitorExit, anyhow::Error> {
76 run_monitor_loop(|| handle.snapshot(), config, None)
77}
78
79pub fn run_mock_monitor(port: u16, registry: &Registry) -> Result<(), anyhow::Error> {
80 let mut monitor = MockMonitor::new();
81 run_monitor_loop(
82 move || monitor.snapshot(),
83 MonitorUiConfig {
84 listen_url: "mock://tui-demo".to_string(),
85 port,
86 registry,
87 shutdown: None,
88 shutdown_complete: None,
89 },
90 Some(mock_setup_text(port, registry)),
91 )
92 .map(|_| ())
93}
94
95fn run_monitor_loop(
96 mut snapshot: impl FnMut() -> MonitorState,
97 config: MonitorUiConfig<'_>,
98 setup_text_override: Option<String>,
99) -> Result<MonitorExit, anyhow::Error> {
100 let mut terminal = setup_terminal()?;
101 let _guard = TerminalGuard;
102 let mut app = MonitorApp {
103 listen_url: config.listen_url,
104 setup_text: setup_text_override.unwrap_or_else(|| setup_text(config.port, config.registry)),
105 show_setup: false,
106 show_help: false,
107 detail: None,
108 focus: FocusPane::Sessions,
109 selected: 0,
110 recent_selected: 0,
111 tick: 0,
112 phase: MonitorPhase::Running,
113 shutdown: config.shutdown,
114 shutdown_complete: config.shutdown_complete,
115 };
116
117 let run_result = run_monitor_events(&mut terminal, &mut snapshot, &mut app);
118 if run_result.is_err() {
119 app.begin_shutdown();
120 let state = snapshot();
121 let _ = terminal.draw(|frame| render(frame, &mut app, &state));
122 app.wait_for_shutdown_completion();
123 }
124 let cursor_result = terminal.show_cursor();
125 let exit = run_result?;
126 cursor_result?;
127 Ok(exit)
128}
129
130fn run_monitor_events(
131 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
132 mut snapshot: impl FnMut() -> MonitorState,
133 app: &mut MonitorApp,
134) -> Result<MonitorExit, anyhow::Error> {
135 loop {
136 let state = snapshot();
137 app.clamp_selection(state.sessions.len(), state.recent.len());
138 app.tick = app.tick.wrapping_add(1);
139 terminal.draw(|frame| render(frame, app, &state))?;
140 if app.shutdown_is_complete() {
141 return Ok(MonitorExit::ShutdownComplete);
142 }
143 if event::poll(Duration::from_millis(250))? {
144 match event::read()? {
145 Event::Key(key) => match key.code {
146 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
147 if app.handle_ctrl_c() {
148 return Ok(MonitorExit::ForceQuit);
149 }
150 }
151 _ if app.phase == MonitorPhase::ShuttingDown => {}
152 KeyCode::Char('y') if app.phase == MonitorPhase::ConfirmingShutdown => {
153 app.begin_shutdown()
154 }
155 KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q')
156 if app.phase == MonitorPhase::ConfirmingShutdown =>
157 {
158 app.cancel_shutdown_confirmation()
159 }
160 _ if app.phase == MonitorPhase::ConfirmingShutdown => {}
161 KeyCode::Char('q') => app.request_shutdown_confirmation(),
162 KeyCode::Char('?') => app.show_help = !app.show_help,
163 KeyCode::Char('b') => app.show_setup = !app.show_setup,
164 KeyCode::Tab => app.focus = app.focus.next(),
165 KeyCode::Down => app.move_down(state.sessions.len(), state.recent.len(), true),
166 KeyCode::Char('j') => {
167 app.move_down(state.sessions.len(), state.recent.len(), false)
168 }
169 KeyCode::Up => app.move_up(state.sessions.len(), state.recent.len(), true),
170 KeyCode::Char('k') => {
171 app.move_up(state.sessions.len(), state.recent.len(), false)
172 }
173 KeyCode::Right => app.focus = FocusPane::Recent,
174 KeyCode::Left => app.focus = FocusPane::Sessions,
175 KeyCode::Enter => {
176 app.detail = match app.focus {
177 FocusPane::Sessions if !state.sessions.is_empty() => {
178 Some(DetailView::Session)
179 }
180 FocusPane::Recent if !state.recent.is_empty() => {
181 Some(DetailView::Request)
182 }
183 _ => None,
184 }
185 }
186 KeyCode::Esc => {
187 if app.show_help {
188 app.show_help = false;
189 } else if app.show_setup {
190 app.show_setup = false;
191 } else {
192 app.detail = None;
193 }
194 }
195 _ => {}
196 },
197 Event::Resize(_, _) => {}
198 _ => {}
199 }
200 }
201 }
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205enum FocusPane {
206 Sessions,
207 Recent,
208}
209
210impl FocusPane {
211 fn next(self) -> Self {
212 match self {
213 Self::Sessions => Self::Recent,
214 Self::Recent => Self::Sessions,
215 }
216 }
217}
218
219#[derive(Clone, Copy, PartialEq, Eq)]
220enum DetailView {
221 Session,
222 Request,
223}
224
225#[derive(Clone, Copy, Debug, PartialEq, Eq)]
226enum MonitorPhase {
227 Running,
228 ConfirmingShutdown,
229 ShuttingDown,
230}
231
232struct MonitorApp {
233 listen_url: String,
234 setup_text: String,
235 show_setup: bool,
236 show_help: bool,
237 detail: Option<DetailView>,
238 focus: FocusPane,
239 selected: usize,
240 recent_selected: usize,
241 tick: usize,
242 phase: MonitorPhase,
243 shutdown: Option<oneshot::Sender<()>>,
244 shutdown_complete: Option<mpsc::Receiver<()>>,
245}
246
247impl MonitorApp {
248 fn handle_ctrl_c(&mut self) -> bool {
249 if self.phase == MonitorPhase::ShuttingDown {
250 true
251 } else {
252 self.begin_shutdown();
253 false
254 }
255 }
256
257 fn request_shutdown_confirmation(&mut self) {
258 if self.phase == MonitorPhase::Running {
259 self.phase = MonitorPhase::ConfirmingShutdown;
260 }
261 }
262
263 fn cancel_shutdown_confirmation(&mut self) {
264 if self.phase == MonitorPhase::ConfirmingShutdown {
265 self.phase = MonitorPhase::Running;
266 }
267 }
268
269 fn begin_shutdown(&mut self) {
270 if self.phase == MonitorPhase::ShuttingDown {
271 return;
272 }
273 self.phase = MonitorPhase::ShuttingDown;
274 if let Some(shutdown) = self.shutdown.take() {
275 let _ = shutdown.send(());
276 }
277 }
278
279 fn shutdown_is_complete(&self) -> bool {
280 let Some(shutdown_complete) = &self.shutdown_complete else {
281 return self.phase == MonitorPhase::ShuttingDown;
282 };
283 match shutdown_complete.try_recv() {
284 Ok(()) | Err(mpsc::TryRecvError::Disconnected) => true,
285 Err(mpsc::TryRecvError::Empty) => false,
286 }
287 }
288
289 fn wait_for_shutdown_completion(&self) {
290 if !self.shutdown_is_complete()
291 && let Some(shutdown_complete) = &self.shutdown_complete
292 {
293 let _ = shutdown_complete.recv();
294 }
295 }
296
297 fn clamp_selection(&mut self, sessions: usize, recent: usize) {
298 self.selected = self.selected.min(sessions.saturating_sub(1));
299 self.recent_selected = self.recent_selected.min(recent.saturating_sub(1));
300 }
301
302 fn move_down(&mut self, sessions: usize, recent: usize, switch_panes: bool) {
303 match self.focus {
304 FocusPane::Sessions => {
305 if switch_panes && self.selected >= sessions.saturating_sub(1) && recent > 0 {
306 self.focus = FocusPane::Recent;
307 self.recent_selected = 0;
308 } else {
309 self.selected = self
310 .selected
311 .saturating_add(1)
312 .min(sessions.saturating_sub(1));
313 }
314 }
315 FocusPane::Recent => {
316 self.recent_selected = self
317 .recent_selected
318 .saturating_add(1)
319 .min(recent.saturating_sub(1));
320 }
321 }
322 }
323
324 fn move_up(&mut self, sessions: usize, recent: usize, switch_panes: bool) {
325 match self.focus {
326 FocusPane::Sessions => self.selected = self.selected.saturating_sub(1),
327 FocusPane::Recent => {
328 if switch_panes && self.recent_selected == 0 && sessions > 0 {
329 self.focus = FocusPane::Sessions;
330 self.selected = sessions.saturating_sub(1);
331 } else {
332 self.recent_selected = self
333 .recent_selected
334 .saturating_sub(1)
335 .min(recent.saturating_sub(1));
336 }
337 }
338 }
339 }
340}
341
342impl Drop for MonitorApp {
343 fn drop(&mut self) {
344 self.begin_shutdown();
345 }
346}
347
348struct TerminalGuard;
349
350impl Drop for TerminalGuard {
351 fn drop(&mut self) {
352 let _ = disable_raw_mode();
353 let _ = execute!(io::stdout(), LeaveAlternateScreen);
354 }
355}
356
357fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, anyhow::Error> {
358 enable_raw_mode()?;
359 execute!(io::stdout(), EnterAlternateScreen)?;
360 let backend = CrosstermBackend::new(io::stdout());
361 let mut terminal = Terminal::new(backend)?;
362 terminal.clear()?;
363 Ok(terminal)
364}
365
366fn render(frame: &mut ratatui::Frame<'_>, app: &mut MonitorApp, state: &MonitorState) {
367 let area = frame.area();
368 frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
369
370 let root = Layout::default()
371 .direction(Direction::Vertical)
372 .constraints([
373 Constraint::Length(1),
374 Constraint::Percentage(30),
375 Constraint::Percentage(20),
376 Constraint::Percentage(25),
377 Constraint::Percentage(25),
378 Constraint::Length(1),
379 ])
380 .split(area);
381
382 render_header(frame, root[0], app, state);
383 match app.detail {
384 Some(DetailView::Session) => render_session_detail(frame, root[1], state, app.selected),
385 Some(DetailView::Request) => {
386 render_request_detail(frame, root[1], state, app.recent_selected)
387 }
388 None => render_sessions(
389 frame,
390 root[1],
391 &state.sessions,
392 app.selected,
393 app.focus == FocusPane::Sessions,
394 ),
395 }
396 render_active(frame, root[2], &state.active, app.tick);
397 render_recent(
398 frame,
399 root[3],
400 &state.recent,
401 app.recent_selected,
402 app.focus == FocusPane::Recent,
403 );
404 render_events(frame, root[4], &state.recent);
405 render_footer(frame, root[5], app);
406
407 if app.show_setup {
408 render_setup_overlay(frame, area, &app.setup_text);
409 }
410 if app.show_help {
411 render_help_overlay(frame, area);
412 }
413 match app.phase {
414 MonitorPhase::Running => {}
415 MonitorPhase::ConfirmingShutdown => render_shutdown_confirmation(frame, area),
416 MonitorPhase::ShuttingDown => render_shutdown_overlay(frame, area, app.tick),
417 }
418}
419
420fn render_header(
421 frame: &mut ratatui::Frame<'_>,
422 area: Rect,
423 app: &MonitorApp,
424 state: &MonitorState,
425) {
426 let uptime = state
427 .started_at
428 .elapsed()
429 .unwrap_or_else(|_| Duration::from_secs(0));
430 let text = Line::from(vec![
431 Span::styled(
432 " claude-codex",
433 Style::default()
434 .fg(BG)
435 .bg(TEAL)
436 .add_modifier(Modifier::BOLD),
437 ),
438 Span::styled(" ", Style::default().fg(BG).bg(TEAL)),
439 Span::styled(&app.listen_url, Style::default().fg(BG).bg(TEAL)),
440 Span::styled(" uptime ", Style::default().fg(BG).bg(TEAL)),
441 Span::styled(
442 format_duration(uptime),
443 Style::default()
444 .fg(BG)
445 .bg(TEAL)
446 .add_modifier(Modifier::BOLD),
447 ),
448 Span::styled(" sessions ", Style::default().fg(BG).bg(TEAL)),
449 Span::styled(
450 state.sessions.len().to_string(),
451 Style::default()
452 .fg(BG)
453 .bg(TEAL)
454 .add_modifier(Modifier::BOLD),
455 ),
456 Span::styled(" active ", Style::default().fg(BG).bg(TEAL)),
457 Span::styled(
458 state.active.len().to_string(),
459 Style::default()
460 .fg(BG)
461 .bg(TEAL)
462 .add_modifier(Modifier::BOLD),
463 ),
464 ]);
465 frame.render_widget(Paragraph::new(text).style(Style::default().bg(TEAL)), area);
466}
467
468fn panel(title: &'static str, focused: bool) -> Block<'static> {
469 let color = if focused { TEAL } else { SEPARATOR };
470 Block::default()
471 .title(Span::styled(
472 format!(" {title} "),
473 Style::default()
474 .fg(if focused { TEAL } else { DIM_WHITE })
475 .add_modifier(Modifier::BOLD),
476 ))
477 .borders(Borders::ALL)
478 .border_type(BorderType::Rounded)
479 .border_style(Style::default().fg(color))
480 .style(Style::default().bg(PANEL_BG))
481}
482
483fn table_header_aligned(
484 cells: impl IntoIterator<Item = (&'static str, Alignment)>,
485) -> Row<'static> {
486 Row::new(
487 cells
488 .into_iter()
489 .map(|(cell, alignment)| {
490 Cell::from(
491 Line::from(Span::styled(cell, Style::default().fg(TEAL))).alignment(alignment),
492 )
493 })
494 .collect::<Vec<_>>(),
495 )
496 .style(Style::default().add_modifier(Modifier::BOLD))
497}
498
499fn render_empty_table_state(
500 frame: &mut ratatui::Frame<'_>,
501 area: Rect,
502 title: &'static str,
503 focused: bool,
504 message: &str,
505) {
506 frame.render_widget(panel(title, focused), area);
507 let content = Rect {
508 x: area.x.saturating_add(1),
509 y: area.y.saturating_add(1),
510 width: area.width.saturating_sub(2),
511 height: area.height.saturating_sub(2),
512 };
513 if content.width == 0 || content.height == 0 {
514 return;
515 }
516
517 let line = Rect {
518 y: content.y + content.height.saturating_sub(1) / 2,
519 height: 1,
520 ..content
521 };
522 frame.render_widget(
523 Paragraph::new(ellipsize(message, line.width.into()))
524 .alignment(Alignment::Center)
525 .style(Style::default().fg(DIM).bg(PANEL_BG)),
526 line,
527 );
528}
529
530fn muted_cell(value: impl Into<String>) -> Cell<'static> {
531 Cell::from(Span::styled(value.into(), Style::default().fg(DIM)))
532}
533
534fn text_cell(value: impl Into<String>) -> Cell<'static> {
535 Cell::from(Span::styled(value.into(), Style::default().fg(DIM_WHITE)))
536}
537
538fn model_cell(value: Option<&str>, width: usize) -> Cell<'static> {
539 text_cell(ellipsize(value.unwrap_or("-"), width))
540}
541
542fn table_column_width(area: Rect, widths: &[Constraint], column: usize) -> usize {
543 let table_width = area.width.saturating_sub(2);
544 Layout::horizontal(widths.to_vec())
545 .spacing(1)
546 .split(Rect::new(0, 0, table_width, 1))
547 .get(column)
548 .map_or(0, |rect| usize::from(rect.width))
549}
550
551fn ellipsize(value: &str, width: usize) -> String {
552 if value.chars().count() <= width {
553 return value.to_string();
554 }
555 if width == 0 {
556 return String::new();
557 }
558
559 value
560 .chars()
561 .take(width.saturating_sub(1))
562 .chain(std::iter::once('…'))
563 .collect()
564}
565
566fn display_session_id(session_id: Option<&str>) -> &str {
567 let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
568 return "no-session";
569 };
570 if uuid::Uuid::parse_str(session_id).is_ok() {
571 return session_id
572 .split_once('-')
573 .map_or(session_id, |(first, _)| first);
574 }
575 session_id
576}
577
578fn number_cell(value: impl Into<String>) -> Cell<'static> {
579 Cell::from(
580 Line::from(Span::styled(value.into(), Style::default().fg(DIM_WHITE)))
581 .alignment(Alignment::Right),
582 )
583}
584
585fn status_cell(value: &str) -> Cell<'static> {
586 Cell::from(Span::styled(value.to_string(), status_style(value)))
587}
588
589fn status_style(value: &str) -> Style {
590 Style::default().fg(status_color(value))
591}
592
593fn status_color(value: &str) -> Color {
594 match value {
595 "completed" => GREEN,
596 "streaming" => TEAL,
597 "compacting" => PURPLE,
598 "failed" => RED,
599 "upstream" => BLUE,
600 "selected" | "started" => YELLOW,
601 _ => DIM_WHITE,
602 }
603}
604
605fn http_status_style(status: Option<u16>) -> Style {
606 Style::default().fg(http_status_color(status))
607}
608
609fn http_status_color(status: Option<u16>) -> Color {
610 match status {
611 Some(200..=299) => GREEN,
612 Some(400..=499) => YELLOW,
613 Some(500..=599) => RED,
614 Some(_) => DIM_WHITE,
615 None => DIM,
616 }
617}
618
619fn rate_cell(value: String) -> Cell<'static> {
620 let color = if value.contains("tok/s") {
621 TEAL
622 } else if value == "-" {
623 DIM
624 } else {
625 DIM_WHITE
626 };
627 Cell::from(
628 Line::from(Span::styled(value, Style::default().fg(color))).alignment(Alignment::Right),
629 )
630}
631
632fn provider_cell(value: Option<&str>) -> Cell<'static> {
633 let value = value.unwrap_or("-");
634 let color = match value {
635 "codex" => TEAL,
636 "kimi" => Color::Rgb(190, 150, 220),
637 "cursor" => Color::Rgb(140, 170, 230),
638 "-" => DIM,
639 _ => DIM_WHITE,
640 };
641 Cell::from(Span::styled(value.to_string(), Style::default().fg(color)))
642}
643
644fn detail_cell(value: &str) -> Cell<'static> {
645 if value.is_empty() || value == "-" {
646 Cell::from(Span::styled("", Style::default().fg(DIM)))
647 } else {
648 Cell::from(Span::styled(value.to_string(), Style::default().fg(YELLOW)))
649 }
650}
651
652fn error_indicator(request: &CompletedRequest) -> &'static str {
653 if request.status == crate::monitor::RequestStatus::Failed
654 || request.http_status.is_some_and(|status| status >= 400)
655 || request
656 .error
657 .as_deref()
658 .is_some_and(|error| !error.is_empty())
659 {
660 "!"
661 } else {
662 ""
663 }
664}
665
666fn compact_tokens(tokens: u64) -> String {
667 if tokens >= 1_000_000 {
668 format!("{:.1}M", tokens as f64 / 1_000_000.0)
669 } else if tokens >= 1_000 {
670 format!("{:.1}k", tokens as f64 / 1_000.0)
671 } else {
672 tokens.to_string()
673 }
674}
675
676fn token_value(value: Option<u64>) -> String {
677 value.map(compact_tokens).unwrap_or_else(|| "-".to_string())
678}
679
680fn spinner(tick: usize) -> &'static str {
681 const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
682 FRAMES[tick % FRAMES.len()]
683}
684
685fn sparkline_bucket(timestamp: SystemTime) -> u64 {
686 timestamp
687 .duration_since(SystemTime::UNIX_EPOCH)
688 .unwrap_or(Duration::ZERO)
689 .as_secs()
690 / SESSION_TOKEN_BUCKET_SECS
691}
692
693fn token_sparkline(samples: &[(SystemTime, u64)], width: usize, now: SystemTime) -> String {
694 const LEVELS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
695
696 if width == 0 {
697 return String::new();
698 }
699
700 let mut buckets = HashMap::<u64, u64>::new();
701 for (timestamp, tokens) in samples {
702 let bucket = sparkline_bucket(*timestamp);
703 let total = buckets.entry(bucket).or_default();
704 *total = total.saturating_add(*tokens);
705 }
706
707 let current_bucket = sparkline_bucket(now);
708 let first_bucket = current_bucket.saturating_sub(width.saturating_sub(1) as u64);
709 (first_bucket..=current_bucket)
710 .map(|bucket| {
711 let value = buckets.get(&bucket).copied().unwrap_or(0);
712 if value == 0 {
713 return ' ';
714 }
715 let scaled = value.min(SESSION_SPARKLINE_MAX_TOKENS);
716 let level = (u128::from(scaled) * LEVELS.len() as u128)
717 .div_ceil(u128::from(SESSION_SPARKLINE_MAX_TOKENS))
718 .saturating_sub(1) as usize;
719 LEVELS[level]
720 })
721 .collect()
722}
723
724fn token_sparkline_line(
725 samples: &[(SystemTime, u64)],
726 width: usize,
727 now: SystemTime,
728) -> Line<'static> {
729 let mut sparkline = token_sparkline(samples, width, now);
730 let current = sparkline
731 .pop()
732 .map_or_else(String::new, |value| value.to_string());
733 Line::from(vec![
734 Span::styled(sparkline, Style::default().fg(BLUE)),
735 Span::styled(current, Style::default().fg(DIM)),
736 ])
737}
738
739fn column_constraints<K>(columns: &[ColumnSpec<K>]) -> Vec<Constraint> {
740 columns.iter().map(ColumnSpec::constraint).collect()
741}
742
743fn column_header<K>(columns: &[ColumnSpec<K>]) -> Row<'static> {
744 table_header_aligned(
745 columns
746 .iter()
747 .map(|column| (column.header, column.alignment)),
748 )
749}
750
751fn target_cell(provider: Option<&str>, model: Option<&str>, width: usize) -> Cell<'static> {
752 let provider = provider.unwrap_or("-");
753 let model = model.unwrap_or("-");
754 text_cell(ellipsize(&format!("{provider}/{model}"), width))
755}
756
757#[derive(Clone, Copy, PartialEq, Eq)]
758enum SessionColumn {
759 Marker,
760 Id,
761 Project,
762 Active,
763 Requests,
764 Failures,
765 Counts,
766 Provider,
767 Model,
768 Target,
769 Effort,
770 Input,
771 Output,
772 Rate,
773 Activity,
774 Status,
775}
776
777fn session_columns(tier: LayoutTier, show_full_sparkline: bool) -> Vec<ColumnSpec<SessionColumn>> {
778 use SessionColumn as C;
779 match (tier, show_full_sparkline) {
780 (LayoutTier::Wide, true) => vec![
781 ColumnSpec::fixed(C::Marker, "", Alignment::Left, 1),
782 ColumnSpec::fixed(C::Id, "ID", Alignment::Left, ID_WIDTH),
783 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_WIDE_WIDTH),
784 ColumnSpec::fixed(C::Active, "A", Alignment::Right, COUNT_WIDTH),
785 ColumnSpec::fixed(C::Requests, "R", Alignment::Right, COUNT_WIDTH),
786 ColumnSpec::fixed(C::Failures, "F", Alignment::Right, COUNT_WIDTH),
787 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
788 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_WIDE_WIDTH),
789 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
790 ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH),
791 ColumnSpec::fixed(C::Output, "Out", Alignment::Right, TOKEN_WIDTH),
792 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
793 ColumnSpec::flex(C::Activity, "Tokens/10s · 4k", Alignment::Left, 1),
794 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
795 ],
796 (LayoutTier::Expanded | LayoutTier::Wide, _) => vec![
797 ColumnSpec::fixed(C::Marker, "", Alignment::Left, 1),
798 ColumnSpec::fixed(C::Id, "ID", Alignment::Left, ID_WIDTH),
799 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_MEDIUM_WIDTH),
800 ColumnSpec::fixed(C::Counts, "A/R/F", Alignment::Right, 7),
801 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
802 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_NARROW_WIDTH),
803 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
804 ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH),
805 ColumnSpec::fixed(C::Output, "Out", Alignment::Right, TOKEN_WIDTH),
806 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
807 ColumnSpec::flex(C::Activity, "Tokens/10s", Alignment::Left, 1),
808 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
809 ],
810 (LayoutTier::Medium, _) => vec![
811 ColumnSpec::fixed(C::Marker, "", Alignment::Left, 1),
812 ColumnSpec::fixed(C::Id, "ID", Alignment::Left, ID_WIDTH),
813 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_MEDIUM_WIDTH),
814 ColumnSpec::fixed(C::Counts, "A/R/F", Alignment::Right, 7),
815 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
816 ColumnSpec::flex(C::Model, "Model", Alignment::Left, 1),
817 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
818 ColumnSpec::fixed(C::Activity, "Tok/10s", Alignment::Left, 8),
819 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
820 ],
821 (LayoutTier::Narrow, _) => vec![
822 ColumnSpec::fixed(C::Marker, "", Alignment::Left, 1),
823 ColumnSpec::fixed(C::Id, "ID", Alignment::Left, ID_WIDTH),
824 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, 10),
825 ColumnSpec::fixed(C::Counts, "A/R/F", Alignment::Right, 7),
826 ColumnSpec::flex(C::Target, "Target", Alignment::Left, 1),
827 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
828 ColumnSpec::fixed(C::Activity, "Trend", Alignment::Left, 6),
829 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
830 ],
831 (LayoutTier::Emergency, _) => vec![
832 ColumnSpec::fixed(C::Marker, "", Alignment::Left, 1),
833 ColumnSpec::fixed(C::Id, "ID", Alignment::Left, ID_WIDTH),
834 ColumnSpec::flex(C::Target, "Target", Alignment::Left, 1),
835 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
836 ],
837 }
838}
839
840fn render_sessions(
841 frame: &mut ratatui::Frame<'_>,
842 area: Rect,
843 sessions: &[SessionSummary],
844 selected: usize,
845 focused: bool,
846) {
847 if sessions.is_empty() {
848 render_empty_table_state(frame, area, "Sessions", focused, "No sessions");
849 return;
850 }
851
852 let tier = LayoutTier::for_outer_width(area.width);
853 let show_full_sparkline = tier == LayoutTier::Wide && area.width >= SESSION_SPARKLINE_MIN_WIDTH;
854 let columns = session_columns(tier, show_full_sparkline);
855 let widths = column_constraints(&columns);
856 let now = SystemTime::now();
857 let rows = sessions.iter().enumerate().map(|(index, session)| {
858 let cells = columns
859 .iter()
860 .enumerate()
861 .map(|(column_index, column)| {
862 let width = table_column_width(area, &widths, column_index);
863 match column.key {
864 SessionColumn::Marker => {
865 let marker = if focused && index == selected {
866 ">"
867 } else {
868 " "
869 };
870 Cell::from(Span::styled(marker, Style::default().fg(TEAL)))
871 }
872 SessionColumn::Id => {
873 text_cell(display_session_id(session.session_id.as_deref()))
874 }
875 SessionColumn::Project => {
876 text_cell(ellipsize(session.project.as_deref().unwrap_or("-"), width))
877 }
878 SessionColumn::Active => number_cell(session.active_count.to_string()),
879 SessionColumn::Requests => number_cell(session.request_count.to_string()),
880 SessionColumn::Failures => number_cell(session.failure_count.to_string()),
881 SessionColumn::Counts => number_cell(format!(
882 "{}/{}/{}",
883 session.active_count, session.request_count, session.failure_count
884 )),
885 SessionColumn::Provider => provider_cell(session.provider.as_deref()),
886 SessionColumn::Model => model_cell(session.model.as_deref(), width),
887 SessionColumn::Target => {
888 target_cell(session.provider.as_deref(), session.model.as_deref(), width)
889 }
890 SessionColumn::Effort => text_cell(session.effort.as_deref().unwrap_or("-")),
891 SessionColumn::Input => number_cell(compact_tokens(session.input_tokens)),
892 SessionColumn::Output => number_cell(compact_tokens(session.output_tokens)),
893 SessionColumn::Rate => rate_cell(session.rate().label()),
894 SessionColumn::Activity => Cell::from(token_sparkline_line(
895 &session.output_token_samples,
896 width,
897 now,
898 )),
899 SessionColumn::Status => status_cell(&session.last_status),
900 }
901 })
902 .collect::<Vec<_>>();
903 Row::new(cells).style(if index == selected {
904 Style::default().bg(SELECTED_BG)
905 } else {
906 Style::default().bg(PANEL_BG)
907 })
908 });
909 let table = Table::new(rows, widths.clone())
910 .header(column_header(&columns))
911 .block(panel("Sessions", focused));
912 let mut table_state = TableState::default().with_selected(Some(selected));
913 frame.render_stateful_widget(table, area, &mut table_state);
914}
915
916#[derive(Clone, Copy, PartialEq, Eq)]
917enum ActiveColumn {
918 Started,
919 Status,
920 Project,
921 Session,
922 Provider,
923 Model,
924 Target,
925 Effort,
926 Endpoint,
927 Input,
928 Output,
929 Rate,
930 Elapsed,
931}
932
933fn active_columns(tier: LayoutTier) -> Vec<ColumnSpec<ActiveColumn>> {
934 use ActiveColumn as C;
935 match tier {
936 LayoutTier::Wide => vec![
937 ColumnSpec::fixed(C::Started, "Started", Alignment::Left, TIME_WIDTH),
938 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
939 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_WIDE_WIDTH),
940 ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH),
941 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
942 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_WIDE_WIDTH),
943 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
944 ColumnSpec::flex(C::Endpoint, "Endpoint", Alignment::Left, 1),
945 ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH),
946 ColumnSpec::fixed(C::Output, "Out", Alignment::Right, TOKEN_WIDTH),
947 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
948 ColumnSpec::fixed(C::Elapsed, "Elapsed", Alignment::Right, DURATION_WIDTH),
949 ],
950 LayoutTier::Expanded => vec![
951 ColumnSpec::fixed(C::Started, "Started", Alignment::Left, TIME_WIDTH),
952 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
953 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_MEDIUM_WIDTH),
954 ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH),
955 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
956 ColumnSpec::flex(C::Model, "Model", Alignment::Left, 1),
957 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
958 ColumnSpec::fixed(C::Endpoint, "Endpoint", Alignment::Left, ENDPOINT_WIDTH),
959 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
960 ColumnSpec::fixed(C::Elapsed, "Elapsed", Alignment::Right, DURATION_WIDTH),
961 ],
962 LayoutTier::Medium => vec![
963 ColumnSpec::fixed(C::Started, "Started", Alignment::Left, TIME_WIDTH),
964 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
965 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
966 ColumnSpec::flex(C::Model, "Model", Alignment::Left, 1),
967 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
968 ColumnSpec::fixed(C::Endpoint, "Endpoint", Alignment::Left, ENDPOINT_WIDTH),
969 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
970 ColumnSpec::fixed(C::Elapsed, "Elapsed", Alignment::Right, DURATION_WIDTH),
971 ],
972 LayoutTier::Narrow => vec![
973 ColumnSpec::fixed(C::Started, "Started", Alignment::Left, TIME_WIDTH),
974 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
975 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
976 ColumnSpec::flex(C::Model, "Model", Alignment::Left, 1),
977 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
978 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
979 ColumnSpec::fixed(C::Elapsed, "Elapsed", Alignment::Right, DURATION_WIDTH),
980 ],
981 LayoutTier::Emergency => vec![
982 ColumnSpec::fixed(C::Started, "Started", Alignment::Left, TIME_WIDTH),
983 ColumnSpec::fixed(C::Status, "Status", Alignment::Left, STATUS_WIDTH),
984 ColumnSpec::flex(C::Target, "Target", Alignment::Left, 1),
985 ColumnSpec::fixed(C::Elapsed, "Elapsed", Alignment::Right, DURATION_WIDTH),
986 ],
987 }
988}
989
990fn render_active(
991 frame: &mut ratatui::Frame<'_>,
992 area: Rect,
993 active: &[ActiveRequest],
994 tick: usize,
995) {
996 if active.is_empty() {
997 render_empty_table_state(frame, area, "Active requests", false, "No active requests");
998 return;
999 }
1000
1001 let columns = active_columns(LayoutTier::for_outer_width(area.width));
1002 let widths = column_constraints(&columns);
1003 let rows = active.iter().map(|request| {
1004 let status = if request.status == crate::monitor::RequestStatus::Compacting {
1005 format!("{}{}", spinner(tick), request.status.label())
1006 } else {
1007 format!("{} {}", spinner(tick), request.status.label())
1008 };
1009 let cells = columns
1010 .iter()
1011 .enumerate()
1012 .map(|(column_index, column)| {
1013 let width = table_column_width(area, &widths, column_index);
1014 match column.key {
1015 ActiveColumn::Started => muted_cell(format_system_time(request.started_at)),
1016 ActiveColumn::Status => Cell::from(Span::styled(
1017 status.clone(),
1018 status_style(request.status.label()),
1019 )),
1020 ActiveColumn::Project => {
1021 text_cell(ellipsize(request.project.as_deref().unwrap_or("-"), width))
1022 }
1023 ActiveColumn::Session => {
1024 text_cell(display_session_id(request.session_id.as_deref()))
1025 }
1026 ActiveColumn::Provider => provider_cell(request.provider.as_deref()),
1027 ActiveColumn::Model => model_cell(request.model.as_deref(), width),
1028 ActiveColumn::Target => {
1029 target_cell(request.provider.as_deref(), request.model.as_deref(), width)
1030 }
1031 ActiveColumn::Effort => text_cell(request.effort.as_deref().unwrap_or("-")),
1032 ActiveColumn::Endpoint => muted_cell(request.endpoint.label()),
1033 ActiveColumn::Input => number_cell(token_value(request.input_tokens)),
1034 ActiveColumn::Output => number_cell(token_value(request.output_tokens)),
1035 ActiveColumn::Rate => rate_cell(request.rate().label()),
1036 ActiveColumn::Elapsed => number_cell(format_duration(request.elapsed())),
1037 }
1038 })
1039 .collect::<Vec<_>>();
1040 Row::new(cells).style(Style::default().bg(PANEL_BG))
1041 });
1042 let table = Table::new(rows, widths.clone())
1043 .header(column_header(&columns))
1044 .block(panel("Active requests", false));
1045 frame.render_widget(table, area);
1046}
1047
1048#[derive(Clone, Copy, PartialEq, Eq)]
1049enum RecentColumn {
1050 Finished,
1051 Code,
1052 Project,
1053 Session,
1054 Provider,
1055 Model,
1056 Target,
1057 Effort,
1058 Endpoint,
1059 Latency,
1060 Rate,
1061 Input,
1062 Output,
1063 Details,
1064 Error,
1065}
1066
1067fn recent_columns(tier: LayoutTier) -> Vec<ColumnSpec<RecentColumn>> {
1068 use RecentColumn as C;
1069 match tier {
1070 LayoutTier::Wide => vec![
1071 ColumnSpec::fixed(C::Finished, "Finished", Alignment::Left, TIME_WIDTH),
1072 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1073 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_WIDE_WIDTH),
1074 ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH),
1075 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1076 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_WIDE_WIDTH),
1077 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
1078 ColumnSpec::fixed(C::Endpoint, "Endpoint", Alignment::Left, ENDPOINT_WIDTH),
1079 ColumnSpec::fixed(C::Latency, "Latency", Alignment::Right, DURATION_WIDTH),
1080 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
1081 ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH),
1082 ColumnSpec::fixed(C::Output, "Out", Alignment::Right, TOKEN_WIDTH),
1083 ColumnSpec::flex(C::Details, "Details", Alignment::Left, 1),
1084 ],
1085 LayoutTier::Expanded => vec![
1086 ColumnSpec::fixed(C::Finished, "Finished", Alignment::Left, TIME_WIDTH),
1087 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1088 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_MEDIUM_WIDTH),
1089 ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH),
1090 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1091 ColumnSpec::flex(C::Model, "Model", Alignment::Left, 1),
1092 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
1093 ColumnSpec::fixed(C::Latency, "Latency", Alignment::Right, DURATION_WIDTH),
1094 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
1095 ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH),
1096 ColumnSpec::fixed(C::Output, "Out", Alignment::Right, TOKEN_WIDTH),
1097 ColumnSpec::fixed(C::Error, "!", Alignment::Right, ERROR_WIDTH),
1098 ],
1099 LayoutTier::Medium => vec![
1100 ColumnSpec::fixed(C::Finished, "Finished", Alignment::Left, TIME_WIDTH),
1101 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1102 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1103 ColumnSpec::flex(C::Model, "Model", Alignment::Left, 1),
1104 ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH),
1105 ColumnSpec::fixed(C::Latency, "Latency", Alignment::Right, DURATION_WIDTH),
1106 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
1107 ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH),
1108 ColumnSpec::fixed(C::Output, "Out", Alignment::Right, TOKEN_WIDTH),
1109 ColumnSpec::fixed(C::Error, "!", Alignment::Right, ERROR_WIDTH),
1110 ],
1111 LayoutTier::Narrow => vec![
1112 ColumnSpec::fixed(C::Finished, "Finished", Alignment::Left, TIME_WIDTH),
1113 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1114 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1115 ColumnSpec::flex(C::Model, "Model", Alignment::Left, 1),
1116 ColumnSpec::fixed(C::Latency, "Latency", Alignment::Right, DURATION_WIDTH),
1117 ColumnSpec::fixed(C::Rate, "Rate", Alignment::Right, RATE_WIDTH),
1118 ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH),
1119 ColumnSpec::fixed(C::Output, "Out", Alignment::Right, TOKEN_WIDTH),
1120 ColumnSpec::fixed(C::Error, "!", Alignment::Right, ERROR_WIDTH),
1121 ],
1122 LayoutTier::Emergency => vec![
1123 ColumnSpec::fixed(C::Finished, "Finished", Alignment::Left, TIME_WIDTH),
1124 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1125 ColumnSpec::flex(C::Target, "Target", Alignment::Left, 1),
1126 ColumnSpec::fixed(C::Latency, "Latency", Alignment::Right, DURATION_WIDTH),
1127 ColumnSpec::fixed(C::Error, "!", Alignment::Right, ERROR_WIDTH),
1128 ],
1129 }
1130}
1131
1132fn http_code_cell(status: Option<u16>) -> Cell<'static> {
1133 Cell::from(
1134 Line::from(Span::styled(
1135 status
1136 .map(|status| status.to_string())
1137 .unwrap_or_else(|| "-".to_string()),
1138 http_status_style(status),
1139 ))
1140 .alignment(Alignment::Right),
1141 )
1142}
1143
1144fn render_recent(
1145 frame: &mut ratatui::Frame<'_>,
1146 area: Rect,
1147 recent: &[CompletedRequest],
1148 selected: usize,
1149 focused: bool,
1150) {
1151 if recent.is_empty() {
1152 render_empty_table_state(
1153 frame,
1154 area,
1155 "Recent requests",
1156 focused,
1157 "No recent requests",
1158 );
1159 return;
1160 }
1161
1162 let columns = recent_columns(LayoutTier::for_outer_width(area.width));
1163 let widths = column_constraints(&columns);
1164 let rows = recent.iter().enumerate().map(|(index, request)| {
1165 let cells = columns
1166 .iter()
1167 .enumerate()
1168 .map(|(column_index, column)| {
1169 let width = table_column_width(area, &widths, column_index);
1170 match column.key {
1171 RecentColumn::Finished => muted_cell(format_system_time(request.finished_at)),
1172 RecentColumn::Code => http_code_cell(request.http_status),
1173 RecentColumn::Project => {
1174 text_cell(ellipsize(request.project.as_deref().unwrap_or("-"), width))
1175 }
1176 RecentColumn::Session => {
1177 text_cell(display_session_id(request.session_id.as_deref()))
1178 }
1179 RecentColumn::Provider => provider_cell(request.provider.as_deref()),
1180 RecentColumn::Model => model_cell(request.model.as_deref(), width),
1181 RecentColumn::Target => {
1182 target_cell(request.provider.as_deref(), request.model.as_deref(), width)
1183 }
1184 RecentColumn::Effort => text_cell(request.effort.as_deref().unwrap_or("-")),
1185 RecentColumn::Endpoint => muted_cell(request.endpoint.label()),
1186 RecentColumn::Latency => number_cell(format_duration(request.latency)),
1187 RecentColumn::Rate => rate_cell(request.rate().label()),
1188 RecentColumn::Input => number_cell(token_value(request.input_tokens)),
1189 RecentColumn::Output => number_cell(token_value(request.output_tokens)),
1190 RecentColumn::Details => detail_cell(request.error.as_deref().unwrap_or("")),
1191 RecentColumn::Error => detail_cell(error_indicator(request)),
1192 }
1193 })
1194 .collect::<Vec<_>>();
1195 Row::new(cells).style(if focused && index == selected {
1196 Style::default().bg(SELECTED_BG)
1197 } else {
1198 Style::default().bg(PANEL_BG)
1199 })
1200 });
1201 let table = Table::new(rows, widths.clone())
1202 .header(column_header(&columns))
1203 .block(panel("Recent requests", focused));
1204 let mut table_state = TableState::default().with_selected(Some(selected));
1205 frame.render_stateful_widget(table, area, &mut table_state);
1206}
1207
1208#[derive(Clone, Copy, PartialEq, Eq)]
1209enum EventColumn {
1210 Time,
1211 Code,
1212 Project,
1213 Session,
1214 Provider,
1215 Model,
1216 Message,
1217}
1218
1219fn event_columns(tier: LayoutTier) -> Vec<ColumnSpec<EventColumn>> {
1220 use EventColumn as C;
1221 match tier {
1222 LayoutTier::Wide => vec![
1223 ColumnSpec::fixed(C::Time, "Time", Alignment::Left, TIME_WIDTH),
1224 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1225 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_WIDE_WIDTH),
1226 ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH),
1227 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1228 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_WIDE_WIDTH),
1229 ColumnSpec::flex(C::Message, "Message", Alignment::Left, 1),
1230 ],
1231 LayoutTier::Expanded => vec![
1232 ColumnSpec::fixed(C::Time, "Time", Alignment::Left, TIME_WIDTH),
1233 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1234 ColumnSpec::fixed(C::Project, "Project", Alignment::Left, PROJECT_MEDIUM_WIDTH),
1235 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1236 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_MEDIUM_WIDTH),
1237 ColumnSpec::flex(C::Message, "Message", Alignment::Left, 1),
1238 ],
1239 LayoutTier::Medium => vec![
1240 ColumnSpec::fixed(C::Time, "Time", Alignment::Left, TIME_WIDTH),
1241 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1242 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1243 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_MEDIUM_WIDTH),
1244 ColumnSpec::flex(C::Message, "Message", Alignment::Left, 1),
1245 ],
1246 LayoutTier::Narrow => vec![
1247 ColumnSpec::fixed(C::Time, "Time", Alignment::Left, TIME_WIDTH),
1248 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1249 ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH),
1250 ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_NARROW_WIDTH),
1251 ColumnSpec::flex(C::Message, "Message", Alignment::Left, 1),
1252 ],
1253 LayoutTier::Emergency => vec![
1254 ColumnSpec::fixed(C::Time, "Time", Alignment::Left, TIME_WIDTH),
1255 ColumnSpec::fixed(C::Code, "Code", Alignment::Right, CODE_WIDTH),
1256 ColumnSpec::flex(C::Message, "Message", Alignment::Left, 1),
1257 ],
1258 }
1259}
1260
1261fn render_events(frame: &mut ratatui::Frame<'_>, area: Rect, recent: &[CompletedRequest]) {
1262 let events = recent
1263 .iter()
1264 .filter(|request| {
1265 request.status == crate::monitor::RequestStatus::Failed
1266 || request.http_status.is_some_and(|status| status >= 400)
1267 || request.error.is_some()
1268 })
1269 .take(12)
1270 .collect::<Vec<_>>();
1271 if events.is_empty() {
1272 render_empty_table_state(frame, area, "Events", false, "No events");
1273 return;
1274 }
1275
1276 let columns = event_columns(LayoutTier::for_outer_width(area.width));
1277 let widths = column_constraints(&columns);
1278 let rows = events.iter().map(|request| {
1279 let message = request
1280 .error
1281 .as_deref()
1282 .filter(|error| !error.is_empty())
1283 .unwrap_or("-");
1284 let cells = columns
1285 .iter()
1286 .enumerate()
1287 .map(|(column_index, column)| {
1288 let width = table_column_width(area, &widths, column_index);
1289 match column.key {
1290 EventColumn::Time => muted_cell(format_system_time(request.finished_at)),
1291 EventColumn::Code => http_code_cell(request.http_status),
1292 EventColumn::Project => {
1293 text_cell(ellipsize(request.project.as_deref().unwrap_or("-"), width))
1294 }
1295 EventColumn::Session => {
1296 text_cell(display_session_id(request.session_id.as_deref()))
1297 }
1298 EventColumn::Provider => provider_cell(request.provider.as_deref()),
1299 EventColumn::Model => model_cell(request.model.as_deref(), width),
1300 EventColumn::Message => detail_cell(message),
1301 }
1302 })
1303 .collect::<Vec<_>>();
1304 Row::new(cells).style(Style::default().bg(PANEL_BG))
1305 });
1306 let table = Table::new(rows, widths.clone())
1307 .header(column_header(&columns))
1308 .block(panel("Events", false));
1309 frame.render_widget(table, area);
1310}
1311
1312fn render_session_detail(
1313 frame: &mut ratatui::Frame<'_>,
1314 area: Rect,
1315 state: &MonitorState,
1316 selected: usize,
1317) {
1318 let lines = if let Some(session) = state.sessions.get(selected) {
1319 vec![
1320 detail_line("session", session.label(), WHITE),
1321 detail_line("project", session.project.as_deref().unwrap_or("-"), TEAL),
1322 detail_line("active requests", session.active_count.to_string(), YELLOW),
1323 detail_line(
1324 "total requests",
1325 session.request_count.to_string(),
1326 DIM_WHITE,
1327 ),
1328 detail_line("failures", session.failure_count.to_string(), RED),
1329 detail_line("provider", session.provider.as_deref().unwrap_or("-"), TEAL),
1330 detail_line("model", session.model.as_deref().unwrap_or("-"), DIM_WHITE),
1331 detail_line("effort", session.effort.as_deref().unwrap_or("-"), YELLOW),
1332 detail_line(
1333 "input tokens",
1334 compact_tokens(session.input_tokens),
1335 DIM_WHITE,
1336 ),
1337 detail_line(
1338 "output tokens",
1339 compact_tokens(session.output_tokens),
1340 DIM_WHITE,
1341 ),
1342 detail_line(
1343 "total tokens",
1344 format!(
1345 "{}/{}",
1346 compact_tokens(session.input_tokens),
1347 compact_tokens(session.output_tokens)
1348 ),
1349 DIM_WHITE,
1350 ),
1351 detail_line("rate", session.rate().label(), TEAL),
1352 detail_line(
1353 "last status",
1354 session.last_status.as_str(),
1355 status_color(&session.last_status),
1356 ),
1357 ]
1358 } else {
1359 vec![Line::from(Span::styled(
1360 "No session selected",
1361 Style::default().fg(DIM),
1362 ))]
1363 };
1364 frame.render_widget(
1365 Paragraph::new(lines)
1366 .style(Style::default().bg(PANEL_BG))
1367 .block(panel("Session detail", true)),
1368 area,
1369 );
1370}
1371
1372fn render_request_detail(
1373 frame: &mut ratatui::Frame<'_>,
1374 area: Rect,
1375 state: &MonitorState,
1376 selected: usize,
1377) {
1378 let lines = if let Some(request) = state.recent.get(selected) {
1379 let mut lines = vec![
1380 detail_line("request", request.request_id.clone(), WHITE),
1381 detail_line(
1382 "session",
1383 display_session_id(request.session_id.as_deref()),
1384 TEAL,
1385 ),
1386 detail_line(
1387 "session seq",
1388 request
1389 .session_seq
1390 .map(|seq| seq.to_string())
1391 .unwrap_or_else(|| "-".to_string()),
1392 DIM_WHITE,
1393 ),
1394 detail_line("endpoint", request.endpoint.label(), DIM_WHITE),
1395 detail_line("started", format_system_time(request.started_at), DIM_WHITE),
1396 detail_line(
1397 "finished",
1398 format_system_time(request.finished_at),
1399 DIM_WHITE,
1400 ),
1401 detail_line(
1402 "status",
1403 request.status.label(),
1404 status_color(request.status.label()),
1405 ),
1406 detail_line(
1407 "http status",
1408 request
1409 .http_status
1410 .map(|status| status.to_string())
1411 .unwrap_or_else(|| "-".to_string()),
1412 http_status_color(request.http_status),
1413 ),
1414 detail_line("provider", request.provider.as_deref().unwrap_or("-"), TEAL),
1415 detail_line("model", request.model.as_deref().unwrap_or("-"), DIM_WHITE),
1416 detail_line("effort", request.effort.as_deref().unwrap_or("-"), YELLOW),
1417 detail_line("latency", format_duration(request.latency), DIM_WHITE),
1418 detail_line("rate", request.rate().label(), TEAL),
1419 detail_line("input tokens", token_value(request.input_tokens), DIM_WHITE),
1420 detail_line(
1421 "output tokens",
1422 token_value(request.output_tokens),
1423 DIM_WHITE,
1424 ),
1425 detail_line(
1426 "stream bytes",
1427 request.streamed_bytes.to_string(),
1428 DIM_WHITE,
1429 ),
1430 detail_line(
1431 "stream chunks",
1432 request.stream_chunks.to_string(),
1433 DIM_WHITE,
1434 ),
1435 ];
1436 if let Some(error) = request.error.as_deref().filter(|error| !error.is_empty()) {
1437 lines.push(detail_line("detail", error, YELLOW));
1438 }
1439 if let Some(path) = &request.traffic_capture_path {
1440 lines.push(detail_line(
1441 "capture",
1442 path.to_string_lossy().into_owned(),
1443 DIM_WHITE,
1444 ));
1445 }
1446 lines
1447 } else {
1448 vec![Line::from(Span::styled(
1449 "No request selected",
1450 Style::default().fg(DIM),
1451 ))]
1452 };
1453 frame.render_widget(
1454 Paragraph::new(lines)
1455 .style(Style::default().bg(PANEL_BG))
1456 .block(panel("Request detail", true)),
1457 area,
1458 );
1459}
1460
1461fn detail_line<'a>(label: &'static str, value: impl Into<String>, value_color: Color) -> Line<'a> {
1462 Line::from(vec![
1463 Span::styled(format!(" {label:<16}"), Style::default().fg(DIM)),
1464 Span::styled(value.into(), Style::default().fg(value_color)),
1465 ])
1466}
1467
1468fn render_footer(frame: &mut ratatui::Frame<'_>, area: Rect, _app: &MonitorApp) {
1469 let spans = vec![
1470 Span::raw(" "),
1471 Span::styled("q", Style::default().fg(TEAL)),
1472 Span::styled(" quit ", Style::default().fg(DIM)),
1473 Span::styled("?", Style::default().fg(TEAL)),
1474 Span::styled(" help ", Style::default().fg(DIM)),
1475 Span::styled("b", Style::default().fg(TEAL)),
1476 Span::styled(" setup ", Style::default().fg(DIM)),
1477 Span::styled("arrows/j/k", Style::default().fg(TEAL)),
1478 Span::styled(" navigate ", Style::default().fg(DIM)),
1479 Span::styled("Tab", Style::default().fg(TEAL)),
1480 Span::styled(" pane ", Style::default().fg(DIM)),
1481 Span::styled("Enter", Style::default().fg(TEAL)),
1482 Span::styled(" open", Style::default().fg(DIM)),
1483 ];
1484 frame.render_widget(
1485 Paragraph::new(Line::from(spans)).style(Style::default().bg(BG)),
1486 area,
1487 );
1488}
1489
1490fn render_shutdown_confirmation(frame: &mut ratatui::Frame<'_>, area: Rect) {
1491 let width = 44.min(area.width);
1492 let height = 5.min(area.height);
1493 let popup = Rect {
1494 x: area.x + area.width.saturating_sub(width) / 2,
1495 y: area.y + area.height.saturating_sub(height) / 2,
1496 width,
1497 height,
1498 };
1499 frame.render_widget(Clear, popup);
1500 frame.render_widget(
1501 Paragraph::new(vec![
1502 Line::from(Span::styled(
1503 "Shut down proxy?",
1504 Style::default().fg(WHITE).add_modifier(Modifier::BOLD),
1505 )),
1506 Line::from(vec![
1507 Span::styled("y", Style::default().fg(TEAL)),
1508 Span::styled(" confirm ", Style::default().fg(DIM_WHITE)),
1509 Span::styled("n/Esc/q", Style::default().fg(TEAL)),
1510 Span::styled(" cancel", Style::default().fg(DIM_WHITE)),
1511 ]),
1512 ])
1513 .alignment(Alignment::Center)
1514 .block(
1515 Block::default()
1516 .borders(Borders::ALL)
1517 .border_type(BorderType::Rounded)
1518 .border_style(Style::default().fg(YELLOW))
1519 .style(Style::default().bg(BG)),
1520 )
1521 .style(Style::default().bg(BG)),
1522 popup,
1523 );
1524}
1525
1526fn render_shutdown_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, tick: usize) {
1527 let width = 40.min(area.width);
1528 let height = 5.min(area.height);
1529 let popup = Rect {
1530 x: area.x + area.width.saturating_sub(width) / 2,
1531 y: area.y + area.height.saturating_sub(height) / 2,
1532 width,
1533 height,
1534 };
1535 frame.render_widget(Clear, popup);
1536 frame.render_widget(
1537 Paragraph::new(vec![
1538 Line::from(vec![
1539 Span::styled(format!("{} ", spinner(tick)), Style::default().fg(TEAL)),
1540 Span::styled(
1541 "Shutting down...",
1542 Style::default().fg(WHITE).add_modifier(Modifier::BOLD),
1543 ),
1544 ]),
1545 Line::from(Span::styled(
1546 "Press Ctrl-C to force quit",
1547 Style::default().fg(DIM_WHITE),
1548 )),
1549 ])
1550 .alignment(Alignment::Center)
1551 .block(
1552 Block::default()
1553 .borders(Borders::ALL)
1554 .border_type(BorderType::Rounded)
1555 .border_style(Style::default().fg(TEAL))
1556 .style(Style::default().bg(BG)),
1557 )
1558 .style(Style::default().bg(BG)),
1559 popup,
1560 );
1561}
1562
1563fn render_help_overlay(frame: &mut ratatui::Frame<'_>, area: Rect) {
1564 let width = 48.min(area.width.saturating_sub(4)).max(24);
1565 let height = 12.min(area.height.saturating_sub(2)).max(8);
1566 let popup = Rect {
1567 x: area.x + area.width.saturating_sub(width) / 2,
1568 y: area.y + area.height.saturating_sub(height) / 2,
1569 width,
1570 height,
1571 };
1572 frame.render_widget(Clear, popup);
1573 let block = Block::default()
1574 .title(Span::styled(" Shortcuts ", Style::default().fg(TEAL)))
1575 .borders(Borders::ALL)
1576 .border_type(BorderType::Rounded)
1577 .border_style(Style::default().fg(TEAL))
1578 .style(Style::default().bg(BG));
1579 let inner = block.inner(popup);
1580 frame.render_widget(block, popup);
1581 let lines = [
1582 ("q / Ctrl-C", "quit proxy"),
1583 ("?", "toggle help"),
1584 ("b", "toggle setup"),
1585 ("arrows", "navigate rows and panes"),
1586 ("j / k", "previous / next row"),
1587 ("Tab", "switch pane"),
1588 ("Enter", "open detail"),
1589 ("Esc", "close overlay / detail"),
1590 ];
1591 let content = lines
1592 .into_iter()
1593 .map(|(key, label)| {
1594 Line::from(vec![
1595 Span::raw(" "),
1596 Span::styled(format!("{key:<10}"), Style::default().fg(TEAL)),
1597 Span::styled(label, Style::default().fg(DIM_WHITE)),
1598 ])
1599 })
1600 .collect::<Vec<_>>();
1601 frame.render_widget(
1602 Paragraph::new(content).style(Style::default().bg(BG)),
1603 inner,
1604 );
1605}
1606
1607fn render_setup_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, setup_text: &str) {
1608 let width = 84.min(area.width.saturating_sub(4)).max(36);
1609 let content_height = setup_text.lines().count() as u16;
1610 let height = (content_height + 4)
1611 .min(area.height.saturating_sub(2))
1612 .max(8);
1613 let popup = Rect {
1614 x: area.x + area.width.saturating_sub(width) / 2,
1615 y: area.y + area.height.saturating_sub(height) / 2,
1616 width,
1617 height,
1618 };
1619 frame.render_widget(Clear, popup);
1620 let block = Block::default()
1621 .title(Span::styled(" Setup ", Style::default().fg(TEAL)))
1622 .borders(Borders::ALL)
1623 .border_type(BorderType::Rounded)
1624 .border_style(Style::default().fg(TEAL))
1625 .style(Style::default().bg(BG));
1626 let inner = block.inner(popup);
1627 frame.render_widget(block, popup);
1628
1629 let mut lines = setup_text
1630 .lines()
1631 .map(|line| {
1632 let style = if line.starts_with("export ") {
1633 Style::default().fg(WHITE)
1634 } else {
1635 Style::default().fg(DIM_WHITE)
1636 };
1637 Line::from(vec![Span::raw(" "), Span::styled(line.to_string(), style)])
1638 })
1639 .collect::<Vec<_>>();
1640 lines.push(Line::from(""));
1641 lines.push(Line::from(vec![
1642 Span::raw(" "),
1643 Span::styled("Esc", Style::default().fg(TEAL)),
1644 Span::styled(" close ", Style::default().fg(DIM)),
1645 Span::styled("b", Style::default().fg(TEAL)),
1646 Span::styled(" toggle setup", Style::default().fg(DIM)),
1647 ]));
1648 frame.render_widget(
1649 Paragraph::new(lines)
1650 .style(Style::default().bg(BG))
1651 .wrap(Wrap { trim: false }),
1652 inner,
1653 );
1654}
1655
1656fn mock_setup_text(port: u16, registry: &Registry) -> String {
1657 format!(
1658 "Mock mode uses deterministic simulated monitor traffic.\nNo proxy server is listening.\nRun `claude-codex serve` to start the proxy.\n\n{}",
1659 setup_text(port, registry)
1660 )
1661}
1662
1663pub fn setup_text(port: u16, registry: &Registry) -> String {
1664 let grouped = registry.grouped_models();
1665 let model_summary = ["codex", "kimi", "cursor"]
1666 .into_iter()
1667 .filter_map(|provider| {
1668 grouped
1669 .get(provider)
1670 .map(|models| format!("{provider}: {} models", models.len()))
1671 })
1672 .collect::<Vec<_>>()
1673 .join(" ");
1674 let mut lines = vec![
1675 format!("Logs: {}", paths::log_file().display()),
1676 format!("Config: {}", paths::config_dir().display()),
1677 format!("Providers: {model_summary}"),
1678 ];
1679 lines.push(format!(
1680 "export ANTHROPIC_BASE_URL=\"http://localhost:{port}\""
1681 ));
1682 lines.push("export ANTHROPIC_AUTH_TOKEN=\"anything\"".to_string());
1683 lines.push("export ANTHROPIC_MODEL=\"gpt-5.6-sol\"".to_string());
1684 lines.push("export ANTHROPIC_SMALL_FAST_MODEL=\"gpt-5.6-luna\"".to_string());
1685 lines.push("export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1".to_string());
1686 lines.join("\n")
1687}
1688
1689fn format_duration(duration: Duration) -> String {
1690 let total = duration.as_secs();
1691 let hours = total / 3600;
1692 let minutes = (total % 3600) / 60;
1693 let seconds = total % 60;
1694 if hours > 0 {
1695 format!("{hours}h{minutes:02}m")
1696 } else if minutes > 0 {
1697 format!("{minutes}m{seconds:02}s")
1698 } else {
1699 format!("{seconds}s")
1700 }
1701}
1702
1703fn format_system_time(time: SystemTime) -> String {
1704 format_system_time_in_zone(time, TimeZone::system())
1705}
1706
1707fn format_system_time_in_zone(time: SystemTime, time_zone: TimeZone) -> String {
1708 let Ok(timestamp) = Timestamp::try_from(time) else {
1709 return "-".to_string();
1710 };
1711 Zoned::new(timestamp, time_zone)
1712 .strftime("%H:%M:%S")
1713 .to_string()
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use ratatui::{backend::TestBackend, buffer::Buffer};
1719
1720 use super::*;
1721 use crate::monitor::{EndpointKind, mock_state};
1722
1723 fn draw(width: u16, height: u16, render: impl FnOnce(&mut ratatui::Frame<'_>)) -> Buffer {
1724 let backend = TestBackend::new(width, height);
1725 let mut terminal = Terminal::new(backend).unwrap();
1726 terminal.draw(render).unwrap();
1727 terminal.backend().buffer().clone()
1728 }
1729
1730 fn buffer_text(buffer: &Buffer) -> String {
1731 (0..buffer.area.height)
1732 .map(|y| {
1733 (0..buffer.area.width)
1734 .map(|x| buffer[(x, y)].symbol())
1735 .collect::<String>()
1736 })
1737 .collect::<Vec<_>>()
1738 .join("\n")
1739 }
1740
1741 fn placeholder_position(buffer: &Buffer, placeholder: &str) -> Option<(u16, u16)> {
1742 let symbols = placeholder
1743 .chars()
1744 .map(|character| character.to_string())
1745 .collect::<Vec<_>>();
1746 (0..buffer.area.height).find_map(|y| {
1747 (0..buffer.area.width).find_map(|x| {
1748 symbols
1749 .iter()
1750 .enumerate()
1751 .all(|(offset, symbol)| {
1752 x + (offset as u16) < buffer.area.width
1753 && buffer[(x + offset as u16, y)].symbol() == symbol
1754 })
1755 .then_some((x, y))
1756 })
1757 })
1758 }
1759
1760 fn assert_centered(buffer: &Buffer, placeholder: &str, expected_y: u16) {
1761 let (x, y) = placeholder_position(buffer, placeholder).unwrap();
1762 let left_space = x.saturating_sub(1);
1763 let right_space = buffer
1764 .area
1765 .width
1766 .saturating_sub(x + placeholder.chars().count() as u16 + 1);
1767 assert_eq!(y, expected_y);
1768 assert!(left_space.abs_diff(right_space) <= 1);
1769 }
1770
1771 fn headers<K>(columns: &[ColumnSpec<K>]) -> Vec<&'static str> {
1772 columns.iter().map(|column| column.header).collect()
1773 }
1774
1775 fn fixed_budget<K>(columns: &[ColumnSpec<K>]) -> u16 {
1776 let widths = columns
1777 .iter()
1778 .map(|column| match column.width {
1779 layout::ColumnWidth::Fixed(width) => width,
1780 layout::ColumnWidth::Flex(_) => 0,
1781 })
1782 .sum::<u16>();
1783 widths.saturating_add(columns.len().saturating_sub(1) as u16)
1784 }
1785
1786 fn fixed_width<K: Copy + PartialEq>(columns: &[ColumnSpec<K>], key: K) -> Option<u16> {
1787 columns
1788 .iter()
1789 .find(|column| column.key == key)
1790 .and_then(|column| match column.width {
1791 layout::ColumnWidth::Fixed(width) => Some(width),
1792 layout::ColumnWidth::Flex(_) => None,
1793 })
1794 }
1795
1796 fn flex_count<K>(columns: &[ColumnSpec<K>]) -> usize {
1797 columns
1798 .iter()
1799 .filter(|column| matches!(column.width, layout::ColumnWidth::Flex(_)))
1800 .count()
1801 }
1802
1803 fn alignment<K: Copy + PartialEq>(columns: &[ColumnSpec<K>], key: K) -> Option<Alignment> {
1804 columns
1805 .iter()
1806 .find(|column| column.key == key)
1807 .map(|column| column.alignment)
1808 }
1809
1810 #[test]
1811 fn format_system_time_applies_non_utc_time_zone() {
1812 let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(20 * 60 * 60);
1813 let time_zone = TimeZone::fixed(jiff::tz::offset(5));
1814
1815 assert_eq!(format_system_time_in_zone(timestamp, time_zone), "01:00:00");
1816 }
1817
1818 #[test]
1819 fn request_tables_share_time_status_provider_model_rhythm() {
1820 assert_eq!(
1821 headers(&active_columns(LayoutTier::Medium))[..4],
1822 ["Started", "Status", "Provider", "Model"]
1823 );
1824 assert_eq!(
1825 headers(&recent_columns(LayoutTier::Medium))[..4],
1826 ["Finished", "Code", "Provider", "Model"]
1827 );
1828 assert_eq!(
1829 headers(&event_columns(LayoutTier::Medium))[..4],
1830 ["Time", "Code", "Provider", "Model"]
1831 );
1832 }
1833
1834 #[test]
1835 fn wide_tables_use_shared_model_and_provider_widths() {
1836 let sessions = session_columns(LayoutTier::Wide, true);
1837 let active = active_columns(LayoutTier::Wide);
1838 let recent = recent_columns(LayoutTier::Wide);
1839 let events = event_columns(LayoutTier::Wide);
1840
1841 assert_eq!(
1842 fixed_width(&sessions, SessionColumn::Model),
1843 Some(MODEL_WIDE_WIDTH)
1844 );
1845 assert_eq!(
1846 fixed_width(&active, ActiveColumn::Model),
1847 Some(MODEL_WIDE_WIDTH)
1848 );
1849 assert_eq!(
1850 fixed_width(&recent, RecentColumn::Model),
1851 Some(MODEL_WIDE_WIDTH)
1852 );
1853 assert_eq!(
1854 fixed_width(&events, EventColumn::Model),
1855 Some(MODEL_WIDE_WIDTH)
1856 );
1857 assert_eq!(
1858 fixed_width(&active, ActiveColumn::Provider),
1859 Some(PROVIDER_WIDTH)
1860 );
1861 assert_eq!(
1862 fixed_width(&recent, RecentColumn::Provider),
1863 Some(PROVIDER_WIDTH)
1864 );
1865 }
1866
1867 #[test]
1868 fn responsive_schemas_fit_their_minimum_terminal_widths() {
1869 assert!(fixed_budget(&session_columns(LayoutTier::Emergency, false)) <= 75);
1870 assert!(fixed_budget(&session_columns(LayoutTier::Narrow, false)) <= 76);
1871 assert!(fixed_budget(&session_columns(LayoutTier::Medium, false)) <= 88);
1872 assert!(fixed_budget(&session_columns(LayoutTier::Expanded, false)) <= 118);
1873 assert!(fixed_budget(&session_columns(LayoutTier::Wide, true)) <= 168);
1874
1875 assert!(fixed_budget(&active_columns(LayoutTier::Emergency)) <= 75);
1876 assert!(fixed_budget(&active_columns(LayoutTier::Narrow)) <= 76);
1877 assert!(fixed_budget(&active_columns(LayoutTier::Medium)) <= 88);
1878 assert!(fixed_budget(&active_columns(LayoutTier::Expanded)) <= 118);
1879 assert!(fixed_budget(&active_columns(LayoutTier::Wide)) <= 152);
1880
1881 assert!(fixed_budget(&recent_columns(LayoutTier::Emergency)) <= 75);
1882 assert!(fixed_budget(&recent_columns(LayoutTier::Narrow)) <= 76);
1883 assert!(fixed_budget(&recent_columns(LayoutTier::Medium)) <= 88);
1884 assert!(fixed_budget(&recent_columns(LayoutTier::Expanded)) <= 118);
1885 assert!(fixed_budget(&recent_columns(LayoutTier::Wide)) <= 152);
1886
1887 assert!(fixed_budget(&event_columns(LayoutTier::Emergency)) <= 75);
1888 assert!(fixed_budget(&event_columns(LayoutTier::Narrow)) <= 76);
1889 assert!(fixed_budget(&event_columns(LayoutTier::Medium)) <= 88);
1890 assert!(fixed_budget(&event_columns(LayoutTier::Expanded)) <= 118);
1891 assert!(fixed_budget(&event_columns(LayoutTier::Wide)) <= 152);
1892 }
1893
1894 #[test]
1895 fn active_table_renders_expected_headers_at_tier_boundaries() {
1896 let state = mock_state();
1897 let render_at = |width| {
1898 let buffer = draw(width, 8, |frame| {
1899 render_active(frame, frame.area(), &state.active, 0)
1900 });
1901 buffer_text(&buffer)
1902 };
1903
1904 let emergency = render_at(77);
1905 assert!(emergency.contains("Started"), "{emergency}");
1906 assert!(emergency.contains("Target"), "{emergency}");
1907 assert!(!emergency.contains("Rate"), "{emergency}");
1908
1909 let narrow = render_at(78);
1910 assert!(narrow.contains("Provider"), "{narrow}");
1911 assert!(narrow.contains("Model"), "{narrow}");
1912 assert!(narrow.contains("Effort"), "{narrow}");
1913 assert!(!narrow.contains("Project"), "{narrow}");
1914
1915 let medium = render_at(90);
1916 assert!(medium.contains("Provider"), "{medium}");
1917 assert!(medium.contains("Model"), "{medium}");
1918 assert!(medium.contains("Endpoint"), "{medium}");
1919 assert!(!medium.contains("Project"), "{medium}");
1920
1921 let expanded = render_at(120);
1922 assert!(expanded.contains("Project"), "{expanded}");
1923 assert!(expanded.contains("Session"), "{expanded}");
1924 assert!(expanded.contains("Endpoint"), "{expanded}");
1925 assert!(!expanded.contains("In"), "{expanded}");
1926
1927 let wide = render_at(154);
1928 assert!(wide.contains("Project"), "{wide}");
1929 assert!(wide.contains("Session"), "{wide}");
1930 assert!(wide.contains("In"), "{wide}");
1931 assert!(wide.contains("Out"), "{wide}");
1932 }
1933
1934 #[test]
1935 fn each_schema_has_one_meaningful_flexible_column() {
1936 for tier in [
1937 LayoutTier::Emergency,
1938 LayoutTier::Narrow,
1939 LayoutTier::Medium,
1940 LayoutTier::Expanded,
1941 LayoutTier::Wide,
1942 ] {
1943 let sessions = session_columns(tier, tier == LayoutTier::Wide);
1944 let active = active_columns(tier);
1945 let recent = recent_columns(tier);
1946 let events = event_columns(tier);
1947
1948 assert_eq!(flex_count(&sessions), 1);
1949 assert_eq!(flex_count(&active), 1);
1950 assert_eq!(flex_count(&recent), 1);
1951 assert_eq!(flex_count(&events), 1);
1952 assert_eq!(
1953 sessions
1954 .iter()
1955 .filter(|column| column.header.is_empty())
1956 .count(),
1957 1
1958 );
1959 assert!(active.iter().all(|column| !column.header.is_empty()));
1960 assert!(recent.iter().all(|column| !column.header.is_empty()));
1961 assert!(events.iter().all(|column| !column.header.is_empty()));
1962 }
1963 }
1964
1965 #[test]
1966 fn metric_columns_are_right_aligned() {
1967 let sessions = session_columns(LayoutTier::Wide, true);
1968 for key in [
1969 SessionColumn::Active,
1970 SessionColumn::Requests,
1971 SessionColumn::Failures,
1972 SessionColumn::Input,
1973 SessionColumn::Output,
1974 SessionColumn::Rate,
1975 ] {
1976 assert_eq!(alignment(&sessions, key), Some(Alignment::Right));
1977 }
1978
1979 let active = active_columns(LayoutTier::Wide);
1980 for key in [
1981 ActiveColumn::Input,
1982 ActiveColumn::Output,
1983 ActiveColumn::Rate,
1984 ActiveColumn::Elapsed,
1985 ] {
1986 assert_eq!(alignment(&active, key), Some(Alignment::Right));
1987 }
1988
1989 let recent = recent_columns(LayoutTier::Wide);
1990 for key in [
1991 RecentColumn::Code,
1992 RecentColumn::Latency,
1993 RecentColumn::Rate,
1994 RecentColumn::Input,
1995 RecentColumn::Output,
1996 ] {
1997 assert_eq!(alignment(&recent, key), Some(Alignment::Right));
1998 }
1999 }
2000
2001 #[test]
2002 fn narrow_schemas_use_available_space_for_context() {
2003 let sessions = session_columns(LayoutTier::Narrow, false);
2004 assert!(
2005 sessions
2006 .iter()
2007 .any(|column| column.key == SessionColumn::Project)
2008 );
2009 assert!(
2010 sessions
2011 .iter()
2012 .any(|column| column.key == SessionColumn::Target)
2013 );
2014
2015 let active = active_columns(LayoutTier::Narrow);
2016 assert!(
2017 active
2018 .iter()
2019 .any(|column| column.key == ActiveColumn::Provider)
2020 );
2021 assert!(
2022 active
2023 .iter()
2024 .any(|column| column.key == ActiveColumn::Model)
2025 );
2026 assert!(
2027 active
2028 .iter()
2029 .any(|column| column.key == ActiveColumn::Effort)
2030 );
2031
2032 let recent = recent_columns(LayoutTier::Narrow);
2033 assert!(
2034 recent
2035 .iter()
2036 .any(|column| column.key == RecentColumn::Provider)
2037 );
2038 assert!(
2039 recent
2040 .iter()
2041 .any(|column| column.key == RecentColumn::Input)
2042 );
2043 assert!(
2044 recent
2045 .iter()
2046 .any(|column| column.key == RecentColumn::Output)
2047 );
2048
2049 let events = event_columns(LayoutTier::Emergency);
2050 assert_eq!(headers(&events), ["Time", "Code", "Message"]);
2051 }
2052
2053 #[test]
2054 fn display_session_id_shortens_uuids() {
2055 assert_eq!(
2056 display_session_id(Some("57c7c914-ada4-4f40-9672-985f950fbb66")),
2057 "57c7c914"
2058 );
2059 }
2060
2061 #[test]
2062 fn display_session_id_handles_atypical_ids() {
2063 assert_eq!(display_session_id(Some("custom-session")), "custom-session");
2064 assert_eq!(display_session_id(Some("")), "no-session");
2065 assert_eq!(display_session_id(None), "no-session");
2066 }
2067
2068 #[test]
2069 fn ellipsize_marks_truncated_values() {
2070 assert_eq!(ellipsize("claude-sonnet-4-6", 16), "claude-sonnet-4…");
2071 assert_eq!(ellipsize("gpt-5.6-sol", 16), "gpt-5.6-sol");
2072 assert_eq!(ellipsize("anything", 0), "");
2073 }
2074
2075 #[test]
2076 fn token_sparkline_uses_fixed_wall_clock_buckets() {
2077 let samples = [
2078 (SystemTime::UNIX_EPOCH + Duration::from_secs(78), 2_000),
2079 (SystemTime::UNIX_EPOCH + Duration::from_secs(85), 3_000),
2080 (SystemTime::UNIX_EPOCH + Duration::from_secs(100), 4_000),
2081 ];
2082 let bucket_start = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
2083 let bucket_end = SystemTime::UNIX_EPOCH + Duration::from_secs(109);
2084 let next_bucket = SystemTime::UNIX_EPOCH + Duration::from_secs(110);
2085
2086 assert_eq!(token_sparkline(&[], 4, bucket_start), " ");
2087 assert_eq!(token_sparkline(&samples, 4, bucket_start), "▄▆ █");
2088 assert_eq!(token_sparkline(&samples, 4, bucket_end), "▄▆ █");
2089 assert_eq!(token_sparkline(&samples, 4, next_bucket), "▆ █ ");
2090 assert_eq!(token_sparkline(&samples, 0, bucket_start), "");
2091 }
2092
2093 #[test]
2094 fn token_sparkline_uses_fixed_shared_scale() {
2095 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
2096 let current = (now, 2_000);
2097 let offscreen_peak = (SystemTime::UNIX_EPOCH, 4_000);
2098
2099 assert_eq!(token_sparkline(&[current], 2, now), " ▄");
2100 assert_eq!(token_sparkline(&[offscreen_peak, current], 2, now), " ▄");
2101 assert_eq!(token_sparkline(&[(now, 10_000)], 1, now), "█");
2102 }
2103
2104 #[test]
2105 fn token_sparkline_dims_the_current_bucket() {
2106 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100);
2107 let samples = [
2108 (SystemTime::UNIX_EPOCH + Duration::from_secs(95), 2_000),
2109 (now, 4_000),
2110 ];
2111
2112 let line = token_sparkline_line(&samples, 2, now);
2113
2114 assert_eq!(line.spans[0].content, "▄");
2115 assert_eq!(line.spans[0].style.fg, Some(BLUE));
2116 assert_eq!(line.spans[1].content, "█");
2117 assert_eq!(line.spans[1].style.fg, Some(DIM));
2118 }
2119
2120 #[test]
2121 fn session_sparkline_appears_at_medium_width_and_expands() {
2122 let monitor = MonitorHandle::new(10);
2123 for (index, tokens) in [1_000, 2_000, 4_000].into_iter().enumerate() {
2124 let request_id = format!("request-{index}");
2125 monitor.request_started(
2126 &request_id,
2127 Some("sess-1".to_string()),
2128 Some(index as u64 + 1),
2129 EndpointKind::Messages,
2130 );
2131 monitor.provider_selected(&request_id, "codex", "gpt-5.6-sol", None);
2132 monitor.request_completed(&request_id, 200, Some(100), Some(tokens));
2133 }
2134 let state = monitor.snapshot();
2135 let render_at = |width| {
2136 let buffer = draw(width, 8, |frame| {
2137 render_sessions(frame, frame.area(), &state.sessions, 0, true)
2138 });
2139 buffer_text(&buffer)
2140 };
2141 let spark_chars = |text: &str| {
2142 text.chars()
2143 .filter(|ch| matches!(ch, '▁' | '▂' | '▃' | '▄' | '▅' | '▆' | '▇' | '█'))
2144 .count()
2145 };
2146
2147 let emergency = render_at(77);
2148 assert!(!emergency.contains("Trend"), "{emergency}");
2149 assert_eq!(spark_chars(&emergency), 0, "{emergency}");
2150
2151 let narrow = render_at(78);
2152 assert!(narrow.contains("Trend"), "{narrow}");
2153 assert!(spark_chars(&narrow) > 0, "{narrow}");
2154
2155 let medium = render_at(90);
2156 assert!(medium.contains("Tok/10s"), "{medium}");
2157 assert!(spark_chars(&medium) > 0, "{medium}");
2158
2159 let expanded = render_at(120);
2160 assert!(expanded.contains("Tokens/10s"), "{expanded}");
2161 assert!(spark_chars(&expanded) > 0, "{expanded}");
2162
2163 let wide = render_at(SESSION_SPARKLINE_MIN_WIDTH);
2164 assert!(wide.contains("Tokens/10s · 4k"), "{wide}");
2165 assert!(spark_chars(&wide) > 0, "{wide}");
2166 }
2167
2168 #[test]
2169 fn empty_tables_hide_columns_and_center_placeholders() {
2170 let sessions = draw(40, 9, |frame| {
2171 render_sessions(frame, frame.area(), &[], 0, true)
2172 });
2173 let sessions_text = buffer_text(&sessions);
2174 assert_centered(&sessions, "No sessions", 4);
2175 assert!(!sessions_text.contains("provider"));
2176 assert!(sessions_text.contains("No sessions"));
2177
2178 let active = draw(27, 6, |frame| render_active(frame, frame.area(), &[], 0));
2179 let active_text = buffer_text(&active);
2180 assert_centered(&active, "No active requests", 2);
2181 assert!(!active_text.contains("started"));
2182 assert!(active_text.contains("No active requests"));
2183
2184 let recent = draw(40, 9, |frame| {
2185 render_recent(frame, frame.area(), &[], 0, false)
2186 });
2187 let recent_text = buffer_text(&recent);
2188 assert_centered(&recent, "No recent requests", 4);
2189 assert!(!recent_text.contains("finished"));
2190 assert!(recent_text.contains("No recent requests"));
2191
2192 let events = draw(40, 9, |frame| render_events(frame, frame.area(), &[]));
2193 let events_text = buffer_text(&events);
2194 assert_centered(&events, "No events", 4);
2195 assert!(!events_text.contains("time"));
2196 assert!(events_text.contains("No events"));
2197 }
2198
2199 #[test]
2200 fn active_status_keeps_full_label_at_narrow_width() {
2201 let monitor = MonitorHandle::new(10);
2202 monitor.request_started("request-1", None, None, EndpointKind::Messages);
2203 monitor.upstream_started("request-1");
2204 let state = monitor.snapshot();
2205
2206 let active = draw(88, 6, |frame| {
2207 render_active(frame, frame.area(), &state.active, 0)
2208 });
2209
2210 let active_text = buffer_text(&active);
2211 assert!(active_text.contains("⠋ upstream"), "{active_text}");
2212 }
2213
2214 #[test]
2215 fn active_compacting_status_is_distinct_at_narrow_width() {
2216 let monitor = MonitorHandle::new(10);
2217 monitor.request_started("request-1", None, None, EndpointKind::Messages);
2218 monitor.compaction_started("request-1");
2219 let state = monitor.snapshot();
2220
2221 let active = draw(88, 6, |frame| {
2222 render_active(frame, frame.area(), &state.active, 0)
2223 });
2224
2225 let active_text = buffer_text(&active);
2226 assert!(active_text.contains("⠋compacting"), "{active_text}");
2227 assert_eq!(status_color("compacting"), PURPLE);
2228 }
2229
2230 #[test]
2231 fn populated_tables_render_rows_without_placeholders() {
2232 let monitor = MonitorHandle::new(10);
2233 monitor.request_started(
2234 "request-1",
2235 Some("sess-1".to_string()),
2236 Some(1),
2237 EndpointKind::Messages,
2238 );
2239 monitor.project_resolved("request-1", "example-project");
2240 monitor.provider_selected(
2241 "request-1",
2242 "codex",
2243 "gpt-5.6-sol",
2244 Some("high".to_string()),
2245 );
2246 let active_state = monitor.snapshot();
2247
2248 let sessions = draw(170, 8, |frame| {
2249 render_sessions(frame, frame.area(), &active_state.sessions, 0, true)
2250 });
2251 let sessions_text = buffer_text(&sessions);
2252 assert!(sessions_text.contains("Provider"));
2253 assert!(sessions_text.contains("Project"));
2254 assert!(sessions_text.contains("example-project"));
2255 assert!(sessions_text.contains("sess-1"));
2256 assert!(!sessions_text.contains("No sessions"));
2257
2258 let active = draw(120, 8, |frame| {
2259 render_active(frame, frame.area(), &active_state.active, 0)
2260 });
2261 let active_text = buffer_text(&active);
2262 assert!(active_text.contains("Started"));
2263 assert!(active_text.contains("gpt-5.6-sol"));
2264 assert!(!active_text.contains("No active requests"));
2265
2266 monitor.request_completed("request-1", 200, Some(100), Some(25));
2267 let completed_state = monitor.snapshot();
2268 let recent = draw(140, 8, |frame| {
2269 render_recent(frame, frame.area(), &completed_state.recent, 0, false)
2270 });
2271 let recent_text = buffer_text(&recent);
2272 assert!(recent_text.contains("Finished"));
2273 assert!(recent_text.contains("200"));
2274 assert!(!recent_text.contains("No recent requests"));
2275
2276 let events = draw(100, 8, |frame| {
2277 render_events(frame, frame.area(), &completed_state.recent)
2278 });
2279 assert!(buffer_text(&events).contains("No events"));
2280 }
2281
2282 #[test]
2283 fn selected_rows_scroll_into_table_viewports() {
2284 let state = mock_state();
2285 let sessions = (0..12)
2286 .map(|index| {
2287 let mut session = state.sessions[0].clone();
2288 session.session_id = Some(format!("row-{index:04}"));
2289 session
2290 })
2291 .collect::<Vec<_>>();
2292 let session_buffer = draw(120, 6, |frame| {
2293 render_sessions(frame, frame.area(), &sessions, 11, true)
2294 });
2295 let session_text = buffer_text(&session_buffer);
2296 assert!(session_text.contains("row-0011"), "{session_text}");
2297 assert!(!session_text.contains("row-0000"), "{session_text}");
2298
2299 let recent = (0..12)
2300 .map(|index| {
2301 let mut request = state.recent[0].clone();
2302 request.project = Some(format!("row-{index:04}"));
2303 request
2304 })
2305 .collect::<Vec<_>>();
2306 let recent_buffer = draw(120, 6, |frame| {
2307 render_recent(frame, frame.area(), &recent, 11, true)
2308 });
2309 let recent_text = buffer_text(&recent_buffer);
2310 assert!(recent_text.contains("row-0011"), "{recent_text}");
2311 assert!(!recent_text.contains("row-0000"), "{recent_text}");
2312 }
2313
2314 #[test]
2315 fn mock_state_renders_representative_panes_at_wide_width() {
2316 let state = mock_state();
2317 let mut app = MonitorApp {
2318 listen_url: "mock://tui-demo".to_string(),
2319 setup_text: String::new(),
2320 show_setup: false,
2321 show_help: false,
2322 detail: None,
2323 focus: FocusPane::Sessions,
2324 selected: 0,
2325 recent_selected: 0,
2326 tick: 0,
2327 phase: MonitorPhase::Running,
2328 shutdown: None,
2329 shutdown_complete: None,
2330 };
2331
2332 let buffer = draw(180, 48, |frame| render(frame, &mut app, &state));
2333 let text = buffer_text(&buffer);
2334
2335 assert!(text.contains("mock://tui-demo"), "{text}");
2336 assert!(text.contains("claude-codex"), "{text}");
2337 assert!(text.contains("streaming"), "{text}");
2338 assert!(text.contains("gpt-5.6-terra"), "{text}");
2339 assert!(text.contains("upstream connection closed"), "{text}");
2340 }
2341
2342 #[test]
2343 fn mock_request_detail_exposes_error_and_capture_fields() {
2344 let state = mock_state();
2345 let failed = state
2346 .recent
2347 .iter()
2348 .position(|request| request.request_id == "req-failed-kimi")
2349 .unwrap();
2350
2351 let detail = draw(140, 22, |frame| {
2352 render_request_detail(frame, frame.area(), &state, failed)
2353 });
2354 let text = buffer_text(&detail);
2355
2356 assert!(text.contains("req-failed-kimi"), "{text}");
2357 assert!(text.contains("upstream connection closed"), "{text}");
2358 assert!(text.contains("req-failed-kimi.json"), "{text}");
2359 }
2360
2361 #[test]
2362 fn recent_table_uses_error_indicator_at_medium_width() {
2363 let monitor = MonitorHandle::new(10);
2364 monitor.request_started("request-1", None, None, EndpointKind::Messages);
2365 monitor.provider_selected("request-1", "codex", "gpt-5.6-sol", None);
2366 monitor.request_failed("request-1", Some(502), "upstream unavailable");
2367 let state = monitor.snapshot();
2368
2369 let recent = draw(110, 8, |frame| {
2370 render_recent(frame, frame.area(), &state.recent, 0, true)
2371 });
2372 let recent_text = buffer_text(&recent);
2373
2374 assert!(recent_text.contains("!"), "{recent_text}");
2375 assert!(!recent_text.contains("Details"), "{recent_text}");
2376 assert!(
2377 !recent_text.contains("upstream unavailable"),
2378 "{recent_text}"
2379 );
2380 }
2381
2382 #[test]
2383 fn recent_table_keeps_detail_text_at_wide_width() {
2384 let monitor = MonitorHandle::new(10);
2385 monitor.request_started("request-1", None, None, EndpointKind::Messages);
2386 monitor.provider_selected("request-1", "codex", "gpt-5.6-sol", None);
2387 monitor.request_failed("request-1", Some(502), "upstream unavailable");
2388 let state = monitor.snapshot();
2389
2390 let recent = draw(180, 8, |frame| {
2391 render_recent(frame, frame.area(), &state.recent, 0, false)
2392 });
2393 let recent_text = buffer_text(&recent);
2394
2395 assert!(recent_text.contains("Details"), "{recent_text}");
2396 assert!(
2397 recent_text.contains("upstream unavailable"),
2398 "{recent_text}"
2399 );
2400 }
2401
2402 #[test]
2403 fn request_detail_renders_full_error_text() {
2404 let monitor = MonitorHandle::new(10);
2405 monitor.request_started(
2406 "request-1",
2407 Some("sess-1".to_string()),
2408 Some(7),
2409 EndpointKind::Messages,
2410 );
2411 monitor.provider_selected(
2412 "request-1",
2413 "codex",
2414 "gpt-5.6-sol",
2415 Some("high".to_string()),
2416 );
2417 monitor.request_failed("request-1", Some(502), "upstream unavailable");
2418 let state = monitor.snapshot();
2419
2420 let detail = draw(120, 20, |frame| {
2421 render_request_detail(frame, frame.area(), &state, 0)
2422 });
2423 let detail_text = buffer_text(&detail);
2424
2425 assert!(detail_text.contains("Request detail"), "{detail_text}");
2426 assert!(detail_text.contains("request-1"), "{detail_text}");
2427 assert!(detail_text.contains("sess-1"), "{detail_text}");
2428 assert!(
2429 detail_text.contains("upstream unavailable"),
2430 "{detail_text}"
2431 );
2432 }
2433
2434 #[test]
2435 fn events_render_matching_request_rows_without_a_placeholder() {
2436 let monitor = MonitorHandle::new(10);
2437 monitor.request_started("request-1", None, None, EndpointKind::Messages);
2438 monitor.request_failed("request-1", Some(502), "upstream unavailable");
2439 let state = monitor.snapshot();
2440
2441 let events = draw(100, 8, |frame| {
2442 render_events(frame, frame.area(), &state.recent)
2443 });
2444 let events_text = buffer_text(&events);
2445 assert!(events_text.contains("Time"));
2446 assert!(events_text.contains("502"));
2447 assert!(events_text.contains("upstream unavailable"));
2448 assert!(!events_text.contains("No events"));
2449 }
2450
2451 #[test]
2452 fn shutdown_confirmation_can_be_cancelled_before_signalling_server() {
2453 let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
2454 let mut app = MonitorApp {
2455 listen_url: "http://127.0.0.1:3000".to_string(),
2456 setup_text: String::new(),
2457 show_setup: false,
2458 show_help: false,
2459 detail: None,
2460 focus: FocusPane::Sessions,
2461 selected: 0,
2462 recent_selected: 0,
2463 tick: 0,
2464 phase: MonitorPhase::Running,
2465 shutdown: Some(shutdown_tx),
2466 shutdown_complete: Some(mpsc::channel().1),
2467 };
2468
2469 app.request_shutdown_confirmation();
2470 assert_eq!(app.phase, MonitorPhase::ConfirmingShutdown);
2471 assert!(matches!(
2472 shutdown_rx.try_recv(),
2473 Err(oneshot::error::TryRecvError::Empty)
2474 ));
2475
2476 let state = MonitorHandle::default().snapshot();
2477 let screen = draw(80, 24, |frame| render(frame, &mut app, &state));
2478 let text = buffer_text(&screen);
2479 assert!(text.contains("Shut down proxy?"), "{text}");
2480 assert!(text.contains("y confirm"), "{text}");
2481 assert!(text.contains("n/Esc/q cancel"), "{text}");
2482
2483 app.cancel_shutdown_confirmation();
2484 assert_eq!(app.phase, MonitorPhase::Running);
2485 assert!(matches!(
2486 shutdown_rx.try_recv(),
2487 Err(oneshot::error::TryRecvError::Empty)
2488 ));
2489
2490 app.request_shutdown_confirmation();
2491 app.begin_shutdown();
2492 assert_eq!(app.phase, MonitorPhase::ShuttingDown);
2493 assert_eq!(shutdown_rx.try_recv(), Ok(()));
2494 }
2495
2496 #[test]
2497 fn ctrl_c_starts_shutdown_then_requests_force_quit() {
2498 let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
2499 let (_shutdown_complete_tx, shutdown_complete_rx) = mpsc::channel();
2500 let mut app = MonitorApp {
2501 listen_url: "http://127.0.0.1:3000".to_string(),
2502 setup_text: String::new(),
2503 show_setup: false,
2504 show_help: false,
2505 detail: None,
2506 focus: FocusPane::Sessions,
2507 selected: 0,
2508 recent_selected: 0,
2509 tick: 0,
2510 phase: MonitorPhase::Running,
2511 shutdown: Some(shutdown_tx),
2512 shutdown_complete: Some(shutdown_complete_rx),
2513 };
2514
2515 assert!(!app.handle_ctrl_c());
2516 assert!(app.handle_ctrl_c());
2517
2518 assert_eq!(app.phase, MonitorPhase::ShuttingDown);
2519 assert_eq!(shutdown_rx.try_recv(), Ok(()));
2520 let state = MonitorHandle::default().snapshot();
2521 let screen = draw(80, 24, |frame| render(frame, &mut app, &state));
2522 let text = buffer_text(&screen);
2523 assert!(text.contains("Shutting down..."));
2524 assert!(text.contains("Press Ctrl-C to force quit"));
2525 }
2526
2527 #[test]
2528 fn shutdown_completion_accepts_notification_and_sender_drop() {
2529 let (complete_tx, complete_rx) = mpsc::channel();
2530 let app = MonitorApp {
2531 listen_url: String::new(),
2532 setup_text: String::new(),
2533 show_setup: false,
2534 show_help: false,
2535 detail: None,
2536 focus: FocusPane::Sessions,
2537 selected: 0,
2538 recent_selected: 0,
2539 tick: 0,
2540 phase: MonitorPhase::Running,
2541 shutdown: None,
2542 shutdown_complete: Some(complete_rx),
2543 };
2544
2545 assert!(!app.shutdown_is_complete());
2546 complete_tx.send(()).unwrap();
2547 assert!(app.shutdown_is_complete());
2548
2549 let (complete_tx, complete_rx) = mpsc::channel();
2550 let mut app = app;
2551 app.shutdown_complete = Some(complete_rx);
2552 drop(complete_tx);
2553 assert!(app.shutdown_is_complete());
2554 }
2555
2556 #[test]
2557 fn header_renders_configured_listen_url() {
2558 let app = MonitorApp {
2559 listen_url: "http://[::]:18765".to_string(),
2560 setup_text: String::new(),
2561 show_setup: false,
2562 show_help: false,
2563 detail: None,
2564 focus: FocusPane::Sessions,
2565 selected: 0,
2566 recent_selected: 0,
2567 tick: 0,
2568 phase: MonitorPhase::Running,
2569 shutdown: None,
2570 shutdown_complete: Some(mpsc::channel().1),
2571 };
2572 let state = MonitorHandle::default().snapshot();
2573
2574 let header = draw(100, 1, |frame| {
2575 render_header(frame, frame.area(), &app, &state)
2576 });
2577
2578 assert!(buffer_text(&header).contains("http://[::]:18765"));
2579 }
2580
2581 #[test]
2582 fn clamp_selection_caps_to_available_sessions() {
2583 let mut app = MonitorApp {
2584 listen_url: "http://127.0.0.1:3000".to_string(),
2585 setup_text: String::new(),
2586 show_setup: false,
2587 show_help: false,
2588 detail: None,
2589 focus: FocusPane::Sessions,
2590 selected: 10,
2591 recent_selected: 10,
2592 tick: 0,
2593 phase: MonitorPhase::Running,
2594 shutdown: None,
2595 shutdown_complete: Some(mpsc::channel().1),
2596 };
2597
2598 app.clamp_selection(3, 4);
2599 assert_eq!(app.selected, 2);
2600 assert_eq!(app.recent_selected, 3);
2601
2602 app.clamp_selection(0, 0);
2603 assert_eq!(app.selected, 0);
2604 assert_eq!(app.recent_selected, 0);
2605 }
2606
2607 #[test]
2608 fn arrow_navigation_moves_between_focus_panes_at_edges() {
2609 let mut app = MonitorApp {
2610 listen_url: "http://127.0.0.1:3000".to_string(),
2611 setup_text: String::new(),
2612 show_setup: false,
2613 show_help: false,
2614 detail: None,
2615 focus: FocusPane::Sessions,
2616 selected: 1,
2617 recent_selected: 0,
2618 tick: 0,
2619 phase: MonitorPhase::Running,
2620 shutdown: None,
2621 shutdown_complete: Some(mpsc::channel().1),
2622 };
2623
2624 app.move_down(2, 3, true);
2625 assert_eq!(app.focus, FocusPane::Recent);
2626 assert_eq!(app.recent_selected, 0);
2627
2628 app.move_up(2, 3, true);
2629 assert_eq!(app.focus, FocusPane::Sessions);
2630 assert_eq!(app.selected, 1);
2631 }
2632
2633 #[test]
2634 fn vim_navigation_stays_within_focused_pane() {
2635 let mut app = MonitorApp {
2636 listen_url: "http://127.0.0.1:3000".to_string(),
2637 setup_text: String::new(),
2638 show_setup: false,
2639 show_help: false,
2640 detail: None,
2641 focus: FocusPane::Sessions,
2642 selected: 1,
2643 recent_selected: 0,
2644 tick: 0,
2645 phase: MonitorPhase::Running,
2646 shutdown: None,
2647 shutdown_complete: Some(mpsc::channel().1),
2648 };
2649
2650 app.move_down(2, 3, false);
2651 assert_eq!(app.focus, FocusPane::Sessions);
2652 assert_eq!(app.selected, 1);
2653
2654 app.focus = FocusPane::Recent;
2655 app.move_up(2, 3, false);
2656 assert_eq!(app.focus, FocusPane::Recent);
2657 assert_eq!(app.recent_selected, 0);
2658 }
2659}