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: &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(" 🦊 ", 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        // Content depends on step
388        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        // Footer
397        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    // Add custom provider entry
482    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    // Update list state selection
503    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    // Center the dialog
511    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    // Filter models
599    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
739// ── Event handling ──────────────────────────────────────────────────────────
740
741fn 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                        // +1 for "add custom" row
769                        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                            // Add custom provider
777                            state.input_mode = InputMode::AddingCustom {
778                                fields: [String::new(), String::new(), String::new()],
779                                active_field: 0,
780                            };
781                        } else {
782                            // Edit API key
783                            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); // quit
803                    }
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                            // Update the provider entry
822                            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                            // Try to fetch models dynamically from the provider's /models endpoint
832                            fetch_and_cache_models(provider_name, &state.providers);
833
834                            // Refresh the model list to include newly fetched models
835                            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                            // Save API key
871                            if !api_key.is_empty() {
872                                auth_store.set_api_key(&name, api_key.clone());
873                            }
874
875                            // Add to provider list
876                            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                            // Try to fetch models from this custom provider
891                            if !api_key.is_empty() {
892                                fetch_and_cache_models(&name, &state.providers);
893                                state.models = load_models();
894                            }
895
896                            // Move back to normal
897                            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                    // Select the first filtered model
924                    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                    // Move to theme step
949                    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                // Save everything and go to done
986                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); // quit
1003            }
1004            _ => {}
1005        }
1006    }
1007    Ok(false)
1008}
1009
1010// ── Finish: persist all selections ──────────────────────────────────────────
1011
1012fn finish_setup(state: &mut WizardState) -> Result<()> {
1013    // Get selected model
1014    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    // Get selected theme
1021    let theme_name = state
1022        .themes
1023        .get(state.theme_selected)
1024        .cloned()
1025        .unwrap_or_else(|| "oxi_dark".to_string());
1026
1027    // Collect custom provider base URLs
1028    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
1045// ── Main entry point ────────────────────────────────────────────────────────
1046
1047/// Run the interactive setup wizard.
1048pub fn run() -> Result<()> {
1049    // Setup terminal
1050    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    // Ensure terminal is restored on panic
1057    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    // Load data
1065    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    // Find the index of the current default model
1084    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    // Find the index of the current theme
1098    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    // Main loop
1123    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                // Ctrl+C always quits
1129                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    // Restore terminal
1142    disable_raw_mode()?;
1143    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1144
1145    Ok(())
1146}