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,
139 Audit,
141 Recorder,
143}
144
145impl Tab {
146 pub const ALL: [Tab; 3] = [Tab::Timeline, Tab::Audit, Tab::Recorder];
148
149 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum Screen {
194 Splash,
196 Login,
198 Main,
200 Settings,
202}
203
204#[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum Action {
276 None,
277 Quit,
278 Undo,
279 Approve(String),
281 Deny(String),
283}
284
285pub struct App {
287 events: Vec<LoggedEvent>,
289 pub selected: usize,
291 pub filter: String,
293 pub mode: Mode,
295 pub status: Option<String>,
297 pub color: bool,
299 pub page_rows: usize,
302 pub tab: Tab,
304 pub daemon_up: bool,
306 pub scorer: Option<String>,
308 pub screen: Screen,
310 pub splash_frame: usize,
312 pub vault: Option<kintsugi_core::admin::SealedVault>,
314 pub authed: bool,
316 pub login_input: zeroize::Zeroizing<String>,
320 pub login_error: Option<String>,
322 pub(crate) password: Option<zeroize::Zeroizing<String>>,
325 pub settings: Option<kintsugi_core::admin::LockedSettings>,
327 pub settings_selected: usize,
329 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 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 pub fn settings_editable(&self) -> bool {
363 self.vault.is_some() && self.password.is_some()
364 }
365
366 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 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 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 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 pub fn start_on_splash(&mut self) {
426 self.screen = Screen::Splash;
427 self.splash_frame = 0;
428 }
429
430 pub fn set_vault(&mut self, vault: Option<kintsugi_core::admin::SealedVault>) {
434 self.vault = vault;
435 }
436
437 pub fn needs_login(&self) -> bool {
439 self.vault.is_some() && !self.authed
440 }
441
442 pub fn submit_login(&mut self) {
446 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 self.screen = Screen::Main;
461 }
462 }
463 }
464
465 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 fn enter_main(&mut self) {
482 self.screen = if self.needs_login() {
483 Screen::Login
484 } else {
485 Screen::Main
486 };
487 }
488
489 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 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 pub fn set_events(&mut self, events: Vec<LoggedEvent>) {
515 self.events = events;
516 self.clamp_selection();
517 }
518
519 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 pub fn visible(&self) -> Vec<&LoggedEvent> {
533 self.filtered_indices()
534 .into_iter()
535 .map(|i| &self.events[i])
536 .collect()
537 }
538
539 pub fn selected_event(&self) -> Option<&LoggedEvent> {
541 self.visible().get(self.selected).copied()
542 }
543
544 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 pub fn on_key(&mut self, key: KeyCode) -> Action {
564 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 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 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 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 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 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 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 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 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 app.on_key(KeyCode::PageDown);
779 assert_eq!(app.selected, 10);
780 app.on_key(KeyCode::Char(' '));
782 assert_eq!(app.selected, 20);
783
784 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 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 app.filter = "agent:claude-code build".into();
818 assert_eq!(app.visible().len(), 1);
819
820 app.filter = "since:1h".into();
822 assert_eq!(app.visible().len(), 3);
823
824 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')); 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')); 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 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 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 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 ev("shell", "psql prod", Class::Safe, Decision::Allow),
928 ]);
929
930 assert_eq!(app.tab, Tab::Timeline);
932 assert_eq!(app.visible().len(), 4);
933
934 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 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 app.on_key(KeyCode::Tab);
948 assert_eq!(app.tab, Tab::Timeline);
949
950 app.on_key(KeyCode::Char('2')); 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 app.on_key(KeyCode::Char('3')); assert_eq!(app.visible().len(), 0);
968 assert_eq!(app.vitals(), (3, 2, 1)); }
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 for _ in 0..crate::splash::FRAMES {
978 app.tick_splash();
979 }
980 assert_eq!(app.screen, Screen::Main);
981
982 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 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 app.on_key(KeyCode::Char(' '));
1012 assert_eq!(app.screen, Screen::Login);
1013
1014 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 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 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 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 app.start_on_splash();
1065 app.on_key(KeyCode::Char(' ')); 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 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); assert!(!app.settings.as_ref().unwrap().recording);
1079 assert!(app.settings_status.as_deref().unwrap().contains("saved"));
1080
1081 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 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}