1use 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;
30const MS_HEURISTIC_THRESHOLD: u64 = 1_000_000_000_000;
32
33struct App {
34 sessions_all: Vec<SessionRecord>,
36 sessions: Vec<SessionRecord>,
38 agent_filter: String,
40 filter_mode: bool,
42 filter_buf: String,
43 clipboard_note: String,
45 events: Vec<Event>,
46 tool_lead_by_call: HashMap<String, u64>,
48 sel_session: usize,
49 sel_event: usize,
50 left_focus: bool,
51 show_help: bool,
52 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
143fn 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
490pub 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 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}