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