1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
11use super::catalog::Credential;
12use super::state::{Edit, EditTarget, FieldValue, Step, Wizard};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Action {
17 Continue,
19 Save,
21}
22
23impl Wizard {
24 pub fn handle_key(&mut self, key: KeyEvent) -> Action {
26 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
29 self.should_quit = true;
30 return Action::Continue;
31 }
32 if let Some(edit) = self.edit.take() {
33 self.handle_edit_key(key, edit);
34 return Action::Continue;
35 }
36 if self.show_tos_confirm {
37 return self.handle_tos_key(key);
39 }
40 if self.show_help {
41 self.show_help = false;
43 return Action::Continue;
44 }
45 self.handle_nav_key(key)
46 }
47
48 fn handle_edit_key(&mut self, key: KeyEvent, mut edit: Edit) {
54 match key.code {
55 KeyCode::Enter => {
56 self.edit = Some(edit);
57 self.commit_edit();
58 self.message = None;
59 }
60 KeyCode::Esc => {
61 self.message = Some("Edit cancelled.".to_string());
62 }
63 KeyCode::Backspace => {
64 edit.buffer.pop();
65 self.edit = Some(edit);
66 }
67 KeyCode::Char(c) => {
68 edit.buffer.push(c);
69 self.edit = Some(edit);
70 }
71 _ => self.edit = Some(edit),
72 }
73 }
74
75 fn handle_nav_key(&mut self, key: KeyEvent) -> Action {
77 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
78 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
79
80 match key.code {
81 KeyCode::Char('s') if ctrl => {
82 if self.needs_tos_confirmation() {
83 self.show_tos_confirm = true;
84 return Action::Continue;
85 }
86 return Action::Save;
87 }
88 KeyCode::Char('q') => self.should_quit = true,
89 KeyCode::Char('?') => self.show_help = true,
90 KeyCode::Char('r') if ctrl => {
91 self.reveal = !self.reveal;
92 self.message = Some(if self.reveal {
93 "Credentials shown.".to_string()
94 } else {
95 "Credentials hidden.".to_string()
96 });
97 }
98 KeyCode::Up | KeyCode::Char('k') => self.move_cursor(-1),
99 KeyCode::Down | KeyCode::Char('j') => self.move_cursor(1),
100 KeyCode::Left | KeyCode::Char('h') => self.adjust(-1),
101 KeyCode::Right | KeyCode::Char('l') => self.adjust(1),
102 KeyCode::Char(' ') => self.toggle(),
103 KeyCode::Char('o') => self.open_signup_page(),
104 KeyCode::Char('v') => self.verify_current(),
105 KeyCode::BackTab => self.back(),
106 KeyCode::Tab if shift => self.back(),
107 KeyCode::Tab => self.forward(),
108 KeyCode::Esc => self.back(),
109 KeyCode::Enter => return self.activate(),
110 _ => {}
111 }
112 Action::Continue
113 }
114
115 fn activate(&mut self) -> Action {
121 let opened = match self.step {
122 Step::Review => {
123 if self.needs_tos_confirmation() {
124 self.show_tos_confirm = true;
125 return Action::Continue;
126 }
127 return Action::Save;
128 }
129 Step::ProviderDetail => self.open_credential_editor(),
130 Step::Defaults | Step::Limits => self.open_field_editor(),
131 _ => false,
132 };
133 if opened {
134 return Action::Continue;
135 }
136 self.forward();
137 Action::Continue
138 }
139
140 fn open_credential_editor(&mut self) -> bool {
143 let Some((index, credential, value)) = self.detail_row().map(|index| {
144 let row = &self.providers[index];
145 (index, row.provider.credential, row.value.clone())
146 }) else {
147 return false;
148 };
149 if credential == Credential::None {
150 return false;
151 }
152 self.edit = Some(Edit {
153 target: EditTarget::Credential(index),
154 buffer: value,
155 masked: credential == Credential::ApiKey,
156 });
157 true
158 }
159
160 fn open_field_editor(&mut self) -> bool {
163 let cursor = self.cursor;
164 let Some(FieldValue::Number(current)) = self.fields().get(cursor).map(|f| &f.value) else {
165 return false;
166 };
167 let buffer = current.map(|n| n.to_string()).unwrap_or_default();
168 self.edit = Some(Edit {
169 target: EditTarget::Field(cursor),
170 buffer,
171 masked: false,
172 });
173 true
174 }
175
176 fn handle_tos_key(&mut self, key: KeyEvent) -> Action {
183 self.show_tos_confirm = false;
184 if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
185 self.claude_code_tos_accepted = true;
186 return Action::Save;
187 }
188 Action::Continue
189 }
190
191 fn toggle(&mut self) {
193 match self.step {
194 Step::Providers => {
195 if let Some(row) = self.providers.get_mut(self.cursor) {
196 row.selected = !row.selected;
197 if row.provider.id == "claude-code" && !row.selected {
201 self.claude_code_tos_accepted = false;
202 }
203 }
204 self.detail = 0;
208 }
209 Step::Agents => {
210 if let Some(row) = self.agents.get_mut(self.cursor) {
211 row.selected = !row.selected;
212 }
213 }
214 Step::Mcp => {
215 if let Some(row) = self.mcp.get_mut(self.cursor) {
216 row.selected = !row.selected;
217 }
218 }
219 Step::Defaults | Step::Limits => {
220 let cursor = self.cursor;
221 if let Some(fields) = self.fields_mut()
222 && let Some(field) = fields.get_mut(cursor)
223 && let FieldValue::Bool(b) = &mut field.value
224 {
225 *b = !*b;
226 }
227 }
228 Step::Welcome | Step::ProviderDetail | Step::Review => {}
229 }
230 }
231
232 fn adjust(&mut self, delta: isize) {
235 match self.step {
236 Step::ProviderDetail => {
237 if let Some(index) = self.detail_row()
239 && let Some(row) = self.providers.get_mut(index)
240 && row.provider.credential == Credential::None
241 {
242 let count = super::state::effort_options().len();
243 let next = row.effort as isize + delta;
244 row.effort = next.rem_euclid(count as isize) as usize;
245 }
246 }
247 Step::Defaults | Step::Limits => {
248 let cursor = self.cursor;
249 let mut changed_provider = false;
250 if let Some(fields) = self.fields_mut()
251 && let Some(field) = fields.get_mut(cursor)
252 && let FieldValue::Choice { options, index } = &mut field.value
253 && !options.is_empty()
254 {
255 let next = *index as isize + delta;
256 *index = next.rem_euclid(options.len() as isize) as usize;
257 changed_provider = true;
258 }
259 if changed_provider && self.step == Step::Defaults && cursor == 0 {
263 self.apply_provider_concurrency_default();
264 }
265 }
266 _ => {}
267 }
268 }
269
270 fn forward(&mut self) {
272 if self.step == Step::ProviderDetail {
273 if let Some(index) = self.detail_row() {
276 self.request_verification(index);
277 }
278 if self.next_detail() {
279 return;
280 }
281 }
282 self.next_step();
283 }
284
285 fn back(&mut self) {
287 if self.step == Step::ProviderDetail && self.prev_detail() {
288 return;
289 }
290 self.prev_step();
291 }
292
293 fn verify_current(&mut self) {
295 match self.step {
296 Step::ProviderDetail => {
297 if let Some(index) = self.detail_row() {
298 self.request_verification(index);
299 self.message = Some("Checking…".to_string());
300 }
301 }
302 Step::Providers | Step::Review => {
303 self.verify_all();
304 self.message = Some("Checking every selected provider…".to_string());
305 }
306 _ => {}
307 }
308 }
309
310 fn open_signup_page(&mut self) {
316 let url = match self.step {
317 Step::ProviderDetail => self
318 .detail_row()
319 .and_then(|i| self.providers.get(i))
320 .and_then(|r| r.provider.signup_url),
321 Step::Providers => self
322 .providers
323 .get(self.cursor)
324 .and_then(|r| r.provider.signup_url),
325 _ => None,
326 };
327 match url {
328 Some(url) => {
329 let opened = (self.opener)(url);
330 self.message = Some(if opened {
331 format!("Opened {url}")
332 } else {
333 format!("Couldn't open a browser. Visit {url}")
334 });
335 }
336 None => self.message = Some("Nothing to open here.".to_string()),
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::commands::setup::state::{Edit, EditTarget, FieldValue};
345 fn press(code: KeyCode) -> KeyEvent {
346 KeyEvent::new(code, KeyModifiers::empty())
347 }
348
349 fn press_with(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
350 KeyEvent::new(code, modifiers)
351 }
352
353 fn wizard() -> (tempfile::TempDir, Wizard) {
354 let dir = tempfile::tempdir().unwrap();
355 let wizard = crate::commands::setup::state::tests::test_wizard(dir.path());
356 (dir, wizard)
357 }
358
359 #[test]
362 fn ctrl_c_quits_from_anywhere_including_mid_edit() {
363 let (_dir, mut w) = wizard();
365 w.edit = Some(Edit {
366 target: EditTarget::Credential(0),
367 buffer: "half-typed".to_string(),
368 masked: true,
369 });
370
371 let action = w.handle_key(press_with(KeyCode::Char('c'), KeyModifiers::CONTROL));
372
373 assert_eq!(action, Action::Continue);
374 assert!(w.should_quit);
375 }
376
377 #[test]
378 fn q_quits_while_navigating() {
379 let (_dir, mut w) = wizard();
380 w.handle_key(press(KeyCode::Char('q')));
381 assert!(w.should_quit);
382 }
383
384 #[test]
385 fn q_types_a_letter_while_editing_rather_than_quitting() {
386 let (_dir, mut w) = wizard();
389 w.edit = Some(Edit {
390 target: EditTarget::Credential(0),
391 buffer: String::new(),
392 masked: true,
393 });
394
395 w.handle_key(press(KeyCode::Char('q')));
396
397 assert!(!w.should_quit);
398 assert_eq!(w.edit.as_ref().expect("still editing").buffer, "q");
399 }
400
401 #[test]
402 fn ctrl_s_saves_from_any_screen() {
403 let (_dir, mut w) = wizard();
404 w.enter(Step::Providers);
405
406 let action = w.handle_key(press_with(KeyCode::Char('s'), KeyModifiers::CONTROL));
407
408 assert_eq!(action, Action::Save);
409 }
410
411 #[test]
412 fn enter_on_the_review_screen_saves() {
413 let (_dir, mut w) = wizard();
414 w.enter(Step::Review);
415
416 assert_eq!(w.handle_key(press(KeyCode::Enter)), Action::Save);
417 }
418
419 #[test]
422 fn the_help_overlay_opens_and_any_key_closes_it() {
423 let (_dir, mut w) = wizard();
424
425 w.handle_key(press(KeyCode::Char('?')));
426 assert!(w.show_help);
427
428 w.handle_key(press(KeyCode::Char('q')));
430 assert!(!w.show_help);
431 assert!(!w.should_quit);
432 }
433
434 #[test]
437 fn typing_backspacing_and_committing_a_credential() {
438 let (_dir, mut w) = wizard();
439 w.providers[0].selected = true;
440 w.enter(Step::ProviderDetail);
441
442 w.handle_key(press(KeyCode::Enter));
443 assert!(w.edit.is_some(), "Enter opens the editor");
444 for c in "sk-antX".chars() {
445 w.handle_key(press(KeyCode::Char(c)));
446 }
447 w.handle_key(press(KeyCode::Backspace));
448 w.handle_key(press(KeyCode::Enter));
449
450 assert!(w.edit.is_none());
451 assert_eq!(w.providers[0].value, "sk-ant");
452 }
453
454 #[test]
455 fn escape_abandons_an_edit_without_changing_the_value() {
456 let (_dir, mut w) = wizard();
457 w.providers[0].selected = true;
458 w.providers[0].value = "sk-ant-original".to_string();
459 w.enter(Step::ProviderDetail);
460 w.handle_key(press(KeyCode::Enter));
461 w.handle_key(press(KeyCode::Char('z')));
462
463 w.handle_key(press(KeyCode::Esc));
464
465 assert!(w.edit.is_none());
466 assert_eq!(w.providers[0].value, "sk-ant-original");
467 assert_eq!(w.message.as_deref(), Some("Edit cancelled."));
468 }
469
470 #[test]
471 fn an_unhandled_key_while_editing_is_ignored() {
472 let (_dir, mut w) = wizard();
473 w.edit = Some(Edit {
474 target: EditTarget::Credential(0),
475 buffer: "abc".to_string(),
476 masked: false,
477 });
478
479 w.handle_key(press(KeyCode::F(5)));
480
481 assert_eq!(w.edit.as_ref().expect("still editing").buffer, "abc");
482 }
483
484 #[test]
485 fn backspace_on_an_empty_buffer_is_harmless() {
486 let (_dir, mut w) = wizard();
487 w.edit = Some(Edit {
488 target: EditTarget::Credential(0),
489 buffer: String::new(),
490 masked: false,
491 });
492
493 w.handle_key(press(KeyCode::Backspace));
494
495 assert!(w.edit.as_ref().expect("still editing").buffer.is_empty());
496 }
497
498 #[test]
499 fn the_claude_code_transport_has_nothing_to_type_so_enter_moves_on() {
500 let (_dir, mut w) = wizard();
501 let index = w
502 .providers
503 .iter()
504 .position(|r| r.provider.id == "claude-code")
505 .expect("the transport is offered");
506 w.providers[index].selected = true;
507 w.enter(Step::ProviderDetail);
508
509 w.handle_key(press(KeyCode::Enter));
510
511 assert!(w.edit.is_none());
512 assert_ne!(w.step, Step::ProviderDetail);
513 }
514
515 #[test]
516 fn enter_on_a_toggle_moves_on_rather_than_doing_nothing() {
517 let (_dir, mut w) = wizard();
518 w.enter(Step::Limits);
519 w.cursor = 3; w.handle_key(press(KeyCode::Enter));
522
523 assert!(w.edit.is_none());
524 assert_ne!(w.step, Step::Limits);
525 }
526
527 #[test]
528 fn enter_on_a_number_field_opens_it_seeded_with_the_current_value() {
529 let (_dir, mut w) = wizard();
530 w.enter(Step::Limits);
531
532 w.handle_key(press(KeyCode::Enter));
533
534 let edit = w.edit.as_ref().expect("the editor opened");
535 assert_eq!(edit.target, EditTarget::Field(0));
536 assert!(!edit.buffer.is_empty(), "seeded from the current value");
537 assert!(!edit.masked, "a limit is not a secret");
538 }
539
540 #[test]
541 fn an_unset_number_field_opens_empty() {
542 let (_dir, mut w) = wizard();
543 w.enter(Step::Limits);
544 w.limits[0].value = FieldValue::Number(None);
545
546 w.handle_key(press(KeyCode::Enter));
547
548 assert!(
549 w.edit
550 .as_ref()
551 .expect("the editor opened")
552 .buffer
553 .is_empty()
554 );
555 }
556
557 #[test]
558 fn opening_an_editor_out_of_range_is_a_no_op() {
559 let (_dir, mut w) = wizard();
560 w.enter(Step::Limits);
561 w.cursor = 99;
562
563 w.handle_key(press(KeyCode::Enter));
564
565 assert!(w.edit.is_none());
566 }
567
568 #[test]
569 fn the_credential_editor_declines_when_no_provider_is_on_screen() {
570 let (_dir, mut w) = wizard();
571 w.enter(Step::ProviderDetail);
572
573 w.handle_key(press(KeyCode::Enter));
574
575 assert!(w.edit.is_none());
576 }
577
578 #[test]
581 fn space_toggles_a_provider_and_resets_the_credential_walk() {
582 let (_dir, mut w) = wizard();
583 w.enter(Step::Providers);
584 w.detail = 3;
585
586 w.handle_key(press(KeyCode::Char(' ')));
587
588 assert!(w.providers[0].selected);
589 assert_eq!(w.detail, 0, "the walk is relative to the new selection");
590
591 w.handle_key(press(KeyCode::Char(' ')));
592 assert!(!w.providers[0].selected);
593 }
594
595 #[test]
596 fn space_toggles_agents_and_mcp_rows_and_booleans() {
597 let dir = tempfile::tempdir().unwrap();
598 let mut w = Wizard::new(
599 crate::config::Config::default(),
600 &|_| None,
601 vec![(
602 "A".to_string(),
603 crate::commands::setup::import::Candidate {
604 config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
605 scope: String::new(),
606 inline_secrets: Vec::new(),
607 },
608 )],
609 Vec::new(),
610 dir.path(),
611 std::sync::Arc::new(|_| true),
612 );
613
614 w.enter(Step::Agents);
615 let before = w.agents[0].selected;
616 w.handle_key(press(KeyCode::Char(' ')));
617 assert_ne!(w.agents[0].selected, before);
618
619 w.enter(Step::Mcp);
620 w.handle_key(press(KeyCode::Char(' ')));
621 assert!(!w.mcp[0].selected);
622
623 w.enter(Step::Limits);
624 w.cursor = 3;
625 let before = w.limits[3].value.clone();
626 w.handle_key(press(KeyCode::Char(' ')));
627 assert_ne!(w.limits[3].value, before);
628 }
629
630 #[test]
631 fn space_on_a_screen_with_nothing_to_toggle_is_harmless() {
632 let (_dir, mut w) = wizard();
633 for step in [Step::Welcome, Step::ProviderDetail, Step::Review] {
634 w.enter(step);
635 w.handle_key(press(KeyCode::Char(' ')));
636 }
637 for step in [Step::Providers, Step::Agents, Step::Mcp] {
639 w.enter(step);
640 w.cursor = 999;
641 w.handle_key(press(KeyCode::Char(' ')));
642 }
643 assert!(!w.should_quit);
644 }
645
646 #[test]
649 fn both_arrow_and_vim_keys_move_the_cursor() {
650 let (_dir, mut w) = wizard();
651 w.enter(Step::Providers);
652
653 w.handle_key(press(KeyCode::Down));
654 assert_eq!(w.cursor, 1);
655 w.handle_key(press(KeyCode::Char('j')));
656 assert_eq!(w.cursor, 2);
657 w.handle_key(press(KeyCode::Up));
658 assert_eq!(w.cursor, 1);
659 w.handle_key(press(KeyCode::Char('k')));
660 assert_eq!(w.cursor, 0);
661 }
662
663 #[test]
664 fn left_and_right_cycle_a_choice_in_both_directions() {
665 let (_dir, mut w) = wizard();
666 w.providers[0].selected = true;
667 let ollama = w
668 .providers
669 .iter()
670 .position(|r| r.provider.id == "ollama")
671 .expect("ollama is offered");
672 w.providers[ollama].selected = true;
673 w.enter(Step::Defaults);
674
675 w.handle_key(press(KeyCode::Right));
676 assert_eq!(w.defaults[0].value.display(), "ollama");
677 w.handle_key(press(KeyCode::Char('h')));
678 assert_eq!(w.defaults[0].value.display(), "anthropic");
679 w.handle_key(press(KeyCode::Left));
681 assert_eq!(w.defaults[0].value.display(), "ollama");
682 }
683
684 #[test]
685 fn choosing_ollama_as_the_default_lowers_the_concurrency_limit() {
686 let (_dir, mut w) = wizard();
687 w.providers[0].selected = true;
688 let ollama = w
689 .providers
690 .iter()
691 .position(|r| r.provider.id == "ollama")
692 .expect("ollama is offered");
693 w.providers[ollama].selected = true;
694 w.enter(Step::Defaults);
695 w.cursor = 0;
696
697 w.handle_key(press(KeyCode::Right));
698
699 assert_eq!(w.defaults[0].value.display(), "ollama");
700 assert_eq!(
701 w.limits[0].value,
702 FieldValue::Number(Some(
703 crate::commands::setup::catalog::OLLAMA_MAX_CONCURRENT_INFERENCES as u64
704 ))
705 );
706 }
707
708 #[test]
709 fn left_and_right_cycle_the_claude_code_effort() {
710 let (_dir, mut w) = wizard();
711 let index = w
712 .providers
713 .iter()
714 .position(|r| r.provider.id == "claude-code")
715 .expect("the transport is offered");
716 w.providers[index].selected = true;
717 w.enter(Step::ProviderDetail);
718 let before = w.providers[index].effort;
719
720 w.handle_key(press(KeyCode::Right));
721 assert_ne!(w.providers[index].effort, before);
722 w.handle_key(press(KeyCode::Left));
723 assert_eq!(w.providers[index].effort, before);
724 w.providers[index].effort = 0;
726 w.handle_key(press(KeyCode::Left));
727 assert_eq!(
728 w.providers[index].effort,
729 crate::commands::setup::state::effort_options().len() - 1
730 );
731 }
732
733 #[test]
734 fn arrows_on_a_keyed_provider_do_not_change_anything() {
735 let (_dir, mut w) = wizard();
736 w.providers[0].selected = true;
737 w.enter(Step::ProviderDetail);
738 let before = w.providers[0].effort;
739
740 w.handle_key(press(KeyCode::Right));
741
742 assert_eq!(w.providers[0].effort, before);
743 }
744
745 #[test]
746 fn arrows_on_a_non_choice_field_or_empty_screen_are_harmless() {
747 let (_dir, mut w) = wizard();
748 w.enter(Step::Limits);
749 w.cursor = 0; w.handle_key(press(KeyCode::Right));
751 assert_eq!(w.limits[0].value.display(), "8");
752
753 w.enter(Step::Welcome);
754 w.handle_key(press(KeyCode::Right));
755
756 w.enter(Step::ProviderDetail);
757 w.handle_key(press(KeyCode::Right));
758
759 assert!(!w.should_quit);
760 }
761
762 #[test]
763 fn an_empty_choice_list_does_not_divide_by_zero() {
764 let (_dir, mut w) = wizard();
765 w.enter(Step::Defaults);
766 w.defaults[0].value = FieldValue::Choice {
767 options: Vec::new(),
768 index: 0,
769 };
770
771 w.handle_key(press(KeyCode::Right));
772
773 assert_eq!(w.defaults[0].value.display(), "(none)");
774 }
775
776 #[test]
779 fn tab_and_shift_tab_walk_the_steps() {
780 let (_dir, mut w) = wizard();
781
782 w.handle_key(press(KeyCode::Tab));
783 assert_eq!(w.step, Step::Providers);
784 w.handle_key(press(KeyCode::BackTab));
785 assert_eq!(w.step, Step::Welcome);
786 w.handle_key(press(KeyCode::Tab));
787 w.handle_key(press_with(KeyCode::Tab, KeyModifiers::SHIFT));
788 assert_eq!(w.step, Step::Welcome);
789 }
790
791 #[test]
792 fn escape_goes_back_a_step() {
793 let (_dir, mut w) = wizard();
794 w.enter(Step::Agents);
795
796 w.handle_key(press(KeyCode::Esc));
797
798 assert_eq!(w.step, Step::Limits);
799 }
800
801 #[test]
802 fn tab_on_the_credential_screen_walks_providers_then_leaves() {
803 let (_dir, mut w) = wizard();
804 let (mut requests, _replies) = w.take_verify_ends().expect("first take");
805 w.providers[0].selected = true;
806 w.providers[0].value = "sk-ant".to_string();
807 w.providers[1].selected = true;
808 w.providers[1].value = "sk-oai".to_string();
809 w.enter(Step::ProviderDetail);
810
811 w.handle_key(press(KeyCode::Tab));
812 assert_eq!(w.step, Step::ProviderDetail);
813 assert_eq!(w.detail, 1, "moved to the second provider");
814
815 w.handle_key(press(KeyCode::Tab));
816 assert_eq!(w.step, Step::Defaults, "past the last provider");
817
818 let mut checked = Vec::new();
821 while let Ok(request) = requests.try_recv() {
822 checked.push(request.provider_id);
823 }
824 assert_eq!(checked, vec!["anthropic", "openai"]);
825 }
826
827 #[test]
828 fn escape_on_the_credential_screen_walks_back_through_providers() {
829 let (_dir, mut w) = wizard();
830 w.providers[0].selected = true;
831 w.providers[1].selected = true;
832 w.enter(Step::ProviderDetail);
833 w.detail = 1;
834
835 w.handle_key(press(KeyCode::Esc));
836 assert_eq!(w.step, Step::ProviderDetail);
837 assert_eq!(w.detail, 0);
838
839 w.handle_key(press(KeyCode::Esc));
840 assert_eq!(w.step, Step::Providers);
841 }
842
843 #[test]
846 fn ctrl_r_toggles_credential_visibility_and_says_which_way() {
847 let (_dir, mut w) = wizard();
848
849 w.handle_key(press_with(KeyCode::Char('r'), KeyModifiers::CONTROL));
850 assert!(w.reveal);
851 assert_eq!(w.message.as_deref(), Some("Credentials shown."));
852
853 w.handle_key(press_with(KeyCode::Char('r'), KeyModifiers::CONTROL));
854 assert!(!w.reveal);
855 assert_eq!(w.message.as_deref(), Some("Credentials hidden."));
856 }
857
858 #[test]
859 fn v_rechecks_the_provider_on_screen() {
860 let (_dir, mut w) = wizard();
861 let (mut requests, _replies) = w.take_verify_ends().expect("first take");
862 w.providers[0].selected = true;
863 w.providers[0].value = "sk-ant".to_string();
864 w.enter(Step::ProviderDetail);
865
866 w.handle_key(press(KeyCode::Char('v')));
867
868 assert_eq!(w.message.as_deref(), Some("Checking…"));
869 assert_eq!(
870 requests.try_recv().expect("queued").provider_id,
871 "anthropic"
872 );
873 }
874
875 #[test]
876 fn v_on_the_list_and_review_screens_rechecks_everything() {
877 let (_dir, mut w) = wizard();
878 let (mut requests, _replies) = w.take_verify_ends().expect("first take");
879 w.providers[0].selected = true;
880 w.providers[0].value = "sk-ant".to_string();
881
882 w.enter(Step::Providers);
883 w.handle_key(press(KeyCode::Char('v')));
884 assert!(requests.try_recv().is_ok());
885
886 w.enter(Step::Review);
887 w.handle_key(press(KeyCode::Char('v')));
888 assert!(requests.try_recv().is_ok());
889 }
890
891 #[test]
892 fn v_elsewhere_does_nothing() {
893 let (_dir, mut w) = wizard();
894 let (mut requests, _replies) = w.take_verify_ends().expect("first take");
895 w.enter(Step::Limits);
896
897 w.handle_key(press(KeyCode::Char('v')));
898
899 assert!(requests.try_recv().is_err());
900 }
901
902 #[test]
903 fn v_on_the_credential_screen_with_no_provider_does_nothing() {
904 let (_dir, mut w) = wizard();
905 w.enter(Step::ProviderDetail);
906
907 w.handle_key(press(KeyCode::Char('v')));
908
909 assert!(w.message.is_none());
910 }
911
912 #[test]
913 fn o_opens_the_signup_page_from_both_provider_screens() {
914 let dir = tempfile::tempdir().unwrap();
915 let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
916 let sink = opened.clone();
917 let mut w = Wizard::new(
918 crate::config::Config::default(),
919 &|_| None,
920 Vec::new(),
921 Vec::new(),
922 dir.path(),
923 std::sync::Arc::new(move |url: &str| {
924 sink.lock().expect("not poisoned").push(url.to_string());
925 true
926 }),
927 );
928
929 w.enter(Step::Providers);
930 w.handle_key(press(KeyCode::Char('o')));
931
932 w.providers[0].selected = true;
933 w.enter(Step::ProviderDetail);
934 w.handle_key(press(KeyCode::Char('o')));
935
936 let urls = opened.lock().expect("not poisoned").clone();
937 assert_eq!(urls.len(), 2);
938 assert!(urls.iter().all(|u| u.starts_with("https://")));
939 assert!(
940 w.message
941 .as_deref()
942 .unwrap_or_default()
943 .starts_with("Opened"),
944 "the user is told which page opened"
945 );
946 }
947
948 #[test]
949 fn a_browser_that_will_not_open_prints_the_url_instead() {
950 let dir = tempfile::tempdir().unwrap();
951 let mut w = Wizard::new(
952 crate::config::Config::default(),
953 &|_| None,
954 Vec::new(),
955 Vec::new(),
956 dir.path(),
957 std::sync::Arc::new(|_| false),
958 );
959 w.enter(Step::Providers);
960
961 w.handle_key(press(KeyCode::Char('o')));
962
963 let message = w.message.as_deref().unwrap_or_default();
964 assert!(message.contains("Couldn't open"), "{message}");
965 assert!(message.contains("https://"), "{message}");
966 }
967
968 #[test]
969 fn o_where_there_is_nothing_to_open_says_so() {
970 let (_dir, mut w) = wizard();
971 let index = w
972 .providers
973 .iter()
974 .position(|r| r.provider.id == "claude-code")
975 .expect("the transport is offered");
976 w.providers[index].selected = true;
977 w.enter(Step::ProviderDetail);
978
979 w.handle_key(press(KeyCode::Char('o')));
980 assert_eq!(w.message.as_deref(), Some("Nothing to open here."));
981
982 w.enter(Step::Limits);
983 w.message = None;
984 w.handle_key(press(KeyCode::Char('o')));
985 assert_eq!(w.message.as_deref(), Some("Nothing to open here."));
986 }
987
988 #[test]
989 fn enter_on_a_list_screen_moves_on_rather_than_opening_an_editor() {
990 for step in [Step::Welcome, Step::Providers, Step::Agents] {
992 let (_dir, mut w) = wizard();
993 w.enter(step);
994
995 w.handle_key(press(KeyCode::Enter));
996
997 assert!(w.edit.is_none());
998 assert_ne!(w.step, step, "{step:?} should have advanced");
999 }
1000 }
1001
1002 #[test]
1003 fn space_on_a_number_field_leaves_it_alone() {
1004 let (_dir, mut w) = wizard();
1006 w.enter(Step::Limits);
1007 w.cursor = 0;
1008 let before = w.limits[0].value.clone();
1009
1010 w.handle_key(press(KeyCode::Char(' ')));
1011
1012 assert_eq!(w.limits[0].value, before);
1013 }
1014
1015 #[test]
1016 fn an_unbound_key_does_nothing() {
1017 let (_dir, mut w) = wizard();
1018 let before = w.step;
1019
1020 w.handle_key(press(KeyCode::F(9)));
1021
1022 assert_eq!(w.step, before);
1023 assert!(!w.should_quit);
1024 }
1025
1026 fn wizard_with_claude_code() -> (tempfile::TempDir, Wizard) {
1029 let (dir, mut w) = wizard();
1030 let index = w
1031 .providers
1032 .iter()
1033 .position(|r| r.provider.id == "claude-code")
1034 .expect("the transport is offered");
1035 w.providers[index].selected = true;
1036 (dir, w)
1037 }
1038
1039 #[test]
1040 fn enter_on_review_with_claude_code_shows_tos_confirmation() {
1041 let (_dir, mut w) = wizard_with_claude_code();
1042 w.enter(Step::Review);
1043
1044 let action = w.handle_key(press(KeyCode::Enter));
1045
1046 assert_eq!(
1047 action,
1048 Action::Continue,
1049 "must not save without ToS acceptance"
1050 );
1051 assert!(w.show_tos_confirm, "the overlay should be showing");
1052 }
1053
1054 #[test]
1055 fn ctrl_s_with_claude_code_shows_tos_confirmation() {
1056 let (_dir, mut w) = wizard_with_claude_code();
1057 w.enter(Step::Providers);
1058
1059 let action = w.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
1060
1061 assert_eq!(action, Action::Continue);
1062 assert!(w.show_tos_confirm);
1063 }
1064
1065 #[test]
1066 fn pressing_y_on_tos_confirmation_accepts_and_saves() {
1067 let (_dir, mut w) = wizard_with_claude_code();
1068 w.enter(Step::Review);
1069 w.handle_key(press(KeyCode::Enter));
1070 assert!(w.show_tos_confirm);
1071
1072 let action = w.handle_key(press(KeyCode::Char('y')));
1073
1074 assert!(w.claude_code_tos_accepted);
1075 assert!(!w.show_tos_confirm);
1076 assert_eq!(
1077 action,
1078 Action::Save,
1079 "confirming is the last word on the save the user already asked for"
1080 );
1081 }
1082
1083 #[test]
1084 fn pressing_uppercase_y_also_accepts_and_saves() {
1085 let (_dir, mut w) = wizard_with_claude_code();
1086 w.enter(Step::Review);
1087 w.handle_key(press(KeyCode::Enter));
1088
1089 let action = w.handle_key(press(KeyCode::Char('Y')));
1090
1091 assert!(w.claude_code_tos_accepted);
1092 assert!(!w.show_tos_confirm);
1093 assert_eq!(action, Action::Save);
1094 }
1095
1096 #[test]
1097 fn dismissing_the_tos_confirmation_stays_put_without_accepting() {
1098 let (_dir, mut w) = wizard_with_claude_code();
1099 w.enter(Step::Review);
1100 w.handle_key(press(KeyCode::Enter));
1101
1102 let action = w.handle_key(press(KeyCode::Char('n')));
1103
1104 assert!(!w.claude_code_tos_accepted);
1105 assert!(!w.show_tos_confirm);
1106 assert_eq!(action, Action::Continue, "the save stays blocked");
1107 assert_eq!(w.step, Step::Review, "declining must not navigate away");
1108 }
1109
1110 #[test]
1111 fn second_enter_on_review_after_accepting_saves() {
1112 let (_dir, mut w) = wizard_with_claude_code();
1113 w.enter(Step::Review);
1114 w.handle_key(press(KeyCode::Enter)); w.handle_key(press(KeyCode::Char('y'))); w.enter(Step::Review);
1118 let action = w.handle_key(press(KeyCode::Enter));
1119
1120 assert_eq!(action, Action::Save, "should save after ToS accepted");
1121 }
1122
1123 #[test]
1124 fn deselecting_claude_code_resets_tos_acceptance() {
1125 let (_dir, mut w) = wizard_with_claude_code();
1126 w.claude_code_tos_accepted = true;
1127
1128 let index = w
1129 .providers
1130 .iter()
1131 .position(|r| r.provider.id == "claude-code")
1132 .unwrap();
1133 w.enter(Step::Providers);
1134 w.cursor = index;
1135 w.handle_key(press(KeyCode::Char(' '))); assert!(!w.claude_code_tos_accepted);
1138 }
1139
1140 #[test]
1141 fn tos_overlay_blocks_all_other_keys() {
1142 let (_dir, mut w) = wizard_with_claude_code();
1143 w.enter(Step::Review);
1144 w.handle_key(press(KeyCode::Enter));
1145 assert!(w.show_tos_confirm);
1146
1147 w.handle_key(press(KeyCode::Char('q')));
1149 assert!(!w.should_quit, "q must not quit while overlay is showing");
1150 assert!(!w.show_tos_confirm, "overlay dismissed");
1151 }
1152
1153 #[test]
1154 fn without_claude_code_review_saves_immediately() {
1155 let (_dir, mut w) = wizard();
1156 w.enter(Step::Review);
1157
1158 let action = w.handle_key(press(KeyCode::Enter));
1159 assert_eq!(action, Action::Save, "no claude-code means no gate");
1160 }
1161}