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`], with a strict priority order:
4//! Ctrl-C, then an open confirmation dialog, then an open text edit, then the
5//! help overlay, then navigation. Editing before navigation matters: while a
6//! field is open, letters are letters, so `q` types a `q` rather than
7//! quitting - losing a half-entered API key to a quit shortcut would be a
8//! genuinely bad way to find out about modal input.
9//!
10//! Navigation resolves shared keys through `crate::tui::keymap`, so arrows,
11//! vim aliases, Space, Enter, Esc, Tab, `?`, and `q` mean here exactly what
12//! they mean in every other Leviath TUI. Enter acts on the focused row - it
13//! toggles a provider, opens an editor, cycles a choice - and only advances
14//! the screen when the cursor is visibly on the step's Continue button.
15
16use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
17use ratatui::layout::Rect;
18
19use super::catalog::Credential;
20use super::state::{
21    ConfirmPurpose, DetailAction, Edit, EditTarget, FieldValue, Picker, Step, Wizard,
22};
23use crate::tui::keymap;
24use crate::tui::widgets::confirm::ConfirmOutcome;
25use crate::tui::widgets::help::handle_help_key;
26use crate::tui::widgets::line_edit::{EditOutcome, LineEdit};
27
28/// What the loop should do after a key press.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Action {
31    /// Keep going.
32    Continue,
33    /// Apply the plan, then stop.
34    Save,
35}
36
37impl Wizard {
38    /// Handle one key press.
39    pub fn handle_key(&mut self, key: KeyEvent) -> Action {
40        // Ctrl-C always works, even mid-edit and even inside a dialog: it is
41        // the one binding a user reaches for expecting it to obey no matter
42        // what. With unsaved choices it asks once; pressed again (the dialog
43        // is then open), it quits unconditionally.
44        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
45            if self.confirm.is_some() || !self.dirty {
46                self.should_quit = true;
47            } else {
48                self.open_quit_confirm();
49            }
50            return Action::Continue;
51        }
52        // Ctrl-R also works mid-edit: revealing what you are typing is most
53        // useful precisely while you are typing it.
54        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('r') {
55            self.reveal = !self.reveal;
56            self.message = Some(if self.reveal {
57                "Credentials shown.".to_string()
58            } else {
59                "Credentials hidden.".to_string()
60            });
61            return Action::Continue;
62        }
63        if self.confirm.is_some() {
64            return self.handle_confirm_key(key);
65        }
66        if let Some(picker) = self.picker.take() {
67            self.handle_picker_key(key, picker);
68            return Action::Continue;
69        }
70        if let Some(edit) = self.edit.take() {
71            self.handle_edit_key(key, edit);
72            return Action::Continue;
73        }
74        if self.show_help {
75            if handle_help_key(&key, &self.help_scroll) {
76                self.show_help = false;
77            }
78            return Action::Continue;
79        }
80        self.handle_nav_key(key)
81    }
82
83    /// Handle one mouse event against the window it was clicked in.
84    ///
85    /// A click acts on what it lands on rather than only selecting it, which
86    /// is the point: the wizard leaned on `o` and `v` and a footer nobody
87    /// read, and a row you can press is the version of that a first-time user
88    /// finds on their own. Clicks are ignored while a dialog, an edit or the
89    /// help overlay is up, because a click cannot mean anything there and
90    /// dismissing them by accident would lose typed input.
91    pub fn handle_mouse(&mut self, mouse: MouseEvent, area: Rect) -> Action {
92        if self.confirm.is_some() || self.edit.is_some() || self.show_help {
93            return Action::Continue;
94        }
95        if let Some(picker) = self.picker.take() {
96            self.handle_picker_mouse(mouse, area, picker);
97            return Action::Continue;
98        }
99        match mouse.kind {
100            MouseEventKind::ScrollDown => self.scroll_by(1),
101            MouseEventKind::ScrollUp => self.scroll_by(-1),
102            MouseEventKind::Down(MouseButton::Left) => {
103                if let Some(row) = super::render::row_at(area, self, mouse.column, mouse.row) {
104                    self.cursor = row;
105                    return self.activate();
106                }
107            }
108            _ => {}
109        }
110        Action::Continue
111    }
112
113    /// Keys while a confirmation dialog is open. Its Yes routes by purpose;
114    /// No always just closes it.
115    fn handle_confirm_key(&mut self, key: KeyEvent) -> Action {
116        let Some(mut pending) = self.confirm.take() else {
117            return Action::Continue;
118        };
119        match pending.dialog.handle(&key) {
120            ConfirmOutcome::Pending => {
121                self.confirm = Some(pending);
122                Action::Continue
123            }
124            ConfirmOutcome::No => Action::Continue,
125            ConfirmOutcome::Yes => match pending.purpose {
126                ConfirmPurpose::QuitDiscard => {
127                    self.should_quit = true;
128                    Action::Continue
129                }
130                ConfirmPurpose::SaveTos => {
131                    self.claude_code_tos_accepted = true;
132                    Action::Save
133                }
134                ConfirmPurpose::NoProviders => {
135                    self.next_step();
136                    Action::Continue
137                }
138            },
139        }
140    }
141
142    /// The mouse while the chooser is open: the wheel moves within the list, a
143    /// click on a row takes it.
144    ///
145    /// A click outside the list is ignored rather than closing the chooser.
146    /// Closing on a stray click would discard a search somebody was halfway
147    /// through typing, and Esc is right there.
148    fn handle_picker_mouse(&mut self, mouse: MouseEvent, area: Rect, mut picker: Picker) {
149        match mouse.kind {
150            MouseEventKind::ScrollDown => picker.move_cursor(1),
151            MouseEventKind::ScrollUp => picker.move_cursor(-1),
152            MouseEventKind::Down(MouseButton::Left) => {
153                if let Some(row) = super::render::picker_row_at(area, &picker, mouse.row) {
154                    picker.cursor = row;
155                    self.commit_picker(picker);
156                    return;
157                }
158            }
159            _ => {}
160        }
161        self.picker = Some(picker);
162    }
163
164    /// Keys while the chooser is open.
165    ///
166    /// Everything that is not navigation goes to the search box, so letters
167    /// type rather than acting: `q` in a chooser means the user is looking for
168    /// Qwen, and quitting setup instead would be indefensible.
169    fn handle_picker_key(&mut self, key: KeyEvent, mut picker: Picker) {
170        match key.code {
171            KeyCode::Up => picker.move_cursor(-1),
172            KeyCode::Down => picker.move_cursor(1),
173            KeyCode::PageUp => picker.move_cursor(-Wizard::PAGE),
174            KeyCode::PageDown => picker.move_cursor(Wizard::PAGE),
175            KeyCode::Home => picker.cursor = 0,
176            KeyCode::End => picker.move_cursor(isize::MAX),
177            _ => {
178                match picker.query.handle_key(&key) {
179                    EditOutcome::Commit => {
180                        self.commit_picker(picker);
181                        return;
182                    }
183                    // Esc closes without choosing, leaving the field as it was.
184                    EditOutcome::Cancel => return,
185                    EditOutcome::Pending => {}
186                }
187                // The filter just changed under the cursor, so a selection
188                // that has been filtered away must not linger off the end.
189                picker.move_cursor(0);
190            }
191        }
192        self.picker = Some(picker);
193    }
194
195    /// Keys while a text field is open.
196    ///
197    /// Takes the edit rather than re-reading `self.edit`: the caller already
198    /// established there is one, so re-checking would add arms nothing can
199    /// reach.
200    fn handle_edit_key(&mut self, key: KeyEvent, mut edit: Edit) {
201        match edit.line.handle_key(&key) {
202            EditOutcome::Commit => {
203                self.edit = Some(edit);
204                self.commit_edit();
205                self.message = None;
206                self.dirty = true;
207            }
208            EditOutcome::Cancel => {
209                self.message = Some("Edit cancelled.".to_string());
210            }
211            EditOutcome::Pending => self.edit = Some(edit),
212        }
213    }
214
215    /// Keys while navigating: surface-specific bindings first, then the
216    /// crate-wide keymap.
217    fn handle_nav_key(&mut self, key: KeyEvent) -> Action {
218        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
219        match key.code {
220            KeyCode::Char('s') if ctrl => return self.try_save(),
221            KeyCode::Char('o') => self.open_signup_page(),
222            KeyCode::Char('v') => self.verify_current(),
223            KeyCode::PageUp => self.scroll_by(-Wizard::PAGE),
224            KeyCode::PageDown => self.scroll_by(Wizard::PAGE),
225            KeyCode::Home => self.scroll_home(),
226            KeyCode::End => self.scroll_end(),
227            // `?` reaches the keymap below; F1 has no keymap action and is
228            // the key that works on the screens where `?` is text.
229            KeyCode::F(1) => self.show_help = true,
230            _ => match keymap::resolve(&key) {
231                Some(keymap::Action::Up) => self.move_cursor(-1),
232                Some(keymap::Action::Down) => self.move_cursor(1),
233                Some(keymap::Action::Left) => self.adjust(-1),
234                Some(keymap::Action::Right) => self.adjust(1),
235                Some(keymap::Action::Toggle) => self.toggle(),
236                Some(keymap::Action::Activate) => return self.activate(),
237                Some(keymap::Action::Back) | Some(keymap::Action::Prev) => self.back(),
238                Some(keymap::Action::Next) => self.forward_guarded(),
239                Some(keymap::Action::Help) => self.show_help = true,
240                Some(keymap::Action::Quit) => self.request_quit(),
241                // Ctrl-C is intercepted in `handle_key`; this arm only fires
242                // when `handle_nav_key` is driven directly (tests do).
243                Some(keymap::Action::ForceQuit) => self.should_quit = true,
244                None => {}
245            },
246        }
247        Action::Continue
248    }
249
250    /// `q`: quit - after a confirmation when there are unsaved choices.
251    fn request_quit(&mut self) {
252        if self.dirty {
253            self.open_quit_confirm();
254        } else {
255            self.should_quit = true;
256        }
257    }
258
259    /// Save, unless the Claude Code terms still need confirming first.
260    fn try_save(&mut self) -> Action {
261        if self.needs_tos_confirmation() {
262            self.open_tos_confirm();
263            return Action::Continue;
264        }
265        Action::Save
266    }
267
268    /// `Enter`: act on the focused row, or - only from the visible Continue
269    /// button - move on.
270    fn activate(&mut self) -> Action {
271        if self.on_continue() {
272            return match self.step {
273                Step::Review => self.try_save(),
274                Step::Providers => {
275                    self.forward_guarded();
276                    Action::Continue
277                }
278                _ => {
279                    self.forward();
280                    Action::Continue
281                }
282            };
283        }
284        match self.step {
285            Step::Providers | Step::Agents | Step::Mcp => self.toggle(),
286            Step::ProviderDetail => match self.detail_actions().get(self.cursor.wrapping_sub(1)) {
287                // Row 0 is the credential: it opens its editor, and the Claude
288                // Code row has nothing to type, so Enter cycles its effort.
289                // `wrapping_sub` turns that row into an index no action has.
290                None => {
291                    if !self.open_credential_editor() {
292                        self.adjust(1);
293                    }
294                }
295                Some(DetailAction::OpenSignup) => self.open_signup_page(),
296                Some(DetailAction::Verify) => self.verify_current(),
297            },
298            Step::Defaults | Step::Limits => self.activate_field(),
299            // Rowless steps put the cursor on their button, so these arms are
300            // reachable only with a hand-forced cursor; acting on nothing is
301            // correct then.
302            Step::Welcome | Step::Review => {}
303        }
304        Action::Continue
305    }
306
307    /// Enter on a Defaults/Limits row always acts on that row's kind: toggle
308    /// a bool, cycle a choice, open the editor for a number.
309    fn activate_field(&mut self) {
310        // The choice is cloned out before acting, because opening the chooser
311        // needs `&mut self` while the field it came from is still borrowed.
312        let Some(field) = self.fields().get(self.cursor) else {
313            // Reachable only with a hand-forced cursor past the fields.
314            return;
315        };
316        let label = field.label;
317        let choice = match &field.value {
318            FieldValue::Bool(_) => {
319                self.toggle();
320                return;
321            }
322            FieldValue::Number(_) => {
323                self.open_field_editor();
324                return;
325            }
326            FieldValue::Choice { options, index } => (options.clone(), *index),
327        };
328        // Unconditionally, because the only list-valued fields in the wizard
329        // are the Defaults screen's provider and model. The tuning screen is
330        // numbers and switches, which the arrows already handle well.
331        self.open_picker(label, choice.0, choice.1);
332    }
333
334    /// Open the credential editor for the provider on screen. Returns false
335    /// when this provider has nothing to type (Claude Code).
336    fn open_credential_editor(&mut self) -> bool {
337        let Some((index, credential, value)) = self.detail_row().map(|index| {
338            let row = &self.providers[index];
339            (index, row.provider.credential, row.value.clone())
340        }) else {
341            return false;
342        };
343        if credential == Credential::None {
344            return false;
345        }
346        self.edit = Some(Edit {
347            target: EditTarget::Credential(index),
348            line: LineEdit::new(value, credential == Credential::ApiKey),
349        });
350        true
351    }
352
353    /// Open the text editor for the selected field. Returns false for fields
354    /// that are not text.
355    fn open_field_editor(&mut self) -> bool {
356        let cursor = self.cursor;
357        let Some(FieldValue::Number(current)) = self.fields().get(cursor).map(|f| &f.value) else {
358            return false;
359        };
360        let buffer = current.map(|n| n.to_string()).unwrap_or_default();
361        self.edit = Some(Edit {
362            target: EditTarget::Field(cursor),
363            line: LineEdit::new(buffer, false),
364        });
365        true
366    }
367
368    /// `Space` (or Enter on a row): toggle whatever the cursor is on.
369    fn toggle(&mut self) {
370        match self.step {
371            Step::Providers => {
372                if let Some(row) = self.providers.get_mut(self.cursor) {
373                    row.selected = !row.selected;
374                    self.dirty = true;
375                    // Deselecting the Claude Code transport withdraws the
376                    // terms acceptance so it must be re-confirmed if
377                    // re-enabled.
378                    if row.provider.id == "claude-code" && !row.selected {
379                        self.claude_code_tos_accepted = false;
380                    }
381                }
382                // The credential screen walks selected providers, so its
383                // position is only meaningful relative to the current
384                // selection.
385                self.detail = 0;
386            }
387            Step::Agents => {
388                if let Some(row) = self.agents.get_mut(self.cursor) {
389                    row.selected = !row.selected;
390                    self.dirty = true;
391                }
392            }
393            Step::Mcp => {
394                if let Some(row) = self.mcp.get_mut(self.cursor) {
395                    row.selected = !row.selected;
396                    self.dirty = true;
397                }
398            }
399            Step::Defaults | Step::Limits => {
400                let cursor = self.cursor;
401                let mut changed = false;
402                if let Some(fields) = self.fields_mut()
403                    && let Some(field) = fields.get_mut(cursor)
404                    && let FieldValue::Bool(b) = &mut field.value
405                {
406                    *b = !*b;
407                    changed = true;
408                }
409                if changed {
410                    self.dirty = true;
411                    // One of those booleans decides whether the tuning screen
412                    // is on the path at all. The field was built from the flag,
413                    // so flipping one flips the other, and the Continue
414                    // button's label changes with it.
415                    if self.step == Step::Defaults && cursor == Wizard::ADVANCED_FIELD {
416                        self.show_advanced = !self.show_advanced;
417                    }
418                }
419            }
420            Step::Welcome | Step::ProviderDetail | Step::Review => {}
421        }
422    }
423
424    /// `←`/`→`: cycle a choice, or step through the credential screen's
425    /// providers.
426    fn adjust(&mut self, delta: isize) {
427        match self.step {
428            Step::ProviderDetail => {
429                // The effort selector is the only cyclable value here.
430                if let Some(index) = self.detail_row()
431                    && let Some(row) = self.providers.get_mut(index)
432                    && row.provider.credential == Credential::None
433                {
434                    let count = super::state::effort_options().len();
435                    let next = row.effort as isize + delta;
436                    row.effort = next.rem_euclid(count as isize) as usize;
437                    self.dirty = true;
438                }
439            }
440            Step::Defaults | Step::Limits => {
441                let cursor = self.cursor;
442                let mut changed_provider = false;
443                if let Some(fields) = self.fields_mut()
444                    && let Some(field) = fields.get_mut(cursor)
445                    && let FieldValue::Choice { options, index } = &mut field.value
446                    && !options.is_empty()
447                {
448                    let next = *index as isize + delta;
449                    *index = next.rem_euclid(options.len() as isize) as usize;
450                    changed_provider = true;
451                }
452                if changed_provider {
453                    self.dirty = true;
454                }
455                // Changing the default provider re-picks the concurrency
456                // default, so an Ollama-first setup does not inherit a number
457                // meant for hosted APIs.
458                if changed_provider && self.step == Step::Defaults && cursor == 0 {
459                    self.apply_provider_concurrency_default();
460                }
461            }
462            _ => {}
463        }
464    }
465
466    /// Advance, but guard the one advance that is almost always a slip:
467    /// leaving the Providers screen with nothing selected.
468    fn forward_guarded(&mut self) {
469        if self.step == Step::Providers && self.selected_providers().is_empty() {
470            self.open_no_providers_confirm();
471            return;
472        }
473        self.forward();
474    }
475
476    /// `Tab`: next provider on the credential screen, otherwise next step.
477    fn forward(&mut self) {
478        if self.step == Step::ProviderDetail {
479            // Verify what was just entered before moving on, so the answer is
480            // waiting rather than starting when the user asks for it.
481            if let Some(index) = self.detail_row() {
482                self.request_verification(index);
483            }
484            if self.next_detail() {
485                return;
486            }
487        }
488        self.next_step();
489    }
490
491    /// `Esc` / `Shift-Tab`: previous provider, otherwise previous step.
492    fn back(&mut self) {
493        if self.step == Step::ProviderDetail && self.prev_detail() {
494            return;
495        }
496        self.prev_step();
497    }
498
499    /// `v`: re-check the provider on screen, or every selected one.
500    fn verify_current(&mut self) {
501        match self.step {
502            Step::ProviderDetail => {
503                if let Some(index) = self.detail_row() {
504                    self.request_verification(index);
505                    self.message = Some("Checking…".to_string());
506                }
507            }
508            Step::Providers | Step::Review => {
509                self.verify_all();
510                self.message = Some("Checking every selected provider…".to_string());
511            }
512            _ => {}
513        }
514    }
515
516    /// `o`: open the current provider's signup page.
517    ///
518    /// The opener is a field rather than a direct call so tests never launch a
519    /// real browser - `lev dash` learned that the hard way when a unit test
520    /// opened one.
521    fn open_signup_page(&mut self) {
522        let url = match self.step {
523            Step::ProviderDetail => self
524                .detail_row()
525                .and_then(|i| self.providers.get(i))
526                .and_then(|r| r.provider.signup_url),
527            Step::Providers => self
528                .providers
529                .get(self.cursor)
530                .and_then(|r| r.provider.signup_url),
531            _ => None,
532        };
533        match url {
534            Some(url) => {
535                let opened = (self.opener)(url);
536                self.message = Some(if opened {
537                    format!("Opened {url}")
538                } else {
539                    format!("Couldn't open a browser. Visit {url}")
540                });
541            }
542            None => self.message = Some("Nothing to open here.".to_string()),
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests;