1use std::{
2 io::{self, Stdout},
3 time::{Duration, SystemTime},
4};
5
6use crossterm::{
7 event::{self, Event, KeyCode, KeyModifiers},
8 execute,
9 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
10};
11use jiff::{Timestamp, Zoned, tz::TimeZone};
12use ratatui::{
13 Terminal,
14 backend::CrosstermBackend,
15 layout::{Alignment, Constraint, Direction, Layout, Rect},
16 style::{Color, Modifier, Style},
17 text::{Line, Span},
18 widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table, Wrap},
19};
20use tokio::sync::oneshot;
21
22use crate::{
23 monitor::{ActiveRequest, CompletedRequest, MonitorHandle, MonitorState, SessionSummary},
24 paths,
25 registry::Registry,
26};
27
28const TEAL: Color = Color::Rgb(78, 201, 176);
29const WHITE: Color = Color::Rgb(240, 244, 248);
30const DIM_WHITE: Color = Color::Rgb(180, 190, 200);
31const SEPARATOR: Color = Color::Rgb(72, 74, 82);
32const BG: Color = Color::Rgb(18, 18, 22);
33const PANEL_BG: Color = Color::Rgb(22, 22, 27);
34const SELECTED_BG: Color = Color::Rgb(42, 45, 54);
35const GREEN: Color = Color::Rgb(120, 200, 120);
36const RED: Color = Color::Rgb(220, 120, 120);
37const YELLOW: Color = Color::Rgb(220, 200, 100);
38const BLUE: Color = Color::Rgb(120, 170, 230);
39const DIM: Color = Color::Rgb(100, 104, 114);
40const RECENT_MODEL_WIDTH: u16 = 36;
41
42const SESSION_TABLE_HEADERS: [(&str, Alignment); 12] = [
43 ("", Alignment::Left),
44 ("ID", Alignment::Left),
45 ("Active", Alignment::Right),
46 ("Reqs", Alignment::Right),
47 ("Fail", Alignment::Right),
48 ("Provider", Alignment::Left),
49 ("Model", Alignment::Left),
50 ("Effort", Alignment::Left),
51 ("In", Alignment::Right),
52 ("Out", Alignment::Right),
53 ("Rate", Alignment::Right),
54 ("Status", Alignment::Left),
55];
56
57const ACTIVE_TABLE_HEADERS: [(&str, Alignment); 8] = [
58 ("Started", Alignment::Left),
59 ("Provider", Alignment::Left),
60 ("Model", Alignment::Left),
61 ("Effort", Alignment::Left),
62 ("Endpoint", Alignment::Left),
63 ("Status", Alignment::Left),
64 ("Rate", Alignment::Right),
65 ("Elapsed", Alignment::Right),
66];
67
68const RECENT_TABLE_HEADERS: [(&str, Alignment); 10] = [
69 ("Finished", Alignment::Left),
70 ("Status", Alignment::Left),
71 ("Provider", Alignment::Left),
72 ("Model", Alignment::Left),
73 ("Effort", Alignment::Left),
74 ("Latency", Alignment::Right),
75 ("Rate", Alignment::Right),
76 ("In", Alignment::Right),
77 ("Out", Alignment::Right),
78 ("Details", Alignment::Left),
79];
80
81const RECENT_INDICATOR_TABLE_HEADERS: [(&str, Alignment); 10] = [
82 ("Finished", Alignment::Left),
83 ("Status", Alignment::Left),
84 ("Provider", Alignment::Left),
85 ("Model", Alignment::Left),
86 ("Effort", Alignment::Left),
87 ("Latency", Alignment::Right),
88 ("Rate", Alignment::Right),
89 ("In", Alignment::Right),
90 ("Out", Alignment::Right),
91 ("Err", Alignment::Left),
92];
93
94const RECENT_DETAIL_WIDTH: u16 = 132;
95
96const EVENTS_TABLE_HEADERS: [(&str, Alignment); 5] = [
97 ("Time", Alignment::Left),
98 ("Status", Alignment::Left),
99 ("Provider", Alignment::Left),
100 ("Model", Alignment::Left),
101 ("Message", Alignment::Left),
102];
103
104pub struct MonitorUiConfig<'a> {
105 pub port: u16,
106 pub registry: &'a Registry,
107 pub shutdown: Option<oneshot::Sender<()>>,
108}
109
110pub fn run_monitor(
111 handle: MonitorHandle,
112 config: MonitorUiConfig<'_>,
113) -> Result<(), anyhow::Error> {
114 let mut terminal = setup_terminal()?;
115 let _guard = TerminalGuard;
116 let mut app = MonitorApp {
117 port: config.port,
118 setup_text: setup_text(config.port, config.registry),
119 show_setup: false,
120 show_help: false,
121 detail: None,
122 focus: FocusPane::Sessions,
123 selected: 0,
124 recent_selected: 0,
125 tick: 0,
126 shutdown: config.shutdown,
127 };
128
129 loop {
130 let state = handle.snapshot();
131 app.clamp_selection(state.sessions.len(), state.recent.len());
132 app.tick = app.tick.wrapping_add(1);
133 terminal.draw(|frame| render(frame, &mut app, &state))?;
134 if event::poll(Duration::from_millis(250))? {
135 match event::read()? {
136 Event::Key(key) => match key.code {
137 KeyCode::Char('q') => {
138 if let Some(shutdown) = app.shutdown.take() {
139 let _ = shutdown.send(());
140 }
141 break;
142 }
143 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
144 if let Some(shutdown) = app.shutdown.take() {
145 let _ = shutdown.send(());
146 }
147 break;
148 }
149 KeyCode::Char('?') => app.show_help = !app.show_help,
150 KeyCode::Char('b') => app.show_setup = !app.show_setup,
151 KeyCode::Tab => app.focus = app.focus.next(),
152 KeyCode::Down => app.move_down(state.sessions.len(), state.recent.len(), true),
153 KeyCode::Char('j') => {
154 app.move_down(state.sessions.len(), state.recent.len(), false)
155 }
156 KeyCode::Up => app.move_up(state.sessions.len(), state.recent.len(), true),
157 KeyCode::Char('k') => {
158 app.move_up(state.sessions.len(), state.recent.len(), false)
159 }
160 KeyCode::Right => app.focus = FocusPane::Recent,
161 KeyCode::Left => app.focus = FocusPane::Sessions,
162 KeyCode::Enter => {
163 app.detail = match app.focus {
164 FocusPane::Sessions if !state.sessions.is_empty() => {
165 Some(DetailView::Session)
166 }
167 FocusPane::Recent if !state.recent.is_empty() => {
168 Some(DetailView::Request)
169 }
170 _ => None,
171 }
172 }
173 KeyCode::Esc => {
174 if app.show_help {
175 app.show_help = false;
176 } else if app.show_setup {
177 app.show_setup = false;
178 } else {
179 app.detail = None;
180 }
181 }
182 _ => {}
183 },
184 Event::Resize(_, _) => {}
185 _ => {}
186 }
187 }
188 }
189 terminal.show_cursor()?;
190 Ok(())
191}
192
193#[derive(Clone, Copy, Debug, PartialEq, Eq)]
194enum FocusPane {
195 Sessions,
196 Recent,
197}
198
199impl FocusPane {
200 fn next(self) -> Self {
201 match self {
202 Self::Sessions => Self::Recent,
203 Self::Recent => Self::Sessions,
204 }
205 }
206}
207
208#[derive(Clone, Copy, PartialEq, Eq)]
209enum DetailView {
210 Session,
211 Request,
212}
213
214struct MonitorApp {
215 port: u16,
216 setup_text: String,
217 show_setup: bool,
218 show_help: bool,
219 detail: Option<DetailView>,
220 focus: FocusPane,
221 selected: usize,
222 recent_selected: usize,
223 tick: usize,
224 shutdown: Option<oneshot::Sender<()>>,
225}
226
227impl MonitorApp {
228 fn clamp_selection(&mut self, sessions: usize, recent: usize) {
229 self.selected = self.selected.min(sessions.saturating_sub(1));
230 self.recent_selected = self.recent_selected.min(recent.saturating_sub(1));
231 }
232
233 fn move_down(&mut self, sessions: usize, recent: usize, switch_panes: bool) {
234 match self.focus {
235 FocusPane::Sessions => {
236 if switch_panes && self.selected >= sessions.saturating_sub(1) && recent > 0 {
237 self.focus = FocusPane::Recent;
238 self.recent_selected = 0;
239 } else {
240 self.selected = self
241 .selected
242 .saturating_add(1)
243 .min(sessions.saturating_sub(1));
244 }
245 }
246 FocusPane::Recent => {
247 self.recent_selected = self
248 .recent_selected
249 .saturating_add(1)
250 .min(recent.saturating_sub(1));
251 }
252 }
253 }
254
255 fn move_up(&mut self, sessions: usize, recent: usize, switch_panes: bool) {
256 match self.focus {
257 FocusPane::Sessions => self.selected = self.selected.saturating_sub(1),
258 FocusPane::Recent => {
259 if switch_panes && self.recent_selected == 0 && sessions > 0 {
260 self.focus = FocusPane::Sessions;
261 self.selected = sessions.saturating_sub(1);
262 } else {
263 self.recent_selected = self
264 .recent_selected
265 .saturating_sub(1)
266 .min(recent.saturating_sub(1));
267 }
268 }
269 }
270 }
271}
272
273impl Drop for MonitorApp {
274 fn drop(&mut self) {
275 if let Some(shutdown) = self.shutdown.take() {
276 let _ = shutdown.send(());
277 }
278 }
279}
280
281struct TerminalGuard;
282
283impl Drop for TerminalGuard {
284 fn drop(&mut self) {
285 let _ = disable_raw_mode();
286 let _ = execute!(io::stdout(), LeaveAlternateScreen);
287 }
288}
289
290fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, anyhow::Error> {
291 enable_raw_mode()?;
292 execute!(io::stdout(), EnterAlternateScreen)?;
293 let backend = CrosstermBackend::new(io::stdout());
294 let mut terminal = Terminal::new(backend)?;
295 terminal.clear()?;
296 Ok(terminal)
297}
298
299fn render(frame: &mut ratatui::Frame<'_>, app: &mut MonitorApp, state: &MonitorState) {
300 let area = frame.area();
301 frame.render_widget(Block::default().style(Style::default().bg(BG)), area);
302
303 let root = Layout::default()
304 .direction(Direction::Vertical)
305 .constraints([
306 Constraint::Length(1),
307 Constraint::Percentage(30),
308 Constraint::Percentage(20),
309 Constraint::Percentage(25),
310 Constraint::Percentage(25),
311 Constraint::Length(1),
312 ])
313 .split(area);
314
315 render_header(frame, root[0], app, state);
316 match app.detail {
317 Some(DetailView::Session) => render_session_detail(frame, root[1], state, app.selected),
318 Some(DetailView::Request) => {
319 render_request_detail(frame, root[1], state, app.recent_selected)
320 }
321 None => render_sessions(
322 frame,
323 root[1],
324 &state.sessions,
325 app.selected,
326 app.focus == FocusPane::Sessions,
327 ),
328 }
329 render_active(frame, root[2], &state.active, app.tick);
330 render_recent(
331 frame,
332 root[3],
333 &state.recent,
334 app.recent_selected,
335 app.focus == FocusPane::Recent,
336 );
337 render_events(frame, root[4], &state.recent);
338 render_footer(frame, root[5], app);
339
340 if app.show_setup {
341 render_setup_overlay(frame, area, &app.setup_text);
342 }
343 if app.show_help {
344 render_help_overlay(frame, area);
345 }
346}
347
348fn render_header(
349 frame: &mut ratatui::Frame<'_>,
350 area: Rect,
351 app: &MonitorApp,
352 state: &MonitorState,
353) {
354 let uptime = state
355 .started_at
356 .elapsed()
357 .unwrap_or_else(|_| Duration::from_secs(0));
358 let text = Line::from(vec![
359 Span::styled(
360 " claude-codex",
361 Style::default()
362 .fg(BG)
363 .bg(TEAL)
364 .add_modifier(Modifier::BOLD),
365 ),
366 Span::styled(" ", Style::default().fg(BG).bg(TEAL)),
367 Span::styled(
368 format!("http://127.0.0.1:{}", app.port),
369 Style::default().fg(BG).bg(TEAL),
370 ),
371 Span::styled(" uptime ", Style::default().fg(BG).bg(TEAL)),
372 Span::styled(
373 format_duration(uptime),
374 Style::default()
375 .fg(BG)
376 .bg(TEAL)
377 .add_modifier(Modifier::BOLD),
378 ),
379 Span::styled(" sessions ", Style::default().fg(BG).bg(TEAL)),
380 Span::styled(
381 state.sessions.len().to_string(),
382 Style::default()
383 .fg(BG)
384 .bg(TEAL)
385 .add_modifier(Modifier::BOLD),
386 ),
387 Span::styled(" active ", Style::default().fg(BG).bg(TEAL)),
388 Span::styled(
389 state.active.len().to_string(),
390 Style::default()
391 .fg(BG)
392 .bg(TEAL)
393 .add_modifier(Modifier::BOLD),
394 ),
395 ]);
396 frame.render_widget(Paragraph::new(text).style(Style::default().bg(TEAL)), area);
397}
398
399fn panel(title: &'static str, focused: bool) -> Block<'static> {
400 let color = if focused { TEAL } else { SEPARATOR };
401 Block::default()
402 .title(Span::styled(
403 format!(" {title} "),
404 Style::default()
405 .fg(if focused { TEAL } else { DIM_WHITE })
406 .add_modifier(Modifier::BOLD),
407 ))
408 .borders(Borders::ALL)
409 .border_type(BorderType::Rounded)
410 .border_style(Style::default().fg(color))
411 .style(Style::default().bg(PANEL_BG))
412}
413
414fn table_header_aligned(
415 cells: impl IntoIterator<Item = (&'static str, Alignment)>,
416) -> Row<'static> {
417 Row::new(
418 cells
419 .into_iter()
420 .map(|(cell, alignment)| {
421 Cell::from(
422 Line::from(Span::styled(cell, Style::default().fg(TEAL))).alignment(alignment),
423 )
424 })
425 .collect::<Vec<_>>(),
426 )
427 .style(Style::default().add_modifier(Modifier::BOLD))
428}
429
430fn render_empty_table_state(
431 frame: &mut ratatui::Frame<'_>,
432 area: Rect,
433 title: &'static str,
434 focused: bool,
435 message: &str,
436) {
437 frame.render_widget(panel(title, focused), area);
438 let content = Rect {
439 x: area.x.saturating_add(1),
440 y: area.y.saturating_add(1),
441 width: area.width.saturating_sub(2),
442 height: area.height.saturating_sub(2),
443 };
444 if content.width == 0 || content.height == 0 {
445 return;
446 }
447
448 let line = Rect {
449 y: content.y + content.height.saturating_sub(1) / 2,
450 height: 1,
451 ..content
452 };
453 frame.render_widget(
454 Paragraph::new(ellipsize(message, line.width.into()))
455 .alignment(Alignment::Center)
456 .style(Style::default().fg(DIM).bg(PANEL_BG)),
457 line,
458 );
459}
460
461fn muted_cell(value: impl Into<String>) -> Cell<'static> {
462 Cell::from(Span::styled(value.into(), Style::default().fg(DIM)))
463}
464
465fn text_cell(value: impl Into<String>) -> Cell<'static> {
466 Cell::from(Span::styled(value.into(), Style::default().fg(DIM_WHITE)))
467}
468
469fn model_cell(value: Option<&str>, width: usize) -> Cell<'static> {
470 text_cell(ellipsize(value.unwrap_or("-"), width))
471}
472
473fn table_column_width(area: Rect, widths: &[Constraint], column: usize) -> usize {
474 let table_width = area.width.saturating_sub(2);
475 Layout::horizontal(widths.to_vec())
476 .spacing(1)
477 .split(Rect::new(0, 0, table_width, 1))
478 .get(column)
479 .map_or(0, |rect| usize::from(rect.width))
480}
481
482fn ellipsize(value: &str, width: usize) -> String {
483 if value.chars().count() <= width {
484 return value.to_string();
485 }
486 if width == 0 {
487 return String::new();
488 }
489
490 value
491 .chars()
492 .take(width.saturating_sub(1))
493 .chain(std::iter::once('…'))
494 .collect()
495}
496
497fn display_session_id(session_id: Option<&str>) -> &str {
498 let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
499 return "no-session";
500 };
501 if uuid::Uuid::parse_str(session_id).is_ok() {
502 return session_id
503 .split_once('-')
504 .map_or(session_id, |(first, _)| first);
505 }
506 session_id
507}
508
509fn number_cell(value: impl Into<String>) -> Cell<'static> {
510 Cell::from(
511 Line::from(Span::styled(value.into(), Style::default().fg(DIM_WHITE)))
512 .alignment(Alignment::Right),
513 )
514}
515
516fn status_cell(value: &str) -> Cell<'static> {
517 Cell::from(Span::styled(value.to_string(), status_style(value)))
518}
519
520fn status_style(value: &str) -> Style {
521 Style::default().fg(status_color(value))
522}
523
524fn status_color(value: &str) -> Color {
525 match value {
526 "completed" => GREEN,
527 "streaming" => TEAL,
528 "failed" => RED,
529 "upstream" => BLUE,
530 "selected" | "started" => YELLOW,
531 _ => DIM_WHITE,
532 }
533}
534
535fn http_status_style(status: Option<u16>) -> Style {
536 Style::default().fg(http_status_color(status))
537}
538
539fn http_status_color(status: Option<u16>) -> Color {
540 match status {
541 Some(200..=299) => GREEN,
542 Some(400..=499) => YELLOW,
543 Some(500..=599) => RED,
544 Some(_) => DIM_WHITE,
545 None => DIM,
546 }
547}
548
549fn rate_cell(value: String) -> Cell<'static> {
550 let color = if value.contains("tok/s") {
551 TEAL
552 } else if value == "-" {
553 DIM
554 } else {
555 DIM_WHITE
556 };
557 Cell::from(
558 Line::from(Span::styled(value, Style::default().fg(color))).alignment(Alignment::Right),
559 )
560}
561
562fn provider_cell(value: Option<&str>) -> Cell<'static> {
563 let value = value.unwrap_or("-");
564 let color = match value {
565 "codex" => TEAL,
566 "kimi" => Color::Rgb(190, 150, 220),
567 "cursor" => Color::Rgb(140, 170, 230),
568 "-" => DIM,
569 _ => DIM_WHITE,
570 };
571 Cell::from(Span::styled(value.to_string(), Style::default().fg(color)))
572}
573
574fn detail_cell(value: &str) -> Cell<'static> {
575 if value.is_empty() || value == "-" {
576 Cell::from(Span::styled("", Style::default().fg(DIM)))
577 } else {
578 Cell::from(Span::styled(value.to_string(), Style::default().fg(YELLOW)))
579 }
580}
581
582fn error_indicator(request: &CompletedRequest) -> &'static str {
583 if request.status == crate::monitor::RequestStatus::Failed
584 || request.http_status.is_some_and(|status| status >= 400)
585 || request
586 .error
587 .as_deref()
588 .is_some_and(|error| !error.is_empty())
589 {
590 "!"
591 } else {
592 ""
593 }
594}
595
596fn compact_tokens(tokens: u64) -> String {
597 if tokens >= 1_000_000 {
598 format!("{:.1}M", tokens as f64 / 1_000_000.0)
599 } else if tokens >= 1_000 {
600 format!("{:.1}k", tokens as f64 / 1_000.0)
601 } else {
602 tokens.to_string()
603 }
604}
605
606fn token_value(value: Option<u64>) -> String {
607 value.map(compact_tokens).unwrap_or_else(|| "-".to_string())
608}
609
610fn spinner(tick: usize) -> &'static str {
611 const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
612 FRAMES[tick % FRAMES.len()]
613}
614
615fn render_sessions(
616 frame: &mut ratatui::Frame<'_>,
617 area: Rect,
618 sessions: &[SessionSummary],
619 selected: usize,
620 focused: bool,
621) {
622 if sessions.is_empty() {
623 render_empty_table_state(frame, area, "Sessions", focused, "No sessions");
624 return;
625 }
626
627 let widths = [
628 Constraint::Length(1),
629 Constraint::Length(8),
630 Constraint::Length(6),
631 Constraint::Length(5),
632 Constraint::Length(5),
633 Constraint::Length(10),
634 Constraint::Percentage(20),
635 Constraint::Length(7),
636 Constraint::Length(9),
637 Constraint::Length(9),
638 Constraint::Length(12),
639 Constraint::Length(10),
640 ];
641 let model_width = table_column_width(area, &widths, 6);
642 let rows = sessions.iter().enumerate().map(|(index, session)| {
643 let marker = if focused && index == selected {
644 ">"
645 } else {
646 " "
647 };
648 Row::new(vec![
649 Cell::from(Span::styled(marker, Style::default().fg(TEAL))),
650 text_cell(display_session_id(session.session_id.as_deref())),
651 number_cell(session.active_count.to_string()),
652 number_cell(session.request_count.to_string()),
653 number_cell(session.failure_count.to_string()),
654 provider_cell(session.provider.as_deref()),
655 model_cell(session.model.as_deref(), model_width),
656 text_cell(session.effort.as_deref().unwrap_or("-")),
657 number_cell(compact_tokens(session.input_tokens)),
658 number_cell(compact_tokens(session.output_tokens)),
659 rate_cell(session.rate().label()),
660 status_cell(&session.last_status),
661 ])
662 .style(if index == selected {
663 Style::default().bg(SELECTED_BG)
664 } else {
665 Style::default().bg(PANEL_BG)
666 })
667 });
668 let table = Table::new(rows, widths)
669 .header(table_header_aligned(SESSION_TABLE_HEADERS))
670 .block(panel("Sessions", focused));
671 frame.render_widget(table, area);
672}
673
674fn render_active(
675 frame: &mut ratatui::Frame<'_>,
676 area: Rect,
677 active: &[ActiveRequest],
678 tick: usize,
679) {
680 if active.is_empty() {
681 render_empty_table_state(frame, area, "Active requests", false, "No active requests");
682 return;
683 }
684
685 let widths = [
686 Constraint::Length(8),
687 Constraint::Length(8),
688 Constraint::Min(18),
689 Constraint::Length(6),
690 Constraint::Length(8),
691 Constraint::Length(14),
692 Constraint::Length(10),
693 Constraint::Length(9),
694 ];
695 let model_width = table_column_width(area, &widths, 2);
696 let rows = active.iter().map(|request| {
697 let status = if matches!(
698 request.status.label(),
699 "upstream" | "streaming" | "selected" | "started"
700 ) {
701 format!("{} {}", spinner(tick), request.status.label())
702 } else {
703 request.status.label().to_string()
704 };
705 Row::new(vec![
706 muted_cell(format_system_time(request.started_at)),
707 provider_cell(request.provider.as_deref()),
708 model_cell(request.model.as_deref(), model_width),
709 text_cell(request.effort.as_deref().unwrap_or("-")),
710 muted_cell(request.endpoint.label()),
711 Cell::from(Span::styled(status, status_style(request.status.label()))),
712 rate_cell(request.rate().label()),
713 number_cell(format_duration(request.elapsed())),
714 ])
715 .style(Style::default().bg(PANEL_BG))
716 });
717 let table = Table::new(rows, widths)
718 .header(table_header_aligned(ACTIVE_TABLE_HEADERS))
719 .block(panel("Active requests", false));
720 frame.render_widget(table, area);
721}
722
723fn render_recent(
724 frame: &mut ratatui::Frame<'_>,
725 area: Rect,
726 recent: &[CompletedRequest],
727 selected: usize,
728 focused: bool,
729) {
730 if recent.is_empty() {
731 render_empty_table_state(
732 frame,
733 area,
734 "Recent requests",
735 focused,
736 "No recent requests",
737 );
738 return;
739 }
740
741 let show_detail = area.width >= RECENT_DETAIL_WIDTH;
742 let widths = if show_detail {
743 vec![
744 Constraint::Length(8),
745 Constraint::Length(6),
746 Constraint::Length(8),
747 Constraint::Length(RECENT_MODEL_WIDTH),
748 Constraint::Length(7),
749 Constraint::Length(8),
750 Constraint::Length(12),
751 Constraint::Length(7),
752 Constraint::Length(7),
753 Constraint::Fill(1),
754 ]
755 } else {
756 vec![
757 Constraint::Length(8),
758 Constraint::Length(6),
759 Constraint::Length(8),
760 Constraint::Length(if area.width >= 105 { 18 } else { 12 }),
761 Constraint::Length(7),
762 Constraint::Length(8),
763 Constraint::Length(10),
764 Constraint::Length(7),
765 Constraint::Length(7),
766 Constraint::Length(3),
767 ]
768 };
769 let model_width = table_column_width(area, &widths, 3);
770 let rows = recent.iter().enumerate().map(|(index, request)| {
771 let detail = if show_detail {
772 request.error.as_deref().unwrap_or("")
773 } else {
774 error_indicator(request)
775 };
776 Row::new(vec![
777 muted_cell(format_system_time(request.finished_at)),
778 Cell::from(Span::styled(
779 request
780 .http_status
781 .map(|status| status.to_string())
782 .unwrap_or_else(|| "-".to_string()),
783 http_status_style(request.http_status),
784 )),
785 provider_cell(request.provider.as_deref()),
786 model_cell(request.model.as_deref(), model_width),
787 text_cell(request.effort.as_deref().unwrap_or("-")),
788 number_cell(format_duration(request.latency)),
789 rate_cell(request.rate().label()),
790 number_cell(token_value(request.input_tokens)),
791 number_cell(token_value(request.output_tokens)),
792 detail_cell(detail),
793 ])
794 .style(if focused && index == selected {
795 Style::default().bg(SELECTED_BG)
796 } else {
797 Style::default().bg(PANEL_BG)
798 })
799 });
800 let headers = if show_detail {
801 RECENT_TABLE_HEADERS
802 } else {
803 RECENT_INDICATOR_TABLE_HEADERS
804 };
805 let table = Table::new(rows, widths)
806 .header(table_header_aligned(headers))
807 .block(panel("Recent requests", focused));
808 frame.render_widget(table, area);
809}
810
811fn render_events(frame: &mut ratatui::Frame<'_>, area: Rect, recent: &[CompletedRequest]) {
812 let events = recent
813 .iter()
814 .filter(|request| {
815 request.status == crate::monitor::RequestStatus::Failed
816 || request.http_status.is_some_and(|status| status >= 400)
817 || request.error.is_some()
818 })
819 .take(12)
820 .collect::<Vec<_>>();
821 if events.is_empty() {
822 render_empty_table_state(frame, area, "Events", false, "No events");
823 return;
824 }
825
826 let widths = [
827 Constraint::Length(8),
828 Constraint::Length(6),
829 Constraint::Length(10),
830 Constraint::Min(18),
831 Constraint::Percentage(50),
832 ];
833 let model_width = table_column_width(area, &widths, 3);
834 let rows = events.iter().map(|request| {
835 let status = request
836 .http_status
837 .map(|status| status.to_string())
838 .unwrap_or_else(|| request.status.label().to_string());
839 let message = request
840 .error
841 .as_deref()
842 .filter(|error| !error.is_empty())
843 .unwrap_or("-");
844 Row::new(vec![
845 muted_cell(format_system_time(request.finished_at)),
846 Cell::from(Span::styled(status, http_status_style(request.http_status))),
847 provider_cell(request.provider.as_deref()),
848 model_cell(request.model.as_deref(), model_width),
849 detail_cell(message),
850 ])
851 .style(Style::default().bg(PANEL_BG))
852 });
853 let table = Table::new(rows, widths)
854 .header(table_header_aligned(EVENTS_TABLE_HEADERS))
855 .block(panel("Events", false));
856 frame.render_widget(table, area);
857}
858
859fn render_session_detail(
860 frame: &mut ratatui::Frame<'_>,
861 area: Rect,
862 state: &MonitorState,
863 selected: usize,
864) {
865 let lines = if let Some(session) = state.sessions.get(selected) {
866 vec![
867 detail_line("session", session.label(), WHITE),
868 detail_line("active requests", session.active_count.to_string(), YELLOW),
869 detail_line(
870 "total requests",
871 session.request_count.to_string(),
872 DIM_WHITE,
873 ),
874 detail_line("failures", session.failure_count.to_string(), RED),
875 detail_line("provider", session.provider.as_deref().unwrap_or("-"), TEAL),
876 detail_line("model", session.model.as_deref().unwrap_or("-"), DIM_WHITE),
877 detail_line("effort", session.effort.as_deref().unwrap_or("-"), YELLOW),
878 detail_line(
879 "input tokens",
880 compact_tokens(session.input_tokens),
881 DIM_WHITE,
882 ),
883 detail_line(
884 "output tokens",
885 compact_tokens(session.output_tokens),
886 DIM_WHITE,
887 ),
888 detail_line(
889 "total tokens",
890 format!(
891 "{}/{}",
892 compact_tokens(session.input_tokens),
893 compact_tokens(session.output_tokens)
894 ),
895 DIM_WHITE,
896 ),
897 detail_line("rate", session.rate().label(), TEAL),
898 detail_line(
899 "last status",
900 session.last_status.as_str(),
901 status_color(&session.last_status),
902 ),
903 ]
904 } else {
905 vec![Line::from(Span::styled(
906 "No session selected",
907 Style::default().fg(DIM),
908 ))]
909 };
910 frame.render_widget(
911 Paragraph::new(lines)
912 .style(Style::default().bg(PANEL_BG))
913 .block(panel("Session detail", true)),
914 area,
915 );
916}
917
918fn render_request_detail(
919 frame: &mut ratatui::Frame<'_>,
920 area: Rect,
921 state: &MonitorState,
922 selected: usize,
923) {
924 let lines = if let Some(request) = state.recent.get(selected) {
925 let mut lines = vec![
926 detail_line("request", request.request_id.clone(), WHITE),
927 detail_line(
928 "session",
929 display_session_id(request.session_id.as_deref()),
930 TEAL,
931 ),
932 detail_line(
933 "session seq",
934 request
935 .session_seq
936 .map(|seq| seq.to_string())
937 .unwrap_or_else(|| "-".to_string()),
938 DIM_WHITE,
939 ),
940 detail_line("endpoint", request.endpoint.label(), DIM_WHITE),
941 detail_line("started", format_system_time(request.started_at), DIM_WHITE),
942 detail_line(
943 "finished",
944 format_system_time(request.finished_at),
945 DIM_WHITE,
946 ),
947 detail_line(
948 "status",
949 request.status.label(),
950 status_color(request.status.label()),
951 ),
952 detail_line(
953 "http status",
954 request
955 .http_status
956 .map(|status| status.to_string())
957 .unwrap_or_else(|| "-".to_string()),
958 http_status_color(request.http_status),
959 ),
960 detail_line("provider", request.provider.as_deref().unwrap_or("-"), TEAL),
961 detail_line("model", request.model.as_deref().unwrap_or("-"), DIM_WHITE),
962 detail_line("effort", request.effort.as_deref().unwrap_or("-"), YELLOW),
963 detail_line("latency", format_duration(request.latency), DIM_WHITE),
964 detail_line("rate", request.rate().label(), TEAL),
965 detail_line("input tokens", token_value(request.input_tokens), DIM_WHITE),
966 detail_line(
967 "output tokens",
968 token_value(request.output_tokens),
969 DIM_WHITE,
970 ),
971 detail_line(
972 "stream bytes",
973 request.streamed_bytes.to_string(),
974 DIM_WHITE,
975 ),
976 detail_line(
977 "stream chunks",
978 request.stream_chunks.to_string(),
979 DIM_WHITE,
980 ),
981 ];
982 if let Some(error) = request.error.as_deref().filter(|error| !error.is_empty()) {
983 lines.push(detail_line("detail", error, YELLOW));
984 }
985 if let Some(path) = &request.traffic_capture_path {
986 lines.push(detail_line(
987 "capture",
988 path.to_string_lossy().into_owned(),
989 DIM_WHITE,
990 ));
991 }
992 lines
993 } else {
994 vec![Line::from(Span::styled(
995 "No request selected",
996 Style::default().fg(DIM),
997 ))]
998 };
999 frame.render_widget(
1000 Paragraph::new(lines)
1001 .style(Style::default().bg(PANEL_BG))
1002 .block(panel("Request detail", true)),
1003 area,
1004 );
1005}
1006
1007fn detail_line<'a>(label: &'static str, value: impl Into<String>, value_color: Color) -> Line<'a> {
1008 Line::from(vec![
1009 Span::styled(format!(" {label:<16}"), Style::default().fg(DIM)),
1010 Span::styled(value.into(), Style::default().fg(value_color)),
1011 ])
1012}
1013
1014fn render_footer(frame: &mut ratatui::Frame<'_>, area: Rect, _app: &MonitorApp) {
1015 let spans = vec![
1016 Span::raw(" "),
1017 Span::styled("q", Style::default().fg(TEAL)),
1018 Span::styled(" quit ", Style::default().fg(DIM)),
1019 Span::styled("?", Style::default().fg(TEAL)),
1020 Span::styled(" help ", Style::default().fg(DIM)),
1021 Span::styled("b", Style::default().fg(TEAL)),
1022 Span::styled(" setup ", Style::default().fg(DIM)),
1023 Span::styled("arrows/j/k", Style::default().fg(TEAL)),
1024 Span::styled(" navigate ", Style::default().fg(DIM)),
1025 Span::styled("Tab", Style::default().fg(TEAL)),
1026 Span::styled(" pane ", Style::default().fg(DIM)),
1027 Span::styled("Enter", Style::default().fg(TEAL)),
1028 Span::styled(" open", Style::default().fg(DIM)),
1029 ];
1030 frame.render_widget(
1031 Paragraph::new(Line::from(spans)).style(Style::default().bg(BG)),
1032 area,
1033 );
1034}
1035
1036fn render_help_overlay(frame: &mut ratatui::Frame<'_>, area: Rect) {
1037 let width = 48.min(area.width.saturating_sub(4)).max(24);
1038 let height = 12.min(area.height.saturating_sub(2)).max(8);
1039 let popup = Rect {
1040 x: area.x + area.width.saturating_sub(width) / 2,
1041 y: area.y + area.height.saturating_sub(height) / 2,
1042 width,
1043 height,
1044 };
1045 frame.render_widget(Clear, popup);
1046 let block = Block::default()
1047 .title(Span::styled(" Shortcuts ", Style::default().fg(TEAL)))
1048 .borders(Borders::ALL)
1049 .border_type(BorderType::Rounded)
1050 .border_style(Style::default().fg(TEAL))
1051 .style(Style::default().bg(BG));
1052 let inner = block.inner(popup);
1053 frame.render_widget(block, popup);
1054 let lines = [
1055 ("q / Ctrl-C", "quit proxy"),
1056 ("?", "toggle help"),
1057 ("b", "toggle setup"),
1058 ("arrows", "navigate rows and panes"),
1059 ("j / k", "previous / next row"),
1060 ("Tab", "switch pane"),
1061 ("Enter", "open detail"),
1062 ("Esc", "close overlay / detail"),
1063 ];
1064 let content = lines
1065 .into_iter()
1066 .map(|(key, label)| {
1067 Line::from(vec![
1068 Span::raw(" "),
1069 Span::styled(format!("{key:<10}"), Style::default().fg(TEAL)),
1070 Span::styled(label, Style::default().fg(DIM_WHITE)),
1071 ])
1072 })
1073 .collect::<Vec<_>>();
1074 frame.render_widget(
1075 Paragraph::new(content).style(Style::default().bg(BG)),
1076 inner,
1077 );
1078}
1079
1080fn render_setup_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, setup_text: &str) {
1081 let width = 84.min(area.width.saturating_sub(4)).max(36);
1082 let content_height = setup_text.lines().count() as u16;
1083 let height = (content_height + 4)
1084 .min(area.height.saturating_sub(2))
1085 .max(8);
1086 let popup = Rect {
1087 x: area.x + area.width.saturating_sub(width) / 2,
1088 y: area.y + area.height.saturating_sub(height) / 2,
1089 width,
1090 height,
1091 };
1092 frame.render_widget(Clear, popup);
1093 let block = Block::default()
1094 .title(Span::styled(" Setup ", Style::default().fg(TEAL)))
1095 .borders(Borders::ALL)
1096 .border_type(BorderType::Rounded)
1097 .border_style(Style::default().fg(TEAL))
1098 .style(Style::default().bg(BG));
1099 let inner = block.inner(popup);
1100 frame.render_widget(block, popup);
1101
1102 let mut lines = setup_text
1103 .lines()
1104 .map(|line| {
1105 let style = if line.starts_with("export ") {
1106 Style::default().fg(WHITE)
1107 } else {
1108 Style::default().fg(DIM_WHITE)
1109 };
1110 Line::from(vec![Span::raw(" "), Span::styled(line.to_string(), style)])
1111 })
1112 .collect::<Vec<_>>();
1113 lines.push(Line::from(""));
1114 lines.push(Line::from(vec![
1115 Span::raw(" "),
1116 Span::styled("Esc", Style::default().fg(TEAL)),
1117 Span::styled(" close ", Style::default().fg(DIM)),
1118 Span::styled("b", Style::default().fg(TEAL)),
1119 Span::styled(" toggle setup", Style::default().fg(DIM)),
1120 ]));
1121 frame.render_widget(
1122 Paragraph::new(lines)
1123 .style(Style::default().bg(BG))
1124 .wrap(Wrap { trim: false }),
1125 inner,
1126 );
1127}
1128
1129pub fn setup_text(port: u16, registry: &Registry) -> String {
1130 let grouped = registry.grouped_models();
1131 let model_summary = ["codex", "kimi", "cursor"]
1132 .into_iter()
1133 .filter_map(|provider| {
1134 grouped
1135 .get(provider)
1136 .map(|models| format!("{provider}: {} models", models.len()))
1137 })
1138 .collect::<Vec<_>>()
1139 .join(" ");
1140 let mut lines = vec![
1141 format!("Logs: {}", paths::log_file().display()),
1142 format!("Config: {}", paths::config_dir().display()),
1143 format!("Providers: {model_summary}"),
1144 ];
1145 lines.push(format!(
1146 "export ANTHROPIC_BASE_URL=\"http://localhost:{port}\""
1147 ));
1148 lines.push("export ANTHROPIC_AUTH_TOKEN=\"anything\"".to_string());
1149 lines.push("export ANTHROPIC_MODEL=\"gpt-5.6-sol\"".to_string());
1150 lines.push("export ANTHROPIC_SMALL_FAST_MODEL=\"gpt-5.6-luna\"".to_string());
1151 lines.push("export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1".to_string());
1152 lines.join("\n")
1153}
1154
1155fn format_duration(duration: Duration) -> String {
1156 let total = duration.as_secs();
1157 let hours = total / 3600;
1158 let minutes = (total % 3600) / 60;
1159 let seconds = total % 60;
1160 if hours > 0 {
1161 format!("{hours}h{minutes:02}m")
1162 } else if minutes > 0 {
1163 format!("{minutes}m{seconds:02}s")
1164 } else {
1165 format!("{seconds}s")
1166 }
1167}
1168
1169fn format_system_time(time: SystemTime) -> String {
1170 format_system_time_in_zone(time, TimeZone::system())
1171}
1172
1173fn format_system_time_in_zone(time: SystemTime, time_zone: TimeZone) -> String {
1174 let Ok(timestamp) = Timestamp::try_from(time) else {
1175 return "-".to_string();
1176 };
1177 Zoned::new(timestamp, time_zone)
1178 .strftime("%H:%M:%S")
1179 .to_string()
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use ratatui::{backend::TestBackend, buffer::Buffer};
1185
1186 use super::*;
1187 use crate::monitor::EndpointKind;
1188
1189 fn draw(width: u16, height: u16, render: impl FnOnce(&mut ratatui::Frame<'_>)) -> Buffer {
1190 let backend = TestBackend::new(width, height);
1191 let mut terminal = Terminal::new(backend).unwrap();
1192 terminal.draw(render).unwrap();
1193 terminal.backend().buffer().clone()
1194 }
1195
1196 fn buffer_text(buffer: &Buffer) -> String {
1197 (0..buffer.area.height)
1198 .map(|y| {
1199 (0..buffer.area.width)
1200 .map(|x| buffer[(x, y)].symbol())
1201 .collect::<String>()
1202 })
1203 .collect::<Vec<_>>()
1204 .join("\n")
1205 }
1206
1207 fn placeholder_position(buffer: &Buffer, placeholder: &str) -> Option<(u16, u16)> {
1208 let symbols = placeholder
1209 .chars()
1210 .map(|character| character.to_string())
1211 .collect::<Vec<_>>();
1212 (0..buffer.area.height).find_map(|y| {
1213 (0..buffer.area.width).find_map(|x| {
1214 symbols
1215 .iter()
1216 .enumerate()
1217 .all(|(offset, symbol)| {
1218 x + (offset as u16) < buffer.area.width
1219 && buffer[(x + offset as u16, y)].symbol() == symbol
1220 })
1221 .then_some((x, y))
1222 })
1223 })
1224 }
1225
1226 fn assert_centered(buffer: &Buffer, placeholder: &str, expected_y: u16) {
1227 let (x, y) = placeholder_position(buffer, placeholder).unwrap();
1228 let left_space = x.saturating_sub(1);
1229 let right_space = buffer
1230 .area
1231 .width
1232 .saturating_sub(x + placeholder.chars().count() as u16 + 1);
1233 assert_eq!(y, expected_y);
1234 assert!(left_space.abs_diff(right_space) <= 1);
1235 }
1236
1237 fn table_header_labels<'a, const N: usize>(
1238 headers: &'a [(&'a str, Alignment); N],
1239 ) -> [&'a str; N] {
1240 headers.map(|(label, _)| label)
1241 }
1242
1243 #[test]
1244 fn format_system_time_applies_non_utc_time_zone() {
1245 let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(20 * 60 * 60);
1246 let time_zone = TimeZone::fixed(jiff::tz::offset(5));
1247
1248 assert_eq!(format_system_time_in_zone(timestamp, time_zone), "01:00:00");
1249 }
1250
1251 #[test]
1252 fn table_headers_use_expected_labels() {
1253 assert_eq!(
1254 table_header_labels(&SESSION_TABLE_HEADERS),
1255 [
1256 "", "ID", "Active", "Reqs", "Fail", "Provider", "Model", "Effort", "In", "Out",
1257 "Rate", "Status",
1258 ]
1259 );
1260 assert_eq!(
1261 table_header_labels(&ACTIVE_TABLE_HEADERS),
1262 [
1263 "Started", "Provider", "Model", "Effort", "Endpoint", "Status", "Rate", "Elapsed",
1264 ]
1265 );
1266 assert_eq!(
1267 table_header_labels(&RECENT_TABLE_HEADERS),
1268 [
1269 "Finished", "Status", "Provider", "Model", "Effort", "Latency", "Rate", "In",
1270 "Out", "Details",
1271 ]
1272 );
1273 assert_eq!(
1274 table_header_labels(&RECENT_INDICATOR_TABLE_HEADERS),
1275 [
1276 "Finished", "Status", "Provider", "Model", "Effort", "Latency", "Rate", "In",
1277 "Out", "Err",
1278 ]
1279 );
1280 assert_eq!(
1281 table_header_labels(&EVENTS_TABLE_HEADERS),
1282 ["Time", "Status", "Provider", "Model", "Message"]
1283 );
1284 assert_eq!(ACTIVE_TABLE_HEADERS[6], ("Rate", Alignment::Right));
1285 assert_eq!(RECENT_TABLE_HEADERS[6], ("Rate", Alignment::Right));
1286 }
1287
1288 #[test]
1289 fn display_session_id_shortens_uuids() {
1290 assert_eq!(
1291 display_session_id(Some("57c7c914-ada4-4f40-9672-985f950fbb66")),
1292 "57c7c914"
1293 );
1294 }
1295
1296 #[test]
1297 fn display_session_id_handles_atypical_ids() {
1298 assert_eq!(display_session_id(Some("custom-session")), "custom-session");
1299 assert_eq!(display_session_id(Some("")), "no-session");
1300 assert_eq!(display_session_id(None), "no-session");
1301 }
1302
1303 #[test]
1304 fn recent_rate_column_keeps_full_width() {
1305 let widths = [
1306 Constraint::Length(8),
1307 Constraint::Length(6),
1308 Constraint::Length(8),
1309 Constraint::Length(RECENT_MODEL_WIDTH),
1310 Constraint::Length(7),
1311 Constraint::Length(8),
1312 Constraint::Length(12),
1313 Constraint::Length(7),
1314 Constraint::Length(7),
1315 Constraint::Fill(1),
1316 ];
1317
1318 assert_eq!(table_column_width(Rect::new(0, 0, 90, 10), &widths, 6), 12);
1319 assert_eq!(table_column_width(Rect::new(0, 0, 90, 10), &widths, 7), 7);
1320 assert_eq!(table_column_width(Rect::new(0, 0, 90, 10), &widths, 8), 7);
1321 assert_eq!(
1322 table_column_width(Rect::new(0, 0, 200, 10), &widths, 3),
1323 usize::from(RECENT_MODEL_WIDTH)
1324 );
1325 assert!(
1326 usize::from(RECENT_MODEL_WIDTH) >= "claude-sonnet-4-6 → gpt-5.6-terra".chars().count()
1327 );
1328 }
1329
1330 #[test]
1331 fn model_column_width_tracks_terminal_width() {
1332 let widths = [
1333 Constraint::Length(8),
1334 Constraint::Length(8),
1335 Constraint::Min(18),
1336 Constraint::Length(6),
1337 Constraint::Length(8),
1338 Constraint::Length(14),
1339 Constraint::Length(10),
1340 Constraint::Length(9),
1341 ];
1342 let narrow = table_column_width(Rect::new(0, 0, 110, 10), &widths, 2);
1343 let wide = table_column_width(Rect::new(0, 0, 212, 10), &widths, 2);
1344
1345 assert!(wide > narrow);
1346 assert!(wide >= "claude-haiku-4-5 → gpt-5.6-luna".chars().count());
1347 }
1348
1349 #[test]
1350 fn ellipsize_marks_truncated_values() {
1351 assert_eq!(ellipsize("claude-sonnet-4-6", 16), "claude-sonnet-4…");
1352 assert_eq!(ellipsize("gpt-5.6-sol", 16), "gpt-5.6-sol");
1353 assert_eq!(ellipsize("anything", 0), "");
1354 }
1355
1356 #[test]
1357 fn empty_tables_hide_columns_and_center_placeholders() {
1358 let sessions = draw(40, 9, |frame| {
1359 render_sessions(frame, frame.area(), &[], 0, true)
1360 });
1361 let sessions_text = buffer_text(&sessions);
1362 assert_centered(&sessions, "No sessions", 4);
1363 assert!(!sessions_text.contains("provider"));
1364 assert!(sessions_text.contains("No sessions"));
1365
1366 let active = draw(27, 6, |frame| render_active(frame, frame.area(), &[], 0));
1367 let active_text = buffer_text(&active);
1368 assert_centered(&active, "No active requests", 2);
1369 assert!(!active_text.contains("started"));
1370 assert!(active_text.contains("No active requests"));
1371
1372 let recent = draw(40, 9, |frame| {
1373 render_recent(frame, frame.area(), &[], 0, false)
1374 });
1375 let recent_text = buffer_text(&recent);
1376 assert_centered(&recent, "No recent requests", 4);
1377 assert!(!recent_text.contains("finished"));
1378 assert!(recent_text.contains("No recent requests"));
1379
1380 let events = draw(40, 9, |frame| render_events(frame, frame.area(), &[]));
1381 let events_text = buffer_text(&events);
1382 assert_centered(&events, "No events", 4);
1383 assert!(!events_text.contains("time"));
1384 assert!(events_text.contains("No events"));
1385 }
1386
1387 #[test]
1388 fn active_status_keeps_full_label_at_narrow_width() {
1389 let monitor = MonitorHandle::new(10);
1390 monitor.request_started("request-1", None, None, EndpointKind::Messages);
1391 monitor.upstream_started("request-1");
1392 let state = monitor.snapshot();
1393
1394 let active = draw(88, 6, |frame| {
1395 render_active(frame, frame.area(), &state.active, 0)
1396 });
1397
1398 let active_text = buffer_text(&active);
1399 assert!(active_text.contains("⠋ upstream"), "{active_text}");
1400 }
1401
1402 #[test]
1403 fn populated_tables_render_rows_without_placeholders() {
1404 let monitor = MonitorHandle::new(10);
1405 monitor.request_started(
1406 "request-1",
1407 Some("sess-1".to_string()),
1408 Some(1),
1409 EndpointKind::Messages,
1410 );
1411 monitor.provider_selected(
1412 "request-1",
1413 "codex",
1414 "gpt-5.6-sol",
1415 Some("high".to_string()),
1416 );
1417 let active_state = monitor.snapshot();
1418
1419 let sessions = draw(160, 8, |frame| {
1420 render_sessions(frame, frame.area(), &active_state.sessions, 0, true)
1421 });
1422 let sessions_text = buffer_text(&sessions);
1423 assert!(sessions_text.contains("Provider"));
1424 assert!(sessions_text.contains("sess-1"));
1425 assert!(!sessions_text.contains("No sessions"));
1426
1427 let active = draw(120, 8, |frame| {
1428 render_active(frame, frame.area(), &active_state.active, 0)
1429 });
1430 let active_text = buffer_text(&active);
1431 assert!(active_text.contains("Started"));
1432 assert!(active_text.contains("gpt-5.6-sol"));
1433 assert!(!active_text.contains("No active requests"));
1434
1435 monitor.request_completed("request-1", 200, Some(100), Some(25));
1436 let completed_state = monitor.snapshot();
1437 let recent = draw(140, 8, |frame| {
1438 render_recent(frame, frame.area(), &completed_state.recent, 0, false)
1439 });
1440 let recent_text = buffer_text(&recent);
1441 assert!(recent_text.contains("Finished"));
1442 assert!(recent_text.contains("200"));
1443 assert!(!recent_text.contains("No recent requests"));
1444
1445 let events = draw(100, 8, |frame| {
1446 render_events(frame, frame.area(), &completed_state.recent)
1447 });
1448 assert!(buffer_text(&events).contains("No events"));
1449 }
1450
1451 #[test]
1452 fn recent_table_uses_error_indicator_at_medium_width() {
1453 let monitor = MonitorHandle::new(10);
1454 monitor.request_started("request-1", None, None, EndpointKind::Messages);
1455 monitor.provider_selected("request-1", "codex", "gpt-5.6-sol", None);
1456 monitor.request_failed("request-1", Some(502), "upstream unavailable");
1457 let state = monitor.snapshot();
1458
1459 let recent = draw(110, 8, |frame| {
1460 render_recent(frame, frame.area(), &state.recent, 0, true)
1461 });
1462 let recent_text = buffer_text(&recent);
1463
1464 assert!(recent_text.contains("Err"), "{recent_text}");
1465 assert!(recent_text.contains("!"), "{recent_text}");
1466 assert!(!recent_text.contains("Details"), "{recent_text}");
1467 assert!(
1468 !recent_text.contains("upstream unavailable"),
1469 "{recent_text}"
1470 );
1471 }
1472
1473 #[test]
1474 fn recent_table_keeps_detail_text_at_wide_width() {
1475 let monitor = MonitorHandle::new(10);
1476 monitor.request_started("request-1", None, None, EndpointKind::Messages);
1477 monitor.provider_selected("request-1", "codex", "gpt-5.6-sol", None);
1478 monitor.request_failed("request-1", Some(502), "upstream unavailable");
1479 let state = monitor.snapshot();
1480
1481 let recent = draw(150, 8, |frame| {
1482 render_recent(frame, frame.area(), &state.recent, 0, false)
1483 });
1484 let recent_text = buffer_text(&recent);
1485
1486 assert!(recent_text.contains("Details"), "{recent_text}");
1487 assert!(
1488 recent_text.contains("upstream unavailable"),
1489 "{recent_text}"
1490 );
1491 }
1492
1493 #[test]
1494 fn request_detail_renders_full_error_text() {
1495 let monitor = MonitorHandle::new(10);
1496 monitor.request_started(
1497 "request-1",
1498 Some("sess-1".to_string()),
1499 Some(7),
1500 EndpointKind::Messages,
1501 );
1502 monitor.provider_selected(
1503 "request-1",
1504 "codex",
1505 "gpt-5.6-sol",
1506 Some("high".to_string()),
1507 );
1508 monitor.request_failed("request-1", Some(502), "upstream unavailable");
1509 let state = monitor.snapshot();
1510
1511 let detail = draw(120, 20, |frame| {
1512 render_request_detail(frame, frame.area(), &state, 0)
1513 });
1514 let detail_text = buffer_text(&detail);
1515
1516 assert!(detail_text.contains("Request detail"), "{detail_text}");
1517 assert!(detail_text.contains("request-1"), "{detail_text}");
1518 assert!(detail_text.contains("sess-1"), "{detail_text}");
1519 assert!(
1520 detail_text.contains("upstream unavailable"),
1521 "{detail_text}"
1522 );
1523 }
1524
1525 #[test]
1526 fn events_render_matching_request_rows_without_a_placeholder() {
1527 let monitor = MonitorHandle::new(10);
1528 monitor.request_started("request-1", None, None, EndpointKind::Messages);
1529 monitor.request_failed("request-1", Some(502), "upstream unavailable");
1530 let state = monitor.snapshot();
1531
1532 let events = draw(100, 8, |frame| {
1533 render_events(frame, frame.area(), &state.recent)
1534 });
1535 let events_text = buffer_text(&events);
1536 assert!(events_text.contains("Time"));
1537 assert!(events_text.contains("502"));
1538 assert!(events_text.contains("upstream unavailable"));
1539 assert!(!events_text.contains("No events"));
1540 }
1541
1542 #[test]
1543 fn clamp_selection_caps_to_available_sessions() {
1544 let mut app = MonitorApp {
1545 port: 3000,
1546 setup_text: String::new(),
1547 show_setup: false,
1548 show_help: false,
1549 detail: None,
1550 focus: FocusPane::Sessions,
1551 selected: 10,
1552 recent_selected: 10,
1553 tick: 0,
1554 shutdown: None,
1555 };
1556
1557 app.clamp_selection(3, 4);
1558 assert_eq!(app.selected, 2);
1559 assert_eq!(app.recent_selected, 3);
1560
1561 app.clamp_selection(0, 0);
1562 assert_eq!(app.selected, 0);
1563 assert_eq!(app.recent_selected, 0);
1564 }
1565
1566 #[test]
1567 fn arrow_navigation_moves_between_focus_panes_at_edges() {
1568 let mut app = MonitorApp {
1569 port: 3000,
1570 setup_text: String::new(),
1571 show_setup: false,
1572 show_help: false,
1573 detail: None,
1574 focus: FocusPane::Sessions,
1575 selected: 1,
1576 recent_selected: 0,
1577 tick: 0,
1578 shutdown: None,
1579 };
1580
1581 app.move_down(2, 3, true);
1582 assert_eq!(app.focus, FocusPane::Recent);
1583 assert_eq!(app.recent_selected, 0);
1584
1585 app.move_up(2, 3, true);
1586 assert_eq!(app.focus, FocusPane::Sessions);
1587 assert_eq!(app.selected, 1);
1588 }
1589
1590 #[test]
1591 fn vim_navigation_stays_within_focused_pane() {
1592 let mut app = MonitorApp {
1593 port: 3000,
1594 setup_text: String::new(),
1595 show_setup: false,
1596 show_help: false,
1597 detail: None,
1598 focus: FocusPane::Sessions,
1599 selected: 1,
1600 recent_selected: 0,
1601 tick: 0,
1602 shutdown: None,
1603 };
1604
1605 app.move_down(2, 3, false);
1606 assert_eq!(app.focus, FocusPane::Sessions);
1607 assert_eq!(app.selected, 1);
1608
1609 app.focus = FocusPane::Recent;
1610 app.move_up(2, 3, false);
1611 assert_eq!(app.focus, FocusPane::Recent);
1612 assert_eq!(app.recent_selected, 0);
1613 }
1614}