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 input_mode: InputMode,
77 models: Vec<ModelEntry>,
79 model_selected: usize,
81 model_filter: String,
83 model_searching: bool,
85 themes: Vec<String>,
87 theme_selected: usize,
89 theme_list_state: ListState,
91 auth_path: PathBuf,
93 settings_path: PathBuf,
95 catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
97}
98
99#[derive(Clone)]
101struct ModelEntry {
102 id: String,
103 provider: String,
104 context_window: u32,
105}
106
107fn mask_key(key: &str) -> String {
111 if key.len() <= 10 {
112 "*".repeat(key.len())
113 } else {
114 format!("{}...{}", &key[..6], &key[key.len() - 4..])
115 }
116}
117
118fn load_providers(
122 auth_store: &crate::store::auth_storage::AuthStorage,
123 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
124) -> Vec<ProviderEntry> {
125 let mut entries = Vec::new();
126
127 let builtin_names: Vec<String> = if let Some(cat) = catalog {
128 cat.list_providers_sync()
129 } else {
130 oxi_sdk::get_builtin_providers()
131 .iter()
132 .map(|p| p.name.to_string())
133 .collect()
134 };
135
136 for name in &builtin_names {
137 let key = auth_store.get_api_key(name);
138
139 let (has_key, key_masked) = match &key {
140 Some(k) => (true, mask_key(k)),
141 None => (false, String::new()),
142 };
143
144 let base_url = if let Some(cat) = catalog {
145 cat.get_provider_sync(name).and_then(|p| p.base_url)
146 } else {
147 oxi_sdk::get_provider_base_url(name)
148 .filter(|s| !s.is_empty())
149 .map(|s| s.to_string())
150 };
151
152 entries.push(ProviderEntry {
153 name: name.clone(),
154 has_key,
155 key_masked,
156 is_custom: false,
157 base_url,
158 });
159 }
160
161 if let Ok(settings) = crate::store::settings::Settings::load() {
163 for cp in &settings.custom_providers {
164 if builtin_names.iter().any(|n| n == &cp.name) {
165 continue;
166 }
167 let actual_key = auth_store.get_api_key(&cp.name);
168
169 let (has_key, key_masked) = match &actual_key {
170 Some(k) => (true, mask_key(k)),
171 None => (false, String::new()),
172 };
173
174 entries.push(ProviderEntry {
175 name: cp.name.clone(),
176 has_key,
177 key_masked,
178 is_custom: true,
179 base_url: Some(cp.base_url.clone()),
180 });
181 }
182 }
183
184 entries
185}
186
187fn load_models(
191 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
192) -> Vec<ModelEntry> {
193 let mut models = Vec::new();
194 let mut seen = std::collections::HashSet::new();
195
196 if let Ok(settings) = crate::store::settings::Settings::load() {
198 for (provider, model_ids) in &settings.dynamic_models {
199 for id in model_ids {
200 let key = format!("{}/{}", provider, id);
201 if seen.insert(key.clone()) {
202 let ctx = if let Some(cat) = catalog {
204 cat.get_model_sync(provider, id)
205 .map(|e| e.context_window)
206 .unwrap_or(128_000)
207 } else {
208 oxi_sdk::get_model_entry(provider, id)
209 .map(|e| e.context_window)
210 .unwrap_or(128_000)
211 };
212 models.push(ModelEntry {
213 id: id.clone(),
214 provider: provider.clone(),
215 context_window: ctx,
216 });
217 }
218 }
219 }
220 }
221
222 if let Some(cat) = catalog {
224 for entry in cat.search_sync("") {
225 let key = format!("{}/{}", entry.provider, entry.model_id);
226 if seen.insert(key) {
227 models.push(ModelEntry {
228 id: entry.model_id,
229 provider: entry.provider,
230 context_window: entry.context_window,
231 });
232 }
233 }
234 } else {
235 for entry in oxi_sdk::get_all_models() {
236 let key = format!("{}/{}", entry.provider, entry.id);
237 if seen.insert(key) {
238 models.push(ModelEntry {
239 id: entry.id.to_string(),
240 provider: entry.provider.to_string(),
241 context_window: entry.context_window,
242 });
243 }
244 }
245 }
246
247 models
248}
249
250fn fetch_and_cache_models(provider_name: &str, providers: &[ProviderEntry]) {
258 let base_url = providers
260 .iter()
261 .find(|p| p.name == provider_name)
262 .and_then(|p| p.base_url.clone())
263 .or_else(|| oxi_sdk::get_provider_base_url(provider_name).map(|s| s.to_string()));
264
265 let base_url = match base_url {
266 Some(url) if !url.is_empty() => url,
267 _ => {
268 tracing::debug!(
269 "Skipping dynamic model fetch for '{}': no base_url",
270 provider_name
271 );
272 return;
273 }
274 };
275
276 let auth_store = crate::store::auth_storage::shared_auth_storage();
278 let api_key = match auth_store.get_api_key(provider_name) {
279 Some(key) => key,
280 None => {
281 tracing::debug!(
282 "Skipping dynamic model fetch for '{}': no API key",
283 provider_name
284 );
285 return;
286 }
287 };
288
289 let api_type = oxi_sdk::get_provider_api(provider_name);
291 let is_openai_compatible = api_type.is_none_or(|api| {
292 matches!(
293 api,
294 oxi_sdk::Api::OpenAiCompletions | oxi_sdk::Api::OpenAiResponses
295 )
296 });
297
298 if !is_openai_compatible {
299 tracing::debug!(
300 "Skipping dynamic model fetch for '{}': not OpenAI-compatible",
301 provider_name
302 );
303 return;
304 }
305
306 tracing::info!(
307 "Fetching models from {}/models for provider '{}'...",
308 base_url,
309 provider_name
310 );
311
312 match oxi_sdk::fetch_models_blocking(&base_url, &api_key) {
313 Ok(model_ids) => {
314 tracing::info!(
315 "Fetched {} models from provider '{}'",
316 model_ids.len(),
317 provider_name
318 );
319
320 if let Ok(mut settings) = crate::store::settings::Settings::load() {
322 settings
323 .dynamic_models
324 .insert(provider_name.to_string(), model_ids);
325 if let Err(e) = settings.save() {
326 tracing::warn!("Failed to save dynamic models cache: {}", e);
327 }
328 }
329 }
330 Err(e) => {
331 tracing::warn!(
332 "Failed to fetch models from provider '{}': {}. \
333 Falling back to static model list.",
334 provider_name,
335 e
336 );
337 }
338 }
339}
340
341fn load_themes() -> Vec<String> {
344 vec![
346 "oxi_dark".to_string(),
347 "oxi_light".to_string(),
348 "nord".to_string(),
349 "catppuccin".to_string(),
350 "github_dark".to_string(),
351 "monokai".to_string(),
352 ]
353}
354
355fn save_settings(
361 model_id: &str,
362 theme_name: &str,
363 custom_base_urls: &[(String, String)],
364) -> Result<()> {
365 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
366
367 if let Some((provider, model_name)) = model_id.split_once('/') {
369 settings.last_used_provider = Some(provider.to_string());
370 settings.last_used_model = Some(model_name.to_string());
371 } else {
372 settings.last_used_model = Some(model_id.to_string());
373 }
374 settings.theme = theme_name.to_string();
375
376 for (name, base_url) in custom_base_urls {
378 let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
379 if !already_exists {
380 settings
381 .custom_providers
382 .push(crate::store::settings::CustomProvider {
383 name: name.clone(),
384 base_url: base_url.clone(),
385 api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
386 api: "openai-completions".to_string(),
387 });
388 }
389 }
390
391 settings.save()?;
392 Ok(())
393}
394
395fn draw_wizard(
398 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
399 state: &mut WizardState,
400) -> Result<()> {
401 terminal.draw(|f| {
402 let size = f.area();
403
404 let chunks = Layout::default()
406 .direction(Direction::Vertical)
407 .constraints([
408 Constraint::Length(3), Constraint::Min(10), Constraint::Length(2), ])
412 .split(size);
413
414 let title = Paragraph::new(Line::from(vec![
416 Span::styled(
417 " oxi ",
418 Style::default()
419 .fg(Color::Rgb(255, 165, 0))
420 .add_modifier(Modifier::BOLD),
421 ),
422 Span::styled(
423 "oxi Setup Wizard",
424 Style::default().add_modifier(Modifier::BOLD),
425 ),
426 ]))
427 .block(Block::default().borders(Borders::TOP));
428 f.render_widget(title, chunks[0]);
429
430 match state.step {
432 0 => draw_provider_step(f, state, chunks[1]),
433 1 => draw_model_step(f, state, chunks[1]),
434 2 => draw_theme_step(f, state, chunks[1]),
435 3 => draw_done_step(f, state, chunks[1]),
436 _ => {}
437 }
438
439 let footer_text = match state.step {
441 0 => match &state.input_mode {
442 InputMode::Normal => {
443 " ↑/↓ navigate · Enter: enter/change API key · d: delete · →: next · q: quit"
444 .to_string()
445 }
446 InputMode::EditingApiKey { .. } => " Enter: save · Esc: cancel".to_string(),
447 InputMode::AddingCustom { .. } => {
448 " Tab: next field · Enter: save · Esc: cancel".to_string()
449 }
450 },
451 1 => {
452 if state.model_searching {
453 " Type: search · Esc: close search · Enter: select · ←: previous".to_string()
454 } else {
455 " ↑/↓ navigate · /: search · Enter: select · ←: previous".to_string()
456 }
457 }
458 2 => " ↑/↓ navigate · Enter: select · ←: previous".to_string(),
459 3 => " Enter: quit".to_string(),
460 _ => String::new(),
461 };
462 let footer = Paragraph::new(Line::from(Span::styled(
463 footer_text,
464 Style::default().fg(Color::DarkGray),
465 )));
466 f.render_widget(footer, chunks[2]);
467 })?;
468
469 Ok(())
470}
471
472fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
473 match &state.input_mode {
474 InputMode::Normal => draw_provider_list(f, state, area),
475 InputMode::EditingApiKey {
476 provider_name,
477 field_text,
478 } => draw_api_key_dialog(f, provider_name, field_text, area),
479 InputMode::AddingCustom {
480 fields,
481 active_field,
482 } => draw_custom_provider_dialog(f, fields, *active_field, area),
483 }
484}
485
486fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
487 let step_indicator = build_step_indicator(state.step);
488
489 let items: Vec<ListItem> = state
490 .providers
491 .iter()
492 .map(|p| {
493 let check = if p.has_key { "[x]" } else { "[ ]" };
494 let key_info = if p.has_key {
495 format!("API key: {}", p.key_masked)
496 } else {
497 "No API key".to_string()
498 };
499 let custom_tag = if p.is_custom { " (custom)" } else { "" };
500
501 let line = Line::from(vec![
502 Span::styled(
503 format!(" {} ", check),
504 Style::default().fg(if p.has_key {
505 Color::Green
506 } else {
507 Color::DarkGray
508 }),
509 ),
510 Span::styled(
511 format!("{:<14}", p.name),
512 Style::default().add_modifier(Modifier::BOLD),
513 ),
514 Span::styled(
515 format!("[{}]", key_info),
516 Style::default().fg(Color::DarkGray),
517 ),
518 Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
519 ]);
520 ListItem::new(line)
521 })
522 .collect();
523
524 let add_custom = ListItem::new(Line::from(vec![
526 Span::styled(" + ", Style::default().fg(Color::Cyan)),
527 Span::styled("Add custom provider...", Style::default().fg(Color::Cyan)),
528 ]));
529
530 let mut all_items = items;
531 all_items.push(add_custom);
532
533 let list = List::new(all_items)
534 .block(
535 Block::default()
536 .borders(Borders::NONE)
537 .title(step_indicator),
538 )
539 .highlight_style(
540 Style::default()
541 .bg(Color::DarkGray)
542 .add_modifier(Modifier::BOLD),
543 );
544
545 state
547 .provider_list_state
548 .select(Some(state.provider_selected));
549 f.render_stateful_widget(list, area, &mut state.provider_list_state);
550}
551
552fn draw_api_key_dialog(f: &mut ratatui::Frame, provider_name: &str, field_text: &str, area: Rect) {
553 let dialog_height = 7u16;
555 let dialog_width = std::cmp::min(area.width, 60);
556 let x = (area.width.saturating_sub(dialog_width)) / 2;
557 let y = (area.height.saturating_sub(dialog_height)) / 2;
558
559 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
560
561 let display_text = if field_text.is_empty() {
562 String::new()
563 } else {
564 "*".repeat(field_text.len())
565 };
566
567 let paragraphs = vec![
568 Line::from(""),
569 Line::from(vec![
570 Span::styled(" API Key: ", Style::default().add_modifier(Modifier::BOLD)),
571 Span::styled(
572 format!("[{:<width$}]", display_text, width = 30),
573 Style::default(),
574 ),
575 if field_text.is_empty() {
576 Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
577 } else {
578 Span::raw("")
579 },
580 ]),
581 ];
582
583 let block = Block::default()
584 .borders(Borders::ALL)
585 .title(format!(" {} API Key ", provider_name));
586
587 let para = Paragraph::new(paragraphs).block(block);
588 f.render_widget(para, dialog_area);
589}
590
591fn draw_custom_provider_dialog(
592 f: &mut ratatui::Frame,
593 fields: &[String; 3],
594 active_field: usize,
595 area: Rect,
596) {
597 let dialog_height = 9u16;
598 let dialog_width = std::cmp::min(area.width, 60);
599 let x = (area.width.saturating_sub(dialog_width)) / 2;
600 let y = (area.height.saturating_sub(dialog_height)) / 2;
601
602 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
603
604 let field_labels = ["Name", "Base URL", "API Key"];
605 let lines: Vec<Line> = std::iter::once(Line::from(""))
606 .chain(field_labels.iter().enumerate().map(|(i, label)| {
607 let display = if i == 2 && !fields[i].is_empty() {
608 "*".repeat(fields[i].len())
609 } else {
610 fields[i].clone()
611 };
612 let is_active = i == active_field;
613 let style = if is_active {
614 Style::default().add_modifier(Modifier::BOLD)
615 } else {
616 Style::default()
617 };
618 Line::from(vec![
619 Span::styled(format!(" {:<10}", format!("{}:", label)), style),
620 Span::styled(format!("[{:<width$}]", display, width = 35), style),
621 if is_active && fields[i].is_empty() {
622 Span::styled("<enter>", Style::default().fg(Color::DarkGray))
623 } else {
624 Span::raw("")
625 },
626 ])
627 }))
628 .collect();
629
630 let block = Block::default()
631 .borders(Borders::ALL)
632 .title(" Add Custom Provider ");
633
634 let para = Paragraph::new(lines).block(block);
635 f.render_widget(para, dialog_area);
636}
637
638fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
639 let step_indicator = build_step_indicator(state.step);
640
641 let filtered: Vec<&ModelEntry> = if state.model_filter.is_empty() {
643 state.models.iter().collect()
644 } else {
645 let filter = state.model_filter.to_lowercase();
646 state
647 .models
648 .iter()
649 .filter(|m| {
650 m.id.to_lowercase().contains(&filter) || m.provider.to_lowercase().contains(&filter)
651 })
652 .collect()
653 };
654
655 let mut lines: Vec<Line> = Vec::new();
656
657 if state.model_searching {
658 lines.push(Line::from(vec![
659 Span::styled(" Search: ", Style::default().fg(Color::Yellow)),
660 Span::styled(
661 &state.model_filter,
662 Style::default().add_modifier(Modifier::BOLD),
663 ),
664 Span::raw("_"),
665 ]));
666 }
667
668 lines.push(Line::from(""));
669
670 for m in &filtered {
671 let ctx_str = if m.context_window >= 1_000_000 {
672 format!("{}M ctx", m.context_window / 1_000_000)
673 } else {
674 format!("{}K ctx", m.context_window / 1_000)
675 };
676 lines.push(Line::from(vec![
677 Span::styled(format!(" {:<40}", m.id), Style::default()),
678 Span::styled(
679 format!("({})", m.provider),
680 Style::default().fg(Color::DarkGray),
681 ),
682 Span::styled(
683 format!(", {}", ctx_str),
684 Style::default().fg(Color::DarkGray),
685 ),
686 ]));
687 }
688
689 let block = Block::default()
690 .borders(Borders::NONE)
691 .title(step_indicator);
692
693 let para = Paragraph::new(lines).block(block);
694 f.render_widget(para, area);
695}
696
697fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
698 let step_indicator = build_step_indicator(state.step);
699
700 let items: Vec<ListItem> = state
701 .themes
702 .iter()
703 .map(|t| ListItem::new(Line::from(format!(" {}", t))))
704 .collect();
705
706 let list = List::new(items)
707 .block(
708 Block::default()
709 .borders(Borders::NONE)
710 .title(step_indicator),
711 )
712 .highlight_style(
713 Style::default()
714 .bg(Color::DarkGray)
715 .add_modifier(Modifier::BOLD),
716 );
717
718 state.theme_list_state.select(Some(state.theme_selected));
719 f.render_stateful_widget(list, area, &mut state.theme_list_state);
720}
721
722fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
723 let settings_path_display = state.settings_path.display().to_string();
724 let auth_path_display = state.auth_path.display().to_string();
725
726 let lines = vec![
727 Line::from(""),
728 Line::from(Span::styled(
729 " Settings saved!",
730 Style::default()
731 .fg(Color::Green)
732 .add_modifier(Modifier::BOLD),
733 )),
734 Line::from(""),
735 Line::from(Span::styled(
736 format!(" Settings file: {}", settings_path_display),
737 Style::default().fg(Color::DarkGray),
738 )),
739 Line::from(Span::styled(
740 format!(" Auth file: {}", auth_path_display),
741 Style::default().fg(Color::DarkGray),
742 )),
743 Line::from(""),
744 Line::from(Span::styled(
745 " Run 'oxi' to start.",
746 Style::default().add_modifier(Modifier::BOLD),
747 )),
748 ];
749
750 let block = Block::default().borders(Borders::NONE);
751 let para = Paragraph::new(lines).block(block);
752 f.render_widget(para, area);
753}
754
755fn build_step_indicator(current_step: usize) -> Line<'static> {
756 let steps = [
757 ("1. Provider Setup", 0),
758 ("2. Default Model", 1),
759 ("3. Theme", 2),
760 ("4. Done", 3),
761 ];
762
763 let spans: Vec<Span> = steps
764 .iter()
765 .flat_map(|(label, step)| {
766 let style = if *step == current_step {
767 Style::default()
768 .add_modifier(Modifier::BOLD)
769 .fg(Color::Cyan)
770 } else if *step < current_step {
771 Style::default().fg(Color::Green)
772 } else {
773 Style::default().fg(Color::DarkGray)
774 };
775 vec![Span::styled(format!(" {}", label), style), Span::raw(" ")]
776 })
777 .collect();
778
779 Line::from(spans)
780}
781
782fn handle_event(
785 state: &mut WizardState,
786 event: Event,
787 auth_store: &crate::store::auth_storage::AuthStorage,
788) -> Result<bool> {
789 match state.step {
790 0 => handle_provider_event(state, event, auth_store),
791 1 => handle_model_event(state, event),
792 2 => handle_theme_event(state, event),
793 3 => handle_done_event(event),
794 _ => Ok(false),
795 }
796}
797
798fn handle_provider_event(
799 state: &mut WizardState,
800 event: Event,
801 auth_store: &crate::store::auth_storage::AuthStorage,
802) -> Result<bool> {
803 match &mut state.input_mode {
804 InputMode::Normal => {
805 if let Event::Key(key) = event {
806 match key.code {
807 KeyCode::Up if state.provider_selected > 0 => {
808 state.provider_selected -= 1;
809 }
810 KeyCode::Down => {
811 let max = state.providers.len();
813 if state.provider_selected < max {
814 state.provider_selected += 1;
815 }
816 }
817 KeyCode::Enter => {
818 if state.provider_selected == state.providers.len() {
819 state.input_mode = InputMode::AddingCustom {
821 fields: [String::new(), String::new(), String::new()],
822 active_field: 0,
823 };
824 } else {
825 let name = state.providers[state.provider_selected].name.clone();
827 state.input_mode = InputMode::EditingApiKey {
828 provider_name: name,
829 field_text: String::new(),
830 };
831 }
832 }
833 KeyCode::Char('d') | KeyCode::Delete
834 if state.provider_selected < state.providers.len() =>
835 {
836 let name = state.providers[state.provider_selected].name.clone();
837 auth_store.remove(&name);
838 state.providers[state.provider_selected].has_key = false;
839 state.providers[state.provider_selected].key_masked = String::new();
840 }
841 KeyCode::Right => {
842 state.step = 1;
843 }
844 KeyCode::Char('q') => {
845 return Ok(true); }
847 _ => {}
848 }
849 }
850 }
851 InputMode::EditingApiKey {
852 provider_name,
853 field_text,
854 } => {
855 if let Event::Key(key) = event {
856 match key.code {
857 KeyCode::Esc => {
858 state.input_mode = InputMode::Normal;
859 }
860 KeyCode::Enter => {
861 if !field_text.is_empty() {
862 auth_store.set_api_key(provider_name, field_text.clone());
863
864 if let Some(entry) = state
866 .providers
867 .iter_mut()
868 .find(|p| p.name == *provider_name)
869 {
870 entry.has_key = true;
871 entry.key_masked = mask_key(field_text);
872 }
873
874 fetch_and_cache_models(provider_name, &state.providers);
876
877 state.models = load_models(state.catalog.as_ref());
879 }
880 state.input_mode = InputMode::Normal;
881 }
882 KeyCode::Backspace => {
883 field_text.pop();
884 }
885 KeyCode::Char(c) => {
886 field_text.push(c);
887 }
888 _ => {}
889 }
890 }
891 }
892 InputMode::AddingCustom {
893 fields,
894 active_field,
895 } => {
896 if let Event::Key(key) = event {
897 match key.code {
898 KeyCode::Esc => {
899 state.input_mode = InputMode::Normal;
900 }
901 KeyCode::Tab => {
902 *active_field = (*active_field + 1) % 3;
903 }
904 KeyCode::BackTab => {
905 *active_field = (*active_field + 2) % 3;
906 }
907 KeyCode::Enter => {
908 let name = fields[0].trim().to_string();
909 let base_url = fields[1].trim().to_string();
910 let api_key = fields[2].trim().to_string();
911
912 if !name.is_empty() && !base_url.is_empty() {
913 if !api_key.is_empty() {
915 auth_store.set_api_key(&name, api_key.clone());
916 }
917
918 let (has_key, key_masked) = if !api_key.is_empty() {
920 (true, mask_key(&api_key))
921 } else {
922 (false, String::new())
923 };
924
925 state.providers.push(ProviderEntry {
926 name: name.clone(),
927 has_key,
928 key_masked,
929 is_custom: true,
930 base_url: Some(base_url),
931 });
932
933 if !api_key.is_empty() {
935 fetch_and_cache_models(&name, &state.providers);
936 state.models = load_models(state.catalog.as_ref());
937 }
938
939 state.input_mode = InputMode::Normal;
941 }
942 }
943 KeyCode::Backspace => {
944 fields[*active_field].pop();
945 }
946 KeyCode::Char(c) => {
947 fields[*active_field].push(c);
948 }
949 _ => {}
950 }
951 }
952 }
953 }
954 Ok(false)
955}
956
957fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
958 if let Event::Key(key) = event {
959 if state.model_searching {
960 match key.code {
961 KeyCode::Esc => {
962 state.model_searching = false;
963 state.model_filter.clear();
964 }
965 KeyCode::Enter => {
966 state.model_searching = false;
968 select_filtered_model(state);
969 }
970 KeyCode::Backspace => {
971 state.model_filter.pop();
972 }
973 KeyCode::Char(c) => {
974 state.model_filter.push(c);
975 }
976 _ => {}
977 }
978 } else {
979 match key.code {
980 KeyCode::Up if state.model_selected > 0 => {
981 state.model_selected -= 1;
982 }
983 KeyCode::Down if state.model_selected + 1 < state.models.len() => {
984 state.model_selected += 1;
985 }
986 KeyCode::Char('/') => {
987 state.model_searching = true;
988 state.model_filter.clear();
989 }
990 KeyCode::Enter => {
991 state.step = 2;
993 }
994 KeyCode::Left => {
995 state.step = 0;
996 }
997 _ => {}
998 }
999 }
1000 }
1001 Ok(false)
1002}
1003
1004fn select_filtered_model(state: &mut WizardState) {
1005 if state.model_filter.is_empty() {
1006 state.step = 2;
1007 return;
1008 }
1009 let filter = state.model_filter.to_lowercase();
1010 if let Some(idx) = state.models.iter().position(|m| {
1011 m.id.to_lowercase().contains(&filter) || m.provider.to_lowercase().contains(&filter)
1012 }) {
1013 state.model_selected = idx;
1014 }
1015 state.step = 2;
1016}
1017
1018fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
1019 if let Event::Key(key) = event {
1020 match key.code {
1021 KeyCode::Up if state.theme_selected > 0 => {
1022 state.theme_selected -= 1;
1023 }
1024 KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
1025 state.theme_selected += 1;
1026 }
1027 KeyCode::Enter => {
1028 finish_setup(state)?;
1030 state.step = 3;
1031 }
1032 KeyCode::Left => {
1033 state.step = 1;
1034 }
1035 _ => {}
1036 }
1037 }
1038 Ok(false)
1039}
1040
1041fn handle_done_event(event: Event) -> Result<bool> {
1042 if let Event::Key(key) = event {
1043 match key.code {
1044 KeyCode::Enter | KeyCode::Char('q') => {
1045 return Ok(true); }
1047 _ => {}
1048 }
1049 }
1050 Ok(false)
1051}
1052
1053fn finish_setup(state: &mut WizardState) -> Result<()> {
1056 let model_id = state
1058 .models
1059 .get(state.model_selected)
1060 .map(|m| format!("{}/{}", m.provider, m.id))
1061 .unwrap_or_default();
1062
1063 let theme_name = state
1065 .themes
1066 .get(state.theme_selected)
1067 .cloned()
1068 .unwrap_or_else(|| "oxi_dark".to_string());
1069
1070 let custom_base_urls: Vec<(String, String)> = state
1072 .providers
1073 .iter()
1074 .filter_map(|p| {
1075 if p.is_custom {
1076 p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1077 } else {
1078 None
1079 }
1080 })
1081 .collect();
1082
1083 save_settings(&model_id, &theme_name, &custom_base_urls)?;
1084
1085 Ok(())
1086}
1087
1088pub async fn run() -> Result<()> {
1092 enable_raw_mode()?;
1094 let mut stdout = io::stdout();
1095 execute!(stdout, EnterAlternateScreen)?;
1096 let backend = CrosstermBackend::new(stdout);
1097 let mut terminal = Terminal::new(backend)?;
1098
1099 let panic_hook = std::panic::take_hook();
1101 std::panic::set_hook(Box::new(move |info| {
1102 let _ = disable_raw_mode();
1103 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1104 panic_hook(info);
1105 }));
1106
1107 let catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>> = {
1109 let paths = crate::services::OxiPaths::default_paths().ok();
1110 if let Some(paths) = paths {
1111 let config = oxi_sdk::CatalogConfig {
1112 cache_path: paths.home.join("cache").join("models-dev.json"),
1113 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
1114 override_path: paths.home.join("catalog").join("overrides.toml"),
1115 fetch_enabled: false,
1117 ..Default::default()
1118 };
1119 oxi_sdk::FileModelCatalog::init(config)
1120 .await
1121 .ok()
1122 .map(|c| c as _)
1123 } else {
1124 None
1125 }
1126 };
1127
1128 let auth_store = crate::store::auth_storage::shared_auth_storage();
1130 let providers = load_providers(&auth_store, catalog.as_ref());
1131 let models = load_models(catalog.as_ref());
1132 let themes = load_themes();
1133
1134 let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1135 dirs::home_dir()
1136 .unwrap_or_default()
1137 .join(".oxi")
1138 .join("auth.json")
1139 });
1140 let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1141 dirs::home_dir()
1142 .unwrap_or_default()
1143 .join(".oxi")
1144 .join("settings.json")
1145 });
1146
1147 let current_model = crate::store::settings::Settings::load()
1149 .ok()
1150 .and_then(|s| s.last_used_model.clone())
1151 .unwrap_or_default();
1152
1153 let model_selected = models
1154 .iter()
1155 .position(|m| {
1156 let full_id = format!("{}/{}", m.provider, m.id);
1157 full_id == current_model || m.id == current_model
1158 })
1159 .unwrap_or(0);
1160
1161 let current_theme = crate::store::settings::Settings::load()
1163 .ok()
1164 .map(|s| s.theme.clone())
1165 .unwrap_or_else(|| "oxi_dark".to_string());
1166
1167 let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1168
1169 let mut state = WizardState {
1170 step: 0,
1171 providers,
1172 provider_selected: 0,
1173 provider_list_state: ListState::default(),
1174 input_mode: InputMode::Normal,
1175 models,
1176 model_selected,
1177 model_filter: String::new(),
1178 model_searching: false,
1179 themes,
1180 theme_selected,
1181 theme_list_state: ListState::default(),
1182 auth_path,
1183 settings_path,
1184 catalog,
1185 };
1186
1187 loop {
1189 draw_wizard(&mut terminal, &mut state)?;
1190
1191 if event::poll(std::time::Duration::from_millis(100))?
1192 && let Event::Key(key) = event::read()?
1193 {
1194 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1196 break;
1197 }
1198
1199 let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1200 if should_quit {
1201 break;
1202 }
1203 }
1204 }
1205
1206 disable_raw_mode()?;
1208 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1209
1210 Ok(())
1211}