use crate::core::event::{Event, SessionRecord, SessionStatus};
use crate::metrics::types::MetricsReport;
use crate::metrics::{index, report};
use crate::store::Store;
use crate::ui::theme;
use anyhow::Result;
use crossterm::{
event::{self as cxev, Event as CxEvent, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use std::collections::HashMap;
use std::path::Path;
use time::OffsetDateTime;
use tokio::sync::broadcast;
use tokio::time::{Duration, interval};
const THIRTY_DAYS_SEC: u64 = 30 * 24 * 3600;
const MS_HEURISTIC_THRESHOLD: u64 = 1_000_000_000_000;
struct App {
sessions_all: Vec<SessionRecord>,
sessions: Vec<SessionRecord>,
agent_filter: String,
filter_mode: bool,
filter_buf: String,
clipboard_note: String,
events: Vec<Event>,
tool_lead_by_call: HashMap<String, u64>,
sel_session: usize,
sel_event: usize,
left_focus: bool,
show_help: bool,
detail: bool,
show_metrics: bool,
metrics: Option<MetricsReport>,
pulse: bool,
store: Store,
workspace: String,
feedback_scores: HashMap<String, u8>,
}
impl App {
fn open(workspace: &Path) -> Result<Self> {
let db = workspace.join(".kaizen/kaizen.db");
let store = Store::open(&db)?;
let ws = workspace.to_string_lossy().to_string();
let sessions_all = store.list_sessions(&ws)?;
let _ = index::ensure_indexed(&store, workspace, false);
let metrics = report::build_report(&store, &ws, 7).ok();
let mut app = Self {
sessions: Vec::new(),
sessions_all,
agent_filter: String::new(),
filter_mode: false,
filter_buf: String::new(),
clipboard_note: String::new(),
events: vec![],
tool_lead_by_call: HashMap::new(),
sel_session: 0,
sel_event: 0,
left_focus: true,
show_help: false,
detail: false,
show_metrics: false,
metrics,
pulse: false,
store,
workspace: ws,
feedback_scores: HashMap::new(),
};
app.reapply_filter();
app.refresh()?;
Ok(app)
}
fn reapply_filter(&mut self) {
let f = self.agent_filter.to_lowercase();
if f.is_empty() {
self.sessions.clone_from(&self.sessions_all);
} else {
self.sessions = self
.sessions_all
.iter()
.filter(|s| s.agent.to_lowercase().contains(&f))
.cloned()
.collect();
}
self.sel_session = self.sel_session.min(self.sessions.len().saturating_sub(1));
}
fn refresh(&mut self) -> Result<()> {
self.sessions_all = self.store.list_sessions(&self.workspace)?;
self.reapply_filter();
self.pulse = !self.pulse;
if let Some(s) = self.sessions.get(self.sel_session) {
self.events = self.store.list_events_for_session(&s.id)?;
self.tool_lead_by_call.clear();
for row in self.store.tool_spans_for_session(&s.id)? {
if let (Some(id), Some(lt)) = (row.tool_call_id, row.lead_time_ms) {
self.tool_lead_by_call.insert(id, lt);
}
}
} else {
self.events.clear();
self.tool_lead_by_call.clear();
}
self.sel_event = self.sel_event.min(self.events.len().saturating_sub(1));
self.metrics = report::build_report(&self.store, &self.workspace, 7).ok();
let ids: Vec<String> = self.sessions.iter().map(|s| s.id.clone()).collect();
self.feedback_scores = self
.store
.feedback_for_sessions(&ids)
.unwrap_or_default()
.into_iter()
.filter_map(|(sid, r)| r.score.map(|s| (sid, s.0)))
.collect();
Ok(())
}
fn selected_session(&self) -> Option<&SessionRecord> {
self.sessions.get(self.sel_session)
}
fn selected_id(&self) -> Option<&str> {
self.selected_session().map(|s| s.id.as_str())
}
fn selected_event(&self) -> Option<&Event> {
self.events.get(self.sel_event)
}
}
fn time_ago_label(now_ms: u64, ts_ms: u64) -> String {
if ts_ms == 0 {
return "?".to_string();
}
let mut ts = ts_ms;
if ts < MS_HEURISTIC_THRESHOLD && now_ms >= MS_HEURISTIC_THRESHOLD {
ts = ts.saturating_mul(1000);
}
let diff_sec = now_ms.saturating_sub(ts) / 1000;
if diff_sec > THIRTY_DAYS_SEC {
return abs_ts_label(ts);
}
match diff_sec {
0 => "just now".to_string(),
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3600),
s => format!("{}d", s / 86_400),
}
}
fn abs_ts_label(ts_ms: u64) -> String {
let Ok(dt) = OffsetDateTime::from_unix_timestamp((ts_ms / 1000) as i64) else {
return "?".to_string();
};
format!(
"{:04}-{:02}-{:02} {:02}:{:02}",
dt.year(),
u8::from(dt.month()),
dt.day(),
dt.hour(),
dt.minute()
)
}
fn truncate(s: &str, max: usize) -> &str {
if s.chars().count() <= max {
return s;
}
s.char_indices()
.nth(max.saturating_sub(1))
.map(|(i, _)| &s[..i])
.unwrap_or(s)
}
fn model_suffix(model: &Option<String>) -> String {
const MAX: usize = 20;
match model {
Some(m) if !m.is_empty() => format!(" {}", truncate(m, MAX)),
_ => " —".to_string(),
}
}
fn session_status_letter(s: &SessionRecord) -> char {
match s.status {
SessionStatus::Running => 'R',
SessionStatus::Waiting => 'W',
SessionStatus::Idle => 'I',
SessionStatus::Done => 'D',
}
}
fn format_event_tokens(e: &Event) -> Option<String> {
let mut out = String::new();
match (e.tokens_in, e.tokens_out) {
(Some(a), Some(b)) => out = format!("{a}/{b}"),
(Some(a), None) => out = a.to_string(),
(None, Some(b)) => out = b.to_string(),
(None, None) => {}
}
if let Some(r) = e.reasoning_tokens {
if out.is_empty() {
out = format!("r{r}");
} else {
out = format!("{out}+r{r}");
}
}
if out.is_empty() { None } else { Some(out) }
}
fn event_row_text(now_ms: u64, e: &Event, lead: &HashMap<String, u64>) -> String {
let age = time_ago_label(now_ms, e.ts_ms);
let tool = e.tool.as_deref().unwrap_or("-");
let lead_s = e
.tool_call_id
.as_ref()
.and_then(|id| lead.get(id).copied())
.map(|ms| format!("{ms}ms"))
.unwrap_or_else(|| "—".to_string());
let tok = format_event_tokens(e)
.map(|s| format!(" tok={s}"))
.unwrap_or_default();
format!("{age} {kind:?} {tool}{tok} {lead_s}", kind = e.kind)
}
fn draw(f: &mut ratatui::Frame, app: &App) {
if app.show_help {
draw_help(f);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(f.area());
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(chunks[0]);
draw_sessions(f, app, panes[0]);
draw_events(f, app, panes[1]);
draw_statusbar(f, app, chunks[1]);
}
fn draw_sessions(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
let border_color = if app.left_focus {
theme::BORDER_ACTIVE
} else {
theme::BORDER_INACTIVE
};
let title = if app.agent_filter.is_empty() {
format!("Sessions ({})", app.sessions.len())
} else {
format!(
"Sessions {}/{} (agent filter: {:?})",
app.sessions.len(),
app.sessions_all.len(),
app.agent_filter
)
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let now = now_ms();
let items: Vec<ListItem> = app
.sessions
.iter()
.map(|s| {
let st = format!("{:?}", s.status);
let st_color = theme::status_color(&st);
let age = time_ago_label(now, s.started_at_ms);
let tag = session_status_letter(s);
let m = model_suffix(&s.model);
let score_span = app.feedback_scores.get(&s.id).map(|&sc| {
let color = match sc {
1..=2 => Color::Red,
3 => Color::Yellow,
_ => Color::Green,
};
Span::styled(format!("★{sc}"), Style::default().fg(color))
});
let mut spans = vec![
Span::styled(format!("{:.10}", s.id), Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled(
format!("{:.7}", s.agent),
Style::default().fg(theme::agent_color(&s.agent)),
),
Span::raw(" "),
Span::styled(format!("{tag}"), Style::default().fg(st_color)),
Span::raw(" "),
Span::styled(age, Style::default().fg(Color::White)),
Span::styled(m, Style::default().fg(Color::Gray)),
];
if let Some(s) = score_span {
spans.push(Span::raw(" "));
spans.push(s);
}
let line = Line::from(spans);
ListItem::new(line)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.sel_session));
f.render_stateful_widget(
ratatui::widgets::List::new(items)
.block(block)
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White)),
area,
&mut state,
);
}
fn draw_events(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
if app.show_metrics {
draw_metrics(f, app, area);
return;
}
let id = app.selected_id().unwrap_or("-");
let model = app
.selected_session()
.and_then(|s| s.model.as_deref().filter(|m| !m.is_empty()))
.map(|m| truncate(m, 24).to_string())
.unwrap_or_else(|| "—".to_string());
let border_color = if !app.left_focus {
theme::BORDER_ACTIVE
} else {
theme::BORDER_INACTIVE
};
let title = format!("Events — {:.18} — {}", id, model);
let now = now_ms();
if app.detail
&& let (true, Some(ev)) = (!app.events.is_empty(), app.selected_event())
{
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(2), Constraint::Length(10)])
.split(area);
let items: Vec<ListItem> = app
.events
.iter()
.map(|e| {
let row = event_row_text(now, e, &app.tool_lead_by_call);
ListItem::new(row)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.sel_event));
let list_block = Block::default()
.title(title.clone())
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
f.render_stateful_widget(
List::new(items)
.block(list_block)
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White)),
split[0],
&mut state,
);
let detail = event_detail_text(ev, &app.tool_lead_by_call);
let det_block = Block::default()
.title("Detail")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
f.render_widget(
Paragraph::new(detail)
.block(det_block)
.wrap(Wrap { trim: true }),
split[1],
);
return;
}
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let items: Vec<ListItem> = app
.events
.iter()
.map(|e| {
let row = event_row_text(now, e, &app.tool_lead_by_call);
ListItem::new(row)
})
.collect();
let mut state = ListState::default();
state.select(Some(app.sel_event));
f.render_stateful_widget(
List::new(items)
.block(block)
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White)),
area,
&mut state,
);
}
fn event_detail_text(ev: &Event, lead: &HashMap<String, u64>) -> String {
let lead_s = ev
.tool_call_id
.as_ref()
.and_then(|id| lead.get(id).copied())
.map(|ms| format!("{ms}ms"))
.unwrap_or_else(|| "—".to_string());
let head = format!(
"seq={} kind={:?} tool={} call_id={} in={:?} out={:?} r={:?} cost_e6={:?} lead={}\n",
ev.seq,
ev.kind,
ev.tool.as_deref().unwrap_or("-"),
ev.tool_call_id.as_deref().unwrap_or("—"),
ev.tokens_in,
ev.tokens_out,
ev.reasoning_tokens,
ev.cost_usd_e6,
lead_s
);
let json = serde_json::to_string_pretty(&ev.payload).unwrap_or_else(|_| ev.payload.to_string());
head + &json
}
fn draw_metrics(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
let block = Block::default()
.title("Metrics")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::BORDER_ACTIVE));
let empty = app.metrics.is_none()
|| app
.metrics
.as_ref()
.is_some_and(|m| m.slowest_tools.is_empty() && m.hottest_files.is_empty());
let text = if empty {
"(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."
.to_string()
} else {
let mut lines = vec!["Slow tools".to_string()];
if let Some(metrics) = &app.metrics {
for row in metrics.slowest_tools.iter().take(4) {
let p95 = row
.p95_ms
.map(|v| format!("{v}ms"))
.unwrap_or_else(|| "-".into());
lines.push(format!("{} p95={} tok={}", row.tool, p95, row.total_tokens));
}
lines.push(String::new());
lines.push("Hot files".into());
for row in metrics.hottest_files.iter().take(4) {
lines.push(format!("{} {}", row.value, row.path));
}
}
lines.join("\n")
};
f.render_widget(Paragraph::new(text).block(block), area);
}
fn draw_statusbar(f: &mut ratatui::Frame, app: &App, area: ratatui::layout::Rect) {
let pulse = if app.pulse { "●" } else { "○" };
let text = if app.filter_mode {
format!(
"FILTER type agent substring | Enter apply | Esc cancel | buffer: {}",
app.filter_buf
)
} else {
let note = if app.clipboard_note.is_empty() {
String::new()
} else {
format!(" | {}", app.clipboard_note)
};
format!(
"LIVE {pulse} j/k Tab m metrics / filter y copy id Enter detail ? help q quit{note}"
)
};
f.render_widget(Paragraph::new(text), area);
}
fn draw_help(f: &mut ratatui::Frame) {
let text = "j/k ↑/↓ move in focused pane | g/G first/last | Tab switch pane\n\
Enter toggle event detail | Esc back | r refresh | q quit\n\
/ filter sessions by agent substring | y copy session id (left pane)";
let block = Block::default().title("Help").borders(Borders::ALL);
f.render_widget(Paragraph::new(text).block(block), f.area());
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub async fn run(workspace: &Path) -> Result<()> {
let mut app = App::open(workspace)?;
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
std::panic::set_hook(Box::new(|_| {
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
}));
let (tx, _rx) = broadcast::channel::<()>(1);
let tx2 = tx.clone();
tokio::spawn(async move {
let mut ticker = interval(Duration::from_millis(500));
loop {
ticker.tick().await;
let _ = tx2.send(());
}
});
let mut rx = tx.subscribe();
loop {
terminal.draw(|f| draw(f, &app))?;
tokio::select! {
_ = rx.recv() => { let _ = app.refresh(); }
_ = tokio::task::spawn_blocking(|| { cxev::poll(Duration::from_millis(50)) }) => {
if cxev::poll(Duration::ZERO)?
&& let CxEvent::Key(k) = cxev::read()?
{
if k.kind != KeyEventKind::Press { continue; }
if app.filter_mode {
match k.code {
KeyCode::Enter => {
app.agent_filter = app.filter_buf.trim().to_string();
app.filter_mode = false;
let _ = app.refresh();
}
KeyCode::Esc => {
app.filter_mode = false;
app.filter_buf.clear();
}
KeyCode::Backspace => {
app.filter_buf.pop();
}
KeyCode::Char(c) => {
app.filter_buf.push(c);
}
_ => {}
}
continue;
}
match k.code {
KeyCode::Char('/') => {
app.filter_mode = true;
app.filter_buf.clone_from(&app.agent_filter);
}
KeyCode::Char('y') if app.left_focus => {
if let Some(id) = app.selected_id() {
match arboard::Clipboard::new() {
Ok(mut cb) => {
if cb.set_text(id).is_ok() {
app.clipboard_note = "copied session id".to_string();
} else {
app.clipboard_note = "clipboard write failed".to_string();
}
}
Err(_) => app.clipboard_note = "no clipboard".to_string(),
}
}
}
KeyCode::Char('q') | KeyCode::Esc if !app.detail && !app.show_help => break,
KeyCode::Char('q') if app.show_help => { app.show_help = false; }
KeyCode::Char('q') => { app.detail = false; app.show_help = false; }
KeyCode::Esc | KeyCode::Backspace => {
app.detail = false;
app.show_help = false;
}
KeyCode::Char('?') => app.show_help = !app.show_help,
KeyCode::Char('m') => app.show_metrics = !app.show_metrics,
KeyCode::Tab => {
app.left_focus = !app.left_focus;
}
KeyCode::Char('r') => { let _ = app.refresh(); }
KeyCode::Char('j') | KeyCode::Down => {
if app.show_metrics || app.left_focus {
if app.sel_session + 1 < app.sessions.len() {
app.sel_session += 1;
}
} else if app.sel_event + 1 < app.events.len() {
app.sel_event += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if app.show_metrics || app.left_focus {
if app.sel_session > 0 {
app.sel_session -= 1;
}
} else if app.sel_event > 0 {
app.sel_event -= 1;
}
}
KeyCode::Char('g') => {
if app.show_metrics || app.left_focus {
app.sel_session = 0;
} else {
app.sel_event = 0;
}
}
KeyCode::Char('G') => {
if app.show_metrics || app.left_focus {
app.sel_session = app.sessions.len().saturating_sub(1);
} else {
app.sel_event = app.events.len().saturating_sub(1);
}
}
KeyCode::Enter if !app.events.is_empty() && !app.show_metrics => {
app.detail = !app.detail;
}
_ => {}
}
if matches!(k.code,
KeyCode::Char('j') | KeyCode::Char('k') | KeyCode::Up | KeyCode::Down
| KeyCode::Char('g') | KeyCode::Char('G')
) && (app.show_metrics || app.left_focus)
{
let _ = app.refresh();
}
}
}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn time_ago_just_now() {
assert_eq!(time_ago_label(10_000, 10_000), "just now");
}
#[test]
fn time_ago_treats_small_ts_as_seconds() {
let now = 1_700_000_000_000u64;
let ts_sec = 1_700_000_000u64;
let label = time_ago_label(now, ts_sec);
assert!(
!label.contains('?'),
"expected relative label, got {label:?}"
);
}
#[test]
fn time_ago_old_uses_absolute() {
let now = 1_700_000_000_000u64;
let old = now - (40u64 * 24 * 3600 * 1000);
let label = time_ago_label(now, old);
assert!(label.contains('-'), "expected date-like label: {label}");
}
}