Skip to main content

oxi/
setup_wizard.rs

1//! Interactive setup wizard for oxi (`oxi setup`).
2//!
3//! Provides a TUI-based configuration experience for:
4//! 1. Provider API key management
5//! 2. Default model selection
6//! 3. Theme selection
7//! 4. Summary and persistence
8//!
9//! Uses crossterm + ratatui for terminal control with proper raw-mode
10//! restoration on panic or early exit.
11
12use 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// ── Provider entry (runtime state) ──────────────────────────────────────────
31
32// ── Provider entry (runtime state) ──────────────────────────────────────────
33
34/// Runtime state for a single provider entry in the wizard list.
35#[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// ── Input mode ──────────────────────────────────────────────────────────────
45
46/// What the wizard is currently editing.
47#[derive(Clone)]
48enum InputMode {
49    /// Normal browsing / selection
50    Normal,
51    /// Editing an API key for a provider
52    EditingApiKey {
53        provider_name: String,
54        field_text: String,
55    },
56    /// Adding a new custom provider (multi-field form)
57    AddingCustom {
58        fields: [String; 3], // [name, base_url, api_key]
59        active_field: usize,
60    },
61}
62
63// ── Wizard state ────────────────────────────────────────────────────────────
64
65/// Top-level wizard state.
66struct WizardState {
67    /// Current step: 0=providers, 1=model, 2=theme, 3=done
68    step: usize,
69    /// Provider entries (presets + custom)
70    providers: Vec<ProviderEntry>,
71    /// Currently selected index in the provider list
72    provider_selected: usize,
73    /// List state for ratatui
74    provider_list_state: ListState,
75    /// Input mode
76    input_mode: InputMode,
77    /// Model entries for step 2
78    models: Vec<ModelEntry>,
79    /// Currently selected model index
80    model_selected: usize,
81    /// Model search filter
82    model_filter: String,
83    /// Whether we're typing in the model search
84    model_searching: bool,
85    /// Theme names for step 3
86    themes: Vec<String>,
87    /// Currently selected theme index
88    theme_selected: usize,
89    /// Theme list state
90    theme_list_state: ListState,
91    /// Auth storage path
92    auth_path: PathBuf,
93    /// Settings path
94    settings_path: PathBuf,
95}
96
97/// A model entry for display.
98#[derive(Clone)]
99struct ModelEntry {
100    id: String,
101    provider: String,
102    context_window: u32,
103}
104
105// ── Masking helper ──────────────────────────────────────────────────────────
106
107/// Mask an API key for display: show first 6 and last 4 chars, rest asterisks.
108fn 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
116// ── Load provider state ─────────────────────────────────────────────────────
117
118/// Build the initial provider list from builtins + stored keys + custom providers.
119fn 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    // Add custom providers from settings that aren't already in builtins
145    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
170// ── Load model list ────────────────────────────────────────────────────────
171
172/// Build the model list from the static model database + dynamic cache.
173fn load_models() -> Vec<ModelEntry> {
174    let mut models = Vec::new();
175    let mut seen = std::collections::HashSet::new();
176
177    // 1. Dynamic models from settings cache (fetched from /models endpoints)
178    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                    // Try to get context_window from model_db, default 128_000
184                    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    // 2. Static models from model_db
198    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
212// ── Fetch and cache dynamic models ─────────────────────────────────────────
213
214/// Try to fetch models from a provider's `/models` endpoint and cache them in settings.
215///
216/// Only works for OpenAI-compatible providers that have a `base_url`.
217/// Non-OpenAI-compatible providers are silently skipped.
218/// On failure, logs a warning and keeps the existing cache (if any).
219fn fetch_and_cache_models(provider_name: &str, providers: &[ProviderEntry]) {
220    // Resolve base_url for this provider
221    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    // Get the API key from auth storage
241    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    // Only fetch for OpenAI-compatible providers (api = openai-completions or openai-responses)
254    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            // Update settings cache
285            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
305// ── Load theme list ─────────────────────────────────────────────────────────
306
307fn load_themes() -> Vec<String> {
308    // Built-in theme names from oxi-cli theme system
309    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
319// ── Save auth keys ──────────────────────────────────────────────────────────
320
321// ── Save settings ───────────────────────────────────────────────────────────
322
323/// Save the selected model and theme to settings.
324fn 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    // Split "provider/model" into separate fields
332    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    // Ensure custom providers with base_url are registered
341    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
359// ── Draw functions ──────────────────────────────────────────────────────────
360
361fn 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        // Create outer layout
369        let chunks = Layout::default()
370            .direction(Direction::Vertical)
371            .constraints([
372                Constraint::Length(3), // Title bar
373                Constraint::Min(10),   // Content
374                Constraint::Length(2), // Footer
375            ])
376            .split(size);
377
378        // Title bar
379        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        // Content depends on step
390        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        // Footer
399        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    // Add custom provider entry
484    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    // Update list state selection
505    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    // Center the dialog
513    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    // Filter models
601    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
741// ── Event handling ──────────────────────────────────────────────────────────
742
743fn 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                        // +1 for "add custom" row
771                        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                            // Add custom provider
779                            state.input_mode = InputMode::AddingCustom {
780                                fields: [String::new(), String::new(), String::new()],
781                                active_field: 0,
782                            };
783                        } else {
784                            // Edit API key
785                            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); // quit
805                    }
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                            // Update the provider entry
824                            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                            // Try to fetch models dynamically from the provider's /models endpoint
834                            fetch_and_cache_models(provider_name, &state.providers);
835
836                            // Refresh the model list to include newly fetched models
837                            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                            // Save API key
873                            if !api_key.is_empty() {
874                                auth_store.set_api_key(&name, api_key.clone());
875                            }
876
877                            // Add to provider list
878                            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                            // Try to fetch models from this custom provider
893                            if !api_key.is_empty() {
894                                fetch_and_cache_models(&name, &state.providers);
895                                state.models = load_models();
896                            }
897
898                            // Move back to normal
899                            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                    // Select the first filtered model
926                    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                    // Move to theme step
951                    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                // Save everything and go to done
988                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); // quit
1005            }
1006            _ => {}
1007        }
1008    }
1009    Ok(false)
1010}
1011
1012// ── Finish: persist all selections ──────────────────────────────────────────
1013
1014fn finish_setup(state: &mut WizardState) -> Result<()> {
1015    // Get selected model
1016    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    // Get selected theme
1023    let theme_name = state
1024        .themes
1025        .get(state.theme_selected)
1026        .cloned()
1027        .unwrap_or_else(|| "oxi_dark".to_string());
1028
1029    // Collect custom provider base URLs
1030    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
1047// ── Main entry point ────────────────────────────────────────────────────────
1048
1049/// Run the interactive setup wizard.
1050pub fn run() -> Result<()> {
1051    // Setup terminal
1052    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    // Ensure terminal is restored on panic
1059    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    // Load data
1067    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    // Find the index of the current default model
1086    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    // Find the index of the current theme
1100    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    // Main loop
1125    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                // Ctrl+C always quits
1131                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    // Restore terminal
1144    disable_raw_mode()?;
1145    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1146
1147    Ok(())
1148}