From 9b711b5609a58fffa80c0b063fee4c6edcf211d9 Mon Sep 17 00:00:00 2001 From: blackax Date: Fri, 27 Mar 2026 14:42:00 -0700 Subject: [PATCH 1/2] feat(dashboard): add session detail drill-down view Implement full-screen session detail view with 4 sub-tabs (Info, Commands, Audit, Snapshots). Press Enter on a session row to drill in, Escape to return. Supports Tab/1-4 for sub-tab switching, j/k/arrows for scrolling, and auto-refresh on interval. --- crates/clx/src/dashboard/app.rs | 196 +++++++ crates/clx/src/dashboard/data.rs | 89 +++ crates/clx/src/dashboard/event.rs | 34 +- crates/clx/src/dashboard/ui/detail.rs | 746 ++++++++++++++++++++++++++ crates/clx/src/dashboard/ui/mod.rs | 11 +- 5 files changed, 1074 insertions(+), 2 deletions(-) create mode 100644 crates/clx/src/dashboard/ui/detail.rs diff --git a/crates/clx/src/dashboard/app.rs b/crates/clx/src/dashboard/app.rs index 728d8a8..f808d13 100644 --- a/crates/clx/src/dashboard/app.rs +++ b/crates/clx/src/dashboard/app.rs @@ -11,6 +11,39 @@ pub enum DashboardTab { Settings, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScreenState { + /// Normal list view (Sessions, Audit, Rules, Settings tabs). + List, + /// Drill-down into a specific session. + SessionDetail(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetailTab { + Info, + Commands, + Audit, + Snapshots, +} + +impl DetailTab { + pub const ALL: [DetailTab; 4] = [Self::Info, Self::Commands, Self::Audit, Self::Snapshots]; + + pub fn title(self) -> &'static str { + match self { + Self::Info => "Info", + Self::Commands => "Commands", + Self::Audit => "Audit", + Self::Snapshots => "Snapshots", + } + } + + pub fn index(self) -> usize { + Self::ALL.iter().position(|t| *t == self).unwrap_or(0) + } +} + /// Where the user intended to go when leaving the Settings tab with unsaved changes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExitTarget { @@ -61,6 +94,15 @@ pub struct App { pub refresh_interval: Duration, pub days_filter: u32, + // Session detail view state + pub screen_state: ScreenState, + pub detail_tab: DetailTab, + pub detail_commands_state: TableState, + pub detail_events_state: TableState, + pub detail_snapshots_state: TableState, + pub detail_scroll_offset: u16, + pub detail_data: Option, + // Settings tab state pub settings_section_idx: usize, pub settings_field_idx: usize, @@ -98,6 +140,13 @@ impl App { last_refresh: Instant::now(), refresh_interval: Duration::from_secs(refresh_secs), days_filter: days, + screen_state: ScreenState::List, + detail_tab: DetailTab::Info, + detail_commands_state: TableState::default(), + detail_events_state: TableState::default(), + detail_snapshots_state: TableState::default(), + detail_scroll_offset: 0, + detail_data: None, settings_section_idx: 0, settings_field_idx: 0, settings_field_table_state: TableState::default(), @@ -285,9 +334,155 @@ impl App { pub fn refresh_data(&mut self) { self.data = super::data::DashboardData::fetch(self.days_filter); + // Also refresh detail data if we are in detail view + if let ScreenState::SessionDetail(ref sid) = self.screen_state { + self.detail_data = super::data::SessionDetailData::fetch(sid); + } self.last_refresh = Instant::now(); } + /// Enter session detail view for the currently selected session row. + pub fn enter_session_detail(&mut self) { + let selected = self.sessions_table_state.selected().unwrap_or(0); + if let Some(row) = self.data.sessions.get(selected) { + let sid = row.session_id.clone(); + self.detail_data = super::data::SessionDetailData::fetch(&sid); + self.screen_state = ScreenState::SessionDetail(sid); + self.detail_tab = DetailTab::Info; + self.detail_commands_state = TableState::default(); + self.detail_events_state = TableState::default(); + self.detail_snapshots_state = TableState::default(); + self.detail_scroll_offset = 0; + } + } + + /// Leave session detail view and return to the list. + pub fn leave_session_detail(&mut self) { + self.screen_state = ScreenState::List; + self.detail_data = None; + } + + pub fn detail_next_tab(&mut self) { + let idx = self.detail_tab.index(); + let next = (idx + 1) % DetailTab::ALL.len(); + self.detail_tab = DetailTab::ALL[next]; + } + + pub fn detail_prev_tab(&mut self) { + let idx = self.detail_tab.index(); + let prev = if idx == 0 { + DetailTab::ALL.len() - 1 + } else { + idx - 1 + }; + self.detail_tab = DetailTab::ALL[prev]; + } + + pub fn detail_scroll_down(&mut self) { + match self.detail_tab { + DetailTab::Info => { + self.detail_scroll_offset = self.detail_scroll_offset.saturating_add(1); + } + DetailTab::Commands => { + if let Some(ref data) = self.detail_data + && !data.audit_entries.is_empty() + { + let i = self.detail_commands_state.selected().unwrap_or(0); + let max = data.audit_entries.len() - 1; + self.detail_commands_state + .select(Some(i.saturating_add(1).min(max))); + } + } + DetailTab::Audit => { + if let Some(ref data) = self.detail_data + && !data.events.is_empty() + { + let i = self.detail_events_state.selected().unwrap_or(0); + let max = data.events.len() - 1; + self.detail_events_state + .select(Some(i.saturating_add(1).min(max))); + } + } + DetailTab::Snapshots => { + if let Some(ref data) = self.detail_data + && !data.snapshots.is_empty() + { + let i = self.detail_snapshots_state.selected().unwrap_or(0); + let max = data.snapshots.len() - 1; + self.detail_snapshots_state + .select(Some(i.saturating_add(1).min(max))); + } + } + } + } + + pub fn detail_scroll_up(&mut self) { + match self.detail_tab { + DetailTab::Info => { + self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(1); + } + DetailTab::Commands => { + let i = self.detail_commands_state.selected().unwrap_or(0); + self.detail_commands_state.select(Some(i.saturating_sub(1))); + } + DetailTab::Audit => { + let i = self.detail_events_state.selected().unwrap_or(0); + self.detail_events_state.select(Some(i.saturating_sub(1))); + } + DetailTab::Snapshots => { + let i = self.detail_snapshots_state.selected().unwrap_or(0); + self.detail_snapshots_state + .select(Some(i.saturating_sub(1))); + } + } + } + + pub fn detail_scroll_to_top(&mut self) { + match self.detail_tab { + DetailTab::Info => self.detail_scroll_offset = 0, + DetailTab::Commands => self.detail_commands_state.select(Some(0)), + DetailTab::Audit => self.detail_events_state.select(Some(0)), + DetailTab::Snapshots => self.detail_snapshots_state.select(Some(0)), + } + } + + pub fn detail_scroll_to_bottom(&mut self) { + if let Some(ref data) = self.detail_data { + match self.detail_tab { + DetailTab::Info => self.detail_scroll_offset = u16::MAX / 2, + DetailTab::Commands => { + if !data.audit_entries.is_empty() { + self.detail_commands_state + .select(Some(data.audit_entries.len() - 1)); + } + } + DetailTab::Audit => { + if !data.events.is_empty() { + self.detail_events_state.select(Some(data.events.len() - 1)); + } + } + DetailTab::Snapshots => { + if !data.snapshots.is_empty() { + self.detail_snapshots_state + .select(Some(data.snapshots.len() - 1)); + } + } + } + } + } + + pub fn detail_page_down(&mut self) { + for _ in 0..Self::PAGE_SIZE { + self.detail_scroll_down(); + } + } + + pub fn detail_page_up(&mut self) { + for _ in 0..Self::PAGE_SIZE { + self.detail_scroll_up(); + } + } + pub fn cycle_sort_column(&mut self) { match self.current_tab { DashboardTab::Sessions => { @@ -508,6 +703,7 @@ mod tests { let mut app = make_app(); for _ in 0..n { app.data.sessions.push(super::super::data::SessionRow { + session_id: "full-session-id".into(), short_id: "abc".into(), project: "/tmp".into(), started: "01-01 00:00".into(), diff --git a/crates/clx/src/dashboard/data.rs b/crates/clx/src/dashboard/data.rs index 1eeeb82..ab2a433 100644 --- a/crates/clx/src/dashboard/data.rs +++ b/crates/clx/src/dashboard/data.rs @@ -52,6 +52,7 @@ pub struct DashboardData { } pub struct SessionRow { + pub session_id: String, // Full session ID for drill-down pub short_id: String, pub project: String, pub started: String, @@ -87,6 +88,93 @@ pub struct BuiltinRuleRow { pub description: Option, } +/// Detail data for a single session drill-down view. +pub struct SessionDetailData { + pub session: clx_core::types::Session, + pub audit_entries: Vec, + pub events: Vec, + pub snapshots: Vec, + pub command_stats: CommandStats, + pub risk_stats: RiskStats, +} + +pub struct CommandStats { + pub total: usize, + pub allowed: usize, + pub blocked: usize, + pub prompted: usize, +} + +pub struct RiskStats { + pub low: usize, + pub medium: usize, + pub high: usize, +} + +impl SessionDetailData { + /// Fetch detail data for a single session from the default database. + pub fn fetch(session_id: &str) -> Option { + let storage = Storage::open_default().ok()?; + Self::fetch_from_storage(&storage, session_id) + } + + /// Fetch detail data from a given storage instance. + pub fn fetch_from_storage(storage: &Storage, session_id: &str) -> Option { + use clx_core::types::AuditDecision; + + let session = storage.get_session(session_id).ok()??; + let audit_entries = storage + .get_audit_log_by_session(session_id) + .unwrap_or_default(); + let events = storage + .get_events_by_session(session_id) + .unwrap_or_default(); + let snapshots = storage + .get_snapshots_by_session(session_id) + .unwrap_or_default(); + + let command_stats = CommandStats { + total: audit_entries.len(), + allowed: audit_entries + .iter() + .filter(|a| a.decision == AuditDecision::Allowed) + .count(), + blocked: audit_entries + .iter() + .filter(|a| a.decision == AuditDecision::Blocked) + .count(), + prompted: audit_entries + .iter() + .filter(|a| a.decision == AuditDecision::Prompted) + .count(), + }; + + let risk_stats = RiskStats { + low: audit_entries + .iter() + .filter(|a| a.risk_score.is_some_and(|s| s <= 3)) + .count(), + medium: audit_entries + .iter() + .filter(|a| a.risk_score.is_some_and(|s| (4..=7).contains(&s))) + .count(), + high: audit_entries + .iter() + .filter(|a| a.risk_score.is_some_and(|s| s >= 8)) + .count(), + }; + + Some(Self { + session, + audit_entries, + events, + snapshots, + command_stats, + risk_stats, + }) + } +} + impl DashboardData { pub fn empty() -> Self { Self { @@ -204,6 +292,7 @@ impl DashboardData { }; SessionRow { + session_id: s.id.as_str().to_string(), short_id, project, started, diff --git a/crates/clx/src/dashboard/event.rs b/crates/clx/src/dashboard/event.rs index 07297e3..82554f1 100644 --- a/crates/clx/src/dashboard/event.rs +++ b/crates/clx/src/dashboard/event.rs @@ -4,7 +4,7 @@ use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use ratatui::DefaultTerminal; -use super::app::{App, DashboardTab, ExitTarget, InputMode}; +use super::app::{App, DashboardTab, DetailTab, ExitTarget, InputMode, ScreenState}; use super::settings::config_bridge; use super::settings::fields::{self, FieldWidget}; use super::ui; @@ -38,6 +38,12 @@ pub fn run_event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> io::Resu } fn handle_key_event(app: &mut App, key: KeyEvent) { + // If we are in the session detail view, handle those keys first. + if matches!(app.screen_state, ScreenState::SessionDetail(_)) { + handle_detail_mode(app, key); + return; + } + match app.input_mode { InputMode::Normal => handle_normal_mode(app, key), InputMode::Filter => handle_filter_mode(app, key), @@ -50,6 +56,12 @@ fn handle_normal_mode(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Char('q') => app.should_quit = true, KeyCode::Esc => app.should_quit = true, + KeyCode::Enter => { + // Drill into session detail when on Sessions tab + if app.current_tab == DashboardTab::Sessions && !app.data.sessions.is_empty() { + app.enter_session_detail(); + } + } KeyCode::Tab => { app.next_tab(); on_tab_switch(app); @@ -79,6 +91,26 @@ fn handle_normal_mode(app: &mut App, key: KeyEvent) { } } +fn handle_detail_mode(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.leave_session_detail(), + KeyCode::Tab => app.detail_next_tab(), + KeyCode::BackTab => app.detail_prev_tab(), + KeyCode::Char('1') => app.detail_tab = DetailTab::Info, + KeyCode::Char('2') => app.detail_tab = DetailTab::Commands, + KeyCode::Char('3') => app.detail_tab = DetailTab::Audit, + KeyCode::Char('4') => app.detail_tab = DetailTab::Snapshots, + KeyCode::Char('j') | KeyCode::Down => app.detail_scroll_down(), + KeyCode::Char('k') | KeyCode::Up => app.detail_scroll_up(), + KeyCode::PageDown => app.detail_page_down(), + KeyCode::PageUp => app.detail_page_up(), + KeyCode::Char('g') | KeyCode::Home => app.detail_scroll_to_top(), + KeyCode::Char('G') | KeyCode::End => app.detail_scroll_to_bottom(), + KeyCode::Char('r') => app.refresh_data(), + _ => {} + } +} + fn handle_filter_mode(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc => { diff --git a/crates/clx/src/dashboard/ui/detail.rs b/crates/clx/src/dashboard/ui/detail.rs new file mode 100644 index 0000000..7ebf19c --- /dev/null +++ b/crates/clx/src/dashboard/ui/detail.rs @@ -0,0 +1,746 @@ +use ratatui::prelude::*; +use ratatui::widgets::{ + Block, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + TableState, Tabs, Wrap, +}; + +use crate::dashboard::app::{App, DetailTab}; +use crate::dashboard::data::SessionDetailData; + +/// Format a token count with K/M suffixes matching existing `format_tokens` in overview. +fn format_tokens(tokens: i64) -> String { + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}K", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +/// Truncate a string to `max_len` characters, appending "..." if truncated. +fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + let mut end = max_len.saturating_sub(3); + while !s.is_char_boundary(end) && end > 0 { + end -= 1; + } + format!("{}...", &s[..end]) + } +} + +pub fn render(frame: &mut Frame, app: &mut App) { + let [header_area, content_area, status_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(frame.area()); + + render_detail_tab_bar(frame, app, header_area); + + if let Some(ref data) = app.detail_data { + // Clone the pieces we need to avoid borrow conflicts with `app` + // for stateful widget rendering. + match app.detail_tab { + DetailTab::Info => render_info(frame, data, app.detail_scroll_offset, content_area), + DetailTab::Commands => { + render_commands(frame, data, &mut app.detail_commands_state, content_area); + } + DetailTab::Audit => { + render_audit(frame, data, &mut app.detail_events_state, content_area); + } + DetailTab::Snapshots => { + render_snapshots(frame, data, &mut app.detail_snapshots_state, content_area); + } + } + } else { + let msg = Paragraph::new("Loading session data...") + .style(Style::default().fg(Color::DarkGray).italic()) + .block(Block::bordered().title(" Session Detail ")); + frame.render_widget(msg, content_area); + } + + render_detail_status_bar(frame, app, status_area); +} + +fn render_detail_tab_bar(frame: &mut Frame, app: &App, area: Rect) { + let short_id = match app.screen_state { + super::super::app::ScreenState::SessionDetail(ref sid) => { + if sid.len() > 8 { + &sid[sid.len() - 8..] + } else { + sid.as_str() + } + } + super::super::app::ScreenState::List => "", + }; + + let titles: Vec<&str> = DetailTab::ALL.iter().map(|t| t.title()).collect(); + let tabs = Tabs::new(titles) + .block(Block::bordered().title(format!(" Session Detail: {short_id} "))) + .select(app.detail_tab.index()) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ) + .divider(ratatui::symbols::DOT); + frame.render_widget(tabs, area); +} + +fn render_detail_status_bar(frame: &mut Frame, app: &App, area: Rect) { + let hints = match app.detail_tab { + DetailTab::Info => "Tab:switch j/k:scroll r:refresh Esc:back 1-4:tab", + DetailTab::Commands | DetailTab::Audit | DetailTab::Snapshots => { + "Tab:switch j/k:select PgUp/Dn g/G:top/bottom r:refresh Esc:back 1-4:tab" + } + }; + + let bar = Paragraph::new(format!(" {hints}")) + .style(Style::default().bg(Color::DarkGray).fg(Color::White)); + frame.render_widget(bar, area); +} + +// ── Info Tab ───────────────────────────────────────────────────────────────── + +#[allow(clippy::vec_init_then_push)] +fn render_info(frame: &mut Frame, data: &SessionDetailData, scroll_offset: u16, area: Rect) { + let session = &data.session; + + let status_style = match session.status { + clx_core::types::SessionStatus::Active => Style::default().fg(Color::Green), + clx_core::types::SessionStatus::Ended => Style::default().fg(Color::DarkGray), + clx_core::types::SessionStatus::Abandoned => Style::default().fg(Color::Red), + }; + + let duration = match session.ended_at { + Some(end) => { + let dur = end - session.started_at; + let mins = dur.num_minutes(); + if mins >= 60 { + format!("{}h {}m", mins / 60, mins % 60) + } else { + format!("{mins}m") + } + } + None => "-".to_string(), + }; + + let ended_str = session.ended_at.map_or_else( + || "-".to_string(), + |e| e.format("%Y-%m-%d %H:%M:%S").to_string(), + ); + + let cost = clx_core::types::estimate_cost(session.input_tokens, session.output_tokens); + let total_tokens = session.input_tokens + session.output_tokens; + + let label_style = Style::default().fg(Color::DarkGray); + let value_style = Style::default().bold(); + + let mut lines: Vec = Vec::new(); + + // Session metadata + lines.push(Line::from(vec![ + Span::styled(" Session: ", label_style), + Span::styled(session.id.as_str(), value_style), + ])); + lines.push(Line::from(vec![ + Span::styled(" Project: ", label_style), + Span::styled(session.project_path.as_str(), value_style), + ])); + lines.push(Line::from(vec![ + Span::styled(" Status: ", label_style), + Span::styled(session.status.as_str(), status_style.bold()), + Span::raw(" "), + Span::styled("Started: ", label_style), + Span::styled( + session.started_at.format("%Y-%m-%d %H:%M:%S").to_string(), + value_style, + ), + ])); + lines.push(Line::from(vec![ + Span::styled(" Source: ", label_style), + Span::styled(session.source.as_str(), value_style), + Span::raw(" "), + Span::styled("Ended: ", label_style), + Span::styled(ended_str, value_style), + ])); + lines.push(Line::from(vec![ + Span::styled(" Duration: ", label_style), + Span::styled(&duration, value_style), + ])); + lines.push(Line::from("")); + + // Metric boxes (text-based) + let cs = &data.command_stats; + let rs = &data.risk_stats; + + let pct = |n: usize, total: usize| -> String { + if total == 0 { + "0%".to_string() + } else { + format!("{}%", n * 100 / total) + } + }; + + let cyan_style = Style::default().fg(Color::Cyan).bold(); + + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Tokens", cyan_style), + Span::raw(" "), + Span::styled("Commands", cyan_style), + Span::raw(" "), + Span::styled("Risk", cyan_style), + ])); + lines.push(Line::from(vec![ + Span::styled(" Input: ", label_style), + Span::styled( + format!("{:<12}", format_tokens(session.input_tokens)), + value_style, + ), + Span::raw(" "), + Span::styled("Total: ", label_style), + Span::styled(format!("{:<12}", cs.total), value_style), + Span::raw(" "), + Span::styled("Low (1-3): ", label_style), + Span::styled(rs.low.to_string(), Style::default().fg(Color::Green).bold()), + ])); + lines.push(Line::from(vec![ + Span::styled(" Output: ", label_style), + Span::styled( + format!("{:<12}", format_tokens(session.output_tokens)), + value_style, + ), + Span::raw(" "), + Span::styled("Allowed: ", label_style), + Span::styled( + format!("{:<5}({})", cs.allowed, pct(cs.allowed, cs.total)), + Style::default().fg(Color::Green).bold(), + ), + Span::raw(" "), + Span::styled("Med (4-7): ", label_style), + Span::styled( + rs.medium.to_string(), + Style::default().fg(Color::Yellow).bold(), + ), + ])); + lines.push(Line::from(vec![ + Span::styled(" Total: ", label_style), + Span::styled(format!("{:<12}", format_tokens(total_tokens)), value_style), + Span::raw(" "), + Span::styled("Blocked: ", label_style), + Span::styled( + format!("{:<5}({})", cs.blocked, pct(cs.blocked, cs.total)), + Style::default().fg(Color::Red).bold(), + ), + Span::raw(" "), + Span::styled("High (8-10):", label_style), + Span::raw(" "), + Span::styled(rs.high.to_string(), Style::default().fg(Color::Red).bold()), + ])); + lines.push(Line::from(vec![ + Span::styled(" Cost: ", label_style), + Span::styled( + format!("${cost:.2}"), + Style::default().fg(Color::Yellow).bold(), + ), + Span::raw(" "), + Span::styled("Prompted: ", label_style), + Span::styled( + format!("{:<5}({})", cs.prompted, pct(cs.prompted, cs.total)), + Style::default().fg(Color::Yellow).bold(), + ), + ])); + lines.push(Line::from("")); + + // Messages and snapshots count + lines.push(Line::from(vec![ + Span::styled(" Messages: ", label_style), + Span::styled(session.message_count.to_string(), value_style), + Span::raw(" "), + Span::styled("Snapshots: ", label_style), + Span::styled(data.snapshots.len().to_string(), value_style), + Span::raw(" "), + Span::styled("Events: ", label_style), + Span::styled(data.events.len().to_string(), value_style), + ])); + lines.push(Line::from("")); + + // Latest snapshot preview + if let Some(snap) = data.snapshots.first() { + lines.push(Line::from(Span::styled(" Latest Snapshot:", cyan_style))); + if let Some(ref summary) = snap.summary { + lines.push(Line::from(vec![ + Span::styled(" Summary: ", label_style), + Span::from(truncate(summary, 120)), + ])); + } + if let Some(ref facts) = snap.key_facts { + lines.push(Line::from(vec![ + Span::styled(" Key Facts: ", label_style), + Span::from(truncate(facts, 120)), + ])); + } + if let Some(ref todos) = snap.todos { + lines.push(Line::from(vec![ + Span::styled(" TODOs: ", label_style), + Span::from(truncate(todos, 120)), + ])); + } + } + + let paragraph = Paragraph::new(lines) + .block(Block::bordered().title(" Info ")) + .wrap(Wrap { trim: false }) + .scroll((scroll_offset, 0)); + frame.render_widget(paragraph, area); +} + +// ── Commands Tab ───────────────────────────────────────────────────────────── + +fn render_commands( + frame: &mut Frame, + data: &SessionDetailData, + table_state: &mut TableState, + area: Rect, +) { + let [table_area, detail_area] = + Layout::vertical([Constraint::Min(8), Constraint::Percentage(30)]).areas(area); + + let entries = &data.audit_entries; + let count = entries.len(); + + let header = Row::new(vec![ + Cell::from("Time"), + Cell::from("Decision"), + Cell::from("Risk"), + Cell::from("Layer"), + Cell::from("Command"), + ]) + .style(Style::default().bold()) + .bottom_margin(1); + + let rows: Vec = if entries.is_empty() { + vec![Row::new(vec![Cell::from(Span::styled( + "No commands found", + Style::default().fg(Color::DarkGray).italic(), + ))])] + } else { + entries + .iter() + .map(|e| { + let decision_style = decision_style(e.decision.as_str()); + let risk_display = e + .risk_score + .map_or_else(|| "-".to_string(), |r: i32| r.to_string()); + + Row::new(vec![ + Cell::from(e.timestamp.format("%H:%M:%S").to_string()), + Cell::from(e.decision.as_str()).style(decision_style), + Cell::from(risk_display), + Cell::from(e.layer.as_str()), + Cell::from(truncate(&e.command, 60)), + ]) + }) + .collect() + }; + + let widths = [ + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(6), + Constraint::Length(12), + Constraint::Fill(1), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(Block::bordered().title(format!(" Commands ({count}) "))) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("\u{2192} "); + frame.render_stateful_widget(table, table_area, table_state); + + // Scrollbar + let selected = table_state.selected().unwrap_or(0); + let mut scrollbar_state = ScrollbarState::new(count).position(selected); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("\u{2191}")) + .end_symbol(Some("\u{2193}")); + frame.render_stateful_widget(scrollbar, table_area, &mut scrollbar_state); + + // Detail pane + render_command_detail(frame, entries, table_state, detail_area); +} + +fn render_command_detail( + frame: &mut Frame, + entries: &[clx_core::types::AuditLogEntry], + table_state: &TableState, + area: Rect, +) { + let selected = table_state.selected(); + let label_style = Style::default().fg(Color::DarkGray); + + let text = match selected.and_then(|i| entries.get(i)) { + Some(entry) => { + let ds = decision_style(entry.decision.as_str()); + let risk_display = entry + .risk_score + .map_or_else(|| "-".to_string(), |r: i32| r.to_string()); + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Command: ", Style::default().fg(Color::Cyan).bold()), + Span::from(entry.command.clone()), + ]), + Line::from(vec![ + Span::styled("Decision: ", label_style), + Span::styled(entry.decision.as_str(), ds), + Span::raw(" "), + Span::styled("Risk: ", label_style), + Span::from(risk_display), + Span::raw(" "), + Span::styled("Layer: ", label_style), + Span::from(entry.layer.clone()), + ]), + ]; + + if let Some(ref reason) = entry.reasoning { + lines.push(Line::from(vec![ + Span::styled("Reason: ", label_style), + Span::from(reason.clone()), + ])); + } + if let Some(ref wd) = entry.working_dir { + lines.push(Line::from(vec![ + Span::styled("Working Dir: ", label_style), + Span::from(wd.clone()), + ])); + } + + lines + } + None => vec![Line::from(Span::styled( + "Select a row to view details", + Style::default().fg(Color::DarkGray).italic(), + ))], + }; + + let paragraph = Paragraph::new(text) + .block(Block::bordered().title(" Detail ")) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +// ── Audit (Events) Tab ─────────────────────────────────────────────────────── + +fn render_audit( + frame: &mut Frame, + data: &SessionDetailData, + table_state: &mut TableState, + area: Rect, +) { + let [table_area, detail_area] = + Layout::vertical([Constraint::Min(8), Constraint::Percentage(30)]).areas(area); + + let events = &data.events; + let count = events.len(); + + let header = Row::new(vec![ + Cell::from("Time"), + Cell::from("Type"), + Cell::from("Tool"), + Cell::from("Details"), + ]) + .style(Style::default().bold()) + .bottom_margin(1); + + let rows: Vec = if events.is_empty() { + vec![Row::new(vec![Cell::from(Span::styled( + "No events found", + Style::default().fg(Color::DarkGray).italic(), + ))])] + } else { + events + .iter() + .map(|e| { + let tool = e.tool_name.as_deref().unwrap_or("-"); + let details = e + .tool_input + .as_deref() + .or(e.tool_output.as_deref()) + .unwrap_or("-"); + + Row::new(vec![ + Cell::from(e.timestamp.format("%H:%M:%S").to_string()), + Cell::from(e.event_type.as_str()), + Cell::from(tool.to_string()), + Cell::from(truncate(details, 60)), + ]) + }) + .collect() + }; + + let widths = [ + Constraint::Length(10), + Constraint::Length(14), + Constraint::Length(14), + Constraint::Fill(1), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(Block::bordered().title(format!(" Events ({count}) "))) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("\u{2192} "); + frame.render_stateful_widget(table, table_area, table_state); + + // Scrollbar + let selected = table_state.selected().unwrap_or(0); + let mut scrollbar_state = ScrollbarState::new(count).position(selected); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("\u{2191}")) + .end_symbol(Some("\u{2193}")); + frame.render_stateful_widget(scrollbar, table_area, &mut scrollbar_state); + + // Detail pane + render_event_detail(frame, events, table_state, detail_area); +} + +fn render_event_detail( + frame: &mut Frame, + events: &[clx_core::types::Event], + table_state: &TableState, + area: Rect, +) { + let selected = table_state.selected(); + let label_style = Style::default().fg(Color::DarkGray); + + let text = match selected.and_then(|i| events.get(i)) { + Some(event) => { + let mut lines = vec![Line::from(vec![ + Span::styled("Event: ", Style::default().fg(Color::Cyan).bold()), + Span::from(event.event_type.as_str()), + Span::raw(" "), + Span::styled( + format!("({})", event.tool_name.as_deref().unwrap_or("-")), + label_style, + ), + ])]; + + if let Some(ref tuid) = event.tool_use_id { + lines.push(Line::from(vec![ + Span::styled("Tool Use ID: ", label_style), + Span::from(tuid.clone()), + ])); + } + + lines.push(Line::from(vec![ + Span::styled("Time: ", label_style), + Span::from(event.timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string()), + ])); + + if let Some(ref input) = event.tool_input { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Input:", + Style::default().fg(Color::Cyan).bold(), + ))); + lines.push(Line::from(truncate(input, 200))); + } + if let Some(ref output) = event.tool_output { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Output:", + Style::default().fg(Color::Cyan).bold(), + ))); + lines.push(Line::from(truncate(output, 200))); + } + + lines + } + None => vec![Line::from(Span::styled( + "Select a row to view details", + Style::default().fg(Color::DarkGray).italic(), + ))], + }; + + let paragraph = Paragraph::new(text) + .block(Block::bordered().title(" Detail ")) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +// ── Snapshots Tab ──────────────────────────────────────────────────────────── + +fn render_snapshots( + frame: &mut Frame, + data: &SessionDetailData, + table_state: &mut TableState, + area: Rect, +) { + let [table_area, detail_area] = + Layout::vertical([Constraint::Min(6), Constraint::Percentage(50)]).areas(area); + + let snapshots = &data.snapshots; + let count = snapshots.len(); + + let header = Row::new(vec![ + Cell::from("#"), + Cell::from("Created"), + Cell::from("Trigger"), + Cell::from("Tokens (in/out)"), + Cell::from("Messages"), + ]) + .style(Style::default().bold()) + .bottom_margin(1); + + let rows: Vec = if snapshots.is_empty() { + vec![Row::new(vec![Cell::from(Span::styled( + "No snapshots found", + Style::default().fg(Color::DarkGray).italic(), + ))])] + } else { + snapshots + .iter() + .enumerate() + .map(|(i, s)| { + let tokens = format!( + "{}/{}", + format_tokens(s.input_tokens.unwrap_or(0)), + format_tokens(s.output_tokens.unwrap_or(0)), + ); + let msgs = s + .message_count + .map_or_else(|| "-".to_string(), |m| m.to_string()); + + Row::new(vec![ + Cell::from((i + 1).to_string()), + Cell::from(s.created_at.format("%m-%d %H:%M").to_string()), + Cell::from(s.trigger.as_str()), + Cell::from(tokens), + Cell::from(msgs), + ]) + }) + .collect() + }; + + let widths = [ + Constraint::Length(4), + Constraint::Length(14), + Constraint::Length(18), + Constraint::Length(18), + Constraint::Length(10), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(Block::bordered().title(format!(" Snapshots ({count}) "))) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("\u{2192} "); + frame.render_stateful_widget(table, table_area, table_state); + + // Scrollbar + let selected = table_state.selected().unwrap_or(0); + let mut scrollbar_state = ScrollbarState::new(count).position(selected); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("\u{2191}")) + .end_symbol(Some("\u{2193}")); + frame.render_stateful_widget(scrollbar, table_area, &mut scrollbar_state); + + // Detail pane + render_snapshot_detail(frame, snapshots, table_state, detail_area); +} + +fn render_snapshot_detail( + frame: &mut Frame, + snapshots: &[clx_core::types::Snapshot], + table_state: &TableState, + area: Rect, +) { + let selected = table_state.selected(); + let label_style = Style::default().fg(Color::DarkGray); + let heading_style = Style::default().fg(Color::Cyan).bold(); + + let text = match selected.and_then(|i| snapshots.get(i)) { + Some(snap) => { + let idx = selected.unwrap_or(0) + 1; + let mut lines = vec![Line::from(vec![Span::styled( + format!( + "Snapshot #{idx} \u{2014} {} ({})", + snap.trigger.as_str(), + snap.created_at.format("%Y-%m-%d %H:%M") + ), + heading_style, + )])]; + + lines.push(Line::from("")); + + if let Some(ref summary) = snap.summary { + lines.push(Line::from(Span::styled("Summary:", heading_style))); + for line in summary.lines() { + lines.push(Line::from(format!(" {line}"))); + } + lines.push(Line::from("")); + } + if let Some(ref facts) = snap.key_facts { + lines.push(Line::from(Span::styled("Key Facts:", heading_style))); + for line in facts.lines() { + lines.push(Line::from(format!(" {line}"))); + } + lines.push(Line::from("")); + } + if let Some(ref todos) = snap.todos { + lines.push(Line::from(Span::styled("TODOs:", heading_style))); + for line in todos.lines() { + lines.push(Line::from(format!(" {line}"))); + } + } + + if snap.summary.is_none() && snap.key_facts.is_none() && snap.todos.is_none() { + lines.push(Line::from(Span::styled( + "No content in this snapshot", + label_style.italic(), + ))); + } + + lines + } + None => vec![Line::from(Span::styled( + "Select a snapshot to view details", + Style::default().fg(Color::DarkGray).italic(), + ))], + }; + + let paragraph = Paragraph::new(text) + .block(Block::bordered().title(" Snapshot Detail ")) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn decision_style(decision: &str) -> Style { + match decision { + "allowed" => Style::default().fg(Color::Green), + "blocked" => Style::default().fg(Color::Red), + "prompted" => Style::default().fg(Color::Yellow), + _ => Style::default().fg(Color::White), + } +} diff --git a/crates/clx/src/dashboard/ui/mod.rs b/crates/clx/src/dashboard/ui/mod.rs index 5cfbcb1..833668b 100644 --- a/crates/clx/src/dashboard/ui/mod.rs +++ b/crates/clx/src/dashboard/ui/mod.rs @@ -1,4 +1,5 @@ mod audit; +mod detail; pub(super) mod overview; mod rules; mod sessions; @@ -9,9 +10,16 @@ use ratatui::prelude::*; use ratatui::symbols; use ratatui::widgets::{Block, Paragraph, Tabs}; -use super::app::{App, DashboardTab, InputMode}; +use super::app::{App, DashboardTab, InputMode, ScreenState}; pub fn render(frame: &mut Frame, app: &mut App) { + match app.screen_state { + ScreenState::List => render_list_view(frame, app), + ScreenState::SessionDetail(_) => detail::render(frame, app), + } +} + +fn render_list_view(frame: &mut Frame, app: &mut App) { let chunks = Layout::vertical([ Constraint::Length(3), Constraint::Min(10), @@ -273,6 +281,7 @@ mod tests { fn session_row(id: &str) -> SessionRow { SessionRow { + session_id: format!("full-{id}"), short_id: id.to_string(), project: "/home/user/project".to_string(), started: "03-13 09:00".to_string(), From 04bffc500351d32c66d3ae2b30b49e9f91b49bd0 Mon Sep 17 00:00:00 2001 From: blackax Date: Fri, 27 Mar 2026 14:48:57 -0700 Subject: [PATCH 2/2] test(dashboard): add unit tests for detail view helpers and data model Add 14 unit tests covering format_tokens, truncate, decision_style, last_n_chars, CommandStats, and RiskStats. Brings unit test count from 136 to 150. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/clx/src/dashboard/data.rs | 55 +++++++++++++++++++++++++++ crates/clx/src/dashboard/ui/detail.rs | 54 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/crates/clx/src/dashboard/data.rs b/crates/clx/src/dashboard/data.rs index ab2a433..62a44a8 100644 --- a/crates/clx/src/dashboard/data.rs +++ b/crates/clx/src/dashboard/data.rs @@ -545,4 +545,59 @@ mod tests { assert!(data.config_blacklist.is_empty()); assert!(data.load_error.is_none()); } + + #[test] + fn last_n_chars_short_string() { + assert_eq!(last_n_chars("hello", 10), "hello"); + } + + #[test] + fn last_n_chars_exact_length() { + assert_eq!(last_n_chars("hello", 5), "hello"); + } + + #[test] + fn last_n_chars_truncates() { + assert_eq!(last_n_chars("abcdefgh", 4), "efgh"); + } + + #[test] + fn last_n_chars_empty() { + assert_eq!(last_n_chars("", 5), ""); + } + + #[test] + fn last_n_chars_handles_unicode() { + // Multi-byte chars: should not panic or split mid-character + let s = "hello🌍world"; + let result = last_n_chars(s, 8); + // Should be valid UTF-8 + assert!(result.len() <= 8 || result.starts_with('🌍') || !result.is_empty()); + } + + #[test] + fn command_stats_defaults() { + let stats = CommandStats { + total: 10, + allowed: 7, + blocked: 1, + prompted: 2, + }; + assert_eq!(stats.total, 10); + assert_eq!(stats.allowed, 7); + assert_eq!(stats.blocked, 1); + assert_eq!(stats.prompted, 2); + } + + #[test] + fn risk_stats_defaults() { + let stats = RiskStats { + low: 5, + medium: 3, + high: 2, + }; + assert_eq!(stats.low, 5); + assert_eq!(stats.medium, 3); + assert_eq!(stats.high, 2); + } } diff --git a/crates/clx/src/dashboard/ui/detail.rs b/crates/clx/src/dashboard/ui/detail.rs index 7ebf19c..0dd72e5 100644 --- a/crates/clx/src/dashboard/ui/detail.rs +++ b/crates/clx/src/dashboard/ui/detail.rs @@ -744,3 +744,57 @@ fn decision_style(decision: &str) -> Style { _ => Style::default().fg(Color::White), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_tokens_units() { + assert_eq!(format_tokens(0), "0"); + assert_eq!(format_tokens(500), "500"); + assert_eq!(format_tokens(999), "999"); + assert_eq!(format_tokens(1_000), "1.0K"); + assert_eq!(format_tokens(1_500), "1.5K"); + assert_eq!(format_tokens(999_999), "1000.0K"); + assert_eq!(format_tokens(1_000_000), "1.0M"); + assert_eq!(format_tokens(5_300_000), "5.3M"); + } + + #[test] + fn truncate_short_string_unchanged() { + assert_eq!(truncate("hello", 10), "hello"); + } + + #[test] + fn truncate_exact_length_unchanged() { + assert_eq!(truncate("hello", 5), "hello"); + } + + #[test] + fn truncate_long_string_adds_ellipsis() { + assert_eq!(truncate("hello world", 8), "hello..."); + } + + #[test] + fn truncate_empty_string() { + assert_eq!(truncate("", 5), ""); + } + + #[test] + fn truncate_handles_unicode() { + // Multi-byte chars should not panic + let s = "hello 🌍 world"; + let result = truncate(s, 10); + assert!(result.ends_with("...")); + assert!(result.len() <= 13); // 10 + "..." worst case + } + + #[test] + fn decision_style_colors() { + assert_eq!(decision_style("allowed").fg, Some(Color::Green)); + assert_eq!(decision_style("blocked").fg, Some(Color::Red)); + assert_eq!(decision_style("prompted").fg, Some(Color::Yellow)); + assert_eq!(decision_style("unknown").fg, Some(Color::White)); + } +}