Skip to main content

kintsugi_tui/
app.rs

1//! TUI application state and input handling — pure and terminal-free, so it is
2//! fully unit-testable. The render layer ([`crate::ui`]) and the event loop
3//! ([`crate::run`]) build on this.
4
5use crossterm::event::KeyCode;
6use kintsugi_core::LoggedEvent;
7
8/// Minimum usable terminal size; below this we show a "too small" notice.
9pub const MIN_WIDTH: u16 = 60;
10pub const MIN_HEIGHT: u16 = 10;
11
12/// The human-facing word for a decision (shared by the list, detail, and filter).
13pub fn outcome_word(d: kintsugi_core::Decision) -> &'static str {
14    match d {
15        kintsugi_core::Decision::Allow => "allowed",
16        kintsugi_core::Decision::Deny => "denied",
17        kintsugi_core::Decision::Hold => "held",
18    }
19}
20
21/// A parsed filter query. Bare words are a substring match over the row's text;
22/// `agent:`, `session:`, `since:`, and `before:` are structured predicates.
23/// `since:`/`before:` take a relative age (`30m`, `2h`, `3d`, `day`, `week`,
24/// `month`): `since:1h` = within the last hour, `before:1d` = older than a day.
25#[derive(Default)]
26struct Query {
27    agent: Option<String>,
28    session: Option<String>,
29    since: Option<time::OffsetDateTime>,
30    before: Option<time::OffsetDateTime>,
31    text: String,
32}
33
34impl Query {
35    fn parse(input: &str) -> Self {
36        let mut q = Query::default();
37        let mut text = Vec::new();
38        for tok in input.split_whitespace() {
39            if let Some(v) = tok.strip_prefix("agent:") {
40                q.agent = Some(v.to_lowercase());
41            } else if let Some(v) = tok.strip_prefix("session:") {
42                q.session = Some(v.to_lowercase());
43            } else if let Some(v) = tok.strip_prefix("since:") {
44                q.since = parse_ago(v);
45            } else if let Some(v) = tok.strip_prefix("before:") {
46                q.before = parse_ago(v);
47            } else {
48                text.push(tok.to_lowercase());
49            }
50        }
51        q.text = text.join(" ");
52        q
53    }
54
55    fn matches(&self, e: &LoggedEvent) -> bool {
56        if let Some(a) = &self.agent {
57            if !e.agent.to_lowercase().contains(a) {
58                return false;
59            }
60        }
61        if let Some(s) = &self.session {
62            if !e
63                .session
64                .as_deref()
65                .is_some_and(|es| es.to_lowercase().contains(s))
66            {
67                return false;
68            }
69        }
70        if let Some(since) = self.since {
71            if e.ts < since {
72                return false;
73            }
74        }
75        if let Some(before) = self.before {
76            if e.ts >= before {
77                return false;
78            }
79        }
80        if !self.text.is_empty() {
81            let n = &self.text;
82            let hit = e.command.to_lowercase().contains(n)
83                || e.agent.to_lowercase().contains(n)
84                || e.class.as_str().contains(n)
85                || e.decision.as_str().contains(n)
86                || outcome_word(e.decision).contains(n)
87                || e.reason.to_lowercase().contains(n)
88                || e.session
89                    .as_deref()
90                    .is_some_and(|s| s.to_lowercase().contains(n));
91            if !hit {
92                return false;
93            }
94        }
95        true
96    }
97}
98
99/// Parse a relative age spec into an absolute instant that long ago.
100fn parse_ago(s: &str) -> Option<time::OffsetDateTime> {
101    use time::{Duration, OffsetDateTime};
102    let d = match s {
103        "day" => Duration::days(1),
104        "week" => Duration::weeks(1),
105        "month" => Duration::days(30),
106        _ => {
107            let split = s.find(|c: char| c.is_alphabetic())?;
108            let n: i64 = s[..split].parse().ok()?;
109            match &s[split..] {
110                "m" => Duration::minutes(n),
111                "h" => Duration::hours(n),
112                "d" => Duration::days(n),
113                "w" => Duration::weeks(n),
114                _ => return None,
115            }
116        }
117    };
118    Some(OffsetDateTime::now_utc() - d)
119}
120
121/// Which view/mode the UI is in.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Mode {
124    /// The timeline list.
125    Normal,
126    /// Editing the filter string.
127    Filter,
128    /// The detail view for the selected event.
129    Detail,
130}
131
132/// The top-level views, switched with `Tab` / `1`,`2`,`3`. Each is the same
133/// table over a different *slice* of the same live log — the structure (which
134/// slice you're looking at) is the information, not decoration.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum Tab {
137    /// Everything, newest first — the full agent + human + watcher stream.
138    Timeline,
139    /// Only the destructive band (catastrophic + ambiguous) — the audit lens.
140    Audit,
141    /// Only passively-recorded human shell commands (agent = `shell`).
142    Recorder,
143}
144
145impl Tab {
146    /// All tabs in display order.
147    pub const ALL: [Tab; 3] = [Tab::Timeline, Tab::Audit, Tab::Recorder];
148
149    /// The short label shown in the tab bar.
150    pub fn title(self) -> &'static str {
151        match self {
152            Tab::Timeline => "Timeline",
153            Tab::Audit => "Audit",
154            Tab::Recorder => "Recorder",
155        }
156    }
157
158    /// One-line empty-state copy when this tab's slice is empty.
159    pub fn empty_copy(self) -> &'static str {
160        match self {
161            Tab::Timeline => {
162                "Run a command through a wired agent (or the $PATH shim) — it appears here."
163            }
164            Tab::Audit => {
165                "Nothing destructive yet. Catastrophic and ambiguous commands surface here."
166            }
167            Tab::Recorder => {
168                "No recorded shell sessions. Install the hook: kintsugi record install."
169            }
170        }
171    }
172
173    /// Whether an event belongs in this tab's slice.
174    fn includes(self, e: &LoggedEvent) -> bool {
175        match self {
176            Tab::Timeline => true,
177            Tab::Audit => e.class != kintsugi_core::Class::Safe,
178            Tab::Recorder => e.agent == "shell",
179        }
180    }
181
182    fn next(self) -> Tab {
183        match self {
184            Tab::Timeline => Tab::Audit,
185            Tab::Audit => Tab::Recorder,
186            Tab::Recorder => Tab::Timeline,
187        }
188    }
189}
190
191/// The top-level screen: launch animation, optional password gate, then the app.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum Screen {
194    /// The animated launch logo (auto-advances; any key skips it).
195    Splash,
196    /// The admin password gate, shown when the settings vault is locked.
197    Login,
198    /// The live application (tabs, timeline, detail, …).
199    Main,
200    /// The settings control panel (view + toggle the locked settings).
201    Settings,
202}
203
204/// The toggleable locked settings, in display order. Booleans flip; `Enforcement`
205/// cycles. Every row is a *tightening* control — there is no row that loosens the
206/// catastrophic floor (spine #1/#2).
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum SettingRow {
209    Recording,
210    Autostart,
211    RequirePasswordToStop,
212    FailClosed,
213    Enforcement,
214}
215
216impl SettingRow {
217    pub const ALL: [SettingRow; 5] = [
218        SettingRow::Recording,
219        SettingRow::Autostart,
220        SettingRow::RequirePasswordToStop,
221        SettingRow::FailClosed,
222        SettingRow::Enforcement,
223    ];
224
225    pub fn label(self) -> &'static str {
226        match self {
227            SettingRow::Recording => "recording",
228            SettingRow::Autostart => "autostart",
229            SettingRow::RequirePasswordToStop => "require-password-to-stop",
230            SettingRow::FailClosed => "fail-closed",
231            SettingRow::Enforcement => "enforcement",
232        }
233    }
234
235    /// The current value of this row, as display text.
236    pub fn value(self, s: &kintsugi_core::admin::LockedSettings) -> String {
237        use kintsugi_core::admin::Enforcement;
238        let yn = |b: bool| if b { "on" } else { "off" }.to_string();
239        match self {
240            SettingRow::Recording => yn(s.recording),
241            SettingRow::Autostart => yn(s.autostart),
242            SettingRow::RequirePasswordToStop => yn(s.require_password_to_stop),
243            SettingRow::FailClosed => yn(s.fail_closed),
244            SettingRow::Enforcement => match s.enforcement {
245                Enforcement::Attended => "attended".into(),
246                Enforcement::Unattended => "unattended".into(),
247                Enforcement::Notify => "notify".into(),
248            },
249        }
250    }
251
252    /// Apply this row's toggle to the settings in place.
253    fn apply(self, s: &mut kintsugi_core::admin::LockedSettings) {
254        use kintsugi_core::admin::Enforcement;
255        match self {
256            SettingRow::Recording => s.recording = !s.recording,
257            SettingRow::Autostart => s.autostart = !s.autostart,
258            SettingRow::RequirePasswordToStop => {
259                s.require_password_to_stop = !s.require_password_to_stop
260            }
261            SettingRow::FailClosed => s.fail_closed = !s.fail_closed,
262            SettingRow::Enforcement => {
263                s.enforcement = match s.enforcement {
264                    Enforcement::Attended => Enforcement::Unattended,
265                    Enforcement::Unattended => Enforcement::Notify,
266                    Enforcement::Notify => Enforcement::Attended,
267                }
268            }
269        }
270    }
271}
272
273/// A side-effecting action the event loop must perform (kept out of pure state).
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum Action {
276    None,
277    Quit,
278    Undo,
279    /// Approve the held command with this id.
280    Approve(String),
281    /// Deny the held command with this id.
282    Deny(String),
283}
284
285/// The whole UI state.
286pub struct App {
287    /// All loaded events, chronological (oldest first).
288    events: Vec<LoggedEvent>,
289    /// Selection index into the *filtered* view.
290    pub selected: usize,
291    /// The active filter string.
292    pub filter: String,
293    /// Current mode.
294    pub mode: Mode,
295    /// A transient status message (e.g. the result of an undo).
296    pub status: Option<String>,
297    /// Whether to use color (respects `NO_COLOR`).
298    pub color: bool,
299    /// Number of timeline data-rows visible in the last render, used as the step
300    /// for `PageUp`/`PageDown`. Set by the renderer each frame; 0 until the first.
301    pub page_rows: usize,
302    /// The active top-level view.
303    pub tab: Tab,
304    /// Whether the daemon answered on the last refresh (for the vitals strip).
305    pub daemon_up: bool,
306    /// The active Tier-2 scorer backend id, if the daemon reported one.
307    pub scorer: Option<String>,
308    /// The current top-level screen (splash → [login] → main).
309    pub screen: Screen,
310    /// Animation frame for the launch splash (advanced by the event loop).
311    pub splash_frame: usize,
312    /// The sealed admin vault, when settings are locked (drives the login gate).
313    pub vault: Option<kintsugi_core::admin::SealedVault>,
314    /// Whether the admin password has been entered this session.
315    pub authed: bool,
316    /// The password being typed on the login screen (never echoed verbatim).
317    /// Zeroized on drop and on a failed attempt so a wrong guess isn't left in
318    /// freed heap.
319    pub login_input: zeroize::Zeroizing<String>,
320    /// The last login error, shown under the prompt.
321    pub login_error: Option<String>,
322    /// The verified admin password, held for the session so settings changes can
323    /// re-seal the vault without re-prompting. Zeroized on drop.
324    pub(crate) password: Option<zeroize::Zeroizing<String>>,
325    /// The decrypted locked settings (populated on entering the Settings screen).
326    pub settings: Option<kintsugi_core::admin::LockedSettings>,
327    /// Selection index on the Settings screen.
328    pub settings_selected: usize,
329    /// Transient result/error line on the Settings screen.
330    pub settings_status: Option<String>,
331}
332
333impl App {
334    pub fn new(color: bool) -> Self {
335        Self {
336            events: Vec::new(),
337            selected: 0,
338            filter: String::new(),
339            mode: Mode::Normal,
340            status: None,
341            color,
342            page_rows: 0,
343            tab: Tab::Timeline,
344            daemon_up: false,
345            scorer: None,
346            // Default to the live app; `run()` opts into the splash at startup, so
347            // unit tests exercise the app directly without animating through it.
348            screen: Screen::Main,
349            splash_frame: 0,
350            vault: None,
351            authed: false,
352            login_input: zeroize::Zeroizing::new(String::new()),
353            login_error: None,
354            password: None,
355            settings: None,
356            settings_selected: 0,
357            settings_status: None,
358        }
359    }
360
361    /// Whether locked settings can be edited (provisioned + authenticated).
362    pub fn settings_editable(&self) -> bool {
363        self.vault.is_some() && self.password.is_some()
364    }
365
366    /// Open the Settings control panel. Decrypts the live settings when we can;
367    /// otherwise falls back to defaults shown read-only (unprovisioned host).
368    pub fn open_settings(&mut self) {
369        if self.settings.is_none() {
370            self.settings = match (&self.vault, &self.password) {
371                (Some(v), Some(pw)) => v.unseal(pw).ok(),
372                _ => None,
373            };
374        }
375        if self.settings.is_none() {
376            self.settings = Some(kintsugi_core::admin::LockedSettings::default());
377        }
378        self.settings_selected = 0;
379        self.settings_status = None;
380        self.screen = Screen::Settings;
381    }
382
383    /// Toggle the selected setting and re-seal the vault. Read-only when the host
384    /// isn't provisioned/authenticated (then it only explains, never pretends).
385    pub fn toggle_selected_setting(&mut self) {
386        let Some(row) = SettingRow::ALL.get(self.settings_selected).copied() else {
387            return;
388        };
389        if !self.settings_editable() {
390            self.settings_status =
391                Some("read-only — provision with `kintsugi admin provision` first".into());
392            return;
393        }
394        let (Some(settings), Some(vault), Some(pw)) =
395            (self.settings.as_mut(), &self.vault, &self.password)
396        else {
397            return;
398        };
399        row.apply(settings);
400        // Re-seal under the held password and persist atomically.
401        match vault.update_settings(pw, settings) {
402            Ok(new_vault) => {
403                let path = kintsugi_core::admin::default_vault_path();
404                match kintsugi_core::admin::save_vault(&path, &new_vault) {
405                    Ok(()) => {
406                        self.vault = Some(new_vault);
407                        self.settings_status =
408                            Some(format!("saved · {} = {}", row.label(), row.value(settings)));
409                    }
410                    Err(e) => {
411                        // Roll back the in-memory toggle so the screen matches disk.
412                        row.apply(settings);
413                        self.settings_status = Some(format!("could not save: {e}"));
414                    }
415                }
416            }
417            Err(e) => {
418                row.apply(settings);
419                self.settings_status = Some(format!("could not re-seal: {e}"));
420            }
421        }
422    }
423
424    /// Begin on the animated launch splash (used by `run()` at startup).
425    pub fn start_on_splash(&mut self) {
426        self.screen = Screen::Splash;
427        self.splash_frame = 0;
428    }
429
430    /// Attach the loaded vault state. A `Locked` vault gates the app behind the
431    /// admin password; `Unprovisioned`/`Degraded` leave it open (viewing the
432    /// audit log was never password-gated — only *changing* settings is).
433    pub fn set_vault(&mut self, vault: Option<kintsugi_core::admin::SealedVault>) {
434        self.vault = vault;
435    }
436
437    /// Whether the password gate must be shown before the app.
438    pub fn needs_login(&self) -> bool {
439        self.vault.is_some() && !self.authed
440    }
441
442    /// Submit the typed password. On success, authenticate and enter the app;
443    /// on failure, clear the field and show an error. The password never appears
444    /// on the wire or in a log — only a constant-time verify against the vault.
445    pub fn submit_login(&mut self) {
446        // Move the typed buffer out (zeroized when dropped on the failure path).
447        let input = std::mem::take(&mut self.login_input);
448        match &self.vault {
449            Some(v) if v.verify_password(input.as_str()) => {
450                self.authed = true;
451                self.password = Some(input);
452                self.login_error = None;
453                self.screen = Screen::Main;
454            }
455            Some(_) => {
456                self.login_error = Some("incorrect password".to_string());
457            }
458            None => {
459                // No vault → nothing to authenticate against; just enter.
460                self.screen = Screen::Main;
461            }
462        }
463    }
464
465    /// Advance the splash animation one tick; once it completes, enter the app.
466    /// Returns true while the splash is still showing (so the loop keeps the
467    /// fast animation cadence).
468    pub fn tick_splash(&mut self) -> bool {
469        if self.screen != Screen::Splash {
470            return false;
471        }
472        self.splash_frame += 1;
473        if self.splash_frame >= crate::splash::FRAMES {
474            self.enter_main();
475        }
476        self.screen == Screen::Splash
477    }
478
479    /// Leave the splash and show the application — via the login gate when the
480    /// vault is locked and the password hasn't been entered yet.
481    fn enter_main(&mut self) {
482        self.screen = if self.needs_login() {
483            Screen::Login
484        } else {
485            Screen::Main
486        };
487    }
488
489    /// Counts for the header vitals strip: (total, held, catastrophic) over the
490    /// full loaded set (not the filtered/tab slice — vitals are global).
491    pub fn vitals(&self) -> (usize, usize, usize) {
492        let mut held = 0;
493        let mut catastrophic = 0;
494        for e in &self.events {
495            if e.decision == kintsugi_core::Decision::Hold {
496                held += 1;
497            }
498            if e.class == kintsugi_core::Class::Catastrophic {
499                catastrophic += 1;
500            }
501        }
502        (self.events.len(), held, catastrophic)
503    }
504
505    /// Switch to a specific tab, resetting selection to the top of its slice.
506    pub fn select_tab(&mut self, tab: Tab) {
507        if self.tab != tab {
508            self.tab = tab;
509            self.selected = 0;
510        }
511    }
512
513    /// Replace the event set (from a fresh log read), keeping selection in range.
514    pub fn set_events(&mut self, events: Vec<LoggedEvent>) {
515        self.events = events;
516        self.clamp_selection();
517    }
518
519    /// Indices into `events` that match the active tab's slice AND the current
520    /// filter (both must hold — the tab narrows, the filter narrows further).
521    pub fn filtered_indices(&self) -> Vec<usize> {
522        let q = Query::parse(&self.filter);
523        self.events
524            .iter()
525            .enumerate()
526            .filter(|(_, e)| self.tab.includes(e) && q.matches(e))
527            .map(|(i, _)| i)
528            .collect()
529    }
530
531    /// The events currently visible (after filtering), in display order.
532    pub fn visible(&self) -> Vec<&LoggedEvent> {
533        self.filtered_indices()
534            .into_iter()
535            .map(|i| &self.events[i])
536            .collect()
537    }
538
539    /// The currently selected event, if any.
540    pub fn selected_event(&self) -> Option<&LoggedEvent> {
541        self.visible().get(self.selected).copied()
542    }
543
544    /// Whether there are no events at all (for the empty state).
545    pub fn is_empty(&self) -> bool {
546        self.events.is_empty()
547    }
548
549    fn visible_len(&self) -> usize {
550        self.filtered_indices().len()
551    }
552
553    fn clamp_selection(&mut self) {
554        let len = self.visible_len();
555        if len == 0 {
556            self.selected = 0;
557        } else if self.selected >= len {
558            self.selected = len - 1;
559        }
560    }
561
562    /// Handle a keypress, returning any side-effecting action for the loop.
563    pub fn on_key(&mut self, key: KeyCode) -> Action {
564        // On the splash, any key except quit skips straight into the app (or the
565        // login gate, if the vault is locked).
566        if self.screen == Screen::Splash {
567            if matches!(key, KeyCode::Char('q') | KeyCode::Esc) {
568                return Action::Quit;
569            }
570            self.enter_main();
571            return Action::None;
572        }
573        if self.screen == Screen::Login {
574            return self.on_key_login(key);
575        }
576        if self.screen == Screen::Settings {
577            return self.on_key_settings(key);
578        }
579        // A keypress dismisses a transient status message.
580        self.status = None;
581        match self.mode {
582            Mode::Normal => self.on_key_normal(key),
583            Mode::Filter => self.on_key_filter(key),
584            Mode::Detail => self.on_key_detail(key),
585        }
586    }
587
588    /// Settings screen: j/k move, enter/space toggle, esc/q back to the app.
589    fn on_key_settings(&mut self, key: KeyCode) -> Action {
590        match key {
591            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('s') => {
592                self.screen = Screen::Main;
593            }
594            KeyCode::Char('j') | KeyCode::Down => {
595                if self.settings_selected + 1 < SettingRow::ALL.len() {
596                    self.settings_selected += 1;
597                }
598                self.settings_status = None;
599            }
600            KeyCode::Char('k') | KeyCode::Up => {
601                self.settings_selected = self.settings_selected.saturating_sub(1);
602                self.settings_status = None;
603            }
604            KeyCode::Enter | KeyCode::Char(' ') => self.toggle_selected_setting(),
605            _ => {}
606        }
607        Action::None
608    }
609
610    /// Login screen: type the password, Enter submits, Esc quits (no bypass).
611    fn on_key_login(&mut self, key: KeyCode) -> Action {
612        match key {
613            KeyCode::Esc => return Action::Quit,
614            KeyCode::Enter => self.submit_login(),
615            KeyCode::Backspace => {
616                self.login_input.pop();
617            }
618            KeyCode::Char(c) => self.login_input.push(c),
619            _ => {}
620        }
621        Action::None
622    }
623
624    fn on_key_normal(&mut self, key: KeyCode) -> Action {
625        match key {
626            KeyCode::Char('q') | KeyCode::Esc => return Action::Quit,
627            KeyCode::Char('j') | KeyCode::Down => self.move_down(),
628            KeyCode::Char('k') | KeyCode::Up => self.move_up(),
629            // Space/b are the primary page keys (pager convention) since Mac
630            // keyboards lack PageUp/PageDown; f and PgUp/PgDn are aliases.
631            KeyCode::Char(' ') | KeyCode::Char('f') | KeyCode::PageDown => self.page_down(),
632            KeyCode::Char('b') | KeyCode::PageUp => self.page_up(),
633            KeyCode::Char('g') | KeyCode::Home => self.selected = 0,
634            KeyCode::Char('G') | KeyCode::End => {
635                let len = self.visible_len();
636                self.selected = len.saturating_sub(1);
637            }
638            KeyCode::Enter => {
639                if self.selected_event().is_some() {
640                    self.mode = Mode::Detail;
641                }
642            }
643            KeyCode::Char('/') => {
644                self.mode = Mode::Filter;
645            }
646            // Tab cycles views; 1/2/3 jump straight to one (the bar shows order).
647            KeyCode::Tab | KeyCode::BackTab => self.select_tab(self.tab.next()),
648            KeyCode::Char('1') => self.select_tab(Tab::Timeline),
649            KeyCode::Char('2') => self.select_tab(Tab::Audit),
650            KeyCode::Char('3') => self.select_tab(Tab::Recorder),
651            KeyCode::Char('u') => return Action::Undo,
652            KeyCode::Char('a') => return self.resolve_selected(true),
653            KeyCode::Char('d') => return self.resolve_selected(false),
654            KeyCode::Char('s') => self.open_settings(),
655            _ => {}
656        }
657        Action::None
658    }
659
660    /// Approve/deny the selected row, but only if it is actually a held command.
661    fn resolve_selected(&self, approve: bool) -> Action {
662        match self.selected_event() {
663            Some(ev) if ev.decision == kintsugi_core::Decision::Hold => {
664                let id = ev.id.to_string();
665                if approve {
666                    Action::Approve(id)
667                } else {
668                    Action::Deny(id)
669                }
670            }
671            _ => Action::None,
672        }
673    }
674
675    fn on_key_filter(&mut self, key: KeyCode) -> Action {
676        match key {
677            KeyCode::Enter | KeyCode::Esc => self.mode = Mode::Normal,
678            KeyCode::Backspace => {
679                self.filter.pop();
680                self.clamp_selection();
681            }
682            KeyCode::Char(c) => {
683                self.filter.push(c);
684                self.selected = 0;
685            }
686            _ => {}
687        }
688        Action::None
689    }
690
691    fn on_key_detail(&mut self, key: KeyCode) -> Action {
692        match key {
693            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => self.mode = Mode::Normal,
694            KeyCode::Char('j') | KeyCode::Down => {
695                self.move_down();
696            }
697            KeyCode::Char('k') | KeyCode::Up => {
698                self.move_up();
699            }
700            _ => {}
701        }
702        Action::None
703    }
704
705    fn move_down(&mut self) {
706        let len = self.visible_len();
707        if len > 0 && self.selected + 1 < len {
708            self.selected += 1;
709        }
710    }
711
712    fn move_up(&mut self) {
713        self.selected = self.selected.saturating_sub(1);
714    }
715
716    /// Step the selection down by one screenful (the last-rendered row count,
717    /// at least 1), clamped to the last row.
718    fn page_down(&mut self) {
719        let len = self.visible_len();
720        if len == 0 {
721            return;
722        }
723        let step = self.page_rows.max(1);
724        self.selected = (self.selected + step).min(len - 1);
725    }
726
727    /// Step the selection up by one screenful.
728    fn page_up(&mut self) {
729        let step = self.page_rows.max(1);
730        self.selected = self.selected.saturating_sub(step);
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use kintsugi_core::{Class, Decision, EventLog, ProposedCommand, Verdict};
738
739    fn ev(agent: &str, raw: &str, class: Class, decision: Decision) -> LoggedEvent {
740        let log = EventLog::open_in_memory().unwrap();
741        let cmd = ProposedCommand::new(agent, "/tmp", vec![raw.into()], raw);
742        log.log_event(&cmd, &Verdict::rules(class, decision, "r"), None)
743            .unwrap()
744    }
745
746    fn sample_app() -> App {
747        let mut app = App::new(false);
748        app.set_events(vec![
749            ev("claude-code", "ls", Class::Safe, Decision::Allow),
750            ev("shim", "rm -rf /", Class::Catastrophic, Decision::Hold),
751            ev("qwen", "make build", Class::Ambiguous, Decision::Hold),
752        ]);
753        app
754    }
755
756    fn ev_session(agent: &str, session: &str, raw: &str) -> LoggedEvent {
757        let log = EventLog::open_in_memory().unwrap();
758        let cmd = ProposedCommand::new(agent, "/tmp", vec![raw.into()], raw)
759            .with_session(Some(session.into()));
760        log.log_event(
761            &cmd,
762            &Verdict::rules(Class::Safe, Decision::Allow, "r"),
763            None,
764        )
765        .unwrap()
766    }
767
768    #[test]
769    fn page_keys_step_by_a_screenful_and_clamp() {
770        let mut app = App::new(false);
771        let many: Vec<_> = (0..50)
772            .map(|i| ev("shim", &format!("cmd {i}"), Class::Safe, Decision::Allow))
773            .collect();
774        app.set_events(many);
775        app.page_rows = 10;
776
777        // PageDown jumps a screenful, never past the last row.
778        app.on_key(KeyCode::PageDown);
779        assert_eq!(app.selected, 10);
780        // Space is the Mac-friendly alias and pages identically.
781        app.on_key(KeyCode::Char(' '));
782        assert_eq!(app.selected, 20);
783
784        // PageUp steps back symmetrically and clamps at the top.
785        app.on_key(KeyCode::PageUp);
786        assert_eq!(app.selected, 10);
787        app.on_key(KeyCode::PageUp);
788        app.on_key(KeyCode::PageUp);
789        assert_eq!(app.selected, 0);
790
791        // Near the end, PageDown stops on the last row rather than overshooting.
792        app.on_key(KeyCode::End);
793        assert_eq!(app.selected, 49);
794        app.on_key(KeyCode::PageDown);
795        assert_eq!(app.selected, 49);
796    }
797
798    #[test]
799    fn structured_filter_tokens() {
800        let mut app = App::new(false);
801        app.set_events(vec![
802            ev_session("claude-code", "s1", "ls"),
803            ev_session("claude-code", "s2", "make build"),
804            ev_session("cursor", "s2", "npm test"),
805        ]);
806
807        app.filter = "agent:claude-code".into();
808        assert_eq!(app.visible().len(), 2);
809
810        app.filter = "session:s2".into();
811        assert_eq!(app.visible().len(), 2);
812
813        app.filter = "agent:cursor session:s2".into();
814        assert_eq!(app.visible().len(), 1);
815
816        // Structured token + free text combine (AND).
817        app.filter = "agent:claude-code build".into();
818        assert_eq!(app.visible().len(), 1);
819
820        // Recent window includes everything just logged.
821        app.filter = "since:1h".into();
822        assert_eq!(app.visible().len(), 3);
823
824        // Empty filter shows all.
825        app.filter = String::new();
826        assert_eq!(app.visible().len(), 3);
827    }
828
829    #[test]
830    fn parse_ago_accepts_known_forms() {
831        assert!(parse_ago("10m").is_some());
832        assert!(parse_ago("2h").is_some());
833        assert!(parse_ago("3d").is_some());
834        assert!(parse_ago("week").is_some());
835        assert!(parse_ago("nonsense").is_none());
836        assert!(parse_ago("5x").is_none());
837    }
838
839    #[test]
840    fn navigation_clamps() {
841        let mut app = sample_app();
842        assert_eq!(app.selected, 0);
843        app.on_key(KeyCode::Char('k')); // up at top stays
844        assert_eq!(app.selected, 0);
845        app.on_key(KeyCode::Char('j'));
846        app.on_key(KeyCode::Char('j'));
847        app.on_key(KeyCode::Char('j')); // past end clamps
848        assert_eq!(app.selected, 2);
849        app.on_key(KeyCode::Char('g'));
850        assert_eq!(app.selected, 0);
851        app.on_key(KeyCode::Char('G'));
852        assert_eq!(app.selected, 2);
853    }
854
855    #[test]
856    fn quit_and_undo_actions() {
857        let mut app = sample_app();
858        assert_eq!(app.on_key(KeyCode::Char('u')), Action::Undo);
859        assert_eq!(app.on_key(KeyCode::Char('q')), Action::Quit);
860        assert_eq!(app.on_key(KeyCode::Esc), Action::Quit);
861    }
862
863    #[test]
864    fn approve_deny_only_on_held_rows() {
865        let mut app = sample_app();
866        // Row 0 is the allowed `ls` → a/d do nothing.
867        app.selected = 0;
868        assert_eq!(app.on_key(KeyCode::Char('a')), Action::None);
869        assert_eq!(app.on_key(KeyCode::Char('d')), Action::None);
870        // Row 1 is the held `rm -rf /` → a/d resolve it.
871        app.selected = 1;
872        let held_id = app.selected_event().unwrap().id.to_string();
873        assert_eq!(
874            app.on_key(KeyCode::Char('a')),
875            Action::Approve(held_id.clone())
876        );
877        assert_eq!(app.on_key(KeyCode::Char('d')), Action::Deny(held_id));
878    }
879
880    #[test]
881    fn filter_mode_edits_and_narrows() {
882        let mut app = sample_app();
883        app.on_key(KeyCode::Char('/'));
884        assert_eq!(app.mode, Mode::Filter);
885        for c in "rm".chars() {
886            app.on_key(KeyCode::Char(c));
887        }
888        assert_eq!(app.filter, "rm");
889        assert_eq!(app.visible().len(), 1);
890        assert_eq!(app.visible()[0].command, "rm -rf /");
891        app.on_key(KeyCode::Backspace);
892        app.on_key(KeyCode::Backspace);
893        assert_eq!(app.visible().len(), 3);
894        app.on_key(KeyCode::Enter);
895        assert_eq!(app.mode, Mode::Normal);
896    }
897
898    #[test]
899    fn enter_opens_detail_and_esc_closes() {
900        let mut app = sample_app();
901        app.on_key(KeyCode::Enter);
902        assert_eq!(app.mode, Mode::Detail);
903        assert!(app.selected_event().is_some());
904        app.on_key(KeyCode::Esc);
905        assert_eq!(app.mode, Mode::Normal);
906    }
907
908    #[test]
909    fn empty_app_is_safe() {
910        let mut app = App::new(false);
911        assert!(app.is_empty());
912        assert!(app.selected_event().is_none());
913        // Keys never panic on an empty list.
914        app.on_key(KeyCode::Char('j'));
915        app.on_key(KeyCode::Enter);
916        assert_eq!(app.mode, Mode::Normal);
917    }
918
919    #[test]
920    fn tabs_slice_the_log_and_compose_with_filter() {
921        let mut app = App::new(false);
922        app.set_events(vec![
923            ev("claude-code", "ls", Class::Safe, Decision::Allow),
924            ev("shim", "rm -rf /", Class::Catastrophic, Decision::Hold),
925            ev("qwen", "make build", Class::Ambiguous, Decision::Hold),
926            // A passively-recorded human command (agent = shell).
927            ev("shell", "psql prod", Class::Safe, Decision::Allow),
928        ]);
929
930        // Timeline shows everything.
931        assert_eq!(app.tab, Tab::Timeline);
932        assert_eq!(app.visible().len(), 4);
933
934        // Audit = destructive band only (catastrophic + ambiguous).
935        app.on_key(KeyCode::Char('2'));
936        assert_eq!(app.tab, Tab::Audit);
937        assert_eq!(app.visible().len(), 2);
938        assert!(app.visible().iter().all(|e| e.class != Class::Safe));
939
940        // Recorder = agent == shell only.
941        app.on_key(KeyCode::Char('3'));
942        assert_eq!(app.tab, Tab::Recorder);
943        assert_eq!(app.visible().len(), 1);
944        assert_eq!(app.visible()[0].command, "psql prod");
945
946        // Tab cycles back to Timeline.
947        app.on_key(KeyCode::Tab);
948        assert_eq!(app.tab, Tab::Timeline);
949
950        // Tab predicate AND the text filter both apply.
951        app.on_key(KeyCode::Char('2')); // Audit
952        app.filter = "rm".into();
953        assert_eq!(app.visible().len(), 1);
954        assert_eq!(app.visible()[0].command, "rm -rf /");
955    }
956
957    #[test]
958    fn vitals_count_held_and_catastrophic_globally() {
959        let mut app = App::new(false);
960        app.set_events(vec![
961            ev("claude-code", "ls", Class::Safe, Decision::Allow),
962            ev("shim", "rm -rf /", Class::Catastrophic, Decision::Hold),
963            ev("qwen", "make build", Class::Ambiguous, Decision::Hold),
964        ]);
965        // Vitals are global (independent of the active tab/filter).
966        app.on_key(KeyCode::Char('3')); // Recorder (empty slice)
967        assert_eq!(app.visible().len(), 0);
968        assert_eq!(app.vitals(), (3, 2, 1)); // total, held, catastrophic
969    }
970
971    #[test]
972    fn splash_ticks_to_main_and_any_key_skips_it() {
973        let mut app = App::new(false);
974        app.start_on_splash();
975        assert_eq!(app.screen, Screen::Splash);
976        // Ticking eventually completes the animation and enters the app.
977        for _ in 0..crate::splash::FRAMES {
978            app.tick_splash();
979        }
980        assert_eq!(app.screen, Screen::Main);
981
982        // From the splash, a non-quit key skips straight in; quit still quits.
983        let mut app = App::new(false);
984        app.start_on_splash();
985        assert_eq!(app.on_key(KeyCode::Char('j')), Action::None);
986        assert_eq!(app.screen, Screen::Main);
987
988        let mut app = App::new(false);
989        app.start_on_splash();
990        assert_eq!(app.on_key(KeyCode::Char('q')), Action::Quit);
991    }
992
993    // Runtime-built test password (not a hard-coded credential literal).
994    fn test_pw(tag: &str) -> String {
995        format!("kintsugi-test-pw-{}-{tag}", std::process::id())
996    }
997
998    #[test]
999    fn login_gate_blocks_until_correct_password() {
1000        let password = test_pw("ok");
1001        let prov = kintsugi_core::admin::provision(
1002            &password,
1003            &kintsugi_core::admin::LockedSettings::default(),
1004        )
1005        .unwrap();
1006        let mut app = App::new(false);
1007        app.set_vault(Some(prov.vault));
1008        app.start_on_splash();
1009
1010        // Skipping the splash lands on the login gate (vault is locked).
1011        app.on_key(KeyCode::Char(' '));
1012        assert_eq!(app.screen, Screen::Login);
1013
1014        // A wrong password is rejected and stays on the gate.
1015        for c in test_pw("bad").chars() {
1016            app.on_key(KeyCode::Char(c));
1017        }
1018        app.on_key(KeyCode::Enter);
1019        assert_eq!(app.screen, Screen::Login);
1020        assert!(app.login_error.is_some());
1021        assert!(app.login_input.is_empty(), "field cleared after a failure");
1022
1023        // The correct password authenticates and enters the app.
1024        for c in password.chars() {
1025            app.on_key(KeyCode::Char(c));
1026        }
1027        app.on_key(KeyCode::Enter);
1028        assert_eq!(app.screen, Screen::Main);
1029        assert!(app.authed);
1030
1031        // Esc on the gate quits rather than bypassing it.
1032        let mut app2 = App::new(false);
1033        app2.set_vault(Some(
1034            kintsugi_core::admin::provision(
1035                &test_pw("other"),
1036                &kintsugi_core::admin::LockedSettings::default(),
1037            )
1038            .unwrap()
1039            .vault,
1040        ));
1041        app2.start_on_splash();
1042        app2.on_key(KeyCode::Char(' '));
1043        assert_eq!(app2.on_key(KeyCode::Esc), Action::Quit);
1044    }
1045
1046    #[test]
1047    fn settings_screen_toggles_persist_to_the_sealed_vault() {
1048        // Isolate the vault on disk so the toggle's save round-trips.
1049        let dir = tempfile::tempdir().unwrap();
1050        let vault_path = dir.path().join("vault.json");
1051        std::env::set_var("KINTSUGI_VAULT", &vault_path);
1052
1053        let password = test_pw("ok");
1054        let prov = kintsugi_core::admin::provision(
1055            &password,
1056            &kintsugi_core::admin::LockedSettings::default(),
1057        )
1058        .unwrap();
1059        kintsugi_core::admin::save_vault(&vault_path, &prov.vault).unwrap();
1060
1061        let mut app = App::new(false);
1062        app.set_vault(Some(prov.vault));
1063        // Authenticate (so the password is held for re-sealing).
1064        app.start_on_splash();
1065        app.on_key(KeyCode::Char(' ')); // skip splash → Login
1066        for c in password.chars() {
1067            app.on_key(KeyCode::Char(c));
1068        }
1069        app.on_key(KeyCode::Enter);
1070        assert_eq!(app.screen, Screen::Main);
1071
1072        // Open settings, move to "recording" (row 0), and toggle it off.
1073        app.on_key(KeyCode::Char('s'));
1074        assert_eq!(app.screen, Screen::Settings);
1075        assert!(app.settings_editable());
1076        assert!(app.settings.as_ref().unwrap().recording);
1077        app.on_key(KeyCode::Enter); // toggle recording
1078        assert!(!app.settings.as_ref().unwrap().recording);
1079        assert!(app.settings_status.as_deref().unwrap().contains("saved"));
1080
1081        // The change is durable: re-load the vault from disk and unseal it.
1082        let reloaded = match kintsugi_core::admin::load_vault(&vault_path) {
1083            kintsugi_core::admin::VaultState::Locked(v) => *v,
1084            _ => panic!("vault should be locked"),
1085        };
1086        let s = reloaded.unseal(&password).unwrap();
1087        assert!(!s.recording, "toggle must persist to disk");
1088
1089        std::env::remove_var("KINTSUGI_VAULT");
1090    }
1091
1092    #[test]
1093    fn settings_are_read_only_without_a_vault() {
1094        let mut app = App::new(false);
1095        app.open_settings();
1096        assert_eq!(app.screen, Screen::Settings);
1097        assert!(!app.settings_editable());
1098        // Toggling explains rather than pretending to change anything.
1099        let before = app.settings.clone();
1100        app.on_key(KeyCode::Enter);
1101        assert_eq!(app.settings, before);
1102        assert!(app
1103            .settings_status
1104            .as_deref()
1105            .unwrap()
1106            .contains("read-only"));
1107    }
1108
1109    #[test]
1110    fn no_vault_skips_the_login_gate() {
1111        let mut app = App::new(false);
1112        app.start_on_splash();
1113        app.on_key(KeyCode::Char(' '));
1114        assert_eq!(app.screen, Screen::Main);
1115        assert!(!app.needs_login());
1116    }
1117
1118    #[test]
1119    fn switching_tab_resets_selection() {
1120        let mut app = sample_app();
1121        app.selected = 2;
1122        app.on_key(KeyCode::Char('2'));
1123        assert_eq!(app.selected, 0);
1124    }
1125
1126    #[test]
1127    fn filter_for_nothing_clamps_selection() {
1128        let mut app = sample_app();
1129        app.selected = 2;
1130        app.on_key(KeyCode::Char('/'));
1131        for c in "zzz".chars() {
1132            app.on_key(KeyCode::Char(c));
1133        }
1134        assert_eq!(app.visible().len(), 0);
1135        assert!(app.selected_event().is_none());
1136    }
1137}