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/// How many recent events to show, and how often to poll for new ones.
27const TAIL: usize = 500;
28const POLL: Duration = Duration::from_millis(250);
29/// Frame cadence while the launch splash animates (≈60ms → ~1.8s total).
30const SPLASH_TICK: Duration = Duration::from_millis(60);
31
32/// Run the TUI against the event log at `db_path`, with snapshots under
33/// `snapshot_dir` (for undo). Restores the terminal on any exit path.
34pub fn run(db_path: &Path, snapshot_dir: &Path) -> Result<()> {
35    let color = std::env::var_os("NO_COLOR").is_none();
36    let mut app = App::new(color);
37    // A locked settings vault gates the app behind the admin password.
38    if let kintsugi_core::admin::VaultState::Locked(v) =
39        kintsugi_core::admin::load_vault(&kintsugi_core::admin::default_vault_path())
40    {
41        app.set_vault(Some(*v));
42    }
43    app.start_on_splash();
44
45    let mut terminal = ratatui::init(); // installs the panic-safe teardown hook
46    let result = event_loop(&mut terminal, &mut app, db_path, snapshot_dir);
47    ratatui::restore();
48    result
49}
50
51fn event_loop(
52    terminal: &mut ratatui::DefaultTerminal,
53    app: &mut App,
54    db_path: &Path,
55    snapshot_dir: &Path,
56) -> Result<()> {
57    // Open the event log once and reuse it across polls — re-opening a SQLite
58    // connection every 250ms (4×/sec) was needless syscalls + parsing on the hot
59    // path. The connection is opened lazily (the daemon may create the db after
60    // the TUI starts) and held for the session.
61    let mut log: Option<EventLog> = None;
62    reload(app, db_path, &mut log);
63    loop {
64        // Page step = timeline data-rows on screen: total height minus the 1-row
65        // header, 2-row footer, and the table's 2 borders + 1 header row.
66        app.page_rows = (terminal.size()?.height as usize).saturating_sub(6).max(1);
67        terminal.draw(|f| ui::render(f, app))?;
68
69        // The splash runs on a fast cadence so the animation is smooth; the live
70        // app polls slower and refreshes data on idle ticks.
71        let tick = if app.screen == Screen::Splash {
72            SPLASH_TICK
73        } else {
74            POLL
75        };
76
77        if event::poll(tick)? {
78            match event::read()? {
79                Event::Key(key) if key.kind == KeyEventKind::Press => match app.on_key(key.code) {
80                    Action::Quit => break,
81                    Action::Undo => undo(app, db_path, snapshot_dir, &mut log),
82                    Action::Approve(id) => resolve(app, &id, true),
83                    Action::Deny(id) => resolve(app, &id, false),
84                    Action::None => {}
85                },
86                Event::Resize(_, _) => { /* redrawn next iteration */ }
87                _ => {}
88            }
89        } else if app.screen == Screen::Splash {
90            app.tick_splash();
91        } else {
92            reload(app, db_path, &mut log);
93        }
94    }
95    Ok(())
96}
97
98/// Approve or deny a held command via the daemon, surfacing the result.
99fn resolve(app: &mut App, id: &str, approve: bool) {
100    let res = if approve {
101        kintsugi_daemon::Client::approve(id)
102    } else {
103        kintsugi_daemon::Client::deny(id)
104    };
105    app.status = Some(match res {
106        Ok(()) if approve => "approved — the requesting agent may proceed".to_string(),
107        Ok(()) => "denied".to_string(),
108        Err(e) => format!("could not resolve (is the daemon running?): {e}"),
109    });
110}
111
112/// Load the most recent events into the app (live refresh), and refresh the
113/// daemon vitals (up/down + active scorer) for the header strip. `log` is the
114/// reused connection (opened lazily once the db exists), avoiding a per-poll
115/// re-open on the hot path.
116fn reload(app: &mut App, db_path: &Path, log: &mut Option<EventLog>) {
117    // Cheap liveness ping + scorer id; both fail-soft so the TUI works headless.
118    app.daemon_up = kintsugi_daemon::Client::is_daemon_running();
119    app.scorer = if app.daemon_up {
120        kintsugi_daemon::Client::status_scorer().ok()
121    } else {
122        None
123    };
124    if log.is_none() && db_path.exists() {
125        *log = EventLog::open(db_path).ok();
126    }
127    if let Some(l) = log.as_ref() {
128        if let Ok(mut events) = l.tail(TAIL) {
129            // `tail` is chronological (oldest-first); show newest at the top.
130            events.reverse();
131            app.set_events(events);
132        }
133    }
134}
135
136/// Undo the most recent not-yet-reverted snapshot, surfacing the result as a
137/// transient status line.
138fn undo(app: &mut App, db_path: &Path, snapshot_dir: &Path, log: &mut Option<EventLog>) {
139    app.status = Some(match try_undo(db_path, snapshot_dir) {
140        Ok(Some(cmd)) => format!("undid `{cmd}`"),
141        Ok(None) => "nothing to undo".to_string(),
142        Err(e) => format!("undo failed: {e}"),
143    });
144    reload(app, db_path, log);
145}
146
147fn try_undo(db_path: &Path, snapshot_dir: &Path) -> Result<Option<String>> {
148    if !db_path.exists() {
149        return Ok(None);
150    }
151    let log = EventLog::open(db_path)?;
152    let Some(manifest) = log.latest_unreverted_snapshot()? else {
153        return Ok(None);
154    };
155    kintsugi_core::restore_snapshot(snapshot_dir, &manifest)?;
156    log.mark_reverted(&manifest.id)?;
157    Ok(Some(manifest.command))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use kintsugi_core::{Class, Decision, ProposedCommand, Verdict};
164
165    #[test]
166    fn reload_reads_live_events() {
167        let tmp = tempfile::tempdir().unwrap();
168        let db = tmp.path().join("e.db");
169        {
170            let log = EventLog::open(&db).unwrap();
171            let cmd = ProposedCommand::new("shim", "/tmp", vec!["ls".into()], "ls");
172            log.log_event(
173                &cmd,
174                &Verdict::rules(Class::Safe, Decision::Allow, "r"),
175                None,
176            )
177            .unwrap();
178        }
179        let mut app = App::new(false);
180        let mut log = None;
181        reload(&mut app, &db, &mut log);
182        assert_eq!(app.visible().len(), 1);
183        assert!(log.is_some(), "the connection is opened once and reused");
184    }
185
186    #[test]
187    fn undo_with_nothing_reports_so() {
188        let tmp = tempfile::tempdir().unwrap();
189        let db = tmp.path().join("e.db");
190        EventLog::open(&db).unwrap();
191        let mut app = App::new(false);
192        undo(&mut app, &db, &tmp.path().join("snapshots"), &mut None);
193        assert_eq!(app.status.as_deref(), Some("nothing to undo"));
194    }
195
196    #[test]
197    fn undo_restores_via_snapshot() {
198        let tmp = tempfile::tempdir().unwrap();
199        let db = tmp.path().join("e.db");
200        let snaps = tmp.path().join("snapshots");
201        let work = tmp.path().join("work");
202        std::fs::create_dir_all(&work).unwrap();
203        let file = work.join("f.txt");
204        std::fs::write(&file, b"orig").unwrap();
205
206        {
207            let log = EventLog::open(&db).unwrap();
208            let cmd =
209                ProposedCommand::new("shim", &work, vec!["rm".into(), "f.txt".into()], "rm f.txt");
210            let m = kintsugi_core::capture_snapshot(&snaps, &cmd)
211                .unwrap()
212                .unwrap();
213            log.record_snapshot(&m).unwrap();
214        }
215        std::fs::write(&file, b"changed").unwrap();
216
217        let mut app = App::new(false);
218        undo(&mut app, &db, &snaps, &mut None);
219        assert!(app.status.as_deref().unwrap().contains("undid"));
220        assert_eq!(std::fs::read(&file).unwrap(), b"orig");
221    }
222}