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};
17
18use super::catalog::Credential;
19use super::state::{ConfirmPurpose, Edit, EditTarget, FieldValue, Step, Wizard};
20use crate::tui::keymap;
21use crate::tui::widgets::confirm::ConfirmOutcome;
22use crate::tui::widgets::help::dismisses_help;
23use crate::tui::widgets::line_edit::{EditOutcome, LineEdit};
24
25/// What the loop should do after a key press.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Action {
28    /// Keep going.
29    Continue,
30    /// Apply the plan, then stop.
31    Save,
32}
33
34impl Wizard {
35    /// Handle one key press.
36    pub fn handle_key(&mut self, key: KeyEvent) -> Action {
37        // Ctrl-C always works, even mid-edit and even inside a dialog: it is
38        // the one binding a user reaches for expecting it to obey no matter
39        // what. With unsaved choices it asks once; pressed again (the dialog
40        // is then open), it quits unconditionally.
41        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
42            if self.confirm.is_some() || !self.dirty {
43                self.should_quit = true;
44            } else {
45                self.open_quit_confirm();
46            }
47            return Action::Continue;
48        }
49        // Ctrl-R also works mid-edit: revealing what you are typing is most
50        // useful precisely while you are typing it.
51        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('r') {
52            self.reveal = !self.reveal;
53            self.message = Some(if self.reveal {
54                "Credentials shown.".to_string()
55            } else {
56                "Credentials hidden.".to_string()
57            });
58            return Action::Continue;
59        }
60        if self.confirm.is_some() {
61            return self.handle_confirm_key(key);
62        }
63        if let Some(edit) = self.edit.take() {
64            self.handle_edit_key(key, edit);
65            return Action::Continue;
66        }
67        if self.show_help {
68            if dismisses_help(&key) {
69                self.show_help = false;
70            }
71            return Action::Continue;
72        }
73        self.handle_nav_key(key)
74    }
75
76    /// Keys while a confirmation dialog is open. Its Yes routes by purpose;
77    /// No always just closes it.
78    fn handle_confirm_key(&mut self, key: KeyEvent) -> Action {
79        let Some(mut pending) = self.confirm.take() else {
80            return Action::Continue;
81        };
82        match pending.dialog.handle(&key) {
83            ConfirmOutcome::Pending => {
84                self.confirm = Some(pending);
85                Action::Continue
86            }
87            ConfirmOutcome::No => Action::Continue,
88            ConfirmOutcome::Yes => match pending.purpose {
89                ConfirmPurpose::QuitDiscard => {
90                    self.should_quit = true;
91                    Action::Continue
92                }
93                ConfirmPurpose::SaveTos => {
94                    self.claude_code_tos_accepted = true;
95                    Action::Save
96                }
97                ConfirmPurpose::NoProviders => {
98                    self.next_step();
99                    Action::Continue
100                }
101            },
102        }
103    }
104
105    /// Keys while a text field is open.
106    ///
107    /// Takes the edit rather than re-reading `self.edit`: the caller already
108    /// established there is one, so re-checking would add arms nothing can
109    /// reach.
110    fn handle_edit_key(&mut self, key: KeyEvent, mut edit: Edit) {
111        match edit.line.handle_key(&key) {
112            EditOutcome::Commit => {
113                self.edit = Some(edit);
114                self.commit_edit();
115                self.message = None;
116                self.dirty = true;
117            }
118            EditOutcome::Cancel => {
119                self.message = Some("Edit cancelled.".to_string());
120            }
121            EditOutcome::Pending => self.edit = Some(edit),
122        }
123    }
124
125    /// Keys while navigating: surface-specific bindings first, then the
126    /// crate-wide keymap.
127    fn handle_nav_key(&mut self, key: KeyEvent) -> Action {
128        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
129        match key.code {
130            KeyCode::Char('s') if ctrl => return self.try_save(),
131            KeyCode::Char('o') => self.open_signup_page(),
132            KeyCode::Char('v') => self.verify_current(),
133            _ => match keymap::resolve(&key) {
134                Some(keymap::Action::Up) => self.move_cursor(-1),
135                Some(keymap::Action::Down) => self.move_cursor(1),
136                Some(keymap::Action::Left) => self.adjust(-1),
137                Some(keymap::Action::Right) => self.adjust(1),
138                Some(keymap::Action::Toggle) => self.toggle(),
139                Some(keymap::Action::Activate) => return self.activate(),
140                Some(keymap::Action::Back) | Some(keymap::Action::Prev) => self.back(),
141                Some(keymap::Action::Next) => self.forward_guarded(),
142                Some(keymap::Action::Help) => self.show_help = true,
143                Some(keymap::Action::Quit) => self.request_quit(),
144                // Ctrl-C is intercepted in `handle_key`; this arm only fires
145                // when `handle_nav_key` is driven directly (tests do).
146                Some(keymap::Action::ForceQuit) => self.should_quit = true,
147                None => {}
148            },
149        }
150        Action::Continue
151    }
152
153    /// `q`: quit - after a confirmation when there are unsaved choices.
154    fn request_quit(&mut self) {
155        if self.dirty {
156            self.open_quit_confirm();
157        } else {
158            self.should_quit = true;
159        }
160    }
161
162    /// Save, unless the Claude Code terms still need confirming first.
163    fn try_save(&mut self) -> Action {
164        if self.needs_tos_confirmation() {
165            self.open_tos_confirm();
166            return Action::Continue;
167        }
168        Action::Save
169    }
170
171    /// `Enter`: act on the focused row, or - only from the visible Continue
172    /// button - move on.
173    fn activate(&mut self) -> Action {
174        if self.on_continue() {
175            return match self.step {
176                Step::Review => self.try_save(),
177                Step::Providers => {
178                    self.forward_guarded();
179                    Action::Continue
180                }
181                _ => {
182                    self.forward();
183                    Action::Continue
184                }
185            };
186        }
187        match self.step {
188            Step::Providers | Step::Agents | Step::Mcp => self.toggle(),
189            Step::ProviderDetail => {
190                // The credential row opens its editor; the Claude Code row has
191                // nothing to type, so Enter cycles its effort instead.
192                if !self.open_credential_editor() {
193                    self.adjust(1);
194                }
195            }
196            Step::Defaults | Step::Limits => self.activate_field(),
197            // Rowless steps put the cursor on their button, so these arms are
198            // reachable only with a hand-forced cursor; acting on nothing is
199            // correct then.
200            Step::Welcome | Step::Review => {}
201        }
202        Action::Continue
203    }
204
205    /// Enter on a Defaults/Limits row always acts on that row's kind: toggle
206    /// a bool, cycle a choice, open the editor for a number.
207    fn activate_field(&mut self) {
208        match self.fields().get(self.cursor).map(|f| &f.value) {
209            Some(FieldValue::Bool(_)) => self.toggle(),
210            Some(FieldValue::Choice { .. }) => self.adjust(1),
211            Some(FieldValue::Number(_)) => {
212                self.open_field_editor();
213            }
214            // Reachable only with a hand-forced cursor past the fields.
215            None => {}
216        }
217    }
218
219    /// Open the credential editor for the provider on screen. Returns false
220    /// when this provider has nothing to type (Claude Code).
221    fn open_credential_editor(&mut self) -> bool {
222        let Some((index, credential, value)) = self.detail_row().map(|index| {
223            let row = &self.providers[index];
224            (index, row.provider.credential, row.value.clone())
225        }) else {
226            return false;
227        };
228        if credential == Credential::None {
229            return false;
230        }
231        self.edit = Some(Edit {
232            target: EditTarget::Credential(index),
233            line: LineEdit::new(value, credential == Credential::ApiKey),
234        });
235        true
236    }
237
238    /// Open the text editor for the selected field. Returns false for fields
239    /// that are not text.
240    fn open_field_editor(&mut self) -> bool {
241        let cursor = self.cursor;
242        let Some(FieldValue::Number(current)) = self.fields().get(cursor).map(|f| &f.value) else {
243            return false;
244        };
245        let buffer = current.map(|n| n.to_string()).unwrap_or_default();
246        self.edit = Some(Edit {
247            target: EditTarget::Field(cursor),
248            line: LineEdit::new(buffer, false),
249        });
250        true
251    }
252
253    /// `Space` (or Enter on a row): toggle whatever the cursor is on.
254    fn toggle(&mut self) {
255        match self.step {
256            Step::Providers => {
257                if let Some(row) = self.providers.get_mut(self.cursor) {
258                    row.selected = !row.selected;
259                    self.dirty = true;
260                    // Deselecting the Claude Code transport withdraws the
261                    // terms acceptance so it must be re-confirmed if
262                    // re-enabled.
263                    if row.provider.id == "claude-code" && !row.selected {
264                        self.claude_code_tos_accepted = false;
265                    }
266                }
267                // The credential screen walks selected providers, so its
268                // position is only meaningful relative to the current
269                // selection.
270                self.detail = 0;
271            }
272            Step::Agents => {
273                if let Some(row) = self.agents.get_mut(self.cursor) {
274                    row.selected = !row.selected;
275                    self.dirty = true;
276                }
277            }
278            Step::Mcp => {
279                if let Some(row) = self.mcp.get_mut(self.cursor) {
280                    row.selected = !row.selected;
281                    self.dirty = true;
282                }
283            }
284            Step::Defaults | Step::Limits => {
285                let cursor = self.cursor;
286                let mut changed = false;
287                if let Some(fields) = self.fields_mut()
288                    && let Some(field) = fields.get_mut(cursor)
289                    && let FieldValue::Bool(b) = &mut field.value
290                {
291                    *b = !*b;
292                    changed = true;
293                }
294                if changed {
295                    self.dirty = true;
296                }
297            }
298            Step::Welcome | Step::ProviderDetail | Step::Review => {}
299        }
300    }
301
302    /// `←`/`→`: cycle a choice, or step through the credential screen's
303    /// providers.
304    fn adjust(&mut self, delta: isize) {
305        match self.step {
306            Step::ProviderDetail => {
307                // The effort selector is the only cyclable value here.
308                if let Some(index) = self.detail_row()
309                    && let Some(row) = self.providers.get_mut(index)
310                    && row.provider.credential == Credential::None
311                {
312                    let count = super::state::effort_options().len();
313                    let next = row.effort as isize + delta;
314                    row.effort = next.rem_euclid(count as isize) as usize;
315                    self.dirty = true;
316                }
317            }
318            Step::Defaults | Step::Limits => {
319                let cursor = self.cursor;
320                let mut changed_provider = false;
321                if let Some(fields) = self.fields_mut()
322                    && let Some(field) = fields.get_mut(cursor)
323                    && let FieldValue::Choice { options, index } = &mut field.value
324                    && !options.is_empty()
325                {
326                    let next = *index as isize + delta;
327                    *index = next.rem_euclid(options.len() as isize) as usize;
328                    changed_provider = true;
329                }
330                if changed_provider {
331                    self.dirty = true;
332                }
333                // Changing the default provider re-picks the concurrency
334                // default, so an Ollama-first setup does not inherit a number
335                // meant for hosted APIs.
336                if changed_provider && self.step == Step::Defaults && cursor == 0 {
337                    self.apply_provider_concurrency_default();
338                }
339            }
340            _ => {}
341        }
342    }
343
344    /// Advance, but guard the one advance that is almost always a slip:
345    /// leaving the Providers screen with nothing selected.
346    fn forward_guarded(&mut self) {
347        if self.step == Step::Providers && self.selected_providers().is_empty() {
348            self.open_no_providers_confirm();
349            return;
350        }
351        self.forward();
352    }
353
354    /// `Tab`: next provider on the credential screen, otherwise next step.
355    fn forward(&mut self) {
356        if self.step == Step::ProviderDetail {
357            // Verify what was just entered before moving on, so the answer is
358            // waiting rather than starting when the user asks for it.
359            if let Some(index) = self.detail_row() {
360                self.request_verification(index);
361            }
362            if self.next_detail() {
363                return;
364            }
365        }
366        self.next_step();
367    }
368
369    /// `Esc` / `Shift-Tab`: previous provider, otherwise previous step.
370    fn back(&mut self) {
371        if self.step == Step::ProviderDetail && self.prev_detail() {
372            return;
373        }
374        self.prev_step();
375    }
376
377    /// `v`: re-check the provider on screen, or every selected one.
378    fn verify_current(&mut self) {
379        match self.step {
380            Step::ProviderDetail => {
381                if let Some(index) = self.detail_row() {
382                    self.request_verification(index);
383                    self.message = Some("Checking…".to_string());
384                }
385            }
386            Step::Providers | Step::Review => {
387                self.verify_all();
388                self.message = Some("Checking every selected provider…".to_string());
389            }
390            _ => {}
391        }
392    }
393
394    /// `o`: open the current provider's signup page.
395    ///
396    /// The opener is a field rather than a direct call so tests never launch a
397    /// real browser - `lev dash` learned that the hard way when a unit test
398    /// opened one.
399    fn open_signup_page(&mut self) {
400        let url = match self.step {
401            Step::ProviderDetail => self
402                .detail_row()
403                .and_then(|i| self.providers.get(i))
404                .and_then(|r| r.provider.signup_url),
405            Step::Providers => self
406                .providers
407                .get(self.cursor)
408                .and_then(|r| r.provider.signup_url),
409            _ => None,
410        };
411        match url {
412            Some(url) => {
413                let opened = (self.opener)(url);
414                self.message = Some(if opened {
415                    format!("Opened {url}")
416                } else {
417                    format!("Couldn't open a browser. Visit {url}")
418                });
419            }
420            None => self.message = Some("Nothing to open here.".to_string()),
421        }
422    }
423}
424
425#[cfg(test)]
426mod tests;