Skip to main content

claude_codex/
tui.rs

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