1use anyhow::Result;
13use crossterm::{
14 event::{self, Event, KeyCode, KeyModifiers},
15 execute,
16 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
17};
18use ratatui::{
19 Terminal,
20 backend::CrosstermBackend,
21 layout::{Constraint, Direction, Layout, Rect},
22 style::{Color, Modifier, Style},
23 text::{Line, Span},
24 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
25};
26
27use std::io;
28use std::path::PathBuf;
29
30#[derive(Clone)]
36struct ProviderEntry {
37 name: String,
38 has_key: bool,
39 key_masked: String,
40 is_custom: bool,
41 base_url: Option<String>,
42}
43
44#[derive(Clone)]
48enum InputMode {
49 Normal,
51 EditingApiKey {
53 provider_name: String,
54 field_text: String,
55 },
56 AddingCustom {
58 fields: [String; 3], active_field: usize,
60 },
61}
62
63struct WizardState {
67 step: usize,
69 providers: Vec<ProviderEntry>,
71 provider_selected: usize,
73 provider_list_state: ListState,
75 provider_filter: String,
77 provider_searching: bool,
79 input_mode: InputMode,
81 models: Vec<ModelEntry>,
83 model_selected: usize,
85 model_filter: String,
87 model_list_state: ListState,
89 models_dirty: bool,
93 themes: Vec<String>,
95 theme_selected: usize,
97 theme_list_state: ListState,
99 auth_path: PathBuf,
101 settings_path: PathBuf,
103 catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
105}
106
107#[derive(Clone)]
109struct ModelEntry {
110 id: String,
111 provider: String,
112 context_window: u32,
113 id_lower: String,
116 provider_lower: String,
118}
119
120impl ModelEntry {
121 fn new(id: String, provider: String, context_window: u32) -> Self {
122 let provider_lower = provider.to_lowercase();
123 let id_lower = id.to_lowercase();
124 Self {
125 id,
126 provider,
127 context_window,
128 id_lower,
129 provider_lower,
130 }
131 }
132}
133
134fn mask_key(key: &str) -> String {
138 if key.len() <= 10 {
139 "*".repeat(key.len())
140 } else {
141 format!("{}...{}", &key[..6], &key[key.len() - 4..])
142 }
143}
144
145fn filtered_provider_indices(state: &WizardState) -> Vec<usize> {
150 if state.provider_filter.is_empty() {
151 (0..state.providers.len()).collect()
152 } else {
153 let f = state.provider_filter.to_lowercase();
154 state
155 .providers
156 .iter()
157 .enumerate()
158 .filter(|(_, p)| p.name.to_lowercase().contains(&f))
159 .map(|(i, _)| i)
160 .collect()
161 }
162}
163
164fn filtered_model_indices(state: &WizardState) -> Vec<usize> {
169 if state.model_filter.is_empty() {
170 (0..state.models.len()).collect()
171 } else {
172 let f = state.model_filter.to_lowercase();
173 state
174 .models
175 .iter()
176 .enumerate()
177 .filter(|(_, m)| m.id_lower.contains(&f) || m.provider_lower.contains(&f))
178 .map(|(i, _)| i)
179 .collect()
180 }
181}
182
183fn ensure_model_selected_visible(state: &mut WizardState) {
187 let filtered = filtered_model_indices(state);
188 if filtered.is_empty() {
189 return;
190 }
191 if !filtered.contains(&state.model_selected) {
192 state.model_selected = filtered[0];
193 }
194}
195
196fn snap_provider_selection(state: &mut WizardState) {
199 let indices = filtered_provider_indices(state);
200 if indices.is_empty() {
201 return;
202 }
203 if !indices.contains(&state.provider_selected) {
204 state.provider_selected = indices[0];
205 }
206}
207
208fn load_providers(
212 auth_store: &crate::store::auth_storage::AuthStorage,
213 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
214) -> Vec<ProviderEntry> {
215 let mut entries = Vec::new();
216
217 let builtin_names: Vec<String> = if let Some(cat) = catalog {
218 cat.list_providers_sync()
219 } else {
220 oxi_sdk::get_builtin_providers()
221 .iter()
222 .map(|p| p.name.to_string())
223 .collect()
224 };
225
226 for name in &builtin_names {
227 let key = auth_store.get_api_key(name);
228
229 let (has_key, key_masked) = match &key {
230 Some(k) => (true, mask_key(k)),
231 None => (false, String::new()),
232 };
233
234 let base_url = if let Some(cat) = catalog {
235 cat.get_provider_sync(name).and_then(|p| p.base_url)
236 } else {
237 oxi_sdk::get_provider_base_url(name)
238 .filter(|s| !s.is_empty())
239 .map(|s| s.to_string())
240 };
241
242 entries.push(ProviderEntry {
243 name: name.clone(),
244 has_key,
245 key_masked,
246 is_custom: false,
247 base_url,
248 });
249 }
250
251 if let Ok(settings) = crate::store::settings::Settings::load() {
253 for cp in &settings.custom_providers {
254 if builtin_names.iter().any(|n| n == &cp.name) {
255 continue;
256 }
257 let actual_key = auth_store.get_api_key(&cp.name);
258
259 let (has_key, key_masked) = match &actual_key {
260 Some(k) => (true, mask_key(k)),
261 None => (false, String::new()),
262 };
263
264 entries.push(ProviderEntry {
265 name: cp.name.clone(),
266 has_key,
267 key_masked,
268 is_custom: true,
269 base_url: Some(cp.base_url.clone()),
270 });
271 }
272 }
273
274 entries
275}
276
277fn load_models(
286 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
287 allowed: Option<&std::collections::HashSet<String>>,
288) -> Vec<ModelEntry> {
289 let permit = |provider: &str| match allowed {
290 None => true,
291 Some(set) => set.contains(provider),
292 };
293
294 let mut models = Vec::new();
295 let mut seen = std::collections::HashSet::new();
296
297 if let Ok(settings) = crate::store::settings::Settings::load() {
299 for (provider, model_ids) in &settings.dynamic_models {
300 if !permit(provider) {
301 continue;
302 }
303 for id in model_ids {
304 let key = format!("{}/{}", provider, id);
305 if seen.insert(key.clone()) {
306 let ctx = if let Some(cat) = catalog {
308 cat.get_model_sync(provider, id)
309 .map(|e| e.context_window)
310 .unwrap_or(128_000)
311 } else {
312 oxi_sdk::get_model_entry(provider, id)
313 .map(|e| e.context_window)
314 .unwrap_or(128_000)
315 };
316 models.push(ModelEntry::new(id.clone(), provider.clone(), ctx));
317 }
318 }
319 }
320 }
321
322 if let Some(cat) = catalog {
324 for entry in cat.search_sync("") {
325 if !permit(&entry.provider) {
326 continue;
327 }
328 let key = format!("{}/{}", entry.provider, entry.model_id);
329 if seen.insert(key) {
330 models.push(ModelEntry::new(
331 entry.model_id,
332 entry.provider,
333 entry.context_window,
334 ));
335 }
336 }
337 } else {
338 for entry in oxi_sdk::get_all_models() {
339 if !permit(entry.provider) {
340 continue;
341 }
342 let key = format!("{}/{}", entry.provider, entry.id);
343 if seen.insert(key) {
344 models.push(ModelEntry::new(
345 entry.id.to_string(),
346 entry.provider.to_string(),
347 entry.context_window,
348 ));
349 }
350 }
351 }
352
353 models
354}
355
356fn keyed_provider_names(providers: &[ProviderEntry]) -> std::collections::HashSet<String> {
360 providers
361 .iter()
362 .filter(|p| p.has_key)
363 .map(|p| p.name.clone())
364 .collect()
365}
366
367fn refresh_models(state: &mut WizardState) {
371 let allowed = keyed_provider_names(&state.providers);
372 let prev = state
373 .models
374 .get(state.model_selected)
375 .map(|m| (m.provider.clone(), m.id.clone()));
376 state.models = load_models(state.catalog.as_ref(), Some(&allowed));
377 state.model_selected = match prev {
378 Some((p, id)) => state
379 .models
380 .iter()
381 .position(|m| m.provider == p && m.id == id)
382 .unwrap_or(0),
383 None => 0,
384 };
385 ensure_model_selected_visible(state);
386}
387
388fn fetch_and_cache_models(provider_name: &str, providers: &[ProviderEntry]) {
396 let base_url = providers
398 .iter()
399 .find(|p| p.name == provider_name)
400 .and_then(|p| p.base_url.clone())
401 .or_else(|| oxi_sdk::get_provider_base_url(provider_name).map(|s| s.to_string()));
402
403 let base_url = match base_url {
404 Some(url) if !url.is_empty() => url,
405 _ => {
406 tracing::debug!(
407 "Skipping dynamic model fetch for '{}': no base_url",
408 provider_name
409 );
410 return;
411 }
412 };
413
414 let auth_store = crate::store::auth_storage::shared_auth_storage();
416 let api_key = match auth_store.get_api_key(provider_name) {
417 Some(key) => key,
418 None => {
419 tracing::debug!(
420 "Skipping dynamic model fetch for '{}': no API key",
421 provider_name
422 );
423 return;
424 }
425 };
426
427 let api_type = oxi_sdk::get_provider_api(provider_name);
429 let is_openai_compatible = api_type.is_none_or(|api| {
430 matches!(
431 api,
432 oxi_sdk::Api::OpenAiCompletions | oxi_sdk::Api::OpenAiResponses
433 )
434 });
435
436 if !is_openai_compatible {
437 tracing::debug!(
438 "Skipping dynamic model fetch for '{}': not OpenAI-compatible",
439 provider_name
440 );
441 return;
442 }
443
444 tracing::info!(
445 "Fetching models from {}/models for provider '{}'...",
446 base_url,
447 provider_name
448 );
449
450 match oxi_sdk::fetch_models_blocking(&base_url, &api_key) {
451 Ok(model_ids) => {
452 tracing::info!(
453 "Fetched {} models from provider '{}'",
454 model_ids.len(),
455 provider_name
456 );
457
458 if let Ok(mut settings) = crate::store::settings::Settings::load() {
460 settings
461 .dynamic_models
462 .insert(provider_name.to_string(), model_ids);
463 if let Err(e) = settings.save() {
464 tracing::warn!("Failed to save dynamic models cache: {}", e);
465 }
466 }
467 }
468 Err(e) => {
469 tracing::warn!(
470 "Failed to fetch models from provider '{}': {}. \
471 Falling back to static model list.",
472 provider_name,
473 e
474 );
475 }
476 }
477}
478
479fn load_themes() -> Vec<String> {
482 vec![
484 "oxi_dark".to_string(),
485 "oxi_light".to_string(),
486 "nord".to_string(),
487 "catppuccin".to_string(),
488 "github_dark".to_string(),
489 "monokai".to_string(),
490 ]
491}
492
493fn save_settings(
499 model_id: &str,
500 theme_name: &str,
501 custom_base_urls: &[(String, String)],
502) -> Result<()> {
503 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
504
505 if let Some((provider, model_name)) = model_id.split_once('/') {
507 settings.last_used_provider = Some(provider.to_string());
508 settings.last_used_model = Some(model_name.to_string());
509 } else {
510 settings.last_used_model = Some(model_id.to_string());
511 }
512 settings.theme = theme_name.to_string();
513
514 for (name, base_url) in custom_base_urls {
516 let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
517 if !already_exists {
518 settings
519 .custom_providers
520 .push(crate::store::settings::CustomProvider {
521 name: name.clone(),
522 base_url: base_url.clone(),
523 api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
524 api: "openai-completions".to_string(),
525 });
526 }
527 }
528
529 settings.save()?;
530 Ok(())
531}
532
533fn draw_wizard(
536 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
537 state: &mut WizardState,
538) -> Result<()> {
539 terminal.draw(|f| render_wizard(f, state))?;
540 Ok(())
541}
542
543fn render_wizard(f: &mut ratatui::Frame, state: &mut WizardState) {
548 let size = f.area();
549
550 let chunks = Layout::default()
552 .direction(Direction::Vertical)
553 .constraints([
554 Constraint::Length(3), Constraint::Length(1), Constraint::Min(8), Constraint::Length(2), ])
559 .split(size);
560
561 let title = Paragraph::new(Line::from(vec![
563 Span::styled(
564 " oxi ",
565 Style::default()
566 .fg(Color::Rgb(255, 165, 0))
567 .add_modifier(Modifier::BOLD),
568 ),
569 Span::styled(
570 "oxi Setup Wizard",
571 Style::default().add_modifier(Modifier::BOLD),
572 ),
573 ]))
574 .block(Block::default().borders(Borders::TOP));
575 f.render_widget(title, chunks[0]);
576
577 f.render_widget(Paragraph::new(build_step_indicator(state.step)), chunks[1]);
581
582 match state.step {
584 0 => draw_provider_step(f, state, chunks[2]),
585 1 => draw_model_step(f, state, chunks[2]),
586 2 => draw_theme_step(f, state, chunks[2]),
587 3 => draw_done_step(f, state, chunks[2]),
588 _ => {}
589 }
590
591 let footer_text = match state.step {
593 0 => match &state.input_mode {
594 InputMode::Normal => {
595 if state.provider_searching {
596 " Type: filter · ↑/↓ navigate · Enter: select & edit key · Esc/←: close search"
597 .to_string()
598 } else {
599 " ↑/↓ navigate · /: search · Enter: API key · d: delete · →: next · Esc: quit"
600 .to_string()
601 }
602 }
603 InputMode::EditingApiKey { .. } => " Enter: save · Esc: cancel".to_string(),
604 InputMode::AddingCustom { .. } => {
605 " Tab: next field · Enter: save · Esc: cancel".to_string()
606 }
607 },
608 1 => " Type: filter · ↑/↓ navigate · Enter: select · Esc: clear/back · ←: previous"
609 .to_string(),
610 2 => " ↑/↓ navigate · Enter: select · Esc/←: previous".to_string(),
611 3 => " Esc or Enter: quit".to_string(),
612 _ => String::new(),
613 };
614 let footer = Paragraph::new(Line::from(Span::styled(
615 footer_text,
616 Style::default().fg(Color::DarkGray),
617 )));
618 f.render_widget(footer, chunks[3]);
619}
620
621fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
622 match &state.input_mode {
623 InputMode::Normal => draw_provider_list(f, state, area),
624 InputMode::EditingApiKey {
625 provider_name,
626 field_text,
627 } => draw_api_key_dialog(f, provider_name, field_text, area),
628 InputMode::AddingCustom {
629 fields,
630 active_field,
631 } => draw_custom_provider_dialog(f, fields, *active_field, area),
632 }
633}
634
635fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
636 let (list_area, search_area) = if state.provider_searching {
638 let chunks = Layout::default()
639 .direction(Direction::Vertical)
640 .constraints([Constraint::Length(1), Constraint::Min(1)])
641 .split(area);
642 (chunks[1], Some(chunks[0]))
643 } else {
644 (area, None)
645 };
646
647 if let Some(search_rect) = search_area {
650 let mut spans = vec![
651 Span::styled(
652 " Filter: ",
653 Style::default()
654 .fg(Color::Yellow)
655 .add_modifier(Modifier::BOLD),
656 ),
657 Span::styled(
658 &state.provider_filter,
659 Style::default().add_modifier(Modifier::BOLD),
660 ),
661 Span::styled(" ", Style::default().bg(Color::Yellow)),
662 ];
663 if state.provider_filter.is_empty() {
664 spans.push(Span::styled(
665 " type a provider name to filter...",
666 Style::default().fg(Color::DarkGray),
667 ));
668 }
669 f.render_widget(Paragraph::new(Line::from(spans)), search_rect);
670 }
671
672 let indices: Vec<usize> = if state.provider_searching {
674 filtered_provider_indices(state)
675 } else {
676 (0..state.providers.len()).collect()
677 };
678
679 let mut items: Vec<ListItem> = indices
680 .iter()
681 .map(|&i| {
682 let p = &state.providers[i];
683 let check = if p.has_key { "[x]" } else { "[ ]" };
684 let key_info = if p.has_key {
685 format!("API key: {}", p.key_masked)
686 } else {
687 "No API key".to_string()
688 };
689 let custom_tag = if p.is_custom { " (custom)" } else { "" };
690
691 let line = Line::from(vec![
692 Span::styled(
693 format!(" {} ", check),
694 Style::default().fg(if p.has_key {
695 Color::Green
696 } else {
697 Color::DarkGray
698 }),
699 ),
700 Span::styled(
701 format!("{:<14}", p.name),
702 Style::default().add_modifier(Modifier::BOLD),
703 ),
704 Span::styled(
705 format!("[{}]", key_info),
706 Style::default().fg(Color::DarkGray),
707 ),
708 Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
709 ]);
710 ListItem::new(line)
711 })
712 .collect();
713
714 if !state.provider_searching {
716 items.push(ListItem::new(Line::from(vec![
717 Span::styled(" + ", Style::default().fg(Color::Cyan)),
718 Span::styled("Add custom provider...", Style::default().fg(Color::Cyan)),
719 ])));
720 }
721
722 let list = List::new(items)
723 .block(Block::default().borders(Borders::NONE))
724 .highlight_style(
725 Style::default()
726 .bg(Color::DarkGray)
727 .add_modifier(Modifier::BOLD),
728 )
729 .highlight_symbol("▶ ");
730
731 let selected_pos = if state.provider_searching {
733 indices.iter().position(|&i| i == state.provider_selected)
734 } else {
735 Some(state.provider_selected)
738 };
739 state.provider_list_state.select(selected_pos);
740 f.render_stateful_widget(list, list_area, &mut state.provider_list_state);
741}
742
743fn draw_api_key_dialog(f: &mut ratatui::Frame, provider_name: &str, field_text: &str, area: Rect) {
744 let dialog_height = 7u16;
746 let dialog_width = std::cmp::min(area.width, 60);
747 let x = (area.width.saturating_sub(dialog_width)) / 2;
748 let y = (area.height.saturating_sub(dialog_height)) / 2;
749
750 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
751
752 let display_text = if field_text.is_empty() {
753 String::new()
754 } else {
755 "*".repeat(field_text.len())
756 };
757
758 let paragraphs = vec![
759 Line::from(""),
760 Line::from(vec![
761 Span::styled(" API Key: ", Style::default().add_modifier(Modifier::BOLD)),
762 Span::styled(
763 format!("[{:<width$}]", display_text, width = 30),
764 Style::default(),
765 ),
766 if field_text.is_empty() {
767 Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
768 } else {
769 Span::raw("")
770 },
771 ]),
772 ];
773
774 let block = Block::default()
775 .borders(Borders::ALL)
776 .title(format!(" {} API Key ", provider_name));
777
778 let para = Paragraph::new(paragraphs).block(block);
779 f.render_widget(para, dialog_area);
780}
781
782fn draw_custom_provider_dialog(
783 f: &mut ratatui::Frame,
784 fields: &[String; 3],
785 active_field: usize,
786 area: Rect,
787) {
788 let dialog_height = 9u16;
789 let dialog_width = std::cmp::min(area.width, 60);
790 let x = (area.width.saturating_sub(dialog_width)) / 2;
791 let y = (area.height.saturating_sub(dialog_height)) / 2;
792
793 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
794
795 let field_labels = ["Name", "Base URL", "API Key"];
796 let lines: Vec<Line> = std::iter::once(Line::from(""))
797 .chain(field_labels.iter().enumerate().map(|(i, label)| {
798 let display = if i == 2 && !fields[i].is_empty() {
799 "*".repeat(fields[i].len())
800 } else {
801 fields[i].clone()
802 };
803 let is_active = i == active_field;
804 let style = if is_active {
805 Style::default().add_modifier(Modifier::BOLD)
806 } else {
807 Style::default()
808 };
809 Line::from(vec![
810 Span::styled(format!(" {:<10}", format!("{}:", label)), style),
811 Span::styled(format!("[{:<width$}]", display, width = 35), style),
812 if is_active && fields[i].is_empty() {
813 Span::styled("<enter>", Style::default().fg(Color::DarkGray))
814 } else {
815 Span::raw("")
816 },
817 ])
818 }))
819 .collect();
820
821 let block = Block::default()
822 .borders(Borders::ALL)
823 .title(" Add Custom Provider ");
824
825 let para = Paragraph::new(lines).block(block);
826 f.render_widget(para, dialog_area);
827}
828
829fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
830 if state.models.is_empty() {
833 let msg = Paragraph::new(vec![
834 Line::from(""),
835 Line::from(Span::styled(
836 " No providers with an API key configured yet.",
837 Style::default()
838 .fg(Color::Yellow)
839 .add_modifier(Modifier::BOLD),
840 )),
841 Line::from(""),
842 Line::from(Span::styled(
843 " Press Left to go back and add a provider key first.",
844 Style::default().fg(Color::DarkGray),
845 )),
846 ]);
847 f.render_widget(msg, area);
848 return;
849 }
850
851 let chunks = Layout::default()
853 .direction(Direction::Vertical)
854 .constraints([Constraint::Length(1), Constraint::Min(1)])
855 .split(area);
856
857 let mut spans = vec![
861 Span::styled(
862 " Filter: ",
863 Style::default()
864 .fg(Color::Yellow)
865 .add_modifier(Modifier::BOLD),
866 ),
867 Span::styled(
868 &state.model_filter,
869 Style::default().add_modifier(Modifier::BOLD),
870 ),
871 Span::styled(" ", Style::default().bg(Color::Yellow)),
872 ];
873 if state.model_filter.is_empty() {
874 spans.push(Span::styled(
875 " type to filter (e.g. 'gpt-4', 'claude', 'gemini')...",
876 Style::default().fg(Color::DarkGray),
877 ));
878 }
879 f.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
880
881 let indices = filtered_model_indices(state);
883 let items: Vec<ListItem> = indices
884 .iter()
885 .map(|&i| {
886 let m = &state.models[i];
887 let ctx_str = if m.context_window >= 1_000_000 {
888 format!("{}M ctx", m.context_window / 1_000_000)
889 } else {
890 format!("{}K ctx", m.context_window / 1_000)
891 };
892 ListItem::new(Line::from(vec![
893 Span::styled(format!("{:<40}", m.id), Style::default()),
894 Span::styled(
895 format!("({})", m.provider),
896 Style::default().fg(Color::DarkGray),
897 ),
898 Span::styled(
899 format!(", {}", ctx_str),
900 Style::default().fg(Color::DarkGray),
901 ),
902 ]))
903 })
904 .collect();
905
906 let list = List::new(items)
907 .block(Block::default().borders(Borders::NONE))
908 .highlight_style(
909 Style::default()
910 .bg(Color::DarkGray)
911 .add_modifier(Modifier::BOLD),
912 )
913 .highlight_symbol("▶ ");
914
915 let selected_pos = indices.iter().position(|&i| i == state.model_selected);
916 state.model_list_state.select(selected_pos);
917 f.render_stateful_widget(list, chunks[1], &mut state.model_list_state);
918
919 if indices.is_empty() {
921 let hint = Paragraph::new(Line::from(Span::styled(
922 " No models match your filter. Press Esc to clear.",
923 Style::default().fg(Color::DarkGray),
924 )));
925 f.render_widget(hint, chunks[1]);
926 }
927}
928
929fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
930 let items: Vec<ListItem> = state
931 .themes
932 .iter()
933 .map(|t| ListItem::new(Line::from(format!(" {}", t))))
934 .collect();
935
936 let list = List::new(items)
937 .block(Block::default().borders(Borders::NONE))
938 .highlight_style(
939 Style::default()
940 .bg(Color::DarkGray)
941 .add_modifier(Modifier::BOLD),
942 );
943
944 state.theme_list_state.select(Some(state.theme_selected));
945 f.render_stateful_widget(list, area, &mut state.theme_list_state);
946}
947
948fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
949 let settings_path_display = state.settings_path.display().to_string();
950 let auth_path_display = state.auth_path.display().to_string();
951
952 let lines = vec![
953 Line::from(""),
954 Line::from(Span::styled(
955 " Settings saved!",
956 Style::default()
957 .fg(Color::Green)
958 .add_modifier(Modifier::BOLD),
959 )),
960 Line::from(""),
961 Line::from(Span::styled(
962 format!(" Settings file: {}", settings_path_display),
963 Style::default().fg(Color::DarkGray),
964 )),
965 Line::from(Span::styled(
966 format!(" Auth file: {}", auth_path_display),
967 Style::default().fg(Color::DarkGray),
968 )),
969 Line::from(""),
970 Line::from(Span::styled(
971 " Run 'oxi' to start.",
972 Style::default().add_modifier(Modifier::BOLD),
973 )),
974 ];
975
976 let block = Block::default().borders(Borders::NONE);
977 let para = Paragraph::new(lines).block(block);
978 f.render_widget(para, area);
979}
980
981fn build_step_indicator(current_step: usize) -> Line<'static> {
982 let steps = [
983 ("1. Provider Setup", 0),
984 ("2. Default Model", 1),
985 ("3. Theme", 2),
986 ("4. Done", 3),
987 ];
988
989 let spans: Vec<Span> = steps
990 .iter()
991 .flat_map(|(label, step)| {
992 let style = if *step == current_step {
993 Style::default()
994 .add_modifier(Modifier::BOLD)
995 .fg(Color::Cyan)
996 } else if *step < current_step {
997 Style::default().fg(Color::Green)
998 } else {
999 Style::default().fg(Color::DarkGray)
1000 };
1001 vec![Span::styled(format!(" {}", label), style), Span::raw(" ")]
1002 })
1003 .collect();
1004
1005 Line::from(spans)
1006}
1007
1008fn handle_event(
1011 state: &mut WizardState,
1012 event: Event,
1013 auth_store: &crate::store::auth_storage::AuthStorage,
1014) -> Result<bool> {
1015 match state.step {
1016 0 => handle_provider_event(state, event, auth_store),
1017 1 => handle_model_event(state, event),
1018 2 => handle_theme_event(state, event),
1019 3 => handle_done_event(event),
1020 _ => Ok(false),
1021 }
1022}
1023
1024fn handle_provider_event(
1025 state: &mut WizardState,
1026 event: Event,
1027 auth_store: &crate::store::auth_storage::AuthStorage,
1028) -> Result<bool> {
1029 if state.provider_searching && matches!(state.input_mode, InputMode::Normal) {
1032 if let Event::Key(key) = event {
1033 match key.code {
1034 KeyCode::Esc => {
1035 state.provider_searching = false;
1036 state.provider_filter.clear();
1037 snap_provider_selection(state);
1038 }
1039 KeyCode::Enter => {
1040 state.provider_searching = false;
1043 state.provider_filter.clear();
1044 if state.provider_selected < state.providers.len() {
1045 let name = state.providers[state.provider_selected].name.clone();
1046 state.input_mode = InputMode::EditingApiKey {
1047 provider_name: name,
1048 field_text: String::new(),
1049 };
1050 }
1051 }
1052 KeyCode::Up => {
1053 let indices = filtered_provider_indices(state);
1054 if let Some(pos) = indices.iter().position(|&i| i == state.provider_selected)
1055 && pos > 0
1056 {
1057 state.provider_selected = indices[pos - 1];
1058 } else if let Some(&first) = indices.first() {
1059 state.provider_selected = first;
1060 }
1061 }
1062 KeyCode::Down => {
1063 let indices = filtered_provider_indices(state);
1064 if let Some(pos) = indices.iter().position(|&i| i == state.provider_selected)
1065 && pos + 1 < indices.len()
1066 {
1067 state.provider_selected = indices[pos + 1];
1068 } else if let Some(&first) = indices.first() {
1069 state.provider_selected = first;
1070 }
1071 }
1072 KeyCode::Backspace => {
1073 state.provider_filter.pop();
1074 snap_provider_selection(state);
1075 }
1076 KeyCode::Char(c) => {
1077 state.provider_filter.push(c);
1078 snap_provider_selection(state);
1079 }
1080 KeyCode::Left => {
1081 state.provider_searching = false;
1082 state.provider_filter.clear();
1083 snap_provider_selection(state);
1084 }
1085 _ => {}
1086 }
1087 }
1088 return Ok(false);
1089 }
1090
1091 match &mut state.input_mode {
1092 InputMode::Normal => {
1093 if let Event::Key(key) = event {
1094 match key.code {
1095 KeyCode::Up if state.provider_selected > 0 => {
1096 state.provider_selected -= 1;
1097 }
1098 KeyCode::Down => {
1099 let max = state.providers.len();
1101 if state.provider_selected < max {
1102 state.provider_selected += 1;
1103 }
1104 }
1105 KeyCode::Enter => {
1106 if state.provider_selected == state.providers.len() {
1107 state.input_mode = InputMode::AddingCustom {
1109 fields: [String::new(), String::new(), String::new()],
1110 active_field: 0,
1111 };
1112 } else {
1113 let name = state.providers[state.provider_selected].name.clone();
1115 state.input_mode = InputMode::EditingApiKey {
1116 provider_name: name,
1117 field_text: String::new(),
1118 };
1119 }
1120 }
1121 KeyCode::Char('d') | KeyCode::Delete
1122 if state.provider_selected < state.providers.len() =>
1123 {
1124 let name = state.providers[state.provider_selected].name.clone();
1125 auth_store.remove(&name);
1126 state.providers[state.provider_selected].has_key = false;
1127 state.providers[state.provider_selected].key_masked = String::new();
1128 state.models_dirty = true;
1129 }
1130 KeyCode::Char('/') => {
1131 state.provider_searching = true;
1132 state.provider_filter.clear();
1133 snap_provider_selection(state);
1136 }
1137 KeyCode::Right => {
1138 state.step = 1;
1139 }
1140 KeyCode::Esc => {
1141 return Ok(true); }
1143 _ => {}
1144 }
1145 }
1146 }
1147 InputMode::EditingApiKey {
1148 provider_name,
1149 field_text,
1150 } => {
1151 if let Event::Key(key) = event {
1152 match key.code {
1153 KeyCode::Esc => {
1154 state.input_mode = InputMode::Normal;
1155 }
1156 KeyCode::Enter => {
1157 if !field_text.is_empty() {
1158 auth_store.set_api_key(provider_name, field_text.clone());
1159
1160 if let Some(entry) = state
1162 .providers
1163 .iter_mut()
1164 .find(|p| p.name == *provider_name)
1165 {
1166 entry.has_key = true;
1167 entry.key_masked = mask_key(field_text);
1168 }
1169
1170 fetch_and_cache_models(provider_name, &state.providers);
1172
1173 state.models_dirty = true;
1177 }
1178 state.input_mode = InputMode::Normal;
1179 }
1180 KeyCode::Backspace => {
1181 field_text.pop();
1182 }
1183 KeyCode::Char(c) => {
1184 field_text.push(c);
1185 }
1186 _ => {}
1187 }
1188 }
1189 }
1190 InputMode::AddingCustom {
1191 fields,
1192 active_field,
1193 } => {
1194 if let Event::Key(key) = event {
1195 match key.code {
1196 KeyCode::Esc => {
1197 state.input_mode = InputMode::Normal;
1198 }
1199 KeyCode::Tab => {
1200 *active_field = (*active_field + 1) % 3;
1201 }
1202 KeyCode::BackTab => {
1203 *active_field = (*active_field + 2) % 3;
1204 }
1205 KeyCode::Enter => {
1206 let name = fields[0].trim().to_string();
1207 let base_url = fields[1].trim().to_string();
1208 let api_key = fields[2].trim().to_string();
1209
1210 if !name.is_empty() && !base_url.is_empty() {
1211 if !api_key.is_empty() {
1213 auth_store.set_api_key(&name, api_key.clone());
1214 }
1215
1216 let (has_key, key_masked) = if !api_key.is_empty() {
1218 (true, mask_key(&api_key))
1219 } else {
1220 (false, String::new())
1221 };
1222
1223 state.providers.push(ProviderEntry {
1224 name: name.clone(),
1225 has_key,
1226 key_masked,
1227 is_custom: true,
1228 base_url: Some(base_url),
1229 });
1230
1231 if !api_key.is_empty() {
1233 fetch_and_cache_models(&name, &state.providers);
1234 }
1235 state.models_dirty = has_key;
1238
1239 state.input_mode = InputMode::Normal;
1241 }
1242 }
1243 KeyCode::Backspace => {
1244 fields[*active_field].pop();
1245 }
1246 KeyCode::Char(c) => {
1247 fields[*active_field].push(c);
1248 }
1249 _ => {}
1250 }
1251 }
1252 }
1253 }
1254 Ok(false)
1255}
1256
1257fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
1258 if let Event::Key(key) = event {
1259 match key.code {
1263 KeyCode::Char(c) => {
1264 state.model_filter.push(c);
1265 ensure_model_selected_visible(state);
1266 }
1267 KeyCode::Backspace => {
1268 state.model_filter.pop();
1269 ensure_model_selected_visible(state);
1270 }
1271 KeyCode::Up => {
1272 let indices = filtered_model_indices(state);
1273 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1274 && pos > 0
1275 {
1276 state.model_selected = indices[pos - 1];
1277 } else if let Some(&first) = indices.first() {
1278 state.model_selected = first;
1279 }
1280 }
1281 KeyCode::Down => {
1282 let indices = filtered_model_indices(state);
1283 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1284 && pos + 1 < indices.len()
1285 {
1286 state.model_selected = indices[pos + 1];
1287 } else if let Some(&first) = indices.first() {
1288 state.model_selected = first;
1289 }
1290 }
1291 KeyCode::Enter => {
1292 if !filtered_model_indices(state).is_empty() {
1297 state.step = 2;
1298 }
1299 }
1300 KeyCode::Esc => {
1301 if !state.model_filter.is_empty() {
1304 state.model_filter.clear();
1305 ensure_model_selected_visible(state);
1306 } else {
1307 state.step = 0;
1308 }
1309 }
1310 KeyCode::Left => {
1311 state.step = 0;
1312 }
1313 _ => {}
1314 }
1315 }
1316 Ok(false)
1317}
1318
1319fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
1320 if let Event::Key(key) = event {
1321 match key.code {
1322 KeyCode::Up if state.theme_selected > 0 => {
1323 state.theme_selected -= 1;
1324 }
1325 KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
1326 state.theme_selected += 1;
1327 }
1328 KeyCode::Enter => {
1329 finish_setup(state)?;
1331 state.step = 3;
1332 }
1333 KeyCode::Esc | KeyCode::Left => {
1334 state.step = 1;
1335 }
1336 _ => {}
1337 }
1338 }
1339 Ok(false)
1340}
1341
1342fn handle_done_event(event: Event) -> Result<bool> {
1343 if let Event::Key(key) = event {
1344 match key.code {
1345 KeyCode::Enter | KeyCode::Esc => {
1346 return Ok(true); }
1348 _ => {}
1349 }
1350 }
1351 Ok(false)
1352}
1353
1354fn finish_setup(state: &mut WizardState) -> Result<()> {
1357 let model_id = state
1359 .models
1360 .get(state.model_selected)
1361 .map(|m| format!("{}/{}", m.provider, m.id))
1362 .unwrap_or_default();
1363
1364 let theme_name = state
1366 .themes
1367 .get(state.theme_selected)
1368 .cloned()
1369 .unwrap_or_else(|| "oxi_dark".to_string());
1370
1371 let custom_base_urls: Vec<(String, String)> = state
1373 .providers
1374 .iter()
1375 .filter_map(|p| {
1376 if p.is_custom {
1377 p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1378 } else {
1379 None
1380 }
1381 })
1382 .collect();
1383
1384 save_settings(&model_id, &theme_name, &custom_base_urls)?;
1385
1386 Ok(())
1387}
1388
1389pub async fn run() -> Result<()> {
1393 enable_raw_mode()?;
1395 let mut stdout = io::stdout();
1396 execute!(stdout, EnterAlternateScreen)?;
1397 let backend = CrosstermBackend::new(stdout);
1398 let mut terminal = Terminal::new(backend)?;
1399
1400 let panic_hook = std::panic::take_hook();
1402 std::panic::set_hook(Box::new(move |info| {
1403 let _ = disable_raw_mode();
1404 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1405 panic_hook(info);
1406 }));
1407
1408 let catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>> = {
1410 let paths = crate::services::OxiPaths::default_paths().ok();
1411 if let Some(paths) = paths {
1412 let config = oxi_sdk::CatalogConfig {
1413 cache_path: paths.home.join("cache").join("models-dev.json"),
1414 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
1415 override_path: paths.home.join("catalog").join("overrides.toml"),
1416 fetch_enabled: false,
1418 ..Default::default()
1419 };
1420 oxi_sdk::FileModelCatalog::init(config)
1421 .await
1422 .ok()
1423 .map(|c| c as _)
1424 } else {
1425 None
1426 }
1427 };
1428
1429 let auth_store = crate::store::auth_storage::shared_auth_storage();
1431 let providers = load_providers(&auth_store, catalog.as_ref());
1432 let allowed = keyed_provider_names(&providers);
1433 let models = load_models(catalog.as_ref(), Some(&allowed));
1434 let themes = load_themes();
1435
1436 let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1437 dirs::home_dir()
1438 .unwrap_or_default()
1439 .join(".oxi")
1440 .join("auth.json")
1441 });
1442 let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1443 dirs::home_dir()
1444 .unwrap_or_default()
1445 .join(".oxi")
1446 .join("settings.json")
1447 });
1448
1449 let current_model = crate::store::settings::Settings::load()
1451 .ok()
1452 .and_then(|s| s.last_used_model.clone())
1453 .unwrap_or_default();
1454
1455 let model_selected = models
1456 .iter()
1457 .position(|m| {
1458 let full_id = format!("{}/{}", m.provider, m.id);
1459 full_id == current_model || m.id == current_model
1460 })
1461 .unwrap_or(0);
1462
1463 let current_theme = crate::store::settings::Settings::load()
1465 .ok()
1466 .map(|s| s.theme.clone())
1467 .unwrap_or_else(|| "oxi_dark".to_string());
1468
1469 let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1470
1471 let mut state = WizardState {
1472 step: 0,
1473 providers,
1474 provider_selected: 0,
1475 provider_list_state: ListState::default(),
1476 provider_filter: String::new(),
1477 provider_searching: false,
1478 input_mode: InputMode::Normal,
1479 models,
1480 model_selected,
1481 model_filter: String::new(),
1482 model_list_state: ListState::default(),
1483 models_dirty: false,
1484 themes,
1485 theme_selected,
1486 theme_list_state: ListState::default(),
1487 auth_path,
1488 settings_path,
1489 catalog,
1490 };
1491
1492 loop {
1494 if state.step == 1 && state.models_dirty {
1498 refresh_models(&mut state);
1499 state.models_dirty = false;
1500 }
1501 draw_wizard(&mut terminal, &mut state)?;
1502
1503 if event::poll(std::time::Duration::from_millis(100))?
1504 && let Event::Key(key) = event::read()?
1505 {
1506 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1508 break;
1509 }
1510
1511 let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1512 if should_quit {
1513 break;
1514 }
1515 }
1516 }
1517
1518 disable_raw_mode()?;
1520 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1521
1522 Ok(())
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527 use super::*;
1528
1529 fn make_state(providers: Vec<&str>, models: Vec<(&str, &str)>) -> WizardState {
1530 WizardState {
1531 step: 0,
1532 providers: providers
1533 .iter()
1534 .map(|n| ProviderEntry {
1535 name: n.to_string(),
1536 has_key: false,
1537 key_masked: String::new(),
1538 is_custom: false,
1539 base_url: None,
1540 })
1541 .collect(),
1542 provider_selected: 0,
1543 provider_list_state: ListState::default(),
1544 provider_filter: String::new(),
1545 provider_searching: false,
1546 input_mode: InputMode::Normal,
1547 models: models
1548 .iter()
1549 .map(|(id, provider)| {
1550 ModelEntry::new(id.to_string(), provider.to_string(), 128_000)
1551 })
1552 .collect(),
1553 model_selected: 0,
1554 model_filter: String::new(),
1555 model_list_state: ListState::default(),
1556 models_dirty: false,
1557 themes: vec![],
1558 theme_selected: 0,
1559 theme_list_state: ListState::default(),
1560 auth_path: PathBuf::new(),
1561 settings_path: PathBuf::new(),
1562 catalog: None,
1563 }
1564 }
1565
1566 #[test]
1567 fn provider_filter_matches_name_case_insensitive() {
1568 let mut s = make_state(vec!["anthropic", "openai", "google", "mistral"], vec![]);
1569 assert_eq!(filtered_provider_indices(&s), vec![0, 1, 2, 3]);
1570
1571 s.provider_filter = "ANT".to_string();
1572 assert_eq!(filtered_provider_indices(&s), vec![0]); s.provider_filter = "goog".to_string();
1575 assert_eq!(filtered_provider_indices(&s), vec![2]); }
1577
1578 #[test]
1579 fn model_filter_matches_id_or_provider() {
1580 let mut s = make_state(
1581 vec![],
1582 vec![
1583 ("gpt-4o", "openai"),
1584 ("gpt-4-turbo", "openai"),
1585 ("claude-3-opus", "anthropic"),
1586 ("gemini-pro", "google"),
1587 ],
1588 );
1589 assert_eq!(filtered_model_indices(&s), vec![0, 1, 2, 3]);
1590
1591 s.model_filter = "gpt".to_string();
1592 assert_eq!(filtered_model_indices(&s), vec![0, 1]);
1593
1594 s.model_filter = "anthropic".to_string();
1595 assert_eq!(filtered_model_indices(&s), vec![2]); s.model_filter = "OPUS".to_string();
1598 assert_eq!(filtered_model_indices(&s), vec![2]); }
1600
1601 #[test]
1602 fn model_filter_empty_result_yields_no_indices() {
1603 let mut state = make_state(vec![], vec![("gpt-4o", "openai")]);
1604 state.model_filter = "zzz".to_string();
1605 assert!(filtered_model_indices(&state).is_empty());
1606 }
1607
1608 #[test]
1609 fn ensure_model_selected_snaps_to_first_match() {
1610 let mut state = make_state(
1611 vec![],
1612 vec![
1613 ("gpt-4o", "openai"),
1614 ("claude-3", "anthropic"),
1615 ("gpt-3.5", "openai"),
1616 ],
1617 );
1618 state.model_filter = "gpt".to_string();
1620 ensure_model_selected_visible(&mut state);
1621 assert_eq!(state.model_selected, 0);
1623
1624 state.model_filter = "claude".to_string();
1626 ensure_model_selected_visible(&mut state);
1627 assert_eq!(state.model_selected, 1);
1628 }
1629
1630 #[test]
1631 fn snap_provider_selection_into_filtered_set() {
1632 let mut state = make_state(vec!["anthropic", "openai", "google"], vec![]);
1633 state.provider_selected = 2; state.provider_filter = "open".to_string();
1635 snap_provider_selection(&mut state);
1636 assert_eq!(state.provider_selected, 1); }
1638
1639 #[test]
1640 fn snap_provider_noop_when_filter_empty_matches_all() {
1641 let mut state = make_state(vec!["anthropic", "openai"], vec![]);
1642 state.provider_selected = 1;
1643 state.provider_filter = String::new();
1644 snap_provider_selection(&mut state);
1645 assert_eq!(state.provider_selected, 1); }
1647 #[test]
1648 fn keyed_provider_names_only_includes_configured() {
1649 let providers = vec![
1650 ProviderEntry {
1651 name: "anthropic".to_string(),
1652 has_key: true,
1653 key_masked: "sk-1...abcd".to_string(),
1654 is_custom: false,
1655 base_url: None,
1656 },
1657 ProviderEntry {
1658 name: "openai".to_string(),
1659 has_key: false,
1660 key_masked: String::new(),
1661 is_custom: false,
1662 base_url: None,
1663 },
1664 ProviderEntry {
1665 name: "local".to_string(),
1666 has_key: true,
1667 key_masked: "x...y".to_string(),
1668 is_custom: true,
1669 base_url: Some("http://localhost:11434".to_string()),
1670 },
1671 ];
1672 let set = keyed_provider_names(&providers);
1673 assert!(set.contains("anthropic"));
1674 assert!(set.contains("local"));
1675 assert!(!set.contains("openai"));
1676 assert_eq!(set.len(), 2);
1677 }
1678
1679 #[test]
1680 fn keyed_provider_names_empty_when_none_configured() {
1681 let providers = vec![ProviderEntry {
1682 name: "openai".to_string(),
1683 has_key: false,
1684 key_masked: String::new(),
1685 is_custom: false,
1686 base_url: None,
1687 }];
1688 assert!(keyed_provider_names(&providers).is_empty());
1689 }
1690 fn render_to_buffer(step: usize, models: Vec<ModelEntry>) -> String {
1693 use ratatui::backend::TestBackend;
1694 let providers = vec![
1695 ProviderEntry {
1696 name: "openai".to_string(),
1697 has_key: true,
1698 key_masked: "k...1".to_string(),
1699 is_custom: false,
1700 base_url: None,
1701 },
1702 ProviderEntry {
1703 name: "anthropic".to_string(),
1704 has_key: false,
1705 key_masked: String::new(),
1706 is_custom: false,
1707 base_url: None,
1708 },
1709 ];
1710 let mut state = WizardState {
1711 step,
1712 providers,
1713 provider_selected: 0,
1714 provider_list_state: ListState::default(),
1715 provider_filter: String::new(),
1716 provider_searching: false,
1717 input_mode: InputMode::Normal,
1718 models,
1719 model_selected: 0,
1720 model_filter: String::new(),
1721 model_list_state: ListState::default(),
1722 themes: vec!["oxi_dark".to_string()],
1723 theme_selected: 0,
1724 theme_list_state: ListState::default(),
1725 auth_path: PathBuf::new(),
1726 settings_path: PathBuf::new(),
1727 catalog: None,
1728 models_dirty: false,
1729 };
1730 let backend = TestBackend::new(90, 24);
1731 let mut terminal = Terminal::new(backend).unwrap();
1732 terminal.draw(|f| render_wizard(f, &mut state)).unwrap();
1733 let buf = terminal.backend().buffer();
1734 let area = buf.area();
1735 let mut out = String::new();
1736 for y in 0..area.height {
1737 for x in 0..area.width {
1738 out.push_str(buf[(x, y)].symbol());
1739 }
1740 out.push('\n');
1741 }
1742 out
1743 }
1744
1745 #[test]
1746 fn step_indicator_visible_on_every_step() {
1747 for (step, label) in [
1751 (0usize, "1. Provider Setup"),
1752 (1, "2. Default Model"),
1753 (2, "3. Theme"),
1754 (3, "4. Done"),
1755 ] {
1756 let models = vec![ModelEntry::new(
1757 "gpt-4o".to_string(),
1758 "openai".to_string(),
1759 128_000,
1760 )];
1761 let rendered = render_to_buffer(step, models);
1762 assert!(
1763 rendered.contains(label),
1764 "step {step}: indicator label {label:?} missing from buffer:\n{rendered}"
1765 );
1766 }
1767 }
1768
1769 #[test]
1770 fn model_step_shows_empty_state_when_no_provider_keyed() {
1771 let rendered = render_to_buffer(1, vec![]);
1774 assert!(rendered.contains("No providers with an API key configured yet."));
1775 assert!(rendered.contains("Press Left to go back"));
1776 }
1777
1778 #[test]
1779 fn model_step_shows_configured_provider_model() {
1780 let models = vec![ModelEntry::new(
1785 "gpt-4o".to_string(),
1786 "openai".to_string(),
1787 128_000,
1788 )];
1789 let rendered = render_to_buffer(1, models);
1790 assert!(rendered.contains("gpt-4o"));
1791 }
1792 fn esc_event() -> Event {
1798 Event::Key(crossterm::event::KeyEvent::new(
1799 KeyCode::Esc,
1800 KeyModifiers::NONE,
1801 ))
1802 }
1803
1804 #[test]
1805 fn esc_quits_from_provider_step_normal() {
1806 let mut state = make_state(vec!["openai"], vec![]);
1807 state.step = 0;
1808 let auth = crate::store::auth_storage::shared_auth_storage();
1809 let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1810 assert!(quit, "Esc on step 0 Normal should quit");
1811 }
1812
1813 #[test]
1814 fn esc_in_provider_search_closes_search_not_quit() {
1815 let mut state = make_state(vec!["openai", "anthropic"], vec![]);
1816 state.step = 0;
1817 state.provider_searching = true;
1818 state.provider_filter = "anth".to_string();
1819 let auth = crate::store::auth_storage::shared_auth_storage();
1820 let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1821 assert!(!quit, "Esc in search must close search, not quit");
1822 assert!(!state.provider_searching);
1823 assert!(state.provider_filter.is_empty());
1824 }
1825
1826 #[test]
1827 fn esc_backs_out_of_model_step_when_filter_empty() {
1828 let mut state = make_state(vec!["openai"], vec![("gpt-4o", "openai")]);
1829 state.step = 1;
1830 state.model_filter = String::new();
1831 handle_model_event(&mut state, esc_event()).unwrap();
1832 assert_eq!(
1833 state.step, 0,
1834 "Esc with empty filter should return to the provider step"
1835 );
1836 }
1837
1838 #[test]
1839 fn esc_clears_model_filter_when_nonempty() {
1840 let mut state = make_state(
1841 vec!["openai"],
1842 vec![("gpt-4o", "openai"), ("gpt-4", "openai")],
1843 );
1844 state.step = 1;
1845 state.model_filter = "gpt".to_string();
1846 handle_model_event(&mut state, esc_event()).unwrap();
1847 assert_eq!(
1848 state.step, 1,
1849 "Esc with a non-empty filter should stay on the model step"
1850 );
1851 assert!(state.model_filter.is_empty(), "Esc should clear the filter");
1852 }
1853
1854 #[test]
1855 fn esc_backs_out_of_theme_step() {
1856 let mut state = make_state(vec!["openai"], vec![]);
1857 state.step = 2;
1858 state.themes = vec!["oxi_dark".to_string()];
1859 handle_theme_event(&mut state, esc_event()).unwrap();
1860 assert_eq!(state.step, 1);
1861 }
1862
1863 #[test]
1864 fn esc_quits_from_done_step() {
1865 assert!(
1866 handle_done_event(esc_event()).unwrap(),
1867 "Esc on the done step should quit"
1868 );
1869 }
1870}