Skip to main content

claude_codex/
tui.rs

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}