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::{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// ── 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: &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    // Add custom providers from settings that aren't already in builtins
145    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
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) = 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                    // Try to get context_window from model_db, default 128_000
184                    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    // 2. Static models from model_db
198    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
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(|| 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    // Get the API key from auth storage
239    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    // Only fetch for OpenAI-compatible providers (api = openai-completions or openai-responses)
252    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            // Update settings cache
283            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
303// ── Load theme list ─────────────────────────────────────────────────────────
304
305fn load_themes() -> Vec<String> {
306    // Built-in theme names from oxi-cli theme system
307    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
317// ── Save auth keys ──────────────────────────────────────────────────────────
318
319// ── Save settings ───────────────────────────────────────────────────────────
320
321/// Save the selected model and theme to settings.
322fn 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    // Split "provider/model" and store as last_used
330    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    // Ensure custom providers with base_url are registered
339    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
357// ── Draw functions ──────────────────────────────────────────────────────────
358
359fn 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        // Create outer layout
367        let chunks = Layout::default()
368            .direction(Direction::Vertical)
369            .constraints([
370                Constraint::Length(3), // Title bar
371                Constraint::Min(10),   // Content
372                Constraint::Length(2), // Footer
373            ])
374            .split(size);
375
376        // Title bar
377        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        // Content depends on step
393        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        // Footer
402        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    // Add custom provider entry
487    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    // Update list state selection
508    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    // Center the dialog
516    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    // Filter models
604    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
744// ── Event handling ──────────────────────────────────────────────────────────
745
746fn 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                        // +1 for "add custom" row
774                        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                            // Add custom provider
782                            state.input_mode = InputMode::AddingCustom {
783                                fields: [String::new(), String::new(), String::new()],
784                                active_field: 0,
785                            };
786                        } else {
787                            // Edit API key
788                            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); // quit
808                    }
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                            // Update the provider entry
827                            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                            // Try to fetch models dynamically from the provider's /models endpoint
837                            fetch_and_cache_models(provider_name, &state.providers);
838
839                            // Refresh the model list to include newly fetched models
840                            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                            // Save API key
876                            if !api_key.is_empty() {
877                                auth_store.set_api_key(&name, api_key.clone());
878                            }
879
880                            // Add to provider list
881                            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                            // Try to fetch models from this custom provider
896                            if !api_key.is_empty() {
897                                fetch_and_cache_models(&name, &state.providers);
898                                state.models = load_models();
899                            }
900
901                            // Move back to normal
902                            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                    // Select the first filtered model
929                    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                    // Move to theme step
954                    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                // Save everything and go to done
991                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); // quit
1008            }
1009            _ => {}
1010        }
1011    }
1012    Ok(false)
1013}
1014
1015// ── Finish: persist all selections ──────────────────────────────────────────
1016
1017fn finish_setup(state: &mut WizardState) -> Result<()> {
1018    // Get selected model
1019    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    // Get selected theme
1026    let theme_name = state
1027        .themes
1028        .get(state.theme_selected)
1029        .cloned()
1030        .unwrap_or_else(|| "oxi_dark".to_string());
1031
1032    // Collect custom provider base URLs
1033    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
1050// ── Main entry point ────────────────────────────────────────────────────────
1051
1052/// Run the interactive setup wizard.
1053pub fn run() -> Result<()> {
1054    // Setup terminal
1055    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    // Ensure terminal is restored on panic
1062    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    // Load data
1070    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    // Find the index of the current default model
1089    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    // Find the index of the current theme
1103    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    // Main loop
1128    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            // Ctrl+C always quits
1135            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    // Restore terminal
1147    disable_raw_mode()?;
1148    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1149
1150    Ok(())
1151}