leviath_cli/commands/setup/
input.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Action {
31 Continue,
33 Save,
35}
36
37impl Wizard {
38 pub fn handle_key(&mut self, key: KeyEvent) -> Action {
40 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 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 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 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 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 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 EditOutcome::Cancel => return,
185 EditOutcome::Pending => {}
186 }
187 picker.move_cursor(0);
190 }
191 }
192 self.picker = Some(picker);
193 }
194
195 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 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 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 Some(keymap::Action::ForceQuit) => self.should_quit = true,
244 None => {}
245 },
246 }
247 Action::Continue
248 }
249
250 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 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 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 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 Step::Welcome | Step::Review => {}
303 }
304 Action::Continue
305 }
306
307 fn activate_field(&mut self) {
310 let Some(field) = self.fields().get(self.cursor) else {
313 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 self.open_picker(label, choice.0, choice.1);
332 }
333
334 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 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 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 if row.provider.id == "claude-code" && !row.selected {
379 self.claude_code_tos_accepted = false;
380 }
381 }
382 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 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 fn adjust(&mut self, delta: isize) {
427 match self.step {
428 Step::ProviderDetail => {
429 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 if changed_provider && self.step == Step::Defaults && cursor == 0 {
459 self.apply_provider_concurrency_default();
460 }
461 }
462 _ => {}
463 }
464 }
465
466 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 fn forward(&mut self) {
478 if self.step == Step::ProviderDetail {
479 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 fn back(&mut self) {
493 if self.step == Step::ProviderDetail && self.prev_detail() {
494 return;
495 }
496 self.prev_step();
497 }
498
499 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 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;