Skip to main content

leviath_cli/commands/setup/
input.rs

1//! Key handling for the setup wizard.
2//!
3//! One entry point, [`Wizard::handle_key`], split into a text-editing mode and
4//! a navigation mode. Editing takes priority: while a field is open, letters
5//! are letters, so `q` types a `q` rather than quitting - losing a
6//! half-entered API key to a quit shortcut would be a genuinely bad way to find
7//! out about modal input.
8
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
11use super::catalog::Credential;
12use super::state::{Edit, EditTarget, FieldValue, Step, Wizard};
13
14/// What the loop should do after a key press.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Action {
17    /// Keep going.
18    Continue,
19    /// Apply the plan, then stop.
20    Save,
21}
22
23impl Wizard {
24    /// Handle one key press.
25    pub fn handle_key(&mut self, key: KeyEvent) -> Action {
26        // Ctrl-C always quits, even mid-edit: it is the one binding a user
27        // reaches for expecting it to work no matter what.
28        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            // A hard gate: nothing else means anything until it is answered.
38            return self.handle_tos_key(key);
39        }
40        if self.show_help {
41            // Any key dismisses the help overlay.
42            self.show_help = false;
43            return Action::Continue;
44        }
45        self.handle_nav_key(key)
46    }
47
48    /// Keys while a text field is open.
49    ///
50    /// Takes the buffer rather than re-reading `self.edit`: the caller already
51    /// established there is one, so re-checking would add arms nothing can
52    /// reach.
53    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    /// Keys while navigating.
76    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    /// `Enter`: open an editor, or advance.
116    ///
117    /// A screen with nothing to type into - a toggle, a choice, a list - falls
118    /// through to "next screen" rather than doing nothing at all, so Enter is
119    /// always the key that moves forward.
120    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    /// Open the credential editor for the provider on screen. Returns false
141    /// when this provider has nothing to type (Claude Code).
142    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    /// Open the text editor for the selected field. Returns false for fields
161    /// that are not text.
162    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    /// Handle a key while the ToS confirmation overlay is showing.
177    ///
178    /// Y accepts the terms and lets the interrupted save proceed - the user
179    /// already asked to save; confirming is the last word, not a detour. Any
180    /// other key dismisses the overlay and stays put, leaving the transport
181    /// selected but unsaveable until it is either confirmed or deselected.
182    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    /// `Space`: toggle whatever the cursor is on.
192    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                    // Deselecting the Claude Code transport withdraws the
198                    // terms acceptance so it must be re-confirmed if
199                    // re-enabled.
200                    if row.provider.id == "claude-code" && !row.selected {
201                        self.claude_code_tos_accepted = false;
202                    }
203                }
204                // The credential screen walks selected providers, so its
205                // position is only meaningful relative to the current
206                // selection.
207                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    /// `←`/`→`: cycle a choice, or step through the credential screen's
233    /// providers.
234    fn adjust(&mut self, delta: isize) {
235        match self.step {
236            Step::ProviderDetail => {
237                // The effort selector is the only cyclable value here.
238                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                // Changing the default provider re-picks the concurrency
260                // default, so an Ollama-first setup does not inherit a number
261                // meant for hosted APIs.
262                if changed_provider && self.step == Step::Defaults && cursor == 0 {
263                    self.apply_provider_concurrency_default();
264                }
265            }
266            _ => {}
267        }
268    }
269
270    /// `Tab`: next provider on the credential screen, otherwise next step.
271    fn forward(&mut self) {
272        if self.step == Step::ProviderDetail {
273            // Verify what was just entered before moving on, so the answer is
274            // waiting rather than starting when the user asks for it.
275            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    /// `Esc` / `Shift-Tab`: previous provider, otherwise previous step.
286    fn back(&mut self) {
287        if self.step == Step::ProviderDetail && self.prev_detail() {
288            return;
289        }
290        self.prev_step();
291    }
292
293    /// `v`: re-check the provider on screen, or every selected one.
294    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    /// `o`: open the current provider's signup page.
311    ///
312    /// The opener is a field rather than a direct call so tests never launch a
313    /// real browser - `lev dash` learned that the hard way when a unit test
314    /// opened one.
315    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    // ─── quitting and saving ────────────────────────────────────────────────
360
361    #[test]
362    fn ctrl_c_quits_from_anywhere_including_mid_edit() {
363        // The one binding a user reaches for expecting it to always work.
364        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        // Losing a half-entered API key to a quit shortcut would be a bad way
387        // to find out about modal input.
388        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    // ─── help overlay ───────────────────────────────────────────────────────
420
421    #[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        // The dismissing key must not also do its normal job.
429        w.handle_key(press(KeyCode::Char('q')));
430        assert!(!w.show_help);
431        assert!(!w.should_quit);
432    }
433
434    // ─── editing ────────────────────────────────────────────────────────────
435
436    #[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; // a boolean
520
521        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    // ─── selection ──────────────────────────────────────────────────────────
579
580    #[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        // Also out-of-range cursors on each list step.
638        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    // ─── movement ───────────────────────────────────────────────────────────
647
648    #[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        // Wrapping backwards from the first option lands on the last.
680        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        // Wrapping backwards from the first level lands on the last.
725        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; // a number, not a choice
750        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    // ─── moving between screens ─────────────────────────────────────────────
777
778    #[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        // Moving on verifies what was just entered, so the answer is waiting
819        // rather than starting when the user asks for it.
820        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    // ─── reveal, verify, open ───────────────────────────────────────────────
844
845    #[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        // Welcome, Providers, Agents and MCP have nothing to type into.
991        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        // Only booleans toggle; a count would have nothing to toggle *to*.
1005        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    // ─── Claude Code ToS confirmation gate ──────────────────────────────────
1027
1028    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)); // shows overlay
1115        w.handle_key(press(KeyCode::Char('y'))); // accept, goes back
1116
1117        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(' '))); // deselect
1136
1137        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        // q should NOT quit - the overlay eats it
1148        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}