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    /// Catalog port handle (None = use legacy global state).
96    catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
97}
98
99/// A model entry for display.
100#[derive(Clone)]
101struct ModelEntry {
102    id: String,
103    provider: String,
104    context_window: u32,
105}
106
107// ── Masking helper ──────────────────────────────────────────────────────────
108
109/// Mask an API key for display: show first 6 and last 4 chars, rest asterisks.
110fn mask_key(key: &str) -> String {
111    if key.len() <= 10 {
112        "*".repeat(key.len())
113    } else {
114        format!("{}...{}", &key[..6], &key[key.len() - 4..])
115    }
116}
117
118// ── Load provider state ─────────────────────────────────────────────────────
119
120/// Build the initial provider list from builtins + stored keys + custom providers.
121fn load_providers(
122    auth_store: &crate::store::auth_storage::AuthStorage,
123    catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
124) -> Vec<ProviderEntry> {
125    let mut entries = Vec::new();
126
127    let builtin_names: Vec<String> = if let Some(cat) = catalog {
128        cat.list_providers_sync()
129    } else {
130        oxi_sdk::get_builtin_providers()
131            .iter()
132            .map(|p| p.name.to_string())
133            .collect()
134    };
135
136    for name in &builtin_names {
137        let key = auth_store.get_api_key(name);
138
139        let (has_key, key_masked) = match &key {
140            Some(k) => (true, mask_key(k)),
141            None => (false, String::new()),
142        };
143
144        let base_url = if let Some(cat) = catalog {
145            cat.get_provider_sync(name).and_then(|p| p.base_url)
146        } else {
147            oxi_sdk::get_provider_base_url(name)
148                .filter(|s| !s.is_empty())
149                .map(|s| s.to_string())
150        };
151
152        entries.push(ProviderEntry {
153            name: name.clone(),
154            has_key,
155            key_masked,
156            is_custom: false,
157            base_url,
158        });
159    }
160
161    // Add custom providers from settings that aren't already in builtins
162    if let Ok(settings) = crate::store::settings::Settings::load() {
163        for cp in &settings.custom_providers {
164            if builtin_names.iter().any(|n| n == &cp.name) {
165                continue;
166            }
167            let actual_key = auth_store.get_api_key(&cp.name);
168
169            let (has_key, key_masked) = match &actual_key {
170                Some(k) => (true, mask_key(k)),
171                None => (false, String::new()),
172            };
173
174            entries.push(ProviderEntry {
175                name: cp.name.clone(),
176                has_key,
177                key_masked,
178                is_custom: true,
179                base_url: Some(cp.base_url.clone()),
180            });
181        }
182    }
183
184    entries
185}
186
187// ── Load model list ────────────────────────────────────────────────────────
188
189/// Build the model list from the catalog port + dynamic cache.
190fn load_models(
191    catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
192) -> Vec<ModelEntry> {
193    let mut models = Vec::new();
194    let mut seen = std::collections::HashSet::new();
195
196    // 1. Dynamic models from settings cache (fetched from /models endpoints)
197    if let Ok(settings) = crate::store::settings::Settings::load() {
198        for (provider, model_ids) in &settings.dynamic_models {
199            for id in model_ids {
200                let key = format!("{}/{}", provider, id);
201                if seen.insert(key.clone()) {
202                    // Try to get context_window from catalog/model_db, default 128_000
203                    let ctx = if let Some(cat) = catalog {
204                        cat.get_model_sync(provider, id)
205                            .map(|e| e.context_window)
206                            .unwrap_or(128_000)
207                    } else {
208                        oxi_sdk::get_model_entry(provider, id)
209                            .map(|e| e.context_window)
210                            .unwrap_or(128_000)
211                    };
212                    models.push(ModelEntry {
213                        id: id.clone(),
214                        provider: provider.clone(),
215                        context_window: ctx,
216                    });
217                }
218            }
219        }
220    }
221
222    // 2. Catalog models (sync read) or static model_db fallback
223    if let Some(cat) = catalog {
224        for entry in cat.search_sync("") {
225            let key = format!("{}/{}", entry.provider, entry.model_id);
226            if seen.insert(key) {
227                models.push(ModelEntry {
228                    id: entry.model_id,
229                    provider: entry.provider,
230                    context_window: entry.context_window,
231                });
232            }
233        }
234    } else {
235        for entry in oxi_sdk::get_all_models() {
236            let key = format!("{}/{}", entry.provider, entry.id);
237            if seen.insert(key) {
238                models.push(ModelEntry {
239                    id: entry.id.to_string(),
240                    provider: entry.provider.to_string(),
241                    context_window: entry.context_window,
242                });
243            }
244        }
245    }
246
247    models
248}
249
250// ── Fetch and cache dynamic models ─────────────────────────────────────────
251
252/// Try to fetch models from a provider's `/models` endpoint and cache them in settings.
253///
254/// Only works for OpenAI-compatible providers that have a `base_url`.
255/// Non-OpenAI-compatible providers are silently skipped.
256/// On failure, logs a warning and keeps the existing cache (if any).
257fn fetch_and_cache_models(provider_name: &str, providers: &[ProviderEntry]) {
258    // Resolve base_url for this provider
259    let base_url = providers
260        .iter()
261        .find(|p| p.name == provider_name)
262        .and_then(|p| p.base_url.clone())
263        .or_else(|| oxi_sdk::get_provider_base_url(provider_name).map(|s| s.to_string()));
264
265    let base_url = match base_url {
266        Some(url) if !url.is_empty() => url,
267        _ => {
268            tracing::debug!(
269                "Skipping dynamic model fetch for '{}': no base_url",
270                provider_name
271            );
272            return;
273        }
274    };
275
276    // Get the API key from auth storage
277    let auth_store = crate::store::auth_storage::shared_auth_storage();
278    let api_key = match auth_store.get_api_key(provider_name) {
279        Some(key) => key,
280        None => {
281            tracing::debug!(
282                "Skipping dynamic model fetch for '{}': no API key",
283                provider_name
284            );
285            return;
286        }
287    };
288
289    // Only fetch for OpenAI-compatible providers (api = openai-completions or openai-responses)
290    let api_type = oxi_sdk::get_provider_api(provider_name);
291    let is_openai_compatible = api_type.is_none_or(|api| {
292        matches!(
293            api,
294            oxi_sdk::Api::OpenAiCompletions | oxi_sdk::Api::OpenAiResponses
295        )
296    });
297
298    if !is_openai_compatible {
299        tracing::debug!(
300            "Skipping dynamic model fetch for '{}': not OpenAI-compatible",
301            provider_name
302        );
303        return;
304    }
305
306    tracing::info!(
307        "Fetching models from {}/models for provider '{}'...",
308        base_url,
309        provider_name
310    );
311
312    match oxi_sdk::fetch_models_blocking(&base_url, &api_key) {
313        Ok(model_ids) => {
314            tracing::info!(
315                "Fetched {} models from provider '{}'",
316                model_ids.len(),
317                provider_name
318            );
319
320            // Update settings cache
321            if let Ok(mut settings) = crate::store::settings::Settings::load() {
322                settings
323                    .dynamic_models
324                    .insert(provider_name.to_string(), model_ids);
325                if let Err(e) = settings.save() {
326                    tracing::warn!("Failed to save dynamic models cache: {}", e);
327                }
328            }
329        }
330        Err(e) => {
331            tracing::warn!(
332                "Failed to fetch models from provider '{}': {}. \
333                 Falling back to static model list.",
334                provider_name,
335                e
336            );
337        }
338    }
339}
340
341// ── Load theme list ─────────────────────────────────────────────────────────
342
343fn load_themes() -> Vec<String> {
344    // Built-in theme names from oxi-cli theme system
345    vec![
346        "oxi_dark".to_string(),
347        "oxi_light".to_string(),
348        "nord".to_string(),
349        "catppuccin".to_string(),
350        "github_dark".to_string(),
351        "monokai".to_string(),
352    ]
353}
354
355// ── Save auth keys ──────────────────────────────────────────────────────────
356
357// ── Save settings ───────────────────────────────────────────────────────────
358
359/// Save the selected model and theme to settings.
360fn save_settings(
361    model_id: &str,
362    theme_name: &str,
363    custom_base_urls: &[(String, String)],
364) -> Result<()> {
365    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
366
367    // Split "provider/model" and store as last_used
368    if let Some((provider, model_name)) = model_id.split_once('/') {
369        settings.last_used_provider = Some(provider.to_string());
370        settings.last_used_model = Some(model_name.to_string());
371    } else {
372        settings.last_used_model = Some(model_id.to_string());
373    }
374    settings.theme = theme_name.to_string();
375
376    // Ensure custom providers with base_url are registered
377    for (name, base_url) in custom_base_urls {
378        let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
379        if !already_exists {
380            settings
381                .custom_providers
382                .push(crate::store::settings::CustomProvider {
383                    name: name.clone(),
384                    base_url: base_url.clone(),
385                    api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
386                    api: "openai-completions".to_string(),
387                });
388        }
389    }
390
391    settings.save()?;
392    Ok(())
393}
394
395// ── Draw functions ──────────────────────────────────────────────────────────
396
397fn draw_wizard(
398    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
399    state: &mut WizardState,
400) -> Result<()> {
401    terminal.draw(|f| {
402        let size = f.area();
403
404        // Create outer layout
405        let chunks = Layout::default()
406            .direction(Direction::Vertical)
407            .constraints([
408                Constraint::Length(3), // Title bar
409                Constraint::Min(10),   // Content
410                Constraint::Length(2), // Footer
411            ])
412            .split(size);
413
414        // Title bar
415        let title = Paragraph::new(Line::from(vec![
416            Span::styled(
417                " oxi ",
418                Style::default()
419                    .fg(Color::Rgb(255, 165, 0))
420                    .add_modifier(Modifier::BOLD),
421            ),
422            Span::styled(
423                "oxi Setup Wizard",
424                Style::default().add_modifier(Modifier::BOLD),
425            ),
426        ]))
427        .block(Block::default().borders(Borders::TOP));
428        f.render_widget(title, chunks[0]);
429
430        // Content depends on step
431        match state.step {
432            0 => draw_provider_step(f, state, chunks[1]),
433            1 => draw_model_step(f, state, chunks[1]),
434            2 => draw_theme_step(f, state, chunks[1]),
435            3 => draw_done_step(f, state, chunks[1]),
436            _ => {}
437        }
438
439        // Footer
440        let footer_text = match state.step {
441            0 => match &state.input_mode {
442                InputMode::Normal => {
443                    "  ↑/↓ navigate · Enter: enter/change API key · d: delete · →: next · q: quit"
444                        .to_string()
445                }
446                InputMode::EditingApiKey { .. } => "  Enter: save · Esc: cancel".to_string(),
447                InputMode::AddingCustom { .. } => {
448                    "  Tab: next field · Enter: save · Esc: cancel".to_string()
449                }
450            },
451            1 => {
452                if state.model_searching {
453                    "  Type: search · Esc: close search · Enter: select · ←: previous".to_string()
454                } else {
455                    "  ↑/↓ navigate · /: search · Enter: select · ←: previous".to_string()
456                }
457            }
458            2 => "  ↑/↓ navigate · Enter: select · ←: previous".to_string(),
459            3 => "  Enter: quit".to_string(),
460            _ => String::new(),
461        };
462        let footer = Paragraph::new(Line::from(Span::styled(
463            footer_text,
464            Style::default().fg(Color::DarkGray),
465        )));
466        f.render_widget(footer, chunks[2]);
467    })?;
468
469    Ok(())
470}
471
472fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
473    match &state.input_mode {
474        InputMode::Normal => draw_provider_list(f, state, area),
475        InputMode::EditingApiKey {
476            provider_name,
477            field_text,
478        } => draw_api_key_dialog(f, provider_name, field_text, area),
479        InputMode::AddingCustom {
480            fields,
481            active_field,
482        } => draw_custom_provider_dialog(f, fields, *active_field, area),
483    }
484}
485
486fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
487    let step_indicator = build_step_indicator(state.step);
488
489    let items: Vec<ListItem> = state
490        .providers
491        .iter()
492        .map(|p| {
493            let check = if p.has_key { "[x]" } else { "[ ]" };
494            let key_info = if p.has_key {
495                format!("API key: {}", p.key_masked)
496            } else {
497                "No API key".to_string()
498            };
499            let custom_tag = if p.is_custom { " (custom)" } else { "" };
500
501            let line = Line::from(vec![
502                Span::styled(
503                    format!(" {} ", check),
504                    Style::default().fg(if p.has_key {
505                        Color::Green
506                    } else {
507                        Color::DarkGray
508                    }),
509                ),
510                Span::styled(
511                    format!("{:<14}", p.name),
512                    Style::default().add_modifier(Modifier::BOLD),
513                ),
514                Span::styled(
515                    format!("[{}]", key_info),
516                    Style::default().fg(Color::DarkGray),
517                ),
518                Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
519            ]);
520            ListItem::new(line)
521        })
522        .collect();
523
524    // Add custom provider entry
525    let add_custom = ListItem::new(Line::from(vec![
526        Span::styled("   + ", Style::default().fg(Color::Cyan)),
527        Span::styled("Add custom provider...", Style::default().fg(Color::Cyan)),
528    ]));
529
530    let mut all_items = items;
531    all_items.push(add_custom);
532
533    let list = List::new(all_items)
534        .block(
535            Block::default()
536                .borders(Borders::NONE)
537                .title(step_indicator),
538        )
539        .highlight_style(
540            Style::default()
541                .bg(Color::DarkGray)
542                .add_modifier(Modifier::BOLD),
543        );
544
545    // Update list state selection
546    state
547        .provider_list_state
548        .select(Some(state.provider_selected));
549    f.render_stateful_widget(list, area, &mut state.provider_list_state);
550}
551
552fn draw_api_key_dialog(f: &mut ratatui::Frame, provider_name: &str, field_text: &str, area: Rect) {
553    // Center the dialog
554    let dialog_height = 7u16;
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 display_text = if field_text.is_empty() {
562        String::new()
563    } else {
564        "*".repeat(field_text.len())
565    };
566
567    let paragraphs = vec![
568        Line::from(""),
569        Line::from(vec![
570            Span::styled("  API Key: ", Style::default().add_modifier(Modifier::BOLD)),
571            Span::styled(
572                format!("[{:<width$}]", display_text, width = 30),
573                Style::default(),
574            ),
575            if field_text.is_empty() {
576                Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
577            } else {
578                Span::raw("")
579            },
580        ]),
581    ];
582
583    let block = Block::default()
584        .borders(Borders::ALL)
585        .title(format!(" {} API Key ", provider_name));
586
587    let para = Paragraph::new(paragraphs).block(block);
588    f.render_widget(para, dialog_area);
589}
590
591fn draw_custom_provider_dialog(
592    f: &mut ratatui::Frame,
593    fields: &[String; 3],
594    active_field: usize,
595    area: Rect,
596) {
597    let dialog_height = 9u16;
598    let dialog_width = std::cmp::min(area.width, 60);
599    let x = (area.width.saturating_sub(dialog_width)) / 2;
600    let y = (area.height.saturating_sub(dialog_height)) / 2;
601
602    let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
603
604    let field_labels = ["Name", "Base URL", "API Key"];
605    let lines: Vec<Line> = std::iter::once(Line::from(""))
606        .chain(field_labels.iter().enumerate().map(|(i, label)| {
607            let display = if i == 2 && !fields[i].is_empty() {
608                "*".repeat(fields[i].len())
609            } else {
610                fields[i].clone()
611            };
612            let is_active = i == active_field;
613            let style = if is_active {
614                Style::default().add_modifier(Modifier::BOLD)
615            } else {
616                Style::default()
617            };
618            Line::from(vec![
619                Span::styled(format!("  {:<10}", format!("{}:", label)), style),
620                Span::styled(format!("[{:<width$}]", display, width = 35), style),
621                if is_active && fields[i].is_empty() {
622                    Span::styled("<enter>", Style::default().fg(Color::DarkGray))
623                } else {
624                    Span::raw("")
625                },
626            ])
627        }))
628        .collect();
629
630    let block = Block::default()
631        .borders(Borders::ALL)
632        .title(" Add Custom Provider ");
633
634    let para = Paragraph::new(lines).block(block);
635    f.render_widget(para, dialog_area);
636}
637
638fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
639    let step_indicator = build_step_indicator(state.step);
640
641    // Filter models
642    let filtered: Vec<&ModelEntry> = if state.model_filter.is_empty() {
643        state.models.iter().collect()
644    } else {
645        let filter = state.model_filter.to_lowercase();
646        state
647            .models
648            .iter()
649            .filter(|m| {
650                m.id.to_lowercase().contains(&filter) || m.provider.to_lowercase().contains(&filter)
651            })
652            .collect()
653    };
654
655    let mut lines: Vec<Line> = Vec::new();
656
657    if state.model_searching {
658        lines.push(Line::from(vec![
659            Span::styled("  Search: ", Style::default().fg(Color::Yellow)),
660            Span::styled(
661                &state.model_filter,
662                Style::default().add_modifier(Modifier::BOLD),
663            ),
664            Span::raw("_"),
665        ]));
666    }
667
668    lines.push(Line::from(""));
669
670    for m in &filtered {
671        let ctx_str = if m.context_window >= 1_000_000 {
672            format!("{}M ctx", m.context_window / 1_000_000)
673        } else {
674            format!("{}K ctx", m.context_window / 1_000)
675        };
676        lines.push(Line::from(vec![
677            Span::styled(format!("  {:<40}", m.id), Style::default()),
678            Span::styled(
679                format!("({})", m.provider),
680                Style::default().fg(Color::DarkGray),
681            ),
682            Span::styled(
683                format!(", {}", ctx_str),
684                Style::default().fg(Color::DarkGray),
685            ),
686        ]));
687    }
688
689    let block = Block::default()
690        .borders(Borders::NONE)
691        .title(step_indicator);
692
693    let para = Paragraph::new(lines).block(block);
694    f.render_widget(para, area);
695}
696
697fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
698    let step_indicator = build_step_indicator(state.step);
699
700    let items: Vec<ListItem> = state
701        .themes
702        .iter()
703        .map(|t| ListItem::new(Line::from(format!("  {}", t))))
704        .collect();
705
706    let list = List::new(items)
707        .block(
708            Block::default()
709                .borders(Borders::NONE)
710                .title(step_indicator),
711        )
712        .highlight_style(
713            Style::default()
714                .bg(Color::DarkGray)
715                .add_modifier(Modifier::BOLD),
716        );
717
718    state.theme_list_state.select(Some(state.theme_selected));
719    f.render_stateful_widget(list, area, &mut state.theme_list_state);
720}
721
722fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
723    let settings_path_display = state.settings_path.display().to_string();
724    let auth_path_display = state.auth_path.display().to_string();
725
726    let lines = vec![
727        Line::from(""),
728        Line::from(Span::styled(
729            "  Settings saved!",
730            Style::default()
731                .fg(Color::Green)
732                .add_modifier(Modifier::BOLD),
733        )),
734        Line::from(""),
735        Line::from(Span::styled(
736            format!("  Settings file: {}", settings_path_display),
737            Style::default().fg(Color::DarkGray),
738        )),
739        Line::from(Span::styled(
740            format!("  Auth file: {}", auth_path_display),
741            Style::default().fg(Color::DarkGray),
742        )),
743        Line::from(""),
744        Line::from(Span::styled(
745            "  Run 'oxi' to start.",
746            Style::default().add_modifier(Modifier::BOLD),
747        )),
748    ];
749
750    let block = Block::default().borders(Borders::NONE);
751    let para = Paragraph::new(lines).block(block);
752    f.render_widget(para, area);
753}
754
755fn build_step_indicator(current_step: usize) -> Line<'static> {
756    let steps = [
757        ("1. Provider Setup", 0),
758        ("2. Default Model", 1),
759        ("3. Theme", 2),
760        ("4. Done", 3),
761    ];
762
763    let spans: Vec<Span> = steps
764        .iter()
765        .flat_map(|(label, step)| {
766            let style = if *step == current_step {
767                Style::default()
768                    .add_modifier(Modifier::BOLD)
769                    .fg(Color::Cyan)
770            } else if *step < current_step {
771                Style::default().fg(Color::Green)
772            } else {
773                Style::default().fg(Color::DarkGray)
774            };
775            vec![Span::styled(format!("  {}", label), style), Span::raw(" ")]
776        })
777        .collect();
778
779    Line::from(spans)
780}
781
782// ── Event handling ──────────────────────────────────────────────────────────
783
784fn handle_event(
785    state: &mut WizardState,
786    event: Event,
787    auth_store: &crate::store::auth_storage::AuthStorage,
788) -> Result<bool> {
789    match state.step {
790        0 => handle_provider_event(state, event, auth_store),
791        1 => handle_model_event(state, event),
792        2 => handle_theme_event(state, event),
793        3 => handle_done_event(event),
794        _ => Ok(false),
795    }
796}
797
798fn handle_provider_event(
799    state: &mut WizardState,
800    event: Event,
801    auth_store: &crate::store::auth_storage::AuthStorage,
802) -> Result<bool> {
803    match &mut state.input_mode {
804        InputMode::Normal => {
805            if let Event::Key(key) = event {
806                match key.code {
807                    KeyCode::Up if state.provider_selected > 0 => {
808                        state.provider_selected -= 1;
809                    }
810                    KeyCode::Down => {
811                        // +1 for "add custom" row
812                        let max = state.providers.len();
813                        if state.provider_selected < max {
814                            state.provider_selected += 1;
815                        }
816                    }
817                    KeyCode::Enter => {
818                        if state.provider_selected == state.providers.len() {
819                            // Add custom provider
820                            state.input_mode = InputMode::AddingCustom {
821                                fields: [String::new(), String::new(), String::new()],
822                                active_field: 0,
823                            };
824                        } else {
825                            // Edit API key
826                            let name = state.providers[state.provider_selected].name.clone();
827                            state.input_mode = InputMode::EditingApiKey {
828                                provider_name: name,
829                                field_text: String::new(),
830                            };
831                        }
832                    }
833                    KeyCode::Char('d') | KeyCode::Delete
834                        if state.provider_selected < state.providers.len() =>
835                    {
836                        let name = state.providers[state.provider_selected].name.clone();
837                        auth_store.remove(&name);
838                        state.providers[state.provider_selected].has_key = false;
839                        state.providers[state.provider_selected].key_masked = String::new();
840                    }
841                    KeyCode::Right => {
842                        state.step = 1;
843                    }
844                    KeyCode::Char('q') => {
845                        return Ok(true); // quit
846                    }
847                    _ => {}
848                }
849            }
850        }
851        InputMode::EditingApiKey {
852            provider_name,
853            field_text,
854        } => {
855            if let Event::Key(key) = event {
856                match key.code {
857                    KeyCode::Esc => {
858                        state.input_mode = InputMode::Normal;
859                    }
860                    KeyCode::Enter => {
861                        if !field_text.is_empty() {
862                            auth_store.set_api_key(provider_name, field_text.clone());
863
864                            // Update the provider entry
865                            if let Some(entry) = state
866                                .providers
867                                .iter_mut()
868                                .find(|p| p.name == *provider_name)
869                            {
870                                entry.has_key = true;
871                                entry.key_masked = mask_key(field_text);
872                            }
873
874                            // Try to fetch models dynamically from the provider's /models endpoint
875                            fetch_and_cache_models(provider_name, &state.providers);
876
877                            // Refresh the model list to include newly fetched models
878                            state.models = load_models(state.catalog.as_ref());
879                        }
880                        state.input_mode = InputMode::Normal;
881                    }
882                    KeyCode::Backspace => {
883                        field_text.pop();
884                    }
885                    KeyCode::Char(c) => {
886                        field_text.push(c);
887                    }
888                    _ => {}
889                }
890            }
891        }
892        InputMode::AddingCustom {
893            fields,
894            active_field,
895        } => {
896            if let Event::Key(key) = event {
897                match key.code {
898                    KeyCode::Esc => {
899                        state.input_mode = InputMode::Normal;
900                    }
901                    KeyCode::Tab => {
902                        *active_field = (*active_field + 1) % 3;
903                    }
904                    KeyCode::BackTab => {
905                        *active_field = (*active_field + 2) % 3;
906                    }
907                    KeyCode::Enter => {
908                        let name = fields[0].trim().to_string();
909                        let base_url = fields[1].trim().to_string();
910                        let api_key = fields[2].trim().to_string();
911
912                        if !name.is_empty() && !base_url.is_empty() {
913                            // Save API key
914                            if !api_key.is_empty() {
915                                auth_store.set_api_key(&name, api_key.clone());
916                            }
917
918                            // Add to provider list
919                            let (has_key, key_masked) = if !api_key.is_empty() {
920                                (true, mask_key(&api_key))
921                            } else {
922                                (false, String::new())
923                            };
924
925                            state.providers.push(ProviderEntry {
926                                name: name.clone(),
927                                has_key,
928                                key_masked,
929                                is_custom: true,
930                                base_url: Some(base_url),
931                            });
932
933                            // Try to fetch models from this custom provider
934                            if !api_key.is_empty() {
935                                fetch_and_cache_models(&name, &state.providers);
936                                state.models = load_models(state.catalog.as_ref());
937                            }
938
939                            // Move back to normal
940                            state.input_mode = InputMode::Normal;
941                        }
942                    }
943                    KeyCode::Backspace => {
944                        fields[*active_field].pop();
945                    }
946                    KeyCode::Char(c) => {
947                        fields[*active_field].push(c);
948                    }
949                    _ => {}
950                }
951            }
952        }
953    }
954    Ok(false)
955}
956
957fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
958    if let Event::Key(key) = event {
959        if state.model_searching {
960            match key.code {
961                KeyCode::Esc => {
962                    state.model_searching = false;
963                    state.model_filter.clear();
964                }
965                KeyCode::Enter => {
966                    // Select the first filtered model
967                    state.model_searching = false;
968                    select_filtered_model(state);
969                }
970                KeyCode::Backspace => {
971                    state.model_filter.pop();
972                }
973                KeyCode::Char(c) => {
974                    state.model_filter.push(c);
975                }
976                _ => {}
977            }
978        } else {
979            match key.code {
980                KeyCode::Up if state.model_selected > 0 => {
981                    state.model_selected -= 1;
982                }
983                KeyCode::Down if state.model_selected + 1 < state.models.len() => {
984                    state.model_selected += 1;
985                }
986                KeyCode::Char('/') => {
987                    state.model_searching = true;
988                    state.model_filter.clear();
989                }
990                KeyCode::Enter => {
991                    // Move to theme step
992                    state.step = 2;
993                }
994                KeyCode::Left => {
995                    state.step = 0;
996                }
997                _ => {}
998            }
999        }
1000    }
1001    Ok(false)
1002}
1003
1004fn select_filtered_model(state: &mut WizardState) {
1005    if state.model_filter.is_empty() {
1006        state.step = 2;
1007        return;
1008    }
1009    let filter = state.model_filter.to_lowercase();
1010    if let Some(idx) = state.models.iter().position(|m| {
1011        m.id.to_lowercase().contains(&filter) || m.provider.to_lowercase().contains(&filter)
1012    }) {
1013        state.model_selected = idx;
1014    }
1015    state.step = 2;
1016}
1017
1018fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
1019    if let Event::Key(key) = event {
1020        match key.code {
1021            KeyCode::Up if state.theme_selected > 0 => {
1022                state.theme_selected -= 1;
1023            }
1024            KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
1025                state.theme_selected += 1;
1026            }
1027            KeyCode::Enter => {
1028                // Save everything and go to done
1029                finish_setup(state)?;
1030                state.step = 3;
1031            }
1032            KeyCode::Left => {
1033                state.step = 1;
1034            }
1035            _ => {}
1036        }
1037    }
1038    Ok(false)
1039}
1040
1041fn handle_done_event(event: Event) -> Result<bool> {
1042    if let Event::Key(key) = event {
1043        match key.code {
1044            KeyCode::Enter | KeyCode::Char('q') => {
1045                return Ok(true); // quit
1046            }
1047            _ => {}
1048        }
1049    }
1050    Ok(false)
1051}
1052
1053// ── Finish: persist all selections ──────────────────────────────────────────
1054
1055fn finish_setup(state: &mut WizardState) -> Result<()> {
1056    // Get selected model
1057    let model_id = state
1058        .models
1059        .get(state.model_selected)
1060        .map(|m| format!("{}/{}", m.provider, m.id))
1061        .unwrap_or_default();
1062
1063    // Get selected theme
1064    let theme_name = state
1065        .themes
1066        .get(state.theme_selected)
1067        .cloned()
1068        .unwrap_or_else(|| "oxi_dark".to_string());
1069
1070    // Collect custom provider base URLs
1071    let custom_base_urls: Vec<(String, String)> = state
1072        .providers
1073        .iter()
1074        .filter_map(|p| {
1075            if p.is_custom {
1076                p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1077            } else {
1078                None
1079            }
1080        })
1081        .collect();
1082
1083    save_settings(&model_id, &theme_name, &custom_base_urls)?;
1084
1085    Ok(())
1086}
1087
1088// ── Main entry point ────────────────────────────────────────────────────────
1089
1090/// Run the interactive setup wizard.
1091pub async fn run() -> Result<()> {
1092    // Setup terminal
1093    enable_raw_mode()?;
1094    let mut stdout = io::stdout();
1095    execute!(stdout, EnterAlternateScreen)?;
1096    let backend = CrosstermBackend::new(stdout);
1097    let mut terminal = Terminal::new(backend)?;
1098
1099    // Ensure terminal is restored on panic
1100    let panic_hook = std::panic::take_hook();
1101    std::panic::set_hook(Box::new(move |info| {
1102        let _ = disable_raw_mode();
1103        let _ = execute!(io::stdout(), LeaveAlternateScreen);
1104        panic_hook(info);
1105    }));
1106
1107    // Initialize the catalog port for model/provider lookups.
1108    let catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>> = {
1109        let paths = crate::services::OxiPaths::default_paths().ok();
1110        if let Some(paths) = paths {
1111            let config = oxi_sdk::CatalogConfig {
1112                cache_path: paths.home.join("cache").join("models-dev.json"),
1113                etag_path: paths.home.join("cache").join("models-dev.json.etag"),
1114                override_path: paths.home.join("catalog").join("overrides.toml"),
1115                // Don't trigger a network refresh during setup.
1116                fetch_enabled: false,
1117                ..Default::default()
1118            };
1119            oxi_sdk::FileModelCatalog::init(config)
1120                .await
1121                .ok()
1122                .map(|c| c as _)
1123        } else {
1124            None
1125        }
1126    };
1127
1128    // Load data
1129    let auth_store = crate::store::auth_storage::shared_auth_storage();
1130    let providers = load_providers(&auth_store, catalog.as_ref());
1131    let models = load_models(catalog.as_ref());
1132    let themes = load_themes();
1133
1134    let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1135        dirs::home_dir()
1136            .unwrap_or_default()
1137            .join(".oxi")
1138            .join("auth.json")
1139    });
1140    let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1141        dirs::home_dir()
1142            .unwrap_or_default()
1143            .join(".oxi")
1144            .join("settings.json")
1145    });
1146
1147    // Find the index of the current default model
1148    let current_model = crate::store::settings::Settings::load()
1149        .ok()
1150        .and_then(|s| s.last_used_model.clone())
1151        .unwrap_or_default();
1152
1153    let model_selected = models
1154        .iter()
1155        .position(|m| {
1156            let full_id = format!("{}/{}", m.provider, m.id);
1157            full_id == current_model || m.id == current_model
1158        })
1159        .unwrap_or(0);
1160
1161    // Find the index of the current theme
1162    let current_theme = crate::store::settings::Settings::load()
1163        .ok()
1164        .map(|s| s.theme.clone())
1165        .unwrap_or_else(|| "oxi_dark".to_string());
1166
1167    let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1168
1169    let mut state = WizardState {
1170        step: 0,
1171        providers,
1172        provider_selected: 0,
1173        provider_list_state: ListState::default(),
1174        input_mode: InputMode::Normal,
1175        models,
1176        model_selected,
1177        model_filter: String::new(),
1178        model_searching: false,
1179        themes,
1180        theme_selected,
1181        theme_list_state: ListState::default(),
1182        auth_path,
1183        settings_path,
1184        catalog,
1185    };
1186
1187    // Main loop
1188    loop {
1189        draw_wizard(&mut terminal, &mut state)?;
1190
1191        if event::poll(std::time::Duration::from_millis(100))?
1192            && let Event::Key(key) = event::read()?
1193        {
1194            // Ctrl+C always quits
1195            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1196                break;
1197            }
1198
1199            let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1200            if should_quit {
1201                break;
1202            }
1203        }
1204    }
1205
1206    // Restore terminal
1207    disable_raw_mode()?;
1208    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1209
1210    Ok(())
1211}