1use crossterm::event::KeyCode;
6use kintsugi_core::LoggedEvent;
7
8pub const MIN_WIDTH: u16 = 60;
10pub const MIN_HEIGHT: u16 = 10;
11
12pub 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#[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
99fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Mode {
124 Normal,
126 Filter,
128 Detail,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum Tab {
137 Timeline,
140 Audit,
142 Recorder,
144 Backstop,
148}
149
150impl Tab {
151 pub const ALL: [Tab; 4] = [Tab::Timeline, Tab::Audit, Tab::Recorder, Tab::Backstop];
153
154 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 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 fn includes(self, e: &LoggedEvent) -> bool {
184 match self {
185 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum Screen {
207 Splash,
209 Login,
211 Main,
213 Settings,
215}
216
217#[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum Action {
289 None,
290 Quit,
291 Undo,
292 Approve(String),
294 Deny(String),
296}
297
298pub struct App {
300 events: Vec<LoggedEvent>,
302 pub selected: usize,
304 pub filter: String,
306 pub mode: Mode,
308 pub status: Option<String>,
310 pub color: bool,
312 pub page_rows: usize,
315 pub tab: Tab,
317 pub daemon_up: bool,
319 pub scorer: Option<String>,
321 pub screen: Screen,
323 pub splash_frame: usize,
325 pub vault: Option<kintsugi_core::admin::SealedVault>,
327 pub authed: bool,
329 pub login_input: zeroize::Zeroizing<String>,
333 pub login_error: Option<String>,
335 pub(crate) password: Option<zeroize::Zeroizing<String>>,
338 pub settings: Option<kintsugi_core::admin::LockedSettings>,
340 pub settings_selected: usize,
342 pub settings_status: Option<String>,
344 pub local_offset: time::UtcOffset,
348 pub last_seq: i64,
351 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 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 pub fn set_local_offset(&mut self, offset: time::UtcOffset) {
390 self.local_offset = offset;
391 }
392
393 pub fn tab_total(&self, tab: Tab) -> usize {
396 self.events.iter().filter(|e| tab.includes(e)).count()
397 }
398
399 pub fn settings_editable(&self) -> bool {
401 self.vault.is_some() && self.password.is_some()
402 }
403
404 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 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 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 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 pub fn start_on_splash(&mut self) {
464 self.screen = Screen::Splash;
465 self.splash_frame = 0;
466 }
467
468 pub fn set_vault(&mut self, vault: Option<kintsugi_core::admin::SealedVault>) {
472 self.vault = vault;
473 }
474
475 pub fn needs_login(&self) -> bool {
477 self.vault.is_some() && !self.authed
478 }
479
480 pub fn submit_login(&mut self) {
484 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 self.screen = Screen::Main;
499 }
500 }
501 }
502
503 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 fn enter_main(&mut self) {
520 self.screen = if self.needs_login() {
521 Screen::Login
522 } else {
523 Screen::Main
524 };
525 }
526
527 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 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 pub fn set_events(&mut self, events: Vec<LoggedEvent>) {
553 self.events = events;
554 self.clamp_selection();
555 }
556
557 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 pub fn visible(&self) -> Vec<&LoggedEvent> {
571 self.filtered_indices()
572 .into_iter()
573 .map(|i| &self.events[i])
574 .collect()
575 }
576
577 pub fn selected_event(&self) -> Option<&LoggedEvent> {
579 self.visible().get(self.selected).copied()
580 }
581
582 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 pub fn on_key(&mut self, key: KeyCode) -> Action {
602 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 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 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 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 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 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 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 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 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 app.on_key(KeyCode::PageDown);
818 assert_eq!(app.selected, 10);
819 app.on_key(KeyCode::Char(' '));
821 assert_eq!(app.selected, 20);
822
823 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 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 app.filter = "agent:claude-code build".into();
857 assert_eq!(app.visible().len(), 1);
858
859 app.filter = "since:1h".into();
861 assert_eq!(app.visible().len(), 3);
862
863 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')); 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')); 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 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 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 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 ev("shell", "psql prod", Class::Safe, Decision::Allow),
967 ev("fs-watch", "removed /tmp/x", Class::Safe, Decision::Allow),
969 ]);
970
971 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 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 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 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 app.on_key(KeyCode::Tab);
996 assert_eq!(app.tab, Tab::Timeline);
997
998 app.on_key(KeyCode::Char('2')); 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 app.on_key(KeyCode::Char('3')); assert_eq!(app.visible().len(), 0);
1016 assert_eq!(app.vitals(), (3, 2, 1)); }
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 for _ in 0..crate::splash::FRAMES {
1026 app.tick_splash();
1027 }
1028 assert_eq!(app.screen, Screen::Main);
1029
1030 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 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 app.on_key(KeyCode::Char(' '));
1060 assert_eq!(app.screen, Screen::Login);
1061
1062 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 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 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 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 app.start_on_splash();
1113 app.on_key(KeyCode::Char(' ')); 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 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); assert!(!app.settings.as_ref().unwrap().recording);
1127 assert!(app.settings_status.as_deref().unwrap().contains("saved"));
1128
1129 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 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}