Skip to main content

conduit_tui/
lib.rs

1mod json_highlight;
2pub mod logging;
3pub mod theme;
4
5use std::collections::HashSet;
6use std::io;
7use std::num::NonZeroU32;
8use std::sync::mpsc;
9
10use chrono_humanize::HumanTime;
11use conduit_core::{
12    Config, Direction, Provider, Storages, TransitPage, TransitQuery, TransitRecord,
13};
14use crossterm::ExecutableCommand;
15use crossterm::event::{self, Event, KeyCode, KeyEventKind};
16use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
17use logging::LogBuffer;
18use ratatui::Terminal;
19use ratatui::layout::{Alignment, Constraint, Layout, Rect};
20use ratatui::style::Style;
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{
23    Block, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
24    Table, TableState,
25};
26use theme::Theme;
27use uuid::Uuid;
28
29/// Format a u64 with thousand separators (e.g. 1234567 -> "1,234,567").
30fn format_thousands(n: u64) -> String {
31    let s = n.to_string();
32    let mut result = String::with_capacity(s.len() + s.len() / 3);
33    for (i, c) in s.chars().enumerate() {
34        if i > 0 && (s.len() - i).is_multiple_of(3) {
35            result.push(',');
36        }
37        result.push(c);
38    }
39    result
40}
41
42trait AsF64: Copy {
43    fn as_f64(self) -> f64;
44}
45impl AsF64 for f64 {
46    fn as_f64(self) -> f64 {
47        self
48    }
49}
50impl AsF64 for u64 {
51    fn as_f64(self) -> f64 {
52        self as f64
53    }
54}
55
56#[inline]
57fn update_range<T: AsF64>(range: &mut Option<(T, T)>, value: Option<T>) {
58    if let Some(v) = value {
59        *range = Some(match *range {
60            Some((min, max)) => {
61                let vf = v.as_f64();
62                (
63                    if vf < min.as_f64() { v } else { min },
64                    if vf > max.as_f64() { v } else { max },
65                )
66            }
67            None => (v, v),
68        });
69    }
70}
71
72/// Build a right-aligned numeric cell with an optional data-bar background.
73/// When `range` is `Some((min, max))`, the cell background is filled
74/// proportionally to where `value` falls in that range.
75fn numeric_cell<'a, T: AsF64>(
76    value: Option<T>,
77    fmt: fn(T) -> String,
78    width: usize,
79    range: Option<(T, T)>,
80    bar_style: Style,
81) -> Cell<'a> {
82    let text = value.map(fmt).unwrap_or_else(|| "-".into());
83    if let (Some(v), Some((min, max))) = (value, range) {
84        let span = max.as_f64() - min.as_f64();
85        let ratio = if span > 0.0 {
86            (v.as_f64() - min.as_f64()) / span
87        } else {
88            1.0
89        };
90        let padded = format!("{text:>width$}");
91        let bar_chars = ((ratio * width as f64).round() as usize).min(width);
92        let (bar_part, rest_part) = padded.split_at(bar_chars);
93        Cell::from(Line::from(vec![
94            Span::styled(bar_part.to_owned(), bar_style),
95            Span::raw(rest_part.to_owned()),
96        ]))
97    } else {
98        Cell::from(Line::from(text).alignment(Alignment::Right))
99    }
100}
101
102/// Pre-extracted display fields from a `TransitRecord`.
103struct DisplayTransitRecord<'a> {
104    time: String,
105    provider: &'a Provider,
106    model: Option<&'a str>,
107    cost: Option<f64>,
108    input_tokens: Option<u64>,
109    output_tokens: Option<u64>,
110}
111
112impl<'a> DisplayTransitRecord<'a> {
113    fn from_record(record: &'a TransitRecord, relative_time: bool) -> Self {
114        let input_tokens = record
115            .usage
116            .as_ref()
117            .and_then(|u| u.get("prompt_tokens").or_else(|| u.get("input_tokens")))
118            .and_then(|v| v.as_u64());
119        let output_tokens = record
120            .usage
121            .as_ref()
122            .and_then(|u| {
123                u.get("completion_tokens")
124                    .or_else(|| u.get("output_tokens"))
125            })
126            .and_then(|v| v.as_u64());
127        Self {
128            time: if relative_time {
129                HumanTime::from(record.stored_at).to_string()
130            } else {
131                record.stored_at.format("%Y-%m-%d %H:%M:%S").to_string()
132            },
133            provider: &record.provider,
134            model: record.model.as_deref(),
135            cost: record.estimate_cost(),
136            input_tokens,
137            output_tokens,
138        }
139    }
140}
141
142enum View {
143    TransitListing,
144    TransitDetail(usize),
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148enum OpenMenu {
149    File,
150    Help,
151}
152
153pub fn start(
154    config: Config,
155    storages: Storages,
156    log_buffer: LogBuffer,
157    theme: Theme,
158) -> anyhow::Result<()> {
159    let prev_hook = std::panic::take_hook();
160    std::panic::set_hook(Box::new(move |phi| {
161        restore_terminal();
162        prev_hook(phi);
163    }));
164
165    terminal::enable_raw_mode()?;
166    io::stdout().execute(EnterAlternateScreen)?;
167    io::stdout().execute(crossterm::event::EnableMouseCapture)?;
168    let mut terminal = Terminal::new(ratatui::backend::CrosstermBackend::new(io::stdout()))?;
169
170    let mut app = App::new(config, storages, log_buffer, theme);
171    let result = app.run(&mut terminal);
172    restore_terminal();
173
174    result
175}
176
177fn restore_terminal() {
178    let _ = io::stdout().execute(crossterm::cursor::Show);
179    let _ = io::stdout().execute(crossterm::event::DisableMouseCapture);
180    let _ = io::stdout().execute(LeaveAlternateScreen);
181    let _ = terminal::disable_raw_mode();
182}
183
184struct App {
185    _config: Config,
186    storages: Storages,
187    theme: Theme,
188    should_quit: bool,
189    view: View,
190    transit_records: Vec<TransitRecord>,
191    latest_transit_stored_at: Option<chrono::DateTime<chrono::Utc>>,
192    oldest_transit_stored_at: Option<chrono::DateTime<chrono::Utc>>,
193    has_older_records: bool,
194    is_loading_newer: bool,
195    is_loading_older: bool,
196    transit_table_state: TableState,
197    auto_follow: bool,
198    unseen_count: usize,
199    use_relative_time: bool,
200    show_data_bars: bool,
201    transit_detail_scroll: u16,
202    log_buffer: LogBuffer,
203    log_area: Rect,
204    mouse_position: (u16, u16),
205    open_menu: Option<OpenMenu>,
206    show_about: bool,
207    menubar_area: Rect,
208    tick: u64,
209    poll_tx: mpsc::Sender<PollMessage>,
210    poll_rx: mpsc::Receiver<PollMessage>,
211    poll_abort: tokio::task::AbortHandle,
212}
213
214impl App {
215    fn new(_config: Config, storages: Storages, log_buffer: LogBuffer, theme: Theme) -> Self {
216        let (tx, rx) = mpsc::channel();
217
218        let handle = tokio::runtime::Handle::current();
219        let poll_tx = tx.clone();
220        let poll_storages = storages.clone();
221        let task = handle.spawn(poll_loop(poll_storages, tx));
222
223        Self {
224            _config,
225            storages,
226            theme,
227            should_quit: false,
228            view: View::TransitListing,
229            transit_records: Vec::new(),
230            latest_transit_stored_at: None,
231            oldest_transit_stored_at: None,
232            has_older_records: true,
233            is_loading_older: false,
234            transit_table_state: TableState::default(),
235            auto_follow: true,
236            unseen_count: 0,
237            use_relative_time: true,
238            show_data_bars: true,
239            transit_detail_scroll: 0,
240            log_buffer,
241            log_area: Rect::ZERO,
242            mouse_position: (0, 0),
243            open_menu: None,
244            show_about: false,
245            menubar_area: Rect::ZERO,
246            tick: 0,
247            is_loading_newer: true,
248            poll_tx,
249            poll_rx: rx,
250            poll_abort: task.abort_handle(),
251        }
252    }
253
254    fn run(&mut self, terminal: &mut ratatui::DefaultTerminal) -> anyhow::Result<()> {
255        while !self.should_quit {
256            self.process_poll_messages();
257            self.tick = self.tick.wrapping_add(1);
258            terminal.draw(|frame| self.render(frame))?;
259            self.handle_events()?;
260        }
261        self.poll_abort.abort();
262        Ok(())
263    }
264
265    fn render(&mut self, frame: &mut ratatui::Frame) {
266        // Paint the base style (background) across the whole frame
267        let area = frame.area();
268        frame.render_widget(Clear, area);
269        frame.render_widget(Block::default().style(self.theme.base), area);
270
271        let has_menubar = self.theme.menubar.is_some();
272        let menubar_height = if has_menubar { 1 } else { 0 };
273
274        let log_lines: Vec<String> = self
275            .log_buffer
276            .lock()
277            .map(|buf| buf.iter().cloned().collect())
278            .unwrap_or_default();
279
280        let log_height = if log_lines.is_empty() {
281            0
282        } else {
283            log_lines.len() as u16 + 2 // +2 for the borders
284        };
285
286        let chunks = Layout::vertical([
287            Constraint::Length(menubar_height),
288            Constraint::Min(5),
289            Constraint::Length(log_height),
290        ])
291        .split(area);
292        let menubar_area = chunks[0];
293        let content_area = chunks[1];
294        let log_area = chunks[2];
295
296        self.menubar_area = menubar_area;
297        if has_menubar {
298            self.render_menubar(frame, menubar_area);
299        }
300
301        match self.view {
302            View::TransitListing => self.render_transit_listing(frame, content_area),
303            View::TransitDetail(index) => self.render_transit_detail(frame, content_area, index),
304        }
305
306        self.log_area = log_area;
307        self.render_log_panel(frame, log_area, log_lines);
308
309        // Render menu dropdowns and about dialog on top of everything
310        if has_menubar {
311            self.render_menu_dropdowns(frame, menubar_area);
312        }
313        if self.show_about {
314            self.render_about_dialog(frame, area);
315        }
316    }
317
318    fn render_log_panel(
319        &self,
320        frame: &mut ratatui::Frame,
321        area: ratatui::layout::Rect,
322        log_lines: Vec<String>,
323    ) {
324        if log_lines.is_empty() {
325            return;
326        }
327
328        let theme = &self.theme;
329        let log_text: Vec<Line> = log_lines
330            .into_iter()
331            .map(|line| {
332                let style = if line.contains(" ERROR ") {
333                    theme.log_error
334                } else if line.contains(" WARN ") {
335                    theme.log_warn
336                } else if line.contains(" INFO ") {
337                    theme.log_info
338                } else if line.contains(" DEBUG ") {
339                    theme.log_debug
340                } else {
341                    theme.log_other
342                };
343                Line::styled(line, style)
344            })
345            .collect();
346
347        let visible = area.height.saturating_sub(2) as usize;
348        let skip = log_text.len().saturating_sub(visible);
349
350        let clear_button_style = if self.is_log_close_hover() {
351            theme.log_close_hover
352        } else {
353            theme.log_close_normal
354        };
355        let log_widget = Paragraph::new(log_text.into_iter().skip(skip).collect::<Vec<_>>()).block(
356            Block::default()
357                .title(Line::from(vec![
358                    Span::raw(" Logs "),
359                    Span::styled("[ x to clear ]", clear_button_style),
360                    Span::raw(" "),
361                ]))
362                .borders(Borders::ALL)
363                .border_style(theme.border)
364                .title_style(theme.title)
365                .title_alignment(theme.title_alignment),
366        );
367
368        frame.render_widget(log_widget, area);
369    }
370
371    /// X position of the " Help" label on the menubar (right-aligned).
372    fn help_menu_x(&self) -> u16 {
373        // " Help " is 6 chars, pinned to right edge
374        self.menubar_area.x + self.menubar_area.width.saturating_sub(HELP_LABEL_WIDTH)
375    }
376
377    fn render_menubar(&self, frame: &mut ratatui::Frame, area: Rect) {
378        let style = self.theme.menubar.unwrap_or(self.theme.base);
379        let hotkey = self.theme.menubar_hotkey;
380        let sel = self.theme.menubar_selected;
381
382        // Fill the bar
383        let bar = Paragraph::new("").style(style);
384        frame.render_widget(bar, area);
385
386        let file_style = if self.open_menu == Some(OpenMenu::File) {
387            sel
388        } else {
389            style
390        };
391        let file_hotkey = if self.open_menu == Some(OpenMenu::File) {
392            sel
393        } else {
394            hotkey
395        };
396
397        // " File" on the left
398        let file_spans = Line::from(vec![
399            Span::styled(" ", file_style),
400            Span::styled("F", file_hotkey),
401            Span::styled("ile", file_style),
402            Span::styled(" ", file_style),
403        ]);
404        let file_area = Rect::new(area.x, area.y, FILE_LABEL_WIDTH, 1);
405        frame.render_widget(Paragraph::new(file_spans), file_area);
406
407        // " Help " on the right
408        let help_style = if self.open_menu == Some(OpenMenu::Help) {
409            sel
410        } else {
411            style
412        };
413        let help_hotkey = if self.open_menu == Some(OpenMenu::Help) {
414            sel
415        } else {
416            hotkey
417        };
418
419        let help_x = self.help_menu_x();
420        let help_spans = Line::from(vec![
421            Span::styled(" ", help_style),
422            Span::styled("H", help_hotkey),
423            Span::styled("elp", help_style),
424            Span::styled(" ", help_style),
425        ]);
426        let help_area = Rect::new(help_x, area.y, HELP_LABEL_WIDTH, 1);
427        frame.render_widget(Paragraph::new(help_spans), help_area);
428
429        // Animated decoration in the center-right (between menus)
430        let (decoration, dec_width) = self.theme.menubar_decoration.frame(self.tick);
431        if dec_width > 0 {
432            let dec_style = self.theme.menubar_decoration_style;
433            let avail_start = area.x + FILE_LABEL_WIDTH;
434            let avail_end = help_x;
435            if avail_end > avail_start + dec_width {
436                // Center it in the available space
437                let mid = avail_start + (avail_end - avail_start - dec_width) / 2;
438                let dec_area = Rect::new(mid, area.y, dec_width, 1);
439                frame.render_widget(
440                    Paragraph::new(Span::styled(decoration, dec_style)),
441                    dec_area,
442                );
443            }
444        }
445    }
446
447    fn render_menu_dropdowns(&self, frame: &mut ratatui::Frame, menubar_area: Rect) {
448        let style = self.theme.menu_dropdown;
449        let sel = self.theme.menu_dropdown_selected;
450
451        match self.open_menu {
452            Some(OpenMenu::File) => {
453                // Dropdown below " File" (x=1)
454                let drop = Rect::new(
455                    menubar_area.x,
456                    menubar_area.y + 1,
457                    12, // " Quit  Alt+Q"
458                    3,  // top border + item + bottom border
459                );
460                let is_hover = self.menu_dropdown_hover(drop) == Some(0);
461                let item_style = if is_hover { sel } else { style };
462                let block = Block::default().borders(Borders::ALL).style(style);
463                frame.render_widget(Clear, drop);
464                frame.render_widget(block, drop);
465                let item_area = Rect::new(drop.x + 1, drop.y + 1, drop.width - 2, 1);
466                frame.render_widget(
467                    Paragraph::new(Line::from(vec![Span::styled(" Quit    ", item_style)])),
468                    item_area,
469                );
470            }
471            Some(OpenMenu::Help) => {
472                // Dropdown below " Help" (right-aligned)
473                let help_x = self.help_menu_x();
474                let drop_x = help_x.saturating_sub(12 - HELP_LABEL_WIDTH);
475                let drop = Rect::new(drop_x, menubar_area.y + 1, 12, 3);
476                let is_hover = self.menu_dropdown_hover(drop) == Some(0);
477                let item_style = if is_hover { sel } else { style };
478                let block = Block::default().borders(Borders::ALL).style(style);
479                frame.render_widget(Clear, drop);
480                frame.render_widget(block, drop);
481                let item_area = Rect::new(drop.x + 1, drop.y + 1, drop.width - 2, 1);
482                frame.render_widget(
483                    Paragraph::new(Line::from(vec![Span::styled(" About   ", item_style)])),
484                    item_area,
485                );
486            }
487            None => {}
488        }
489    }
490
491    fn render_about_dialog(&self, frame: &mut ratatui::Frame, area: Rect) {
492        let w = 40u16;
493        let h = 7u16;
494        let x = area.x + area.width.saturating_sub(w) / 2;
495        let y = area.y + area.height.saturating_sub(h) / 2;
496        let dialog = Rect::new(x, y, w.min(area.width), h.min(area.height));
497
498        let style = self.theme.menu_dropdown;
499        let title_style = self.theme.title;
500
501        frame.render_widget(Clear, dialog);
502        let text = vec![
503            Line::raw(""),
504            Line::from("Conduit").alignment(Alignment::Center),
505            Line::raw(""),
506            Line::from("API Transit Dashboard").alignment(Alignment::Center),
507            Line::from("Press any key to close").alignment(Alignment::Center),
508        ];
509        let block = Block::default()
510            .title(" About ")
511            .title_alignment(Alignment::Center)
512            .title_style(title_style)
513            .borders(Borders::ALL)
514            .style(style);
515        frame.render_widget(Paragraph::new(text).block(block), dialog);
516    }
517
518    /// Returns which item index (0-based) the mouse is hovering over inside a dropdown.
519    fn menu_dropdown_hover(&self, drop: Rect) -> Option<usize> {
520        let (mx, my) = self.mouse_position;
521        // Items start at drop.y + 1 (after top border), inside drop.x+1..drop.x+w-1
522        if mx > drop.x
523            && mx < drop.x + drop.width - 1
524            && my > drop.y
525            && my < drop.y + drop.height - 1
526        {
527            Some((my - drop.y - 1) as usize)
528        } else {
529            None
530        }
531    }
532
533    fn render_transit_listing(&mut self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
534        let [table_area, status_area] =
535            Layout::vertical([Constraint::Min(5), Constraint::Length(1)]).areas(area);
536
537        let theme = &self.theme;
538
539        let header = Row::new(vec![
540            Cell::from("Time"),
541            Cell::from("Provider"),
542            Cell::from("Model"),
543            Cell::from(Line::from("Cost").alignment(Alignment::Right)),
544            Cell::from(Line::from("Input Tokens").alignment(Alignment::Right)),
545            Cell::from(Line::from("Output Tokens").alignment(Alignment::Right)),
546        ])
547        .style(theme.table_header);
548
549        let bar_style = theme.data_bar;
550
551        let mut cost_range: Option<(f64, f64)> = None;
552        let mut input_range: Option<(u64, u64)> = None;
553        let mut output_range: Option<(u64, u64)> = None;
554        let display_records: Vec<DisplayTransitRecord> = self
555            .transit_records
556            .iter()
557            .map(|r| {
558                let dr = DisplayTransitRecord::from_record(r, self.use_relative_time);
559                if self.show_data_bars {
560                    update_range(&mut cost_range, dr.cost);
561                    update_range(&mut input_range, dr.input_tokens);
562                    update_range(&mut output_range, dr.output_tokens);
563                }
564                dr
565            })
566            .collect();
567
568        let mut rows: Vec<Row> = display_records
569            .iter()
570            .map(|dr| {
571                Row::new(vec![
572                    Cell::from(dr.time.as_str()),
573                    Cell::from(dr.provider.to_string()),
574                    Cell::from(dr.model.unwrap_or("-")),
575                    numeric_cell(dr.cost, |c| format!("${:.5}", c), 12, cost_range, bar_style),
576                    numeric_cell(
577                        dr.input_tokens,
578                        format_thousands,
579                        12,
580                        input_range,
581                        bar_style,
582                    ),
583                    numeric_cell(
584                        dr.output_tokens,
585                        format_thousands,
586                        13,
587                        output_range,
588                        bar_style,
589                    ),
590                ])
591            })
592            .collect();
593
594        if self.is_loading_newer {
595            rows.push(
596                Row::new(vec![Cell::from(""), Cell::from("Loading...")]).style(theme.loading),
597            );
598        }
599
600        let widths = [
601            Constraint::Length(19),
602            Constraint::Length(12),
603            Constraint::Length(20),
604            Constraint::Length(12), // NB: leave room for hundreds and symbols before the decimals
605            Constraint::Length(12),
606            Constraint::Length(13),
607        ];
608        let title = if self.unseen_count > 0 {
609            Line::from(vec![
610                Span::raw(" Requests "),
611                Span::styled(
612                    theme
613                        .unseen_badge_fmt
614                        .replace("{}", &self.unseen_count.to_string()),
615                    theme.unseen_badge,
616                ),
617            ])
618        } else {
619            Line::from(" Requests ")
620        };
621        let table = Table::new(rows, widths)
622            .header(header)
623            .block(
624                Block::default()
625                    .title(title)
626                    .borders(Borders::ALL)
627                    .border_style(theme.border)
628                    .title_style(theme.title)
629                    .title_alignment(theme.title_alignment),
630            )
631            .row_highlight_style(theme.row_highlight);
632
633        frame.render_stateful_widget(table, table_area, &mut self.transit_table_state);
634
635        let content_len = self.transit_records.len();
636        let viewport = self.visible_table_rows(table_area.height);
637        let scroll_pos = self.transit_table_state.selected().unwrap_or(0);
638        let mut scrollbar_state = ScrollbarState::new(content_len.saturating_sub(viewport))
639            .position(scroll_pos.saturating_sub(viewport / 2));
640        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
641        frame.render_stateful_widget(scrollbar, table_area, &mut scrollbar_state);
642
643        let status = format!(
644            "Enter/Right/l: details, q: quit, f: go to latest, t: {}, b: data bars {}",
645            if self.use_relative_time {
646                "absolute times"
647            } else {
648                "relative times"
649            },
650            if self.show_data_bars { "off" } else { "on" },
651        );
652
653        frame.render_widget(Paragraph::new(status).style(theme.status), status_area);
654    }
655
656    fn visible_table_rows(&self, area_height: u16) -> usize {
657        area_height.saturating_sub(3) as usize // borders + header
658    }
659
660    fn render_transit_detail(
661        &mut self,
662        frame: &mut ratatui::Frame,
663        area: ratatui::layout::Rect,
664        index: usize,
665    ) {
666        let [content_area, status_area] =
667            Layout::vertical([Constraint::Min(5), Constraint::Length(1)]).areas(area);
668
669        let theme = &self.theme;
670        let record = &self.transit_records[index];
671
672        let label_style = theme.label;
673
674        let mut lines = vec![
675            Line::from(vec![
676                Span::styled("Transit ID: ", label_style),
677                Span::raw(record.transit_id.to_string()),
678            ]),
679            Line::from(vec![
680                Span::styled("Time:       ", label_style),
681                Span::raw(
682                    record
683                        .stored_at
684                        .format("%Y-%m-%d %H:%M:%S%.6f UTC")
685                        .to_string(),
686                ),
687            ]),
688            Line::from(vec![
689                Span::styled("Provider:   ", label_style),
690                Span::raw(record.provider.to_string()),
691            ]),
692            Line::from(vec![
693                Span::styled("Model:      ", label_style),
694                Span::raw(record.model.as_deref().unwrap_or("-").to_string()),
695            ]),
696            Line::from(vec![
697                Span::styled("Header ID:  ", label_style),
698                Span::raw(record.header_id.as_deref().unwrap_or("-").to_string()),
699            ]),
700            Line::from(vec![
701                Span::styled("Body ID:    ", label_style),
702                Span::raw(record.body_id.as_deref().unwrap_or("-").to_string()),
703            ]),
704            Line::from(vec![
705                Span::styled("Est. Cost:  ", label_style),
706                Span::raw(
707                    record
708                        .estimate_cost()
709                        .map(|c| format!("${:.6}", c))
710                        .unwrap_or_else(|| "-".to_string()),
711                ),
712            ]),
713        ];
714
715        if let Some(ref vh) = record.vh_headers {
716            lines.push(Line::raw(""));
717            lines.push(Line::from(Span::styled("Valohai Headers:", label_style)));
718            let mut keys: Vec<_> = vh.keys().collect();
719            keys.sort();
720            for key in keys {
721                lines.push(Line::from(vec![
722                    Span::styled(format!("  {}: ", key), label_style),
723                    Span::raw(vh[key].clone()),
724                ]));
725            }
726        }
727
728        if let Some(ref usage) = record.usage {
729            lines.push(Line::raw(""));
730            lines.push(Line::from(Span::styled("Full Usage:", label_style)));
731            lines.extend(json_highlight::json_to_lines(usage, theme));
732        }
733
734        let content_len = lines.len();
735        let viewport = content_area.height.saturating_sub(2) as usize;
736        let max_scroll = content_len.saturating_sub(viewport) as u16;
737        self.transit_detail_scroll = self.transit_detail_scroll.min(max_scroll);
738        let detail = Paragraph::new(lines)
739            .block(
740                Block::default()
741                    .title(" Request Details ")
742                    .borders(Borders::ALL)
743                    .border_style(theme.border)
744                    .title_style(theme.title)
745                    .title_alignment(theme.title_alignment),
746            )
747            .scroll((self.transit_detail_scroll, 0));
748        frame.render_widget(detail, content_area);
749
750        let mut scrollbar_state = ScrollbarState::new(content_len.saturating_sub(viewport))
751            .position(self.transit_detail_scroll as usize);
752        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
753        frame.render_stateful_widget(scrollbar, content_area, &mut scrollbar_state);
754
755        let position = format!(" {}/{} ", index + 1, self.transit_records.len());
756        let status = format!(
757            "{}| Backspace/Left/h: back, Up/Down: scroll, [: previvous, ]: next",
758            position,
759        );
760        frame.render_widget(Paragraph::new(status).style(theme.status), status_area);
761    }
762
763    fn handle_events(&mut self) -> anyhow::Result<()> {
764        if event::poll(std::time::Duration::from_millis(100))? {
765            let ev = event::read()?;
766            if let Event::Mouse(mouse) = &ev {
767                self.mouse_position = (mouse.column, mouse.row);
768            }
769
770            // About dialog eats all input
771            if self.show_about {
772                if matches!(ev, Event::Key(key) if key.kind == KeyEventKind::Press)
773                    || matches!(ev, Event::Mouse(m) if m.kind == event::MouseEventKind::Down(event::MouseButton::Left))
774                {
775                    self.show_about = false;
776                }
777                return Ok(());
778            }
779
780            // Menu bar events (when a menu is open or Alt shortcuts)
781            if self.handle_menu_events(&ev)? {
782                return Ok(());
783            }
784
785            match self.view {
786                View::TransitListing => self.handle_listing_events(ev)?,
787                View::TransitDetail(_) => self.handle_detail_events(ev)?,
788            }
789        }
790        Ok(())
791    }
792
793    /// Handle menu-related events. Returns true if the event was consumed.
794    fn handle_menu_events(&mut self, ev: &Event) -> anyhow::Result<bool> {
795        let has_menubar = self.theme.menubar.is_some();
796
797        // Alt+F / Alt+H to open menus
798        if let Event::Key(key) = ev
799            && key.kind == KeyEventKind::Press
800            && has_menubar
801        {
802            if key.modifiers.contains(event::KeyModifiers::ALT) {
803                match key.code {
804                    KeyCode::Char('f') | KeyCode::Char('F') => {
805                        self.open_menu = if self.open_menu == Some(OpenMenu::File) {
806                            None
807                        } else {
808                            Some(OpenMenu::File)
809                        };
810                        return Ok(true);
811                    }
812                    KeyCode::Char('h') | KeyCode::Char('H') => {
813                        self.open_menu = if self.open_menu == Some(OpenMenu::Help) {
814                            None
815                        } else {
816                            Some(OpenMenu::Help)
817                        };
818                        return Ok(true);
819                    }
820                    _ => {}
821                }
822            }
823
824            // When a menu is open, handle navigation
825            if self.open_menu.is_some() {
826                match key.code {
827                    KeyCode::Esc => {
828                        self.open_menu = None;
829                        return Ok(true);
830                    }
831                    KeyCode::Left | KeyCode::Right => {
832                        self.open_menu = Some(match self.open_menu {
833                            Some(OpenMenu::File) => OpenMenu::Help,
834                            _ => OpenMenu::File,
835                        });
836                        return Ok(true);
837                    }
838                    KeyCode::Enter => {
839                        match self.open_menu {
840                            Some(OpenMenu::File) => self.should_quit = true,
841                            Some(OpenMenu::Help) => {
842                                self.show_about = true;
843                                self.open_menu = None;
844                            }
845                            None => {}
846                        }
847                        return Ok(true);
848                    }
849                    _ => {
850                        self.open_menu = None;
851                        return Ok(true);
852                    }
853                }
854            }
855        }
856
857        // Mouse clicks on the menu bar and dropdowns
858        if let Event::Mouse(mouse) = ev {
859            if mouse.kind == event::MouseEventKind::Down(event::MouseButton::Left) && has_menubar {
860                let (mx, my) = (mouse.column, mouse.row);
861
862                // Click on menu bar (row 0)?
863                if my == 0 {
864                    let help_x = self.help_menu_x();
865                    if mx < FILE_LABEL_WIDTH {
866                        // " File"
867                        self.open_menu = if self.open_menu == Some(OpenMenu::File) {
868                            None
869                        } else {
870                            Some(OpenMenu::File)
871                        };
872                        return Ok(true);
873                    } else if mx >= help_x && mx < help_x + HELP_LABEL_WIDTH {
874                        // " Help"
875                        self.open_menu = if self.open_menu == Some(OpenMenu::Help) {
876                            None
877                        } else {
878                            Some(OpenMenu::Help)
879                        };
880                        return Ok(true);
881                    } else if self.open_menu.is_some() {
882                        self.open_menu = None;
883                        return Ok(true);
884                    }
885                }
886
887                // Click inside an open dropdown?
888                if self.open_menu == Some(OpenMenu::File) {
889                    let drop = Rect::new(0, 1, 12, 3);
890                    if let Some(0) = self.menu_dropdown_hover_at(drop, mx, my) {
891                        self.should_quit = true;
892                        return Ok(true);
893                    } else {
894                        self.open_menu = None;
895                        return Ok(true);
896                    }
897                }
898                if self.open_menu == Some(OpenMenu::Help) {
899                    let help_x = self.help_menu_x();
900                    let drop_x = help_x.saturating_sub(12 - HELP_LABEL_WIDTH);
901                    let drop = Rect::new(drop_x, 1, 12, 3);
902                    if let Some(0) = self.menu_dropdown_hover_at(drop, mx, my) {
903                        self.show_about = true;
904                        self.open_menu = None;
905                        return Ok(true);
906                    } else {
907                        self.open_menu = None;
908                        return Ok(true);
909                    }
910                }
911            }
912
913            // Any click outside when menu is open closes it
914            if mouse.kind == event::MouseEventKind::Down(event::MouseButton::Left)
915                && self.open_menu.is_some()
916            {
917                self.open_menu = None;
918                return Ok(true);
919            }
920        }
921
922        Ok(false)
923    }
924
925    fn menu_dropdown_hover_at(&self, drop: Rect, mx: u16, my: u16) -> Option<usize> {
926        if mx > drop.x
927            && mx < drop.x + drop.width - 1
928            && my > drop.y
929            && my < drop.y + drop.height - 1
930        {
931            Some((my - drop.y - 1) as usize)
932        } else {
933            None
934        }
935    }
936
937    fn handle_listing_events(&mut self, ev: Event) -> anyhow::Result<()> {
938        match ev {
939            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
940                KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
941                KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
942                    self.should_quit = true;
943                }
944                KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
945                    self.open_selected_detail();
946                }
947                KeyCode::Up | KeyCode::Char('k') => {
948                    let i = self.transit_table_state.selected().unwrap_or(0);
949                    let prev = i.saturating_sub(1);
950                    self.transit_table_state.select(Some(prev));
951                    if prev == 0 {
952                        self.auto_follow = true;
953                        self.unseen_count = 0;
954                    }
955                }
956                KeyCode::Down | KeyCode::Char('j') => {
957                    self.auto_follow = false;
958                    let i = self.transit_table_state.selected().unwrap_or(0);
959                    let next = (i + 1).min(self.transit_records.len().saturating_sub(1));
960                    self.transit_table_state.select(Some(next));
961                    self.request_older_records_if_needed();
962                }
963                KeyCode::PageUp => {
964                    let i = self.transit_table_state.selected().unwrap_or(0);
965                    let prev = i.saturating_sub(PG_BUTTON_JUMP);
966                    self.transit_table_state.select(Some(prev));
967                    if prev == 0 {
968                        self.auto_follow = true;
969                        self.unseen_count = 0;
970                    }
971                }
972                KeyCode::PageDown => {
973                    self.auto_follow = false;
974                    let i = self.transit_table_state.selected().unwrap_or(0);
975                    let next =
976                        (i + PG_BUTTON_JUMP).min(self.transit_records.len().saturating_sub(1));
977                    self.transit_table_state.select(Some(next));
978                    self.request_older_records_if_needed();
979                }
980                KeyCode::Home => {
981                    self.auto_follow = true;
982                    self.select_first();
983                }
984                KeyCode::End => {
985                    self.auto_follow = false;
986                    if !self.transit_records.is_empty() {
987                        self.transit_table_state
988                            .select(Some(self.transit_records.len() - 1));
989                    }
990                    self.request_older_records_if_needed();
991                }
992                KeyCode::Char('t') | KeyCode::Char('T') => {
993                    self.use_relative_time = !self.use_relative_time;
994                }
995                KeyCode::Char('b') | KeyCode::Char('B') => {
996                    self.show_data_bars = !self.show_data_bars;
997                }
998                KeyCode::Char('f') | KeyCode::Char('F') => {
999                    self.auto_follow = !self.auto_follow;
1000                    if self.auto_follow {
1001                        self.select_first();
1002                    }
1003                }
1004                KeyCode::Char('x') => {
1005                    self.clear_logs();
1006                }
1007                _ => {}
1008            },
1009            Event::Mouse(mouse) => match mouse.kind {
1010                event::MouseEventKind::ScrollUp => {
1011                    let i = self.transit_table_state.selected().unwrap_or(0);
1012                    let prev = i.saturating_sub(1);
1013                    self.transit_table_state.select(Some(prev));
1014                    if prev == 0 {
1015                        self.auto_follow = true;
1016                        self.unseen_count = 0;
1017                    }
1018                }
1019                event::MouseEventKind::ScrollDown => {
1020                    self.auto_follow = false;
1021                    let i = self.transit_table_state.selected().unwrap_or(0);
1022                    let next = (i + 1).min(self.transit_records.len().saturating_sub(1));
1023                    self.transit_table_state.select(Some(next));
1024                    self.request_older_records_if_needed();
1025                }
1026                event::MouseEventKind::Down(event::MouseButton::Left) => {
1027                    if self.is_log_close_hit(mouse.column, mouse.row) {
1028                        self.clear_logs();
1029                    }
1030                }
1031                _ => {}
1032            },
1033            _ => {}
1034        }
1035        Ok(())
1036    }
1037
1038    fn clear_logs(&mut self) {
1039        if let Ok(mut buf) = self.log_buffer.lock() {
1040            buf.clear();
1041        }
1042    }
1043
1044    fn is_log_close_hit(&self, column: u16, row: u16) -> bool {
1045        let area = self.log_area;
1046        if area.height == 0 {
1047            return false;
1048        }
1049        // " Logs [ x to clear ] "
1050        row == area.y && column >= area.x + 7 && column < area.x + 21
1051    }
1052
1053    fn is_log_close_hover(&self) -> bool {
1054        let (col, row) = self.mouse_position;
1055        self.is_log_close_hit(col, row)
1056    }
1057
1058    fn open_selected_detail(&mut self) {
1059        if let Some(index) = self.transit_table_state.selected()
1060            && index < self.transit_records.len()
1061        {
1062            self.transit_detail_scroll = 0;
1063            self.view = View::TransitDetail(index);
1064        }
1065    }
1066
1067    fn handle_detail_events(&mut self, ev: Event) -> anyhow::Result<()> {
1068        match ev {
1069            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
1070                KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
1071                KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1072                    self.should_quit = true;
1073                }
1074                KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => {
1075                    self.view = View::TransitListing;
1076                }
1077                KeyCode::Up | KeyCode::Char('k') => {
1078                    self.transit_detail_scroll = self.transit_detail_scroll.saturating_sub(1);
1079                }
1080                KeyCode::Down | KeyCode::Char('j') => {
1081                    self.transit_detail_scroll = self.transit_detail_scroll.saturating_add(1);
1082                }
1083                KeyCode::PageUp => {
1084                    self.transit_detail_scroll = self
1085                        .transit_detail_scroll
1086                        .saturating_sub(PG_BUTTON_JUMP as u16);
1087                }
1088                KeyCode::PageDown => {
1089                    self.transit_detail_scroll = self
1090                        .transit_detail_scroll
1091                        .saturating_add(PG_BUTTON_JUMP as u16);
1092                }
1093                KeyCode::Home => {
1094                    self.transit_detail_scroll = 0;
1095                }
1096                KeyCode::End => {
1097                    self.transit_detail_scroll = u16::MAX;
1098                }
1099                KeyCode::Char('[') => {
1100                    if let View::TransitDetail(index) = self.view
1101                        && index > 0
1102                    {
1103                        let new_index = index - 1;
1104                        self.transit_detail_scroll = 0;
1105                        self.view = View::TransitDetail(new_index);
1106                        self.transit_table_state.select(Some(new_index));
1107                    }
1108                }
1109                KeyCode::Char(']') => {
1110                    if let View::TransitDetail(index) = self.view {
1111                        let max = self.transit_records.len().saturating_sub(1);
1112                        if index < max {
1113                            let new_index = index + 1;
1114                            self.transit_detail_scroll = 0;
1115                            self.view = View::TransitDetail(new_index);
1116                            self.transit_table_state.select(Some(new_index));
1117                        }
1118                    }
1119                }
1120                _ => {}
1121            },
1122            Event::Mouse(mouse) => match mouse.kind {
1123                event::MouseEventKind::ScrollUp => {
1124                    self.transit_detail_scroll = self.transit_detail_scroll.saturating_sub(3);
1125                }
1126                event::MouseEventKind::ScrollDown => {
1127                    self.transit_detail_scroll = self.transit_detail_scroll.saturating_add(3);
1128                }
1129                event::MouseEventKind::Down(event::MouseButton::Left) => {
1130                    if self.is_log_close_hit(mouse.column, mouse.row) {
1131                        self.clear_logs();
1132                    }
1133                }
1134                _ => {}
1135            },
1136            _ => {}
1137        }
1138        Ok(())
1139    }
1140
1141    fn process_poll_messages(&mut self) {
1142        while let Ok(msg) = self.poll_rx.try_recv() {
1143            match msg {
1144                PollMessage::Loading => {
1145                    self.is_loading_newer = true;
1146                }
1147                PollMessage::Page(page) => {
1148                    self.is_loading_newer = false;
1149                    if self.oldest_transit_stored_at.is_none() {
1150                        self.has_older_records = page.has_more;
1151                    }
1152                    let new_records: Vec<_> = page
1153                        .records
1154                        .into_iter()
1155                        .filter(|r| {
1156                            !self
1157                                .transit_records
1158                                .iter()
1159                                .any(|existing| existing.transit_id == r.transit_id)
1160                        })
1161                        .collect();
1162                    if !new_records.is_empty() {
1163                        self.latest_transit_stored_at = new_records.first().map(|r| r.stored_at);
1164                        if self.oldest_transit_stored_at.is_none() {
1165                            self.oldest_transit_stored_at = new_records.last().map(|r| r.stored_at);
1166                        }
1167                        let new_record_count = new_records.len();
1168                        self.transit_records.splice(0..0, new_records);
1169                        let in_detail = matches!(self.view, View::TransitDetail(_));
1170                        if !self.auto_follow || in_detail {
1171                            self.unseen_count += new_record_count;
1172                        }
1173                        if self.auto_follow && !in_detail {
1174                            self.select_first();
1175                        } else {
1176                            let current_offset = self.transit_table_state.offset();
1177                            *self.transit_table_state.offset_mut() =
1178                                current_offset + new_record_count;
1179                            if let Some(selected) = self.transit_table_state.selected() {
1180                                self.transit_table_state
1181                                    .select(Some(selected + new_record_count));
1182                            }
1183                            if let View::TransitDetail(ref mut index) = self.view {
1184                                *index += new_record_count;
1185                            }
1186                        }
1187                    }
1188                }
1189                PollMessage::OlderPage(page) => {
1190                    self.is_loading_older = false;
1191                    self.has_older_records = page.has_more;
1192                    let new_records: Vec<_> = page
1193                        .records
1194                        .into_iter()
1195                        .filter(|r| {
1196                            !self
1197                                .transit_records
1198                                .iter()
1199                                .any(|existing| existing.transit_id == r.transit_id)
1200                        })
1201                        .collect();
1202                    if !new_records.is_empty() {
1203                        self.oldest_transit_stored_at = new_records.last().map(|r| r.stored_at);
1204                        self.transit_records.extend(new_records);
1205                    }
1206                }
1207                PollMessage::Backfill(updated_records) => {
1208                    for updated in updated_records {
1209                        if let Some(existing) = self
1210                            .transit_records
1211                            .iter_mut()
1212                            .find(|r| r.transit_id == updated.transit_id)
1213                        {
1214                            existing.model = updated.model;
1215                            existing.usage = updated.usage;
1216                        }
1217                    }
1218                }
1219                PollMessage::Error(err) => {
1220                    self.is_loading_newer = false;
1221                    self.is_loading_older = false;
1222                    tracing::error!("{}", err);
1223                }
1224            }
1225        }
1226    }
1227
1228    fn select_first(&mut self) {
1229        self.unseen_count = 0;
1230        if self.transit_records.is_empty() {
1231            self.transit_table_state.select(None);
1232        } else {
1233            self.transit_table_state.select(Some(0));
1234        }
1235    }
1236
1237    fn request_older_records_if_needed(&mut self) {
1238        let selected = self.transit_table_state.selected().unwrap_or(0);
1239        let at_end = !self.transit_records.is_empty()
1240            && selected >= self.transit_records.len().saturating_sub(1);
1241
1242        if !at_end || !self.has_older_records || self.is_loading_older {
1243            return;
1244        }
1245        self.is_loading_older = true;
1246
1247        let cursor = self
1248            .oldest_transit_stored_at
1249            .or_else(|| self.transit_records.last().map(|r| r.stored_at));
1250
1251        let storages = self.storages.clone();
1252        let tx = self.poll_tx.clone();
1253        let handle = tokio::runtime::Handle::current();
1254        handle.spawn(async move {
1255            let limit = NonZeroU32::new(OLDER_PAGE_SIZE).unwrap();
1256            let result = storages
1257                .transit
1258                .list_transits(TransitQuery {
1259                    cursor,
1260                    direction: Direction::Before,
1261                    limit,
1262                })
1263                .await;
1264            match result {
1265                Ok(page) => {
1266                    let _ = tx.send(PollMessage::OlderPage(page));
1267                }
1268                Err(e) => {
1269                    let _ = tx.send(PollMessage::Error(e.to_string()));
1270                }
1271            }
1272        });
1273    }
1274}
1275
1276const PG_BUTTON_JUMP: usize = 10;
1277const FILE_LABEL_WIDTH: u16 = 6; // " File "
1278const HELP_LABEL_WIDTH: u16 = 6; // " Help "
1279const INITIAL_PAGE_SIZE: u32 = 50;
1280const OLDER_PAGE_SIZE: u32 = 25;
1281const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
1282
1283enum PollMessage {
1284    Loading,
1285    Page(TransitPage),
1286    OlderPage(TransitPage),
1287    Backfill(Vec<TransitRecord>),
1288    Error(String),
1289}
1290
1291async fn poll_loop(storages: Storages, tx: mpsc::Sender<PollMessage>) {
1292    let limit = NonZeroU32::new(INITIAL_PAGE_SIZE).unwrap();
1293    let mut incomplete_ids: HashSet<Uuid> = HashSet::new();
1294
1295    let _ = tx.send(PollMessage::Loading);
1296    let initial = storages
1297        .transit
1298        .list_transits(TransitQuery {
1299            cursor: None,
1300            direction: Direction::Before,
1301            limit,
1302        })
1303        .await;
1304
1305    let mut cursor = match initial {
1306        Ok(page) => {
1307            let stored_at = page.records.first().map(|r| r.stored_at);
1308            track_incomplete(&mut incomplete_ids, &page.records);
1309            let _ = tx.send(PollMessage::Page(page));
1310            stored_at
1311        }
1312        Err(e) => {
1313            let _ = tx.send(PollMessage::Error(e.to_string()));
1314            None
1315        }
1316    };
1317
1318    loop {
1319        tokio::time::sleep(POLL_INTERVAL).await;
1320
1321        let _ = tx.send(PollMessage::Loading);
1322
1323        if !incomplete_ids.is_empty() {
1324            let ids: Vec<Uuid> = incomplete_ids.iter().copied().collect();
1325            if let Ok(records) = storages.transit.get_transits(ids).await {
1326                let filled: Vec<TransitRecord> =
1327                    records.into_iter().filter(|r| r.usage.is_some()).collect();
1328                for r in &filled {
1329                    incomplete_ids.remove(&r.transit_id);
1330                }
1331                if !filled.is_empty() {
1332                    let _ = tx.send(PollMessage::Backfill(filled));
1333                }
1334            }
1335        }
1336
1337        let result = storages
1338            .transit
1339            .list_transits(TransitQuery {
1340                cursor,
1341                direction: Direction::After,
1342                limit,
1343            })
1344            .await;
1345
1346        match result {
1347            Ok(page) => {
1348                if let Some(first) = page.records.first() {
1349                    cursor = Some(first.stored_at);
1350                }
1351                track_incomplete(&mut incomplete_ids, &page.records);
1352                let _ = tx.send(PollMessage::Page(page));
1353            }
1354            Err(e) => {
1355                let _ = tx.send(PollMessage::Error(e.to_string()));
1356            }
1357        }
1358    }
1359}
1360
1361fn track_incomplete(incomplete_ids: &mut HashSet<Uuid>, records: &[TransitRecord]) {
1362    for record in records {
1363        // TODO: here we could also read from conduit config if usage is even meant to be recorded
1364        if record.usage.is_none() {
1365            incomplete_ids.insert(record.transit_id);
1366        } else {
1367            incomplete_ids.remove(&record.transit_id);
1368        }
1369    }
1370}