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