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 themes: Vec<String>,
91 theme_selected: usize,
93 theme_list_state: ListState,
95 auth_path: PathBuf,
97 settings_path: PathBuf,
99 catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
101}
102
103#[derive(Clone)]
105struct ModelEntry {
106 id: String,
107 provider: String,
108 context_window: u32,
109 id_lower: String,
112 provider_lower: String,
114}
115
116impl ModelEntry {
117 fn new(id: String, provider: String, context_window: u32) -> Self {
118 let provider_lower = provider.to_lowercase();
119 let id_lower = id.to_lowercase();
120 Self {
121 id,
122 provider,
123 context_window,
124 id_lower,
125 provider_lower,
126 }
127 }
128}
129
130fn mask_key(key: &str) -> String {
134 if key.len() <= 10 {
135 "*".repeat(key.len())
136 } else {
137 format!("{}...{}", &key[..6], &key[key.len() - 4..])
138 }
139}
140
141fn filtered_provider_indices(state: &WizardState) -> Vec<usize> {
146 if state.provider_filter.is_empty() {
147 (0..state.providers.len()).collect()
148 } else {
149 let f = state.provider_filter.to_lowercase();
150 state
151 .providers
152 .iter()
153 .enumerate()
154 .filter(|(_, p)| p.name.to_lowercase().contains(&f))
155 .map(|(i, _)| i)
156 .collect()
157 }
158}
159
160fn filtered_model_indices(state: &WizardState) -> Vec<usize> {
165 if state.model_filter.is_empty() {
166 (0..state.models.len()).collect()
167 } else {
168 let f = state.model_filter.to_lowercase();
169 state
170 .models
171 .iter()
172 .enumerate()
173 .filter(|(_, m)| m.id_lower.contains(&f) || m.provider_lower.contains(&f))
174 .map(|(i, _)| i)
175 .collect()
176 }
177}
178
179fn ensure_model_selected_visible(state: &mut WizardState) {
183 let filtered = filtered_model_indices(state);
184 if filtered.is_empty() {
185 return;
186 }
187 if !filtered.contains(&state.model_selected) {
188 state.model_selected = filtered[0];
189 }
190}
191
192fn snap_provider_selection(state: &mut WizardState) {
195 let indices = filtered_provider_indices(state);
196 if indices.is_empty() {
197 return;
198 }
199 if !indices.contains(&state.provider_selected) {
200 state.provider_selected = indices[0];
201 }
202}
203
204fn load_providers(
208 auth_store: &crate::store::auth_storage::AuthStorage,
209 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
210) -> Vec<ProviderEntry> {
211 let mut entries = Vec::new();
212
213 let builtin_names: Vec<String> = if let Some(cat) = catalog {
214 cat.list_providers_sync()
215 } else {
216 oxi_sdk::get_builtin_providers()
217 .iter()
218 .map(|p| p.name.to_string())
219 .collect()
220 };
221
222 for name in &builtin_names {
223 let key = auth_store.get_api_key(name);
224
225 let (has_key, key_masked) = match &key {
226 Some(k) => (true, mask_key(k)),
227 None => (false, String::new()),
228 };
229
230 let base_url = if let Some(cat) = catalog {
231 cat.get_provider_sync(name).and_then(|p| p.base_url)
232 } else {
233 oxi_sdk::get_provider_base_url(name)
234 .filter(|s| !s.is_empty())
235 .map(|s| s.to_string())
236 };
237
238 entries.push(ProviderEntry {
239 name: name.clone(),
240 has_key,
241 key_masked,
242 is_custom: false,
243 base_url,
244 });
245 }
246
247 if let Ok(settings) = crate::store::settings::Settings::load() {
249 for cp in &settings.custom_providers {
250 if builtin_names.iter().any(|n| n == &cp.name) {
251 continue;
252 }
253 let actual_key = auth_store.get_api_key(&cp.name);
254
255 let (has_key, key_masked) = match &actual_key {
256 Some(k) => (true, mask_key(k)),
257 None => (false, String::new()),
258 };
259
260 entries.push(ProviderEntry {
261 name: cp.name.clone(),
262 has_key,
263 key_masked,
264 is_custom: true,
265 base_url: Some(cp.base_url.clone()),
266 });
267 }
268 }
269
270 entries
271}
272
273fn load_models(
277 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
278) -> Vec<ModelEntry> {
279 let mut models = Vec::new();
280 let mut seen = std::collections::HashSet::new();
281
282 if let Ok(settings) = crate::store::settings::Settings::load() {
284 for (provider, model_ids) in &settings.dynamic_models {
285 for id in model_ids {
286 let key = format!("{}/{}", provider, id);
287 if seen.insert(key.clone()) {
288 let ctx = if let Some(cat) = catalog {
290 cat.get_model_sync(provider, id)
291 .map(|e| e.context_window)
292 .unwrap_or(128_000)
293 } else {
294 oxi_sdk::get_model_entry(provider, id)
295 .map(|e| e.context_window)
296 .unwrap_or(128_000)
297 };
298 models.push(ModelEntry::new(id.clone(), provider.clone(), ctx));
299 }
300 }
301 }
302 }
303
304 if let Some(cat) = catalog {
306 for entry in cat.search_sync("") {
307 let key = format!("{}/{}", entry.provider, entry.model_id);
308 if seen.insert(key) {
309 models.push(ModelEntry::new(
310 entry.model_id,
311 entry.provider,
312 entry.context_window,
313 ));
314 }
315 }
316 } else {
317 for entry in oxi_sdk::get_all_models() {
318 let key = format!("{}/{}", entry.provider, entry.id);
319 if seen.insert(key) {
320 models.push(ModelEntry::new(
321 entry.id.to_string(),
322 entry.provider.to_string(),
323 entry.context_window,
324 ));
325 }
326 }
327 }
328
329 models
330}
331
332fn fetch_and_cache_models(provider_name: &str, providers: &[ProviderEntry]) {
340 let base_url = providers
342 .iter()
343 .find(|p| p.name == provider_name)
344 .and_then(|p| p.base_url.clone())
345 .or_else(|| oxi_sdk::get_provider_base_url(provider_name).map(|s| s.to_string()));
346
347 let base_url = match base_url {
348 Some(url) if !url.is_empty() => url,
349 _ => {
350 tracing::debug!(
351 "Skipping dynamic model fetch for '{}': no base_url",
352 provider_name
353 );
354 return;
355 }
356 };
357
358 let auth_store = crate::store::auth_storage::shared_auth_storage();
360 let api_key = match auth_store.get_api_key(provider_name) {
361 Some(key) => key,
362 None => {
363 tracing::debug!(
364 "Skipping dynamic model fetch for '{}': no API key",
365 provider_name
366 );
367 return;
368 }
369 };
370
371 let api_type = oxi_sdk::get_provider_api(provider_name);
373 let is_openai_compatible = api_type.is_none_or(|api| {
374 matches!(
375 api,
376 oxi_sdk::Api::OpenAiCompletions | oxi_sdk::Api::OpenAiResponses
377 )
378 });
379
380 if !is_openai_compatible {
381 tracing::debug!(
382 "Skipping dynamic model fetch for '{}': not OpenAI-compatible",
383 provider_name
384 );
385 return;
386 }
387
388 tracing::info!(
389 "Fetching models from {}/models for provider '{}'...",
390 base_url,
391 provider_name
392 );
393
394 match oxi_sdk::fetch_models_blocking(&base_url, &api_key) {
395 Ok(model_ids) => {
396 tracing::info!(
397 "Fetched {} models from provider '{}'",
398 model_ids.len(),
399 provider_name
400 );
401
402 if let Ok(mut settings) = crate::store::settings::Settings::load() {
404 settings
405 .dynamic_models
406 .insert(provider_name.to_string(), model_ids);
407 if let Err(e) = settings.save() {
408 tracing::warn!("Failed to save dynamic models cache: {}", e);
409 }
410 }
411 }
412 Err(e) => {
413 tracing::warn!(
414 "Failed to fetch models from provider '{}': {}. \
415 Falling back to static model list.",
416 provider_name,
417 e
418 );
419 }
420 }
421}
422
423fn load_themes() -> Vec<String> {
426 vec![
428 "oxi_dark".to_string(),
429 "oxi_light".to_string(),
430 "nord".to_string(),
431 "catppuccin".to_string(),
432 "github_dark".to_string(),
433 "monokai".to_string(),
434 ]
435}
436
437fn save_settings(
443 model_id: &str,
444 theme_name: &str,
445 custom_base_urls: &[(String, String)],
446) -> Result<()> {
447 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
448
449 if let Some((provider, model_name)) = model_id.split_once('/') {
451 settings.last_used_provider = Some(provider.to_string());
452 settings.last_used_model = Some(model_name.to_string());
453 } else {
454 settings.last_used_model = Some(model_id.to_string());
455 }
456 settings.theme = theme_name.to_string();
457
458 for (name, base_url) in custom_base_urls {
460 let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
461 if !already_exists {
462 settings
463 .custom_providers
464 .push(crate::store::settings::CustomProvider {
465 name: name.clone(),
466 base_url: base_url.clone(),
467 api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
468 api: "openai-completions".to_string(),
469 });
470 }
471 }
472
473 settings.save()?;
474 Ok(())
475}
476
477fn draw_wizard(
480 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
481 state: &mut WizardState,
482) -> Result<()> {
483 terminal.draw(|f| {
484 let size = f.area();
485
486 let chunks = Layout::default()
488 .direction(Direction::Vertical)
489 .constraints([
490 Constraint::Length(3), Constraint::Min(10), Constraint::Length(2), ])
494 .split(size);
495
496 let title = Paragraph::new(Line::from(vec![
498 Span::styled(
499 " oxi ",
500 Style::default()
501 .fg(Color::Rgb(255, 165, 0))
502 .add_modifier(Modifier::BOLD),
503 ),
504 Span::styled(
505 "oxi Setup Wizard",
506 Style::default().add_modifier(Modifier::BOLD),
507 ),
508 ]))
509 .block(Block::default().borders(Borders::TOP));
510 f.render_widget(title, chunks[0]);
511
512 match state.step {
514 0 => draw_provider_step(f, state, chunks[1]),
515 1 => draw_model_step(f, state, chunks[1]),
516 2 => draw_theme_step(f, state, chunks[1]),
517 3 => draw_done_step(f, state, chunks[1]),
518 _ => {}
519 }
520
521 let footer_text = match state.step {
523 0 => match &state.input_mode {
524 InputMode::Normal => {
525 if state.provider_searching {
526 " Type: filter · ↑/↓ navigate · Enter: select & edit key · Esc: close search · ←: previous".to_string()
527 } else {
528 " ↑/↓ navigate · /: search · Enter: API key · d: delete · →: next · q: quit".to_string()
529 }
530 }
531 InputMode::EditingApiKey { .. } => " Enter: save · Esc: cancel".to_string(),
532 InputMode::AddingCustom { .. } => {
533 " Tab: next field · Enter: save · Esc: cancel".to_string()
534 }
535 },
536 1 => " Type: filter · ↑/↓ navigate · Enter: select · Esc: clear filter · ←: previous".to_string(),
537 2 => " ↑/↓ navigate · Enter: select · ←: previous".to_string(),
538 3 => " Enter: quit".to_string(),
539 _ => String::new(),
540 };
541 let footer = Paragraph::new(Line::from(Span::styled(
542 footer_text,
543 Style::default().fg(Color::DarkGray),
544 )));
545 f.render_widget(footer, chunks[2]);
546 })?;
547
548 Ok(())
549}
550
551fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
552 match &state.input_mode {
553 InputMode::Normal => draw_provider_list(f, state, area),
554 InputMode::EditingApiKey {
555 provider_name,
556 field_text,
557 } => draw_api_key_dialog(f, provider_name, field_text, area),
558 InputMode::AddingCustom {
559 fields,
560 active_field,
561 } => draw_custom_provider_dialog(f, fields, *active_field, area),
562 }
563}
564
565fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
566 let step_indicator = build_step_indicator(state.step);
567
568 let (list_area, search_area) = if state.provider_searching {
570 let chunks = Layout::default()
571 .direction(Direction::Vertical)
572 .constraints([Constraint::Length(1), Constraint::Min(1)])
573 .split(area);
574 (chunks[1], Some(chunks[0]))
575 } else {
576 (area, None)
577 };
578
579 if let Some(search_rect) = search_area {
582 let mut spans = vec![
583 Span::styled(
584 " Filter: ",
585 Style::default()
586 .fg(Color::Yellow)
587 .add_modifier(Modifier::BOLD),
588 ),
589 Span::styled(
590 &state.provider_filter,
591 Style::default().add_modifier(Modifier::BOLD),
592 ),
593 Span::styled(" ", Style::default().bg(Color::Yellow)),
594 ];
595 if state.provider_filter.is_empty() {
596 spans.push(Span::styled(
597 " type a provider name to filter...",
598 Style::default().fg(Color::DarkGray),
599 ));
600 }
601 f.render_widget(Paragraph::new(Line::from(spans)), search_rect);
602 }
603
604 let indices: Vec<usize> = if state.provider_searching {
606 filtered_provider_indices(state)
607 } else {
608 (0..state.providers.len()).collect()
609 };
610
611 let mut items: Vec<ListItem> = indices
612 .iter()
613 .map(|&i| {
614 let p = &state.providers[i];
615 let check = if p.has_key { "[x]" } else { "[ ]" };
616 let key_info = if p.has_key {
617 format!("API key: {}", p.key_masked)
618 } else {
619 "No API key".to_string()
620 };
621 let custom_tag = if p.is_custom { " (custom)" } else { "" };
622
623 let line = Line::from(vec![
624 Span::styled(
625 format!(" {} ", check),
626 Style::default().fg(if p.has_key {
627 Color::Green
628 } else {
629 Color::DarkGray
630 }),
631 ),
632 Span::styled(
633 format!("{:<14}", p.name),
634 Style::default().add_modifier(Modifier::BOLD),
635 ),
636 Span::styled(
637 format!("[{}]", key_info),
638 Style::default().fg(Color::DarkGray),
639 ),
640 Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
641 ]);
642 ListItem::new(line)
643 })
644 .collect();
645
646 if !state.provider_searching {
648 items.push(ListItem::new(Line::from(vec![
649 Span::styled(" + ", Style::default().fg(Color::Cyan)),
650 Span::styled("Add custom provider...", Style::default().fg(Color::Cyan)),
651 ])));
652 }
653
654 let list = List::new(items)
655 .block(
656 Block::default()
657 .borders(Borders::NONE)
658 .title(step_indicator),
659 )
660 .highlight_style(
661 Style::default()
662 .bg(Color::DarkGray)
663 .add_modifier(Modifier::BOLD),
664 )
665 .highlight_symbol("▶ ");
666
667 let selected_pos = if state.provider_searching {
669 indices.iter().position(|&i| i == state.provider_selected)
670 } else {
671 Some(state.provider_selected)
674 };
675 state.provider_list_state.select(selected_pos);
676 f.render_stateful_widget(list, list_area, &mut state.provider_list_state);
677}
678
679fn draw_api_key_dialog(f: &mut ratatui::Frame, provider_name: &str, field_text: &str, area: Rect) {
680 let dialog_height = 7u16;
682 let dialog_width = std::cmp::min(area.width, 60);
683 let x = (area.width.saturating_sub(dialog_width)) / 2;
684 let y = (area.height.saturating_sub(dialog_height)) / 2;
685
686 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
687
688 let display_text = if field_text.is_empty() {
689 String::new()
690 } else {
691 "*".repeat(field_text.len())
692 };
693
694 let paragraphs = vec![
695 Line::from(""),
696 Line::from(vec![
697 Span::styled(" API Key: ", Style::default().add_modifier(Modifier::BOLD)),
698 Span::styled(
699 format!("[{:<width$}]", display_text, width = 30),
700 Style::default(),
701 ),
702 if field_text.is_empty() {
703 Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
704 } else {
705 Span::raw("")
706 },
707 ]),
708 ];
709
710 let block = Block::default()
711 .borders(Borders::ALL)
712 .title(format!(" {} API Key ", provider_name));
713
714 let para = Paragraph::new(paragraphs).block(block);
715 f.render_widget(para, dialog_area);
716}
717
718fn draw_custom_provider_dialog(
719 f: &mut ratatui::Frame,
720 fields: &[String; 3],
721 active_field: usize,
722 area: Rect,
723) {
724 let dialog_height = 9u16;
725 let dialog_width = std::cmp::min(area.width, 60);
726 let x = (area.width.saturating_sub(dialog_width)) / 2;
727 let y = (area.height.saturating_sub(dialog_height)) / 2;
728
729 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
730
731 let field_labels = ["Name", "Base URL", "API Key"];
732 let lines: Vec<Line> = std::iter::once(Line::from(""))
733 .chain(field_labels.iter().enumerate().map(|(i, label)| {
734 let display = if i == 2 && !fields[i].is_empty() {
735 "*".repeat(fields[i].len())
736 } else {
737 fields[i].clone()
738 };
739 let is_active = i == active_field;
740 let style = if is_active {
741 Style::default().add_modifier(Modifier::BOLD)
742 } else {
743 Style::default()
744 };
745 Line::from(vec![
746 Span::styled(format!(" {:<10}", format!("{}:", label)), style),
747 Span::styled(format!("[{:<width$}]", display, width = 35), style),
748 if is_active && fields[i].is_empty() {
749 Span::styled("<enter>", Style::default().fg(Color::DarkGray))
750 } else {
751 Span::raw("")
752 },
753 ])
754 }))
755 .collect();
756
757 let block = Block::default()
758 .borders(Borders::ALL)
759 .title(" Add Custom Provider ");
760
761 let para = Paragraph::new(lines).block(block);
762 f.render_widget(para, dialog_area);
763}
764
765fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
766 let step_indicator = build_step_indicator(state.step);
767
768 let chunks = Layout::default()
770 .direction(Direction::Vertical)
771 .constraints([Constraint::Length(1), Constraint::Min(1)])
772 .split(area);
773
774 let mut spans = vec![
778 Span::styled(
779 " Filter: ",
780 Style::default()
781 .fg(Color::Yellow)
782 .add_modifier(Modifier::BOLD),
783 ),
784 Span::styled(
785 &state.model_filter,
786 Style::default().add_modifier(Modifier::BOLD),
787 ),
788 Span::styled(" ", Style::default().bg(Color::Yellow)),
789 ];
790 if state.model_filter.is_empty() {
791 spans.push(Span::styled(
792 " type to filter (e.g. 'gpt-4', 'claude', 'gemini')...",
793 Style::default().fg(Color::DarkGray),
794 ));
795 }
796 f.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
797
798 let indices = filtered_model_indices(state);
800 let items: Vec<ListItem> = indices
801 .iter()
802 .map(|&i| {
803 let m = &state.models[i];
804 let ctx_str = if m.context_window >= 1_000_000 {
805 format!("{}M ctx", m.context_window / 1_000_000)
806 } else {
807 format!("{}K ctx", m.context_window / 1_000)
808 };
809 ListItem::new(Line::from(vec![
810 Span::styled(format!("{:<40}", m.id), Style::default()),
811 Span::styled(
812 format!("({})", m.provider),
813 Style::default().fg(Color::DarkGray),
814 ),
815 Span::styled(
816 format!(", {}", ctx_str),
817 Style::default().fg(Color::DarkGray),
818 ),
819 ]))
820 })
821 .collect();
822
823 let list = List::new(items)
824 .block(
825 Block::default()
826 .borders(Borders::NONE)
827 .title(step_indicator),
828 )
829 .highlight_style(
830 Style::default()
831 .bg(Color::DarkGray)
832 .add_modifier(Modifier::BOLD),
833 )
834 .highlight_symbol("▶ ");
835
836 let selected_pos = indices.iter().position(|&i| i == state.model_selected);
837 state.model_list_state.select(selected_pos);
838 f.render_stateful_widget(list, chunks[1], &mut state.model_list_state);
839
840 if indices.is_empty() {
842 let hint = Paragraph::new(Line::from(Span::styled(
843 " No models match your filter. Press Esc to clear.",
844 Style::default().fg(Color::DarkGray),
845 )));
846 f.render_widget(hint, chunks[1]);
847 }
848}
849
850fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
851 let step_indicator = build_step_indicator(state.step);
852
853 let items: Vec<ListItem> = state
854 .themes
855 .iter()
856 .map(|t| ListItem::new(Line::from(format!(" {}", t))))
857 .collect();
858
859 let list = List::new(items)
860 .block(
861 Block::default()
862 .borders(Borders::NONE)
863 .title(step_indicator),
864 )
865 .highlight_style(
866 Style::default()
867 .bg(Color::DarkGray)
868 .add_modifier(Modifier::BOLD),
869 );
870
871 state.theme_list_state.select(Some(state.theme_selected));
872 f.render_stateful_widget(list, area, &mut state.theme_list_state);
873}
874
875fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
876 let settings_path_display = state.settings_path.display().to_string();
877 let auth_path_display = state.auth_path.display().to_string();
878
879 let lines = vec![
880 Line::from(""),
881 Line::from(Span::styled(
882 " Settings saved!",
883 Style::default()
884 .fg(Color::Green)
885 .add_modifier(Modifier::BOLD),
886 )),
887 Line::from(""),
888 Line::from(Span::styled(
889 format!(" Settings file: {}", settings_path_display),
890 Style::default().fg(Color::DarkGray),
891 )),
892 Line::from(Span::styled(
893 format!(" Auth file: {}", auth_path_display),
894 Style::default().fg(Color::DarkGray),
895 )),
896 Line::from(""),
897 Line::from(Span::styled(
898 " Run 'oxi' to start.",
899 Style::default().add_modifier(Modifier::BOLD),
900 )),
901 ];
902
903 let block = Block::default().borders(Borders::NONE);
904 let para = Paragraph::new(lines).block(block);
905 f.render_widget(para, area);
906}
907
908fn build_step_indicator(current_step: usize) -> Line<'static> {
909 let steps = [
910 ("1. Provider Setup", 0),
911 ("2. Default Model", 1),
912 ("3. Theme", 2),
913 ("4. Done", 3),
914 ];
915
916 let spans: Vec<Span> = steps
917 .iter()
918 .flat_map(|(label, step)| {
919 let style = if *step == current_step {
920 Style::default()
921 .add_modifier(Modifier::BOLD)
922 .fg(Color::Cyan)
923 } else if *step < current_step {
924 Style::default().fg(Color::Green)
925 } else {
926 Style::default().fg(Color::DarkGray)
927 };
928 vec![Span::styled(format!(" {}", label), style), Span::raw(" ")]
929 })
930 .collect();
931
932 Line::from(spans)
933}
934
935fn handle_event(
938 state: &mut WizardState,
939 event: Event,
940 auth_store: &crate::store::auth_storage::AuthStorage,
941) -> Result<bool> {
942 match state.step {
943 0 => handle_provider_event(state, event, auth_store),
944 1 => handle_model_event(state, event),
945 2 => handle_theme_event(state, event),
946 3 => handle_done_event(event),
947 _ => Ok(false),
948 }
949}
950
951fn handle_provider_event(
952 state: &mut WizardState,
953 event: Event,
954 auth_store: &crate::store::auth_storage::AuthStorage,
955) -> Result<bool> {
956 if state.provider_searching && matches!(state.input_mode, InputMode::Normal) {
959 if let Event::Key(key) = event {
960 match key.code {
961 KeyCode::Esc => {
962 state.provider_searching = false;
963 state.provider_filter.clear();
964 snap_provider_selection(state);
965 }
966 KeyCode::Enter => {
967 state.provider_searching = false;
970 state.provider_filter.clear();
971 if state.provider_selected < state.providers.len() {
972 let name = state.providers[state.provider_selected].name.clone();
973 state.input_mode = InputMode::EditingApiKey {
974 provider_name: name,
975 field_text: String::new(),
976 };
977 }
978 }
979 KeyCode::Up => {
980 let indices = filtered_provider_indices(state);
981 if let Some(pos) = indices.iter().position(|&i| i == state.provider_selected)
982 && pos > 0
983 {
984 state.provider_selected = indices[pos - 1];
985 } else if let Some(&first) = indices.first() {
986 state.provider_selected = first;
987 }
988 }
989 KeyCode::Down => {
990 let indices = filtered_provider_indices(state);
991 if let Some(pos) = indices.iter().position(|&i| i == state.provider_selected)
992 && pos + 1 < indices.len()
993 {
994 state.provider_selected = indices[pos + 1];
995 } else if let Some(&first) = indices.first() {
996 state.provider_selected = first;
997 }
998 }
999 KeyCode::Backspace => {
1000 state.provider_filter.pop();
1001 snap_provider_selection(state);
1002 }
1003 KeyCode::Char(c) => {
1004 state.provider_filter.push(c);
1005 snap_provider_selection(state);
1006 }
1007 KeyCode::Left => {
1008 state.provider_searching = false;
1009 state.provider_filter.clear();
1010 snap_provider_selection(state);
1011 }
1012 _ => {}
1013 }
1014 }
1015 return Ok(false);
1016 }
1017
1018 match &mut state.input_mode {
1019 InputMode::Normal => {
1020 if let Event::Key(key) = event {
1021 match key.code {
1022 KeyCode::Up if state.provider_selected > 0 => {
1023 state.provider_selected -= 1;
1024 }
1025 KeyCode::Down => {
1026 let max = state.providers.len();
1028 if state.provider_selected < max {
1029 state.provider_selected += 1;
1030 }
1031 }
1032 KeyCode::Enter => {
1033 if state.provider_selected == state.providers.len() {
1034 state.input_mode = InputMode::AddingCustom {
1036 fields: [String::new(), String::new(), String::new()],
1037 active_field: 0,
1038 };
1039 } else {
1040 let name = state.providers[state.provider_selected].name.clone();
1042 state.input_mode = InputMode::EditingApiKey {
1043 provider_name: name,
1044 field_text: String::new(),
1045 };
1046 }
1047 }
1048 KeyCode::Char('d') | KeyCode::Delete
1049 if state.provider_selected < state.providers.len() =>
1050 {
1051 let name = state.providers[state.provider_selected].name.clone();
1052 auth_store.remove(&name);
1053 state.providers[state.provider_selected].has_key = false;
1054 state.providers[state.provider_selected].key_masked = String::new();
1055 }
1056 KeyCode::Char('/') => {
1057 state.provider_searching = true;
1058 state.provider_filter.clear();
1059 snap_provider_selection(state);
1062 }
1063 KeyCode::Right => {
1064 state.step = 1;
1065 }
1066 KeyCode::Char('q') => {
1067 return Ok(true); }
1069 _ => {}
1070 }
1071 }
1072 }
1073 InputMode::EditingApiKey {
1074 provider_name,
1075 field_text,
1076 } => {
1077 if let Event::Key(key) = event {
1078 match key.code {
1079 KeyCode::Esc => {
1080 state.input_mode = InputMode::Normal;
1081 }
1082 KeyCode::Enter => {
1083 if !field_text.is_empty() {
1084 auth_store.set_api_key(provider_name, field_text.clone());
1085
1086 if let Some(entry) = state
1088 .providers
1089 .iter_mut()
1090 .find(|p| p.name == *provider_name)
1091 {
1092 entry.has_key = true;
1093 entry.key_masked = mask_key(field_text);
1094 }
1095
1096 fetch_and_cache_models(provider_name, &state.providers);
1098
1099 state.models = load_models(state.catalog.as_ref());
1101 }
1102 state.input_mode = InputMode::Normal;
1103 }
1104 KeyCode::Backspace => {
1105 field_text.pop();
1106 }
1107 KeyCode::Char(c) => {
1108 field_text.push(c);
1109 }
1110 _ => {}
1111 }
1112 }
1113 }
1114 InputMode::AddingCustom {
1115 fields,
1116 active_field,
1117 } => {
1118 if let Event::Key(key) = event {
1119 match key.code {
1120 KeyCode::Esc => {
1121 state.input_mode = InputMode::Normal;
1122 }
1123 KeyCode::Tab => {
1124 *active_field = (*active_field + 1) % 3;
1125 }
1126 KeyCode::BackTab => {
1127 *active_field = (*active_field + 2) % 3;
1128 }
1129 KeyCode::Enter => {
1130 let name = fields[0].trim().to_string();
1131 let base_url = fields[1].trim().to_string();
1132 let api_key = fields[2].trim().to_string();
1133
1134 if !name.is_empty() && !base_url.is_empty() {
1135 if !api_key.is_empty() {
1137 auth_store.set_api_key(&name, api_key.clone());
1138 }
1139
1140 let (has_key, key_masked) = if !api_key.is_empty() {
1142 (true, mask_key(&api_key))
1143 } else {
1144 (false, String::new())
1145 };
1146
1147 state.providers.push(ProviderEntry {
1148 name: name.clone(),
1149 has_key,
1150 key_masked,
1151 is_custom: true,
1152 base_url: Some(base_url),
1153 });
1154
1155 if !api_key.is_empty() {
1157 fetch_and_cache_models(&name, &state.providers);
1158 state.models = load_models(state.catalog.as_ref());
1159 }
1160
1161 state.input_mode = InputMode::Normal;
1163 }
1164 }
1165 KeyCode::Backspace => {
1166 fields[*active_field].pop();
1167 }
1168 KeyCode::Char(c) => {
1169 fields[*active_field].push(c);
1170 }
1171 _ => {}
1172 }
1173 }
1174 }
1175 }
1176 Ok(false)
1177}
1178
1179fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
1180 if let Event::Key(key) = event {
1181 match key.code {
1185 KeyCode::Char(c) => {
1186 state.model_filter.push(c);
1187 ensure_model_selected_visible(state);
1188 }
1189 KeyCode::Backspace => {
1190 state.model_filter.pop();
1191 ensure_model_selected_visible(state);
1192 }
1193 KeyCode::Up => {
1194 let indices = filtered_model_indices(state);
1195 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1196 && pos > 0
1197 {
1198 state.model_selected = indices[pos - 1];
1199 } else if let Some(&first) = indices.first() {
1200 state.model_selected = first;
1201 }
1202 }
1203 KeyCode::Down => {
1204 let indices = filtered_model_indices(state);
1205 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1206 && pos + 1 < indices.len()
1207 {
1208 state.model_selected = indices[pos + 1];
1209 } else if let Some(&first) = indices.first() {
1210 state.model_selected = first;
1211 }
1212 }
1213 KeyCode::Enter => {
1214 if !filtered_model_indices(state).is_empty() {
1219 state.step = 2;
1220 }
1221 }
1222 KeyCode::Esc => {
1223 state.model_filter.clear();
1225 ensure_model_selected_visible(state);
1226 }
1227 KeyCode::Left => {
1228 state.step = 0;
1229 }
1230 _ => {}
1231 }
1232 }
1233 Ok(false)
1234}
1235
1236fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
1237 if let Event::Key(key) = event {
1238 match key.code {
1239 KeyCode::Up if state.theme_selected > 0 => {
1240 state.theme_selected -= 1;
1241 }
1242 KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
1243 state.theme_selected += 1;
1244 }
1245 KeyCode::Enter => {
1246 finish_setup(state)?;
1248 state.step = 3;
1249 }
1250 KeyCode::Left => {
1251 state.step = 1;
1252 }
1253 _ => {}
1254 }
1255 }
1256 Ok(false)
1257}
1258
1259fn handle_done_event(event: Event) -> Result<bool> {
1260 if let Event::Key(key) = event {
1261 match key.code {
1262 KeyCode::Enter | KeyCode::Char('q') => {
1263 return Ok(true); }
1265 _ => {}
1266 }
1267 }
1268 Ok(false)
1269}
1270
1271fn finish_setup(state: &mut WizardState) -> Result<()> {
1274 let model_id = state
1276 .models
1277 .get(state.model_selected)
1278 .map(|m| format!("{}/{}", m.provider, m.id))
1279 .unwrap_or_default();
1280
1281 let theme_name = state
1283 .themes
1284 .get(state.theme_selected)
1285 .cloned()
1286 .unwrap_or_else(|| "oxi_dark".to_string());
1287
1288 let custom_base_urls: Vec<(String, String)> = state
1290 .providers
1291 .iter()
1292 .filter_map(|p| {
1293 if p.is_custom {
1294 p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1295 } else {
1296 None
1297 }
1298 })
1299 .collect();
1300
1301 save_settings(&model_id, &theme_name, &custom_base_urls)?;
1302
1303 Ok(())
1304}
1305
1306pub async fn run() -> Result<()> {
1310 enable_raw_mode()?;
1312 let mut stdout = io::stdout();
1313 execute!(stdout, EnterAlternateScreen)?;
1314 let backend = CrosstermBackend::new(stdout);
1315 let mut terminal = Terminal::new(backend)?;
1316
1317 let panic_hook = std::panic::take_hook();
1319 std::panic::set_hook(Box::new(move |info| {
1320 let _ = disable_raw_mode();
1321 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1322 panic_hook(info);
1323 }));
1324
1325 let catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>> = {
1327 let paths = crate::services::OxiPaths::default_paths().ok();
1328 if let Some(paths) = paths {
1329 let config = oxi_sdk::CatalogConfig {
1330 cache_path: paths.home.join("cache").join("models-dev.json"),
1331 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
1332 override_path: paths.home.join("catalog").join("overrides.toml"),
1333 fetch_enabled: false,
1335 ..Default::default()
1336 };
1337 oxi_sdk::FileModelCatalog::init(config)
1338 .await
1339 .ok()
1340 .map(|c| c as _)
1341 } else {
1342 None
1343 }
1344 };
1345
1346 let auth_store = crate::store::auth_storage::shared_auth_storage();
1348 let providers = load_providers(&auth_store, catalog.as_ref());
1349 let models = load_models(catalog.as_ref());
1350 let themes = load_themes();
1351
1352 let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1353 dirs::home_dir()
1354 .unwrap_or_default()
1355 .join(".oxi")
1356 .join("auth.json")
1357 });
1358 let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1359 dirs::home_dir()
1360 .unwrap_or_default()
1361 .join(".oxi")
1362 .join("settings.json")
1363 });
1364
1365 let current_model = crate::store::settings::Settings::load()
1367 .ok()
1368 .and_then(|s| s.last_used_model.clone())
1369 .unwrap_or_default();
1370
1371 let model_selected = models
1372 .iter()
1373 .position(|m| {
1374 let full_id = format!("{}/{}", m.provider, m.id);
1375 full_id == current_model || m.id == current_model
1376 })
1377 .unwrap_or(0);
1378
1379 let current_theme = crate::store::settings::Settings::load()
1381 .ok()
1382 .map(|s| s.theme.clone())
1383 .unwrap_or_else(|| "oxi_dark".to_string());
1384
1385 let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1386
1387 let mut state = WizardState {
1388 step: 0,
1389 providers,
1390 provider_selected: 0,
1391 provider_list_state: ListState::default(),
1392 provider_filter: String::new(),
1393 provider_searching: false,
1394 input_mode: InputMode::Normal,
1395 models,
1396 model_selected,
1397 model_filter: String::new(),
1398 model_list_state: ListState::default(),
1399 themes,
1400 theme_selected,
1401 theme_list_state: ListState::default(),
1402 auth_path,
1403 settings_path,
1404 catalog,
1405 };
1406
1407 loop {
1409 draw_wizard(&mut terminal, &mut state)?;
1410
1411 if event::poll(std::time::Duration::from_millis(100))?
1412 && let Event::Key(key) = event::read()?
1413 {
1414 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1416 break;
1417 }
1418
1419 let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1420 if should_quit {
1421 break;
1422 }
1423 }
1424 }
1425
1426 disable_raw_mode()?;
1428 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1429
1430 Ok(())
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436
1437 fn make_state(providers: Vec<&str>, models: Vec<(&str, &str)>) -> WizardState {
1438 WizardState {
1439 step: 0,
1440 providers: providers
1441 .iter()
1442 .map(|n| ProviderEntry {
1443 name: n.to_string(),
1444 has_key: false,
1445 key_masked: String::new(),
1446 is_custom: false,
1447 base_url: None,
1448 })
1449 .collect(),
1450 provider_selected: 0,
1451 provider_list_state: ListState::default(),
1452 provider_filter: String::new(),
1453 provider_searching: false,
1454 input_mode: InputMode::Normal,
1455 models: models
1456 .iter()
1457 .map(|(id, provider)| {
1458 ModelEntry::new(id.to_string(), provider.to_string(), 128_000)
1459 })
1460 .collect(),
1461 model_selected: 0,
1462 model_filter: String::new(),
1463 model_list_state: ListState::default(),
1464 themes: vec![],
1465 theme_selected: 0,
1466 theme_list_state: ListState::default(),
1467 auth_path: PathBuf::new(),
1468 settings_path: PathBuf::new(),
1469 catalog: None,
1470 }
1471 }
1472
1473 #[test]
1474 fn provider_filter_matches_name_case_insensitive() {
1475 let mut s = make_state(vec!["anthropic", "openai", "google", "mistral"], vec![]);
1476 assert_eq!(filtered_provider_indices(&s), vec![0, 1, 2, 3]);
1477
1478 s.provider_filter = "ANT".to_string();
1479 assert_eq!(filtered_provider_indices(&s), vec![0]); s.provider_filter = "goog".to_string();
1482 assert_eq!(filtered_provider_indices(&s), vec![2]); }
1484
1485 #[test]
1486 fn model_filter_matches_id_or_provider() {
1487 let mut s = make_state(
1488 vec![],
1489 vec![
1490 ("gpt-4o", "openai"),
1491 ("gpt-4-turbo", "openai"),
1492 ("claude-3-opus", "anthropic"),
1493 ("gemini-pro", "google"),
1494 ],
1495 );
1496 assert_eq!(filtered_model_indices(&s), vec![0, 1, 2, 3]);
1497
1498 s.model_filter = "gpt".to_string();
1499 assert_eq!(filtered_model_indices(&s), vec![0, 1]);
1500
1501 s.model_filter = "anthropic".to_string();
1502 assert_eq!(filtered_model_indices(&s), vec![2]); s.model_filter = "OPUS".to_string();
1505 assert_eq!(filtered_model_indices(&s), vec![2]); }
1507
1508 #[test]
1509 fn model_filter_empty_result_yields_no_indices() {
1510 let mut state = make_state(vec![], vec![("gpt-4o", "openai")]);
1511 state.model_filter = "zzz".to_string();
1512 assert!(filtered_model_indices(&state).is_empty());
1513 }
1514
1515 #[test]
1516 fn ensure_model_selected_snaps_to_first_match() {
1517 let mut state = make_state(
1518 vec![],
1519 vec![
1520 ("gpt-4o", "openai"),
1521 ("claude-3", "anthropic"),
1522 ("gpt-3.5", "openai"),
1523 ],
1524 );
1525 state.model_filter = "gpt".to_string();
1527 ensure_model_selected_visible(&mut state);
1528 assert_eq!(state.model_selected, 0);
1530
1531 state.model_filter = "claude".to_string();
1533 ensure_model_selected_visible(&mut state);
1534 assert_eq!(state.model_selected, 1);
1535 }
1536
1537 #[test]
1538 fn snap_provider_selection_into_filtered_set() {
1539 let mut state = make_state(vec!["anthropic", "openai", "google"], vec![]);
1540 state.provider_selected = 2; state.provider_filter = "open".to_string();
1542 snap_provider_selection(&mut state);
1543 assert_eq!(state.provider_selected, 1); }
1545
1546 #[test]
1547 fn snap_provider_noop_when_filter_empty_matches_all() {
1548 let mut state = make_state(vec!["anthropic", "openai"], vec![]);
1549 state.provider_selected = 1;
1550 state.provider_filter = String::new();
1551 snap_provider_selection(&mut state);
1552 assert_eq!(state.provider_selected, 1); }
1554}