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: &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(" 🦊 ", Style::default().fg(Color::Rgb(255, 165, 0))),
379 Span::styled(
380 "oxi Setup Wizard",
381 Style::default().add_modifier(Modifier::BOLD),
382 ),
383 ]))
384 .block(Block::default().borders(Borders::TOP));
385 f.render_widget(title, chunks[0]);
386
387 match state.step {
389 0 => draw_provider_step(f, state, chunks[1]),
390 1 => draw_model_step(f, state, chunks[1]),
391 2 => draw_theme_step(f, state, chunks[1]),
392 3 => draw_done_step(f, state, chunks[1]),
393 _ => {}
394 }
395
396 let footer_text = match state.step {
398 0 => match &state.input_mode {
399 InputMode::Normal => {
400 " ↑/↓ navigate · Enter: enter/change API key · d: delete · →: next · q: quit"
401 .to_string()
402 }
403 InputMode::EditingApiKey { .. } => " Enter: save · Esc: cancel".to_string(),
404 InputMode::AddingCustom { .. } => {
405 " Tab: next field · Enter: save · Esc: cancel".to_string()
406 }
407 },
408 1 => {
409 if state.model_searching {
410 " Type: search · Esc: close search · Enter: select · ←: previous".to_string()
411 } else {
412 " ↑/↓ navigate · /: search · Enter: select · ←: previous".to_string()
413 }
414 }
415 2 => " ↑/↓ navigate · Enter: select · ←: previous".to_string(),
416 3 => " Enter: quit".to_string(),
417 _ => String::new(),
418 };
419 let footer = Paragraph::new(Line::from(Span::styled(
420 footer_text,
421 Style::default().fg(Color::DarkGray),
422 )));
423 f.render_widget(footer, chunks[2]);
424 })?;
425
426 Ok(())
427}
428
429fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
430 match &state.input_mode {
431 InputMode::Normal => draw_provider_list(f, state, area),
432 InputMode::EditingApiKey {
433 provider_name,
434 field_text,
435 } => draw_api_key_dialog(f, provider_name, field_text, area),
436 InputMode::AddingCustom {
437 fields,
438 active_field,
439 } => draw_custom_provider_dialog(f, fields, *active_field, area),
440 }
441}
442
443fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
444 let step_indicator = build_step_indicator(state.step);
445
446 let items: Vec<ListItem> = state
447 .providers
448 .iter()
449 .map(|p| {
450 let check = if p.has_key { "[x]" } else { "[ ]" };
451 let key_info = if p.has_key {
452 format!("API key: {}", p.key_masked)
453 } else {
454 "No API key".to_string()
455 };
456 let custom_tag = if p.is_custom { " (custom)" } else { "" };
457
458 let line = Line::from(vec![
459 Span::styled(
460 format!(" {} ", check),
461 Style::default().fg(if p.has_key {
462 Color::Green
463 } else {
464 Color::DarkGray
465 }),
466 ),
467 Span::styled(
468 format!("{:<14}", p.name),
469 Style::default().add_modifier(Modifier::BOLD),
470 ),
471 Span::styled(
472 format!("[{}]", key_info),
473 Style::default().fg(Color::DarkGray),
474 ),
475 Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
476 ]);
477 ListItem::new(line)
478 })
479 .collect();
480
481 let add_custom = ListItem::new(Line::from(vec![
483 Span::styled(" + ", Style::default().fg(Color::Cyan)),
484 Span::styled("Add custom provider...", Style::default().fg(Color::Cyan)),
485 ]));
486
487 let mut all_items = items;
488 all_items.push(add_custom);
489
490 let list = List::new(all_items)
491 .block(
492 Block::default()
493 .borders(Borders::NONE)
494 .title(step_indicator),
495 )
496 .highlight_style(
497 Style::default()
498 .bg(Color::DarkGray)
499 .add_modifier(Modifier::BOLD),
500 );
501
502 state
504 .provider_list_state
505 .select(Some(state.provider_selected));
506 f.render_stateful_widget(list, area, &mut state.provider_list_state);
507}
508
509fn draw_api_key_dialog(f: &mut ratatui::Frame, provider_name: &str, field_text: &str, area: Rect) {
510 let dialog_height = 7u16;
512 let dialog_width = std::cmp::min(area.width, 60);
513 let x = (area.width.saturating_sub(dialog_width)) / 2;
514 let y = (area.height.saturating_sub(dialog_height)) / 2;
515
516 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
517
518 let display_text = if field_text.is_empty() {
519 String::new()
520 } else {
521 "*".repeat(field_text.len())
522 };
523
524 let paragraphs = vec![
525 Line::from(""),
526 Line::from(vec![
527 Span::styled(" API Key: ", Style::default().add_modifier(Modifier::BOLD)),
528 Span::styled(
529 format!("[{:<width$}]", display_text, width = 30),
530 Style::default(),
531 ),
532 if field_text.is_empty() {
533 Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
534 } else {
535 Span::raw("")
536 },
537 ]),
538 ];
539
540 let block = Block::default()
541 .borders(Borders::ALL)
542 .title(format!(" {} API Key ", provider_name));
543
544 let para = Paragraph::new(paragraphs).block(block);
545 f.render_widget(para, dialog_area);
546}
547
548fn draw_custom_provider_dialog(
549 f: &mut ratatui::Frame,
550 fields: &[String; 3],
551 active_field: usize,
552 area: Rect,
553) {
554 let dialog_height = 9u16;
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 field_labels = ["Name", "Base URL", "API Key"];
562 let lines: Vec<Line> = std::iter::once(Line::from(""))
563 .chain(field_labels.iter().enumerate().map(|(i, label)| {
564 let display = if i == 2 && !fields[i].is_empty() {
565 "*".repeat(fields[i].len())
566 } else {
567 fields[i].clone()
568 };
569 let is_active = i == active_field;
570 let style = if is_active {
571 Style::default().add_modifier(Modifier::BOLD)
572 } else {
573 Style::default()
574 };
575 Line::from(vec![
576 Span::styled(format!(" {:<10}", format!("{}:", label)), style),
577 Span::styled(format!("[{:<width$}]", display, width = 35), style),
578 if is_active && fields[i].is_empty() {
579 Span::styled("<enter>", Style::default().fg(Color::DarkGray))
580 } else {
581 Span::raw("")
582 },
583 ])
584 }))
585 .collect();
586
587 let block = Block::default()
588 .borders(Borders::ALL)
589 .title(" Add Custom Provider ");
590
591 let para = Paragraph::new(lines).block(block);
592 f.render_widget(para, dialog_area);
593}
594
595fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
596 let step_indicator = build_step_indicator(state.step);
597
598 let filtered: Vec<&ModelEntry> = if state.model_filter.is_empty() {
600 state.models.iter().collect()
601 } else {
602 let filter = state.model_filter.to_lowercase();
603 state
604 .models
605 .iter()
606 .filter(|m| {
607 m.id.to_lowercase().contains(&filter) || m.provider.to_lowercase().contains(&filter)
608 })
609 .collect()
610 };
611
612 let mut lines: Vec<Line> = Vec::new();
613
614 if state.model_searching {
615 lines.push(Line::from(vec![
616 Span::styled(" Search: ", Style::default().fg(Color::Yellow)),
617 Span::styled(
618 &state.model_filter,
619 Style::default().add_modifier(Modifier::BOLD),
620 ),
621 Span::raw("_"),
622 ]));
623 }
624
625 lines.push(Line::from(""));
626
627 for m in &filtered {
628 let ctx_str = if m.context_window >= 1_000_000 {
629 format!("{}M ctx", m.context_window / 1_000_000)
630 } else {
631 format!("{}K ctx", m.context_window / 1_000)
632 };
633 lines.push(Line::from(vec![
634 Span::styled(format!(" {:<40}", m.id), Style::default()),
635 Span::styled(
636 format!("({})", m.provider),
637 Style::default().fg(Color::DarkGray),
638 ),
639 Span::styled(
640 format!(", {}", ctx_str),
641 Style::default().fg(Color::DarkGray),
642 ),
643 ]));
644 }
645
646 let block = Block::default()
647 .borders(Borders::NONE)
648 .title(step_indicator);
649
650 let para = Paragraph::new(lines).block(block);
651 f.render_widget(para, area);
652}
653
654fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
655 let step_indicator = build_step_indicator(state.step);
656
657 let items: Vec<ListItem> = state
658 .themes
659 .iter()
660 .map(|t| ListItem::new(Line::from(format!(" {}", t))))
661 .collect();
662
663 let list = List::new(items)
664 .block(
665 Block::default()
666 .borders(Borders::NONE)
667 .title(step_indicator),
668 )
669 .highlight_style(
670 Style::default()
671 .bg(Color::DarkGray)
672 .add_modifier(Modifier::BOLD),
673 );
674
675 state.theme_list_state.select(Some(state.theme_selected));
676 f.render_stateful_widget(list, area, &mut state.theme_list_state);
677}
678
679fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
680 let settings_path_display = state.settings_path.display().to_string();
681 let auth_path_display = state.auth_path.display().to_string();
682
683 let lines = vec![
684 Line::from(""),
685 Line::from(Span::styled(
686 " Settings saved!",
687 Style::default()
688 .fg(Color::Green)
689 .add_modifier(Modifier::BOLD),
690 )),
691 Line::from(""),
692 Line::from(Span::styled(
693 format!(" Settings file: {}", settings_path_display),
694 Style::default().fg(Color::DarkGray),
695 )),
696 Line::from(Span::styled(
697 format!(" Auth file: {}", auth_path_display),
698 Style::default().fg(Color::DarkGray),
699 )),
700 Line::from(""),
701 Line::from(Span::styled(
702 " Run 'oxi' to start.",
703 Style::default().add_modifier(Modifier::BOLD),
704 )),
705 ];
706
707 let block = Block::default().borders(Borders::NONE);
708 let para = Paragraph::new(lines).block(block);
709 f.render_widget(para, area);
710}
711
712fn build_step_indicator(current_step: usize) -> Line<'static> {
713 let steps = [
714 ("1. Provider Setup", 0),
715 ("2. Default Model", 1),
716 ("3. Theme", 2),
717 ("4. Done", 3),
718 ];
719
720 let spans: Vec<Span> = steps
721 .iter()
722 .flat_map(|(label, step)| {
723 let style = if *step == current_step {
724 Style::default()
725 .add_modifier(Modifier::BOLD)
726 .fg(Color::Cyan)
727 } else if *step < current_step {
728 Style::default().fg(Color::Green)
729 } else {
730 Style::default().fg(Color::DarkGray)
731 };
732 vec![Span::styled(format!(" {}", label), style), Span::raw(" ")]
733 })
734 .collect();
735
736 Line::from(spans)
737}
738
739fn handle_event(
742 state: &mut WizardState,
743 event: Event,
744 auth_store: &crate::store::auth_storage::AuthStorage,
745) -> Result<bool> {
746 match state.step {
747 0 => handle_provider_event(state, event, auth_store),
748 1 => handle_model_event(state, event),
749 2 => handle_theme_event(state, event),
750 3 => handle_done_event(event),
751 _ => Ok(false),
752 }
753}
754
755fn handle_provider_event(
756 state: &mut WizardState,
757 event: Event,
758 auth_store: &crate::store::auth_storage::AuthStorage,
759) -> Result<bool> {
760 match &mut state.input_mode {
761 InputMode::Normal => {
762 if let Event::Key(key) = event {
763 match key.code {
764 KeyCode::Up if state.provider_selected > 0 => {
765 state.provider_selected -= 1;
766 }
767 KeyCode::Down => {
768 let max = state.providers.len();
770 if state.provider_selected < max {
771 state.provider_selected += 1;
772 }
773 }
774 KeyCode::Enter => {
775 if state.provider_selected == state.providers.len() {
776 state.input_mode = InputMode::AddingCustom {
778 fields: [String::new(), String::new(), String::new()],
779 active_field: 0,
780 };
781 } else {
782 let name = state.providers[state.provider_selected].name.clone();
784 state.input_mode = InputMode::EditingApiKey {
785 provider_name: name,
786 field_text: String::new(),
787 };
788 }
789 }
790 KeyCode::Char('d') | KeyCode::Delete
791 if state.provider_selected < state.providers.len() =>
792 {
793 let name = state.providers[state.provider_selected].name.clone();
794 auth_store.remove(&name);
795 state.providers[state.provider_selected].has_key = false;
796 state.providers[state.provider_selected].key_masked = String::new();
797 }
798 KeyCode::Right => {
799 state.step = 1;
800 }
801 KeyCode::Char('q') => {
802 return Ok(true); }
804 _ => {}
805 }
806 }
807 }
808 InputMode::EditingApiKey {
809 provider_name,
810 field_text,
811 } => {
812 if let Event::Key(key) = event {
813 match key.code {
814 KeyCode::Esc => {
815 state.input_mode = InputMode::Normal;
816 }
817 KeyCode::Enter => {
818 if !field_text.is_empty() {
819 auth_store.set_api_key(provider_name, field_text.clone());
820
821 if let Some(entry) = state
823 .providers
824 .iter_mut()
825 .find(|p| p.name == *provider_name)
826 {
827 entry.has_key = true;
828 entry.key_masked = mask_key(field_text);
829 }
830
831 fetch_and_cache_models(provider_name, &state.providers);
833
834 state.models = load_models();
836 }
837 state.input_mode = InputMode::Normal;
838 }
839 KeyCode::Backspace => {
840 field_text.pop();
841 }
842 KeyCode::Char(c) => {
843 field_text.push(c);
844 }
845 _ => {}
846 }
847 }
848 }
849 InputMode::AddingCustom {
850 fields,
851 active_field,
852 } => {
853 if let Event::Key(key) = event {
854 match key.code {
855 KeyCode::Esc => {
856 state.input_mode = InputMode::Normal;
857 }
858 KeyCode::Tab => {
859 *active_field = (*active_field + 1) % 3;
860 }
861 KeyCode::BackTab => {
862 *active_field = (*active_field + 2) % 3;
863 }
864 KeyCode::Enter => {
865 let name = fields[0].trim().to_string();
866 let base_url = fields[1].trim().to_string();
867 let api_key = fields[2].trim().to_string();
868
869 if !name.is_empty() && !base_url.is_empty() {
870 if !api_key.is_empty() {
872 auth_store.set_api_key(&name, api_key.clone());
873 }
874
875 let (has_key, key_masked) = if !api_key.is_empty() {
877 (true, mask_key(&api_key))
878 } else {
879 (false, String::new())
880 };
881
882 state.providers.push(ProviderEntry {
883 name: name.clone(),
884 has_key,
885 key_masked,
886 is_custom: true,
887 base_url: Some(base_url),
888 });
889
890 if !api_key.is_empty() {
892 fetch_and_cache_models(&name, &state.providers);
893 state.models = load_models();
894 }
895
896 state.input_mode = InputMode::Normal;
898 }
899 }
900 KeyCode::Backspace => {
901 fields[*active_field].pop();
902 }
903 KeyCode::Char(c) => {
904 fields[*active_field].push(c);
905 }
906 _ => {}
907 }
908 }
909 }
910 }
911 Ok(false)
912}
913
914fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
915 if let Event::Key(key) = event {
916 if state.model_searching {
917 match key.code {
918 KeyCode::Esc => {
919 state.model_searching = false;
920 state.model_filter.clear();
921 }
922 KeyCode::Enter => {
923 state.model_searching = false;
925 select_filtered_model(state);
926 }
927 KeyCode::Backspace => {
928 state.model_filter.pop();
929 }
930 KeyCode::Char(c) => {
931 state.model_filter.push(c);
932 }
933 _ => {}
934 }
935 } else {
936 match key.code {
937 KeyCode::Up if state.model_selected > 0 => {
938 state.model_selected -= 1;
939 }
940 KeyCode::Down if state.model_selected + 1 < state.models.len() => {
941 state.model_selected += 1;
942 }
943 KeyCode::Char('/') => {
944 state.model_searching = true;
945 state.model_filter.clear();
946 }
947 KeyCode::Enter => {
948 state.step = 2;
950 }
951 KeyCode::Left => {
952 state.step = 0;
953 }
954 _ => {}
955 }
956 }
957 }
958 Ok(false)
959}
960
961fn select_filtered_model(state: &mut WizardState) {
962 if state.model_filter.is_empty() {
963 state.step = 2;
964 return;
965 }
966 let filter = state.model_filter.to_lowercase();
967 if let Some(idx) = state.models.iter().position(|m| {
968 m.id.to_lowercase().contains(&filter) || m.provider.to_lowercase().contains(&filter)
969 }) {
970 state.model_selected = idx;
971 }
972 state.step = 2;
973}
974
975fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
976 if let Event::Key(key) = event {
977 match key.code {
978 KeyCode::Up if state.theme_selected > 0 => {
979 state.theme_selected -= 1;
980 }
981 KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
982 state.theme_selected += 1;
983 }
984 KeyCode::Enter => {
985 finish_setup(state)?;
987 state.step = 3;
988 }
989 KeyCode::Left => {
990 state.step = 1;
991 }
992 _ => {}
993 }
994 }
995 Ok(false)
996}
997
998fn handle_done_event(event: Event) -> Result<bool> {
999 if let Event::Key(key) = event {
1000 match key.code {
1001 KeyCode::Enter | KeyCode::Char('q') => {
1002 return Ok(true); }
1004 _ => {}
1005 }
1006 }
1007 Ok(false)
1008}
1009
1010fn finish_setup(state: &mut WizardState) -> Result<()> {
1013 let model_id = state
1015 .models
1016 .get(state.model_selected)
1017 .map(|m| format!("{}/{}", m.provider, m.id))
1018 .unwrap_or_default();
1019
1020 let theme_name = state
1022 .themes
1023 .get(state.theme_selected)
1024 .cloned()
1025 .unwrap_or_else(|| "oxi_dark".to_string());
1026
1027 let custom_base_urls: Vec<(String, String)> = state
1029 .providers
1030 .iter()
1031 .filter_map(|p| {
1032 if p.is_custom {
1033 p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1034 } else {
1035 None
1036 }
1037 })
1038 .collect();
1039
1040 save_settings(&model_id, &theme_name, &custom_base_urls)?;
1041
1042 Ok(())
1043}
1044
1045pub fn run() -> Result<()> {
1049 enable_raw_mode()?;
1051 let mut stdout = io::stdout();
1052 execute!(stdout, EnterAlternateScreen)?;
1053 let backend = CrosstermBackend::new(stdout);
1054 let mut terminal = Terminal::new(backend)?;
1055
1056 let panic_hook = std::panic::take_hook();
1058 std::panic::set_hook(Box::new(move |info| {
1059 let _ = disable_raw_mode();
1060 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1061 panic_hook(info);
1062 }));
1063
1064 let auth_store = crate::store::auth_storage::shared_auth_storage();
1066 let providers = load_providers(&auth_store);
1067 let models = load_models();
1068 let themes = load_themes();
1069
1070 let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1071 dirs::home_dir()
1072 .unwrap_or_default()
1073 .join(".oxi")
1074 .join("auth.json")
1075 });
1076 let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1077 dirs::home_dir()
1078 .unwrap_or_default()
1079 .join(".oxi")
1080 .join("settings.json")
1081 });
1082
1083 let current_model = crate::store::settings::Settings::load()
1085 .ok()
1086 .and_then(|s| s.last_used_model.clone())
1087 .unwrap_or_default();
1088
1089 let model_selected = models
1090 .iter()
1091 .position(|m| {
1092 let full_id = format!("{}/{}", m.provider, m.id);
1093 full_id == current_model || m.id == current_model
1094 })
1095 .unwrap_or(0);
1096
1097 let current_theme = crate::store::settings::Settings::load()
1099 .ok()
1100 .map(|s| s.theme.clone())
1101 .unwrap_or_else(|| "oxi_dark".to_string());
1102
1103 let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1104
1105 let mut state = WizardState {
1106 step: 0,
1107 providers,
1108 provider_selected: 0,
1109 provider_list_state: ListState::default(),
1110 input_mode: InputMode::Normal,
1111 models,
1112 model_selected,
1113 model_filter: String::new(),
1114 model_searching: false,
1115 themes,
1116 theme_selected,
1117 theme_list_state: ListState::default(),
1118 auth_path,
1119 settings_path,
1120 };
1121
1122 loop {
1124 draw_wizard(&mut terminal, &mut state)?;
1125
1126 if event::poll(std::time::Duration::from_millis(100))? {
1127 if let Event::Key(key) = event::read()? {
1128 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1130 break;
1131 }
1132
1133 let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1134 if should_quit {
1135 break;
1136 }
1137 }
1138 }
1139 }
1140
1141 disable_raw_mode()?;
1143 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1144
1145 Ok(())
1146}