Skip to main content

codetether_agent/tui/
audit_view.rs

1//! Audit view — subagent activity inspector for the TUI.
2//!
3//! Renders recent entries from the global [`AuditLog`](crate::audit::AuditLog),
4//! filtered by default to subagent-relevant categories (`Swarm`,
5//! `ToolExecution`, `Cognition`). Lets the operator see *what the
6//! sub-agents just did* without leaving the TUI — which tool was
7//! called, by which persona, against which session, with what
8//! outcome.
9//!
10//! State is refreshed from the async audit log on each TUI tick via
11//! [`refresh_audit_snapshot`]. Rendering itself is synchronous and
12//! reads from the cached [`AuditViewState::entries`] snapshot.
13
14use ratatui::{
15    Frame,
16    layout::{Constraint, Direction, Layout, Rect},
17    style::{Color, Modifier, Style, Stylize},
18    text::{Line, Span},
19    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
20};
21
22use crate::audit::{AuditCategory, AuditEntry, AuditOutcome, try_audit_log};
23
24/// Maximum number of entries the view caches / renders per category.
25const SNAPSHOT_LIMIT: usize = 500;
26
27/// Which slice of the audit log the view is currently showing.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum AuditFilter {
30    /// Subagent-relevant only: Swarm + ToolExecution + Cognition.
31    Subagents,
32    /// Every category.
33    All,
34}
35
36impl AuditFilter {
37    fn matches(self, e: &AuditEntry) -> bool {
38        match self {
39            Self::All => true,
40            Self::Subagents => matches!(
41                e.category,
42                AuditCategory::Swarm | AuditCategory::ToolExecution | AuditCategory::Cognition
43            ),
44        }
45    }
46
47    /// Short label for the view header.
48    pub fn label(self) -> &'static str {
49        match self {
50            Self::Subagents => "Subagents",
51            Self::All => "All",
52        }
53    }
54}
55
56/// State for the audit view: the cached entries plus UI cursor.
57#[derive(Debug)]
58pub struct AuditViewState {
59    pub entries: Vec<AuditEntry>,
60    pub selected: usize,
61    pub filter: AuditFilter,
62    /// Monotonic counter; bumps each refresh so the footer can show
63    /// "updated N ticks ago" without pulling chrono into every frame.
64    pub refresh_counter: u64,
65}
66
67impl Default for AuditViewState {
68    fn default() -> Self {
69        Self {
70            entries: Vec::new(),
71            selected: 0,
72            filter: AuditFilter::Subagents,
73            refresh_counter: 0,
74        }
75    }
76}
77
78impl AuditViewState {
79    /// Move the selection cursor up; saturates at 0.
80    pub fn select_prev(&mut self) {
81        self.selected = self.selected.saturating_sub(1);
82    }
83
84    /// Move the selection cursor down; saturates at `entries.len() - 1`.
85    pub fn select_next(&mut self) {
86        if self.selected + 1 < self.entries.len() {
87            self.selected += 1;
88        }
89    }
90
91    /// Toggle between [`AuditFilter::Subagents`] and [`AuditFilter::All`].
92    pub fn toggle_filter(&mut self) {
93        self.filter = match self.filter {
94            AuditFilter::Subagents => AuditFilter::All,
95            AuditFilter::All => AuditFilter::Subagents,
96        };
97        self.selected = 0;
98    }
99}
100
101/// Pull fresh entries from the global audit log into `state`.
102///
103/// Called on each TUI tick. When the global audit log has not been
104/// initialized (e.g. running without the server), this is a cheap no-op.
105pub async fn refresh_audit_snapshot(state: &mut AuditViewState) {
106    let Some(log) = try_audit_log() else {
107        return;
108    };
109    let recent = log.recent(SNAPSHOT_LIMIT).await;
110    state.entries = recent
111        .into_iter()
112        .filter(|e| state.filter.matches(e))
113        .collect();
114    if state.selected >= state.entries.len() {
115        state.selected = state.entries.len().saturating_sub(1);
116    }
117    state.refresh_counter = state.refresh_counter.wrapping_add(1);
118}
119
120/// Render the audit view: list on the left, detail on the right.
121pub fn render_audit_view(f: &mut Frame, state: &mut AuditViewState, area: Rect) {
122    let chunks = Layout::default()
123        .direction(Direction::Vertical)
124        .constraints([
125            Constraint::Length(1),
126            Constraint::Min(1),
127            Constraint::Length(1),
128        ])
129        .split(area);
130
131    render_header(f, state, chunks[0]);
132    render_body(f, state, chunks[1]);
133    render_footer(f, state, chunks[2]);
134}
135
136fn render_header(f: &mut Frame, state: &AuditViewState, area: Rect) {
137    let line = Line::from(vec![
138        "Audit".bold(),
139        " · ".dim(),
140        Span::styled(
141            state.filter.label(),
142            Style::default()
143                .fg(Color::Cyan)
144                .add_modifier(Modifier::BOLD),
145        ),
146        " · ".dim(),
147        format!("{} entries", state.entries.len()).into(),
148    ]);
149    f.render_widget(Paragraph::new(line), area);
150}
151
152fn render_body(f: &mut Frame, state: &mut AuditViewState, area: Rect) {
153    let chunks = Layout::default()
154        .direction(Direction::Horizontal)
155        .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
156        .split(area);
157
158    render_list(f, state, chunks[0]);
159    render_detail(f, state, chunks[1]);
160}
161
162fn render_list(f: &mut Frame, state: &mut AuditViewState, area: Rect) {
163    let items: Vec<ListItem> = state
164        .entries
165        .iter()
166        .map(|e| ListItem::new(format_row(e)))
167        .collect();
168
169    let mut list_state = ListState::default();
170    if !state.entries.is_empty() {
171        list_state.select(Some(state.selected));
172    }
173
174    let list = List::new(items)
175        .block(Block::default().borders(Borders::ALL).title(" Events "))
176        .highlight_style(
177            Style::default()
178                .bg(Color::DarkGray)
179                .add_modifier(Modifier::BOLD),
180        )
181        .highlight_symbol("▶ ");
182    f.render_stateful_widget(list, area, &mut list_state);
183}
184
185fn render_detail(f: &mut Frame, state: &AuditViewState, area: Rect) {
186    let block = Block::default().borders(Borders::ALL).title(" Detail ");
187    let Some(entry) = state.entries.get(state.selected) else {
188        f.render_widget(
189            Paragraph::new("No audit entries yet.".dim()).block(block),
190            area,
191        );
192        return;
193    };
194    let detail_json = entry
195        .detail
196        .as_ref()
197        .map(|v| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()))
198        .unwrap_or_else(|| "(none)".to_string());
199
200    let body = vec![
201        kv_line("id", &entry.id),
202        kv_line("time", &entry.timestamp.format("%H:%M:%S%.3f").to_string()),
203        kv_line("category", &format!("{:?}", entry.category)),
204        kv_line("action", &entry.action),
205        kv_line(
206            "outcome",
207            match entry.outcome {
208                AuditOutcome::Success => "success",
209                AuditOutcome::Failure => "failure",
210                AuditOutcome::Denied => "denied",
211            },
212        ),
213        kv_line("principal", entry.principal.as_deref().unwrap_or("-")),
214        kv_line("session", entry.session_id.as_deref().unwrap_or("-")),
215        kv_line(
216            "duration_ms",
217            &entry
218                .duration_ms
219                .map(|d| d.to_string())
220                .unwrap_or_else(|| "-".into()),
221        ),
222        Line::from(""),
223        "detail:".dim().into(),
224        Line::from(detail_json),
225    ];
226
227    f.render_widget(
228        Paragraph::new(body).block(block).wrap(Wrap { trim: false }),
229        area,
230    );
231}
232
233fn render_footer(f: &mut Frame, _state: &AuditViewState, area: Rect) {
234    let hint = Line::from(vec![
235        "↑/↓".bold(),
236        " select · ".dim(),
237        "f".bold(),
238        " toggle filter · ".dim(),
239        "Esc".bold(),
240        " chat".dim(),
241    ]);
242    f.render_widget(Paragraph::new(hint), area);
243}
244
245fn format_row(e: &AuditEntry) -> Line<'static> {
246    let ts = e.timestamp.format("%H:%M:%S").to_string();
247    let outcome_color = match e.outcome {
248        AuditOutcome::Success => Color::Green,
249        AuditOutcome::Failure => Color::Red,
250        AuditOutcome::Denied => Color::Yellow,
251    };
252    let outcome_mark = match e.outcome {
253        AuditOutcome::Success => "✓",
254        AuditOutcome::Failure => "✗",
255        AuditOutcome::Denied => "⊘",
256    };
257    Line::from(vec![
258        Span::styled(ts, Style::default().fg(Color::DarkGray)),
259        " ".into(),
260        Span::styled(outcome_mark.to_string(), Style::default().fg(outcome_color)),
261        " ".into(),
262        Span::styled(
263            format!("{:<10}", category_short(e.category)),
264            Style::default().fg(Color::Cyan),
265        ),
266        " ".into(),
267        Span::raw(truncate(&e.action, 40)),
268        " ".into(),
269        Span::styled(
270            format!("[{}]", e.principal.as_deref().unwrap_or("-")),
271            Style::default().fg(Color::DarkGray),
272        ),
273    ])
274}
275
276fn kv_line(key: &str, value: &str) -> Line<'static> {
277    Line::from(vec![
278        Span::styled(format!("{key:>12}: "), Style::default().fg(Color::DarkGray)),
279        Span::raw(value.to_string()),
280    ])
281}
282
283fn category_short(c: AuditCategory) -> &'static str {
284    match c {
285        AuditCategory::Api => "api",
286        AuditCategory::ToolExecution => "tool",
287        AuditCategory::Session => "session",
288        AuditCategory::Cognition => "cognition",
289        AuditCategory::Swarm => "swarm",
290        AuditCategory::Auth => "auth",
291        AuditCategory::K8s => "k8s",
292        AuditCategory::Sandbox => "sandbox",
293        AuditCategory::Config => "config",
294    }
295}
296
297fn truncate(s: &str, max: usize) -> String {
298    if s.chars().count() <= max {
299        s.to_string()
300    } else {
301        let cut: String = s.chars().take(max.saturating_sub(1)).collect();
302        format!("{cut}…")
303    }
304}