Skip to main content

kaizen/ui/
tui.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Two-pane TUI: session list (left) + events (right).
3
4use crate::core::event::{Event, SessionRecord, SessionStatus};
5use crate::metrics::types::MetricsReport;
6use crate::metrics::{index, report};
7use crate::store::Store;
8use crate::store::span_tree::SpanNode;
9use crate::ui::theme;
10use anyhow::Result;
11use crossterm::{
12    event::{self as cxev, Event as CxEvent, KeyCode, KeyEventKind},
13    execute,
14    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
15};
16use ratatui::{
17    Terminal,
18    backend::CrosstermBackend,
19    layout::{Constraint, Direction, Layout},
20    style::{Color, Style},
21    text::{Line, Span},
22    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
23};
24use std::collections::HashMap;
25use std::path::Path;
26use time::OffsetDateTime;
27use tokio::sync::broadcast;
28use tokio::time::{Duration, interval};
29
30const THIRTY_DAYS_SEC: u64 = 30 * 24 * 3600;
31/// Timestamps below this are treated as Unix **seconds** when `now` looks like ms (ingest mistakes).
32const MS_HEURISTIC_THRESHOLD: u64 = 1_000_000_000_000;
33
34struct App {
35    /// Unfiltered from store (last refresh).
36    sessions_all: Vec<SessionRecord>,
37    /// Filtered by agent substring (see `agent_filter`).
38    sessions: Vec<SessionRecord>,
39    /// Lowercase substring match on `SessionRecord::agent` (empty = show all).
40    agent_filter: String,
41    /// Typing a new filter; Enter commits, Esc cancels.
42    filter_mode: bool,
43    filter_buf: String,
44    /// Shown in status after `y` copy.
45    clipboard_note: String,
46    events: Vec<Event>,
47    /// `tool_call_id` -> `lead_time_ms` for the selected session (from `tool_spans`).
48    tool_lead_by_call: HashMap<String, u64>,
49    sel_session: usize,
50    sel_event: usize,
51    left_focus: bool,
52    show_help: bool,
53    /// When true, show payload / fields for `events[sel_event]` in a strip under the list.
54    detail: bool,
55    show_metrics: bool,
56    metrics: Option<MetricsReport>,
57    pulse: bool,
58    store: Store,
59    workspace: String,
60    /// session_id -> score (1..=5) for visible sessions; populated each refresh.
61    feedback_scores: HashMap<String, u8>,
62    /// Flat span nodes for selected session (depth-indented in detail pane).
63    span_nodes: Vec<SpanNode>,
64}
65
66impl App {
67    fn open(workspace: &Path) -> Result<Self> {
68        let db = workspace.join(".kaizen/kaizen.db");
69        let store = Store::open(&db)?;
70        let ws = workspace.to_string_lossy().to_string();
71        let sessions_all = store.list_sessions(&ws)?;
72        let _ = index::ensure_indexed(&store, workspace, false);
73        let metrics = report::build_report(&store, &ws, 7).ok();
74        let mut app = Self {
75            sessions: Vec::new(),
76            sessions_all,
77            agent_filter: String::new(),
78            filter_mode: false,
79            filter_buf: String::new(),
80            clipboard_note: String::new(),
81            events: vec![],
82            tool_lead_by_call: HashMap::new(),
83            sel_session: 0,
84            sel_event: 0,
85            left_focus: true,
86            show_help: false,
87            detail: false,
88            show_metrics: false,
89            metrics,
90            pulse: false,
91            store,
92            workspace: ws,
93            feedback_scores: HashMap::new(),
94            span_nodes: vec![],
95        };
96        app.reapply_filter();
97        app.refresh()?;
98        Ok(app)
99    }
100
101    fn reapply_filter(&mut self) {
102        let f = self.agent_filter.to_lowercase();
103        if f.is_empty() {
104            self.sessions.clone_from(&self.sessions_all);
105        } else {
106            self.sessions = self
107                .sessions_all
108                .iter()
109                .filter(|s| s.agent.to_lowercase().contains(&f))
110                .cloned()
111                .collect();
112        }
113        self.sel_session = self.sel_session.min(self.sessions.len().saturating_sub(1));
114    }
115
116    fn refresh(&mut self) -> Result<()> {
117        self.sessions_all = self.store.list_sessions(&self.workspace)?;
118        self.reapply_filter();
119        self.pulse = !self.pulse;
120        if let Some(s) = self.sessions.get(self.sel_session) {
121            self.events = self.store.list_events_for_session(&s.id)?;
122            self.tool_lead_by_call.clear();
123            for row in self.store.tool_spans_for_session(&s.id)? {
124                if let (Some(id), Some(lt)) = (row.tool_call_id, row.lead_time_ms) {
125                    self.tool_lead_by_call.insert(id, lt);
126                }
127            }
128            self.span_nodes = self.store.session_span_tree(&s.id).unwrap_or_default();
129        } else {
130            self.events.clear();
131            self.tool_lead_by_call.clear();
132            self.span_nodes.clear();
133        }
134        self.sel_event = self.sel_event.min(self.events.len().saturating_sub(1));
135        self.metrics = report::build_report(&self.store, &self.workspace, 7).ok();
136        let ids: Vec<String> = self.sessions.iter().map(|s| s.id.clone()).collect();
137        self.feedback_scores = self
138            .store
139            .feedback_for_sessions(&ids)
140            .unwrap_or_default()
141            .into_iter()
142            .filter_map(|(sid, r)| r.score.map(|s| (sid, s.0)))
143            .collect();
144        Ok(())
145    }
146
147    fn selected_session(&self) -> Option<&SessionRecord> {
148        self.sessions.get(self.sel_session)
149    }
150
151    fn selected_id(&self) -> Option<&str> {
152        self.selected_session().map(|s| s.id.as_str())
153    }
154
155    fn selected_event(&self) -> Option<&Event> {
156        self.events.get(self.sel_event)
157    }
158}
159
160/// Relative or absolute time; guards bogus `ts_ms` and very old events.
161fn time_ago_label(now_ms: u64, ts_ms: u64) -> String {
162    if ts_ms == 0 {
163        return "?".to_string();
164    }
165    let mut ts = ts_ms;
166    if ts < MS_HEURISTIC_THRESHOLD && now_ms >= MS_HEURISTIC_THRESHOLD {
167        ts = ts.saturating_mul(1000);
168    }
169    let diff_sec = now_ms.saturating_sub(ts) / 1000;
170    if diff_sec > THIRTY_DAYS_SEC {
171        return abs_ts_label(ts);
172    }
173    match diff_sec {
174        0 => "just now".to_string(),
175        s if s < 60 => format!("{s}s"),
176        s if s < 3600 => format!("{}m", s / 60),
177        s if s < 86_400 => format!("{}h", s / 3600),
178        s => format!("{}d", s / 86_400),
179    }
180}
181
182fn abs_ts_label(ts_ms: u64) -> String {
183    let Ok(dt) = OffsetDateTime::from_unix_timestamp((ts_ms / 1000) as i64) else {
184        return "?".to_string();
185    };
186    format!(
187        "{:04}-{:02}-{:02} {:02}:{:02}",
188        dt.year(),
189        u8::from(dt.month()),
190        dt.day(),
191        dt.hour(),
192        dt.minute()
193    )
194}
195
196fn truncate(s: &str, max: usize) -> &str {
197    if s.chars().count() <= max {
198        return s;
199    }
200    s.char_indices()
201        .nth(max.saturating_sub(1))
202        .map(|(i, _)| &s[..i])
203        .unwrap_or(s)
204}
205
206fn model_suffix(model: &Option<String>) -> String {
207    const MAX: usize = 20;
208    match model {
209        Some(m) if !m.is_empty() => format!(" {}", truncate(m, MAX)),
210        _ => " —".to_string(),
211    }
212}
213
214fn session_status_letter(s: &SessionRecord) -> char {
215    match s.status {
216        SessionStatus::Running => 'R',
217        SessionStatus::Waiting => 'W',
218        SessionStatus::Idle => 'I',
219        SessionStatus::Done => 'D',
220    }
221}
222
223fn format_event_tokens(e: &Event) -> Option<String> {
224    let mut out = String::new();
225    match (e.tokens_in, e.tokens_out) {
226        (Some(a), Some(b)) => out = format!("{a}/{b}"),
227        (Some(a), None) => out = a.to_string(),
228        (None, Some(b)) => out = b.to_string(),
229        (None, None) => {}
230    }
231    if let Some(r) = e.reasoning_tokens {
232        if out.is_empty() {
233            out = format!("r{r}");
234        } else {
235            out = format!("{out}+r{r}");
236        }
237    }
238    if out.is_empty() { None } else { Some(out) }
239}
240
241fn event_row_text(now_ms: u64, e: &Event, lead: &HashMap<String, u64>) -> String {
242    let age = time_ago_label(now_ms, e.ts_ms);
243    let tool = e.tool.as_deref().unwrap_or("-");
244    let lead_s = e
245        .tool_call_id
246        .as_ref()
247        .and_then(|id| lead.get(id).copied())
248        .map(|ms| format!("{ms}ms"))
249        .unwrap_or_else(|| "—".to_string());
250    let tok = format_event_tokens(e)
251        .map(|s| format!(" tok={s}"))
252        .unwrap_or_default();
253    format!("{age}  {kind:?}  {tool}{tok}  {lead_s}", kind = e.kind)
254}
255
256fn draw(f: &mut ratatui::Frame, app: &App) {
257    if app.show_help {
258        draw_help(f);
259        return;
260    }
261    let chunks = Layout::default()
262        .direction(Direction::Vertical)
263        .constraints([Constraint::Min(1), Constraint::Length(1)])
264        .split(f.area());
265
266    let panes = Layout::default()
267        .direction(Direction::Horizontal)
268        .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
269        .split(chunks[0]);
270
271    draw_sessions(f, app, panes[0]);
272    draw_events(f, app, panes[1]);
273    draw_statusbar(f, app, chunks[1]);
274}
275
276fn draw_sessions(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
277    let border_color = if app.left_focus {
278        theme::BORDER_ACTIVE
279    } else {
280        theme::BORDER_INACTIVE
281    };
282    let title = if app.agent_filter.is_empty() {
283        format!("Sessions ({})", app.sessions.len())
284    } else {
285        format!(
286            "Sessions {}/{} (agent filter: {:?})",
287            app.sessions.len(),
288            app.sessions_all.len(),
289            app.agent_filter
290        )
291    };
292    let block = Block::default()
293        .title(title)
294        .borders(Borders::ALL)
295        .border_style(Style::default().fg(border_color));
296    let now = now_ms();
297    let items: Vec<ListItem> = app
298        .sessions
299        .iter()
300        .map(|s| {
301            let st = format!("{:?}", s.status);
302            let st_color = theme::status_color(&st);
303            let age = time_ago_label(now, s.started_at_ms);
304            let tag = session_status_letter(s);
305            let m = model_suffix(&s.model);
306            let score_span = app.feedback_scores.get(&s.id).map(|&sc| {
307                let color = match sc {
308                    1..=2 => Color::Red,
309                    3 => Color::Yellow,
310                    _ => Color::Green,
311                };
312                Span::styled(format!("★{sc}"), Style::default().fg(color))
313            });
314            let mut spans = vec![
315                Span::styled(format!("{:.10}", s.id), Style::default().fg(Color::Gray)),
316                Span::raw(" "),
317                Span::styled(
318                    format!("{:.7}", s.agent),
319                    Style::default().fg(theme::agent_color(&s.agent)),
320                ),
321                Span::raw(" "),
322                Span::styled(format!("{tag}"), Style::default().fg(st_color)),
323                Span::raw(" "),
324                Span::styled(age, Style::default().fg(Color::White)),
325                Span::styled(m, Style::default().fg(Color::Gray)),
326            ];
327            if let Some(s) = score_span {
328                spans.push(Span::raw(" "));
329                spans.push(s);
330            }
331            let line = Line::from(spans);
332            ListItem::new(line)
333        })
334        .collect();
335    let mut state = ListState::default();
336    state.select(Some(app.sel_session));
337    f.render_stateful_widget(
338        ratatui::widgets::List::new(items)
339            .block(block)
340            .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)),
341        area,
342        &mut state,
343    );
344}
345
346fn draw_events(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
347    if app.show_metrics {
348        draw_metrics(f, app, area);
349        return;
350    }
351    let id = app.selected_id().unwrap_or("-");
352    let model = app
353        .selected_session()
354        .and_then(|s| s.model.as_deref().filter(|m| !m.is_empty()))
355        .map(|m| truncate(m, 24).to_string())
356        .unwrap_or_else(|| "—".to_string());
357    let border_color = if !app.left_focus {
358        theme::BORDER_ACTIVE
359    } else {
360        theme::BORDER_INACTIVE
361    };
362    let title = format!("Events — {:.18} — {}", id, model);
363    let now = now_ms();
364    if app.detail
365        && let (true, Some(ev)) = (!app.events.is_empty(), app.selected_event())
366    {
367        let split = Layout::default()
368            .direction(Direction::Vertical)
369            .constraints([Constraint::Min(2), Constraint::Length(10)])
370            .split(area);
371        let items: Vec<ListItem> = app
372            .events
373            .iter()
374            .map(|e| {
375                let row = event_row_text(now, e, &app.tool_lead_by_call);
376                ListItem::new(row)
377            })
378            .collect();
379        let mut state = ListState::default();
380        state.select(Some(app.sel_event));
381        let list_block = Block::default()
382            .title(title.clone())
383            .borders(Borders::ALL)
384            .border_style(Style::default().fg(border_color));
385        f.render_stateful_widget(
386            List::new(items)
387                .block(list_block)
388                .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)),
389            split[0],
390            &mut state,
391        );
392        let detail = event_detail_text(ev, &app.tool_lead_by_call);
393        let det_block = Block::default()
394            .title("Detail")
395            .borders(Borders::ALL)
396            .border_style(Style::default().fg(border_color));
397        f.render_widget(
398            Paragraph::new(detail)
399                .block(det_block)
400                .wrap(Wrap { trim: true }),
401            split[1],
402        );
403        return;
404    }
405    if !app.span_nodes.is_empty() {
406        let max_depth: u32 = app
407            .span_nodes
408            .iter()
409            .map(|n| n.span.depth)
410            .max()
411            .unwrap_or(0);
412        let strip_h = (max_depth + 3).min(8) as u16;
413        let split = Layout::default()
414            .direction(Direction::Vertical)
415            .constraints([Constraint::Min(2), Constraint::Length(strip_h)])
416            .split(area);
417        let block = Block::default()
418            .title(title)
419            .borders(Borders::ALL)
420            .border_style(Style::default().fg(border_color));
421        let items: Vec<ListItem> = app
422            .events
423            .iter()
424            .map(|e| ListItem::new(event_row_text(now, e, &app.tool_lead_by_call)))
425            .collect();
426        let mut state = ListState::default();
427        state.select(Some(app.sel_event));
428        f.render_stateful_widget(
429            List::new(items)
430                .block(block)
431                .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)),
432            split[0],
433            &mut state,
434        );
435        let span_text: Vec<Line> = span_depth_lines(&app.span_nodes);
436        let span_block = Block::default()
437            .title("Span tree")
438            .borders(Borders::ALL)
439            .border_style(Style::default().fg(theme::BORDER_INACTIVE));
440        f.render_widget(
441            Paragraph::new(span_text)
442                .block(span_block)
443                .wrap(Wrap { trim: false }),
444            split[1],
445        );
446        return;
447    }
448    let block = Block::default()
449        .title(title)
450        .borders(Borders::ALL)
451        .border_style(Style::default().fg(border_color));
452    let items: Vec<ListItem> = app
453        .events
454        .iter()
455        .map(|e| {
456            let row = event_row_text(now, e, &app.tool_lead_by_call);
457            ListItem::new(row)
458        })
459        .collect();
460    let mut state = ListState::default();
461    state.select(Some(app.sel_event));
462    f.render_stateful_widget(
463        List::new(items)
464            .block(block)
465            .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)),
466        area,
467        &mut state,
468    );
469}
470
471fn span_depth_lines(nodes: &[SpanNode]) -> Vec<Line<'static>> {
472    let mut lines = Vec::new();
473    for n in nodes {
474        push_span_line(&mut lines, n, 0);
475    }
476    lines
477}
478
479fn push_span_line(lines: &mut Vec<Line<'static>>, node: &SpanNode, depth: u32) {
480    let indent = "  ".repeat(depth as usize);
481    let prefix = if depth == 0 { "┌ " } else { "├ " };
482    let cost = node
483        .span
484        .subtree_cost_usd_e6
485        .map(|c| format!(" ${:.4}", c as f64 / 1_000_000.0))
486        .unwrap_or_default();
487    let text = format!("{}{}{}{}", indent, prefix, node.span.tool, cost);
488    lines.push(Line::from(Span::raw(text)));
489    for child in &node.children {
490        push_span_line(lines, child, depth + 1);
491    }
492}
493
494fn event_detail_text(ev: &Event, lead: &HashMap<String, u64>) -> String {
495    let lead_s = ev
496        .tool_call_id
497        .as_ref()
498        .and_then(|id| lead.get(id).copied())
499        .map(|ms| format!("{ms}ms"))
500        .unwrap_or_else(|| "—".to_string());
501    let head = format!(
502        "seq={}  kind={:?}  tool={}  call_id={}  in={:?} out={:?} r={:?}  cost_e6={:?}  lead={}\n",
503        ev.seq,
504        ev.kind,
505        ev.tool.as_deref().unwrap_or("-"),
506        ev.tool_call_id.as_deref().unwrap_or("—"),
507        ev.tokens_in,
508        ev.tokens_out,
509        ev.reasoning_tokens,
510        ev.cost_usd_e6,
511        lead_s
512    );
513    let json = serde_json::to_string_pretty(&ev.payload).unwrap_or_else(|_| ev.payload.to_string());
514    head + &json
515}
516
517fn draw_metrics(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
518    let block = Block::default()
519        .title("Metrics")
520        .borders(Borders::ALL)
521        .border_style(Style::default().fg(theme::BORDER_ACTIVE));
522    let empty = app.metrics.is_none()
523        || app
524            .metrics
525            .as_ref()
526            .is_some_and(|m| m.slowest_tools.is_empty() && m.hottest_files.is_empty());
527    let text = if empty {
528        "(No metrics in this window yet. Run `kaizen metrics` in a shell, or `r` here after a repo is indexed.)\n\nMetrics need a successful snapshot + events for tool spans — see docs/telemetry-journey.md."
529            .to_string()
530    } else {
531        let mut lines = vec!["Slow tools".to_string()];
532        if let Some(metrics) = &app.metrics {
533            for row in metrics.slowest_tools.iter().take(4) {
534                let p95 = row
535                    .p95_ms
536                    .map(|v| format!("{v}ms"))
537                    .unwrap_or_else(|| "-".into());
538                lines.push(format!("{} p95={} tok={}", row.tool, p95, row.total_tokens));
539            }
540            lines.push(String::new());
541            lines.push("Hot files".into());
542            for row in metrics.hottest_files.iter().take(4) {
543                lines.push(format!("{} {}", row.value, row.path));
544            }
545        }
546        lines.join("\n")
547    };
548    f.render_widget(Paragraph::new(text).block(block), area);
549}
550
551fn draw_statusbar(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
552    let pulse = if app.pulse { "●" } else { "○" };
553    let text = if app.filter_mode {
554        format!(
555            "FILTER  type agent substring  |  Enter apply  |  Esc cancel  |  buffer: {}",
556            app.filter_buf
557        )
558    } else {
559        let note = if app.clipboard_note.is_empty() {
560            String::new()
561        } else {
562            format!("  |  {}", app.clipboard_note)
563        };
564        format!(
565            "LIVE {pulse}  j/k  Tab  m metrics  / filter  y copy id  Enter detail  ? help  q quit{note}"
566        )
567    };
568    f.render_widget(Paragraph::new(text), area);
569}
570
571fn draw_help(f: &mut ratatui::Frame) {
572    let text = "j/k ↑/↓  move in focused pane  |  g/G first/last  |  Tab  switch pane\n\
573                Enter  toggle event detail  |  Esc  back  |  r  refresh  |  q  quit\n\
574                /  filter sessions by agent substring  |  y  copy session id (left pane)";
575    let block = Block::default().title("Help").borders(Borders::ALL);
576    f.render_widget(Paragraph::new(text).block(block), f.area());
577}
578
579fn now_ms() -> u64 {
580    std::time::SystemTime::now()
581        .duration_since(std::time::UNIX_EPOCH)
582        .unwrap_or_default()
583        .as_millis() as u64
584}
585
586/// Entry point. Opens terminal, polls SQLite every 500 ms, handles keys.
587pub async fn run(workspace: &Path) -> Result<()> {
588    let mut app = App::open(workspace)?;
589    enable_raw_mode()?;
590    let mut stdout = std::io::stdout();
591    execute!(stdout, EnterAlternateScreen)?;
592    let backend = CrosstermBackend::new(stdout);
593    let mut terminal = Terminal::new(backend)?;
594
595    std::panic::set_hook(Box::new(|_| {
596        let _ = disable_raw_mode();
597        let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
598    }));
599
600    let (tx, _rx) = broadcast::channel::<()>(1);
601    let tx2 = tx.clone();
602    tokio::spawn(async move {
603        let mut ticker = interval(Duration::from_millis(500));
604        loop {
605            ticker.tick().await;
606            let _ = tx2.send(());
607        }
608    });
609    let mut rx = tx.subscribe();
610
611    loop {
612        terminal.draw(|f| draw(f, &app))?;
613        tokio::select! {
614            _ = rx.recv() => { let _ = app.refresh(); }
615            _ = tokio::task::spawn_blocking(|| { cxev::poll(Duration::from_millis(50)) }) => {
616                if cxev::poll(Duration::ZERO)?
617                    && let CxEvent::Key(k) = cxev::read()?
618                {
619                    if k.kind != KeyEventKind::Press { continue; }
620                    if app.filter_mode {
621                        match k.code {
622                            KeyCode::Enter => {
623                                app.agent_filter = app.filter_buf.trim().to_string();
624                                app.filter_mode = false;
625                                let _ = app.refresh();
626                            }
627                            KeyCode::Esc => {
628                                app.filter_mode = false;
629                                app.filter_buf.clear();
630                            }
631                            KeyCode::Backspace => {
632                                app.filter_buf.pop();
633                            }
634                            KeyCode::Char(c) => {
635                                app.filter_buf.push(c);
636                            }
637                            _ => {}
638                        }
639                        continue;
640                    }
641                    match k.code {
642                        KeyCode::Char('/') => {
643                            app.filter_mode = true;
644                            app.filter_buf.clone_from(&app.agent_filter);
645                        }
646                        KeyCode::Char('y') if app.left_focus => {
647                            if let Some(id) = app.selected_id() {
648                                match arboard::Clipboard::new() {
649                                    Ok(mut cb) => {
650                                        if cb.set_text(id).is_ok() {
651                                            app.clipboard_note = "copied session id".to_string();
652                                        } else {
653                                            app.clipboard_note = "clipboard write failed".to_string();
654                                        }
655                                    }
656                                    Err(_) => app.clipboard_note = "no clipboard".to_string(),
657                                }
658                            }
659                        }
660                        KeyCode::Char('q') | KeyCode::Esc if !app.detail && !app.show_help => break,
661                        KeyCode::Char('q') if app.show_help => { app.show_help = false; }
662                        KeyCode::Char('q') => { app.detail = false; app.show_help = false; }
663                        KeyCode::Esc | KeyCode::Backspace => {
664                            app.detail = false;
665                            app.show_help = false;
666                        }
667                        KeyCode::Char('?') => app.show_help = !app.show_help,
668                        KeyCode::Char('m') => app.show_metrics = !app.show_metrics,
669                        KeyCode::Tab => {
670                            app.left_focus = !app.left_focus;
671                        }
672                        KeyCode::Char('r') => { let _ = app.refresh(); }
673                        KeyCode::Char('j') | KeyCode::Down => {
674                            if app.show_metrics || app.left_focus {
675                                if app.sel_session + 1 < app.sessions.len() {
676                                    app.sel_session += 1;
677                                }
678                            } else if app.sel_event + 1 < app.events.len() {
679                                app.sel_event += 1;
680                            }
681                        }
682                        KeyCode::Char('k') | KeyCode::Up => {
683                            if app.show_metrics || app.left_focus {
684                                if app.sel_session > 0 {
685                                    app.sel_session -= 1;
686                                }
687                            } else if app.sel_event > 0 {
688                                app.sel_event -= 1;
689                            }
690                        }
691                        KeyCode::Char('g') => {
692                            if app.show_metrics || app.left_focus {
693                                app.sel_session = 0;
694                            } else {
695                                app.sel_event = 0;
696                            }
697                        }
698                        KeyCode::Char('G') => {
699                            if app.show_metrics || app.left_focus {
700                                app.sel_session = app.sessions.len().saturating_sub(1);
701                            } else {
702                                app.sel_event = app.events.len().saturating_sub(1);
703                            }
704                        }
705                        KeyCode::Enter if !app.events.is_empty() && !app.show_metrics => {
706                            app.detail = !app.detail;
707                        }
708                        _ => {}
709                    }
710                    // Reload events when the selected session index changes.
711                    if matches!(k.code,
712                        KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Up | KeyCode::Down
713                        | KeyCode::Char('g') | KeyCode::Char('G')
714                    ) && (app.show_metrics || app.left_focus)
715                    {
716                        let _ = app.refresh();
717                    }
718                }
719            }
720        }
721    }
722
723    disable_raw_mode()?;
724    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
725    Ok(())
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn time_ago_just_now() {
734        assert_eq!(time_ago_label(10_000, 10_000), "just now");
735    }
736
737    #[test]
738    fn time_ago_treats_small_ts_as_seconds() {
739        let now = 1_700_000_000_000u64;
740        let ts_sec = 1_700_000_000u64;
741        let label = time_ago_label(now, ts_sec);
742        assert!(
743            !label.contains('?'),
744            "expected relative label, got {label:?}"
745        );
746    }
747
748    #[test]
749    fn time_ago_old_uses_absolute() {
750        let now = 1_700_000_000_000u64;
751        let old = now - (40u64 * 24 * 3600 * 1000);
752        let label = time_ago_label(now, old);
753        assert!(label.contains('-'), "expected date-like label: {label}");
754    }
755}