1use 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;
31const MS_HEURISTIC_THRESHOLD: u64 = 1_000_000_000_000;
33
34struct App {
35 sessions_all: Vec<SessionRecord>,
37 sessions: Vec<SessionRecord>,
39 agent_filter: String,
41 filter_mode: bool,
43 filter_buf: String,
44 clipboard_note: String,
46 events: Vec<Event>,
47 tool_lead_by_call: HashMap<String, u64>,
49 sel_session: usize,
50 sel_event: usize,
51 left_focus: bool,
52 show_help: bool,
53 detail: bool,
55 show_metrics: bool,
56 metrics: Option<MetricsReport>,
57 pulse: bool,
58 store: Store,
59 workspace: String,
60 feedback_scores: HashMap<String, u8>,
62 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
160fn 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
586pub 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 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}