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