Skip to main content

kintsugi_tui/
lib.rs

1//! Kintsugi ratatui terminal UI (Phase 4).
2//!
3//! A real, interactive timeline over the live event log: keyboard navigation,
4//! filtering, a detail view, and undo — all driven by data read from the SQLite
5//! log (polled, so updates appear without a restart). The event loop never blocks
6//! on I/O long enough to freeze rendering, and the terminal is always restored on
7//! exit, panic, or signal (`ratatui::init`/`restore` install the teardown).
8
9#![forbid(unsafe_code)]
10
11pub mod app;
12pub mod splash;
13pub mod ui;
14
15use std::path::Path;
16use std::time::Duration;
17
18use anyhow::Result;
19use crossterm::event::{self, Event, KeyEventKind};
20use kintsugi_core::EventLog;
21
22pub use app::{Action, App, Mode, Screen};
23
24pub const VERSION: &str = env!("CARGO_PKG_VERSION");
25
26/// The fs-watch backstop is a firehose; cap its live view. Full history is on
27/// the append-only log (`kintsugi log`) and reachable by filtering (below).
28const BACKSTOP_TAIL: usize = 500;
29/// Commands / audit / recorder are low-volume; load effectively all and let the
30/// in-view scroll paginate, so backstop noise can never evict a command row.
31/// Also the depth the backstop is loaded to while a filter is active.
32const TIMELINE_TAIL: usize = 5000;
33/// How often to poll for new events.
34const POLL: Duration = Duration::from_millis(250);
35/// Frame cadence while the launch splash animates (≈60ms → ~1.8s total).
36const SPLASH_TICK: Duration = Duration::from_millis(60);
37
38/// Run the TUI against the event log at `db_path`, with snapshots under
39/// `snapshot_dir` (for undo). Restores the terminal on any exit path.
40pub fn run(db_path: &Path, snapshot_dir: &Path) -> Result<()> {
41    let color = std::env::var_os("NO_COLOR").is_none();
42    let mut app = App::new(color);
43    // Read the local timezone offset now, before ratatui spawns anything: the
44    // time crate only proves `current_local_offset` sound while single-threaded.
45    // Events are stored in UTC; this renders them in the viewer's zone. Falls
46    // back to UTC if the offset can't be determined safely.
47    app.set_local_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC));
48    // A locked settings vault gates the app behind the admin password.
49    if let kintsugi_core::admin::VaultState::Locked(v) =
50        kintsugi_core::admin::load_vault(&kintsugi_core::admin::default_vault_path())
51    {
52        app.set_vault(Some(*v));
53    }
54    app.start_on_splash();
55
56    let mut terminal = ratatui::init(); // installs the panic-safe teardown hook
57    let result = event_loop(&mut terminal, &mut app, db_path, snapshot_dir);
58    ratatui::restore();
59    result
60}
61
62fn event_loop(
63    terminal: &mut ratatui::DefaultTerminal,
64    app: &mut App,
65    db_path: &Path,
66    snapshot_dir: &Path,
67) -> Result<()> {
68    // Open the event log once and reuse it across polls — re-opening a SQLite
69    // connection every 250ms (4×/sec) was needless syscalls + parsing on the hot
70    // path. The connection is opened lazily (the daemon may create the db after
71    // the TUI starts) and held for the session.
72    let mut log: Option<EventLog> = None;
73    reload(app, db_path, &mut log);
74    loop {
75        // Page step = timeline data-rows on screen: total height minus the 1-row
76        // header, 2-row footer, and the table's 2 borders + 1 header row.
77        app.page_rows = (terminal.size()?.height as usize).saturating_sub(6).max(1);
78        terminal.draw(|f| ui::render(f, app))?;
79
80        // The splash runs on a fast cadence so the animation is smooth; the live
81        // app polls slower and refreshes data on idle ticks.
82        let tick = if app.screen == Screen::Splash {
83            SPLASH_TICK
84        } else {
85            POLL
86        };
87
88        if event::poll(tick)? {
89            match event::read()? {
90                Event::Key(key) if key.kind == KeyEventKind::Press => match app.on_key(key.code) {
91                    Action::Quit => break,
92                    Action::Undo => undo(app, db_path, snapshot_dir, &mut log),
93                    Action::Approve(id) => resolve(app, &id, true),
94                    Action::Deny(id) => resolve(app, &id, false),
95                    Action::None => {}
96                },
97                Event::Resize(_, _) => { /* redrawn next iteration */ }
98                _ => {}
99            }
100        } else if app.screen == Screen::Splash {
101            app.tick_splash();
102        } else {
103            reload(app, db_path, &mut log);
104        }
105    }
106    Ok(())
107}
108
109/// Approve or deny a held command via the daemon, surfacing the result.
110fn resolve(app: &mut App, id: &str, approve: bool) {
111    let res = if approve {
112        kintsugi_daemon::Client::approve(id)
113    } else {
114        kintsugi_daemon::Client::deny(id)
115    };
116    app.status = Some(match res {
117        Ok(()) if approve => "approved — the requesting agent may proceed".to_string(),
118        Ok(()) => "denied".to_string(),
119        Err(e) => format!("could not resolve (is the daemon running?): {e}"),
120    });
121}
122
123/// Load the most recent events into the app (live refresh), and refresh the
124/// daemon vitals (up/down + active scorer) for the header strip. `log` is the
125/// reused connection (opened lazily once the db exists), avoiding a per-poll
126/// re-open on the hot path.
127fn reload(app: &mut App, db_path: &Path, log: &mut Option<EventLog>) {
128    // Cheap liveness ping + scorer id; both fail-soft so the TUI works headless.
129    app.daemon_up = kintsugi_daemon::Client::is_daemon_running();
130    app.scorer = if app.daemon_up {
131        kintsugi_daemon::Client::status_scorer().ok()
132    } else {
133        None
134    };
135    if log.is_none() && db_path.exists() {
136        *log = EventLog::open(db_path).ok();
137    }
138    if let Some(l) = log.as_ref() {
139        let seq = l.latest_seq().unwrap_or(0);
140        // Skip the heavy load when nothing that affects it changed: the log didn't
141        // grow AND the filter is unchanged. Keeps the 250ms poll near-free when idle.
142        if seq == app.last_seq && app.filter == app.last_filter {
143            return;
144        }
145        let filtering = !app.filter.trim().is_empty();
146        // 500 cap is for the idle live tail; while the user is actively filtering,
147        // load the backstop as deep as the command tabs so the universal filter
148        // reaches the same depth on every tab (Timeline/Audit/Recorder/Backstop).
149        let backstop_limit = if filtering {
150            TIMELINE_TAIL
151        } else {
152            BACKSTOP_TAIL
153        };
154
155        // Two windows: the command timeline (everything except fs-watch) and the
156        // backstop. Unioned, they feed the existing tab partitioning, so a noisy
157        // backstop can no longer push commands out of the fetch window.
158        use kintsugi_core::log::Filter;
159        let mut events = l
160            .query(&Filter {
161                agent_not: Some("fs-watch".to_string()),
162                limit: Some(TIMELINE_TAIL),
163                ..Filter::default()
164            })
165            .unwrap_or_default();
166        if let Ok(mut backstop) = l.query(&Filter {
167            agent: Some("fs-watch".to_string()),
168            limit: Some(backstop_limit),
169            ..Filter::default()
170        }) {
171            events.append(&mut backstop);
172        }
173        // Newest-first for display (matches the prior tail().reverse() ordering).
174        events.sort_by_key(|e| std::cmp::Reverse(e.seq));
175        app.set_events(events);
176        app.last_seq = seq;
177        app.last_filter = app.filter.clone();
178    }
179}
180
181/// Undo the most recent not-yet-reverted snapshot, surfacing the result as a
182/// transient status line.
183fn undo(app: &mut App, db_path: &Path, snapshot_dir: &Path, log: &mut Option<EventLog>) {
184    app.status = Some(match try_undo(db_path, snapshot_dir) {
185        Ok(Some(cmd)) => format!("undid `{cmd}`"),
186        Ok(None) => "nothing to undo".to_string(),
187        Err(e) => format!("undo failed: {e}"),
188    });
189    app.last_seq = -1; // force the gate to reload so the result shows immediately
190    reload(app, db_path, log);
191}
192
193fn try_undo(db_path: &Path, snapshot_dir: &Path) -> Result<Option<String>> {
194    if !db_path.exists() {
195        return Ok(None);
196    }
197    let log = EventLog::open(db_path)?;
198    let Some(manifest) = log.latest_unreverted_snapshot()? else {
199        return Ok(None);
200    };
201    kintsugi_core::restore_snapshot(snapshot_dir, &manifest)?;
202    log.mark_reverted(&manifest.id)?;
203    Ok(Some(manifest.command))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use kintsugi_core::{Class, Decision, ProposedCommand, Verdict};
210
211    #[test]
212    fn reload_reads_live_events() {
213        let tmp = tempfile::tempdir().unwrap();
214        let db = tmp.path().join("e.db");
215        {
216            let log = EventLog::open(&db).unwrap();
217            let cmd = ProposedCommand::new("shim", "/tmp", vec!["ls".into()], "ls");
218            log.log_event(
219                &cmd,
220                &Verdict::rules(Class::Safe, Decision::Allow, "r"),
221                None,
222            )
223            .unwrap();
224        }
225        let mut app = App::new(false);
226        let mut log = None;
227        reload(&mut app, &db, &mut log);
228        assert_eq!(app.visible().len(), 1);
229        assert!(log.is_some(), "the connection is opened once and reused");
230    }
231
232    #[test]
233    fn undo_with_nothing_reports_so() {
234        let tmp = tempfile::tempdir().unwrap();
235        let db = tmp.path().join("e.db");
236        EventLog::open(&db).unwrap();
237        let mut app = App::new(false);
238        undo(&mut app, &db, &tmp.path().join("snapshots"), &mut None);
239        assert_eq!(app.status.as_deref(), Some("nothing to undo"));
240    }
241
242    #[test]
243    fn undo_restores_via_snapshot() {
244        let tmp = tempfile::tempdir().unwrap();
245        let db = tmp.path().join("e.db");
246        let snaps = tmp.path().join("snapshots");
247        let work = tmp.path().join("work");
248        std::fs::create_dir_all(&work).unwrap();
249        let file = work.join("f.txt");
250        std::fs::write(&file, b"orig").unwrap();
251
252        {
253            let log = EventLog::open(&db).unwrap();
254            let cmd =
255                ProposedCommand::new("shim", &work, vec!["rm".into(), "f.txt".into()], "rm f.txt");
256            let m = kintsugi_core::capture_snapshot(&snaps, &cmd)
257                .unwrap()
258                .unwrap();
259            log.record_snapshot(&m).unwrap();
260        }
261        std::fs::write(&file, b"changed").unwrap();
262
263        let mut app = App::new(false);
264        undo(&mut app, &db, &snaps, &mut None);
265        assert!(app.status.as_deref().unwrap().contains("undid"));
266        assert_eq!(std::fs::read(&file).unwrap(), b"orig");
267    }
268}