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