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    /// Provider name filter (live while `provider_searching` is true)
76    provider_filter: String,
77    /// Whether the provider list is being filtered by typing (`/`)
78    provider_searching: bool,
79    /// Input mode
80    input_mode: InputMode,
81    /// Model entries for step 2
82    models: Vec<ModelEntry>,
83    /// Currently selected model index (into `models`)
84    model_selected: usize,
85    /// Model filter — the list is always filtered live (fzf-style)
86    model_filter: String,
87    /// List state for the filtered model list
88    model_list_state: ListState,
89    /// Dirty flag: a provider key was added/removed since the last model-list
90    /// rebuild. The main loop rebuilds (filtered to configured providers) before
91    /// drawing step 1.
92    models_dirty: bool,
93    /// Theme names for step 3
94    themes: Vec<String>,
95    /// Currently selected theme index
96    theme_selected: usize,
97    /// Theme list state
98    theme_list_state: ListState,
99    /// Auth storage path
100    auth_path: PathBuf,
101    /// Settings path
102    settings_path: PathBuf,
103    /// Catalog port handle (None = use legacy global state).
104    catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
105}
106
107/// A model entry for display.
108#[derive(Clone)]
109struct ModelEntry {
110    id: String,
111    provider: String,
112    context_window: u32,
113    /// Lowercased `id`, cached once at load so per-keystroke filtering of the
114    /// 5000+ model catalog doesn't allocate on every keypress.
115    id_lower: String,
116    /// Lowercased `provider`.
117    provider_lower: String,
118}
119
120impl ModelEntry {
121    fn new(id: String, provider: String, context_window: u32) -> Self {
122        let provider_lower = provider.to_lowercase();
123        let id_lower = id.to_lowercase();
124        Self {
125            id,
126            provider,
127            context_window,
128            id_lower,
129            provider_lower,
130        }
131    }
132}
133
134// ── Masking helper ──────────────────────────────────────────────────────────
135
136/// Mask an API key for display: show first 6 and last 4 chars, rest asterisks.
137fn mask_key(key: &str) -> String {
138    if key.len() <= 10 {
139        "*".repeat(key.len())
140    } else {
141        format!("{}...{}", &key[..6], &key[key.len() - 4..])
142    }
143}
144
145// ── Filter helpers ──────────────────────────────────────────────────────────
146
147/// Indices of providers whose name matches the current filter (case-insensitive
148/// substring). Empty filter ⇒ all providers.
149fn filtered_provider_indices(state: &WizardState) -> Vec<usize> {
150    if state.provider_filter.is_empty() {
151        (0..state.providers.len()).collect()
152    } else {
153        let f = state.provider_filter.to_lowercase();
154        state
155            .providers
156            .iter()
157            .enumerate()
158            .filter(|(_, p)| p.name.to_lowercase().contains(&f))
159            .map(|(i, _)| i)
160            .collect()
161    }
162}
163
164/// Indices of models matching the current filter (matches id OR provider,
165/// case-insensitive substring). Empty filter ⇒ all models. Uses the
166/// pre-lowercased `id_lower`/`provider_lower` cached on each `ModelEntry` so
167/// the per-keystroke filter doesn't allocate per item.
168fn filtered_model_indices(state: &WizardState) -> Vec<usize> {
169    if state.model_filter.is_empty() {
170        (0..state.models.len()).collect()
171    } else {
172        let f = state.model_filter.to_lowercase();
173        state
174            .models
175            .iter()
176            .enumerate()
177            .filter(|(_, m)| m.id_lower.contains(&f) || m.provider_lower.contains(&f))
178            .map(|(i, _)| i)
179            .collect()
180    }
181}
182
183/// Clamp `model_selected` so it always points at an item present in the
184/// filtered list. If the current selection was filtered out, snap to the
185/// first match. No-op when the filter yields nothing.
186fn ensure_model_selected_visible(state: &mut WizardState) {
187    let filtered = filtered_model_indices(state);
188    if filtered.is_empty() {
189        return;
190    }
191    if !filtered.contains(&state.model_selected) {
192        state.model_selected = filtered[0];
193    }
194}
195
196/// Snap `provider_selected` back into the filtered provider set after the
197/// filter changes. No-op when the filter yields nothing.
198fn snap_provider_selection(state: &mut WizardState) {
199    let indices = filtered_provider_indices(state);
200    if indices.is_empty() {
201        return;
202    }
203    if !indices.contains(&state.provider_selected) {
204        state.provider_selected = indices[0];
205    }
206}
207
208// ── Load provider state ─────────────────────────────────────────────────────
209
210/// Build the initial provider list from builtins + stored keys + custom providers.
211fn load_providers(
212    auth_store: &crate::store::auth_storage::AuthStorage,
213    catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
214) -> Vec<ProviderEntry> {
215    let mut entries = Vec::new();
216
217    let builtin_names: Vec<String> = if let Some(cat) = catalog {
218        cat.list_providers_sync()
219    } else {
220        oxi_sdk::get_builtin_providers()
221            .iter()
222            .map(|p| p.name.to_string())
223            .collect()
224    };
225
226    for name in &builtin_names {
227        let key = auth_store.get_api_key(name);
228
229        let (has_key, key_masked) = match &key {
230            Some(k) => (true, mask_key(k)),
231            None => (false, String::new()),
232        };
233
234        let base_url = if let Some(cat) = catalog {
235            cat.get_provider_sync(name).and_then(|p| p.base_url)
236        } else {
237            oxi_sdk::get_provider_base_url(name)
238                .filter(|s| !s.is_empty())
239                .map(|s| s.to_string())
240        };
241
242        entries.push(ProviderEntry {
243            name: name.clone(),
244            has_key,
245            key_masked,
246            is_custom: false,
247            base_url,
248        });
249    }
250
251    // Add custom providers from settings that aren't already in builtins
252    if let Ok(settings) = crate::store::settings::Settings::load() {
253        for cp in &settings.custom_providers {
254            if builtin_names.iter().any(|n| n == &cp.name) {
255                continue;
256            }
257            let actual_key = auth_store.get_api_key(&cp.name);
258
259            let (has_key, key_masked) = match &actual_key {
260                Some(k) => (true, mask_key(k)),
261                None => (false, String::new()),
262            };
263
264            entries.push(ProviderEntry {
265                name: cp.name.clone(),
266                has_key,
267                key_masked,
268                is_custom: true,
269                base_url: Some(cp.base_url.clone()),
270            });
271        }
272    }
273
274    entries
275}
276
277// ── Load model list ────────────────────────────────────────────────────────
278
279/// Build the model list from the catalog port + dynamic cache.
280///
281/// When `allowed` is `Some`, only models whose provider is in the set are
282/// returned — the wizard uses this to restrict step 2 to providers the user
283/// actually configured (added an API key to) in step 1. `Some(empty)` yields an
284/// empty list; `None` disables the provider filter entirely.
285fn load_models(
286    catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
287    allowed: Option<&std::collections::HashSet<String>>,
288) -> Vec<ModelEntry> {
289    let permit = |provider: &str| match allowed {
290        None => true,
291        Some(set) => set.contains(provider),
292    };
293
294    let mut models = Vec::new();
295    let mut seen = std::collections::HashSet::new();
296
297    // 1. Dynamic models from settings cache (fetched from /models endpoints)
298    if let Ok(settings) = crate::store::settings::Settings::load() {
299        for (provider, model_ids) in &settings.dynamic_models {
300            if !permit(provider) {
301                continue;
302            }
303            for id in model_ids {
304                let key = format!("{}/{}", provider, id);
305                if seen.insert(key.clone()) {
306                    // Try to get context_window from catalog/model_db, default 128_000
307                    let ctx = if let Some(cat) = catalog {
308                        cat.get_model_sync(provider, id)
309                            .map(|e| e.context_window)
310                            .unwrap_or(128_000)
311                    } else {
312                        oxi_sdk::get_model_entry(provider, id)
313                            .map(|e| e.context_window)
314                            .unwrap_or(128_000)
315                    };
316                    models.push(ModelEntry::new(id.clone(), provider.clone(), ctx));
317                }
318            }
319        }
320    }
321
322    // 2. Catalog models (sync read) or static model_db fallback
323    if let Some(cat) = catalog {
324        for entry in cat.search_sync("") {
325            if !permit(&entry.provider) {
326                continue;
327            }
328            let key = format!("{}/{}", entry.provider, entry.model_id);
329            if seen.insert(key) {
330                models.push(ModelEntry::new(
331                    entry.model_id,
332                    entry.provider,
333                    entry.context_window,
334                ));
335            }
336        }
337    } else {
338        for entry in oxi_sdk::get_all_models() {
339            if !permit(entry.provider) {
340                continue;
341            }
342            let key = format!("{}/{}", entry.provider, entry.id);
343            if seen.insert(key) {
344                models.push(ModelEntry::new(
345                    entry.id.to_string(),
346                    entry.provider.to_string(),
347                    entry.context_window,
348                ));
349            }
350        }
351    }
352
353    models
354}
355
356/// Names of providers the user has configured (added an API key to) in step 1.
357/// Step 2's model list is restricted to these so the user only chooses among
358/// models they can actually call.
359fn keyed_provider_names(providers: &[ProviderEntry]) -> std::collections::HashSet<String> {
360    providers
361        .iter()
362        .filter(|p| p.has_key)
363        .map(|p| p.name.clone())
364        .collect()
365}
366
367/// Rebuild `state.models` from the currently-configured providers, keeping the
368/// selection on the same model when it survives the rebuild. Called by the main
369/// loop whenever the provider-key set changes and the user is on step 1.
370fn refresh_models(state: &mut WizardState) {
371    let allowed = keyed_provider_names(&state.providers);
372    let prev = state
373        .models
374        .get(state.model_selected)
375        .map(|m| (m.provider.clone(), m.id.clone()));
376    state.models = load_models(state.catalog.as_ref(), Some(&allowed));
377    state.model_selected = match prev {
378        Some((p, id)) => state
379            .models
380            .iter()
381            .position(|m| m.provider == p && m.id == id)
382            .unwrap_or(0),
383        None => 0,
384    };
385    ensure_model_selected_visible(state);
386}
387
388// ── Fetch and cache dynamic models ─────────────────────────────────────────
389
390/// Try to fetch models from a provider's `/models` endpoint and cache them in settings.
391///
392/// Only works for OpenAI-compatible providers that have a `base_url`.
393/// Non-OpenAI-compatible providers are silently skipped.
394/// On failure, logs a warning and keeps the existing cache (if any).
395fn fetch_and_cache_models(provider_name: &str, providers: &[ProviderEntry]) {
396    // Resolve base_url for this provider
397    let base_url = providers
398        .iter()
399        .find(|p| p.name == provider_name)
400        .and_then(|p| p.base_url.clone())
401        .or_else(|| oxi_sdk::get_provider_base_url(provider_name).map(|s| s.to_string()));
402
403    let base_url = match base_url {
404        Some(url) if !url.is_empty() => url,
405        _ => {
406            tracing::debug!(
407                "Skipping dynamic model fetch for '{}': no base_url",
408                provider_name
409            );
410            return;
411        }
412    };
413
414    // Get the API key from auth storage
415    let auth_store = crate::store::auth_storage::shared_auth_storage();
416    let api_key = match auth_store.get_api_key(provider_name) {
417        Some(key) => key,
418        None => {
419            tracing::debug!(
420                "Skipping dynamic model fetch for '{}': no API key",
421                provider_name
422            );
423            return;
424        }
425    };
426
427    // Only fetch for OpenAI-compatible providers (api = openai-completions or openai-responses)
428    let api_type = oxi_sdk::get_provider_api(provider_name);
429    let is_openai_compatible = api_type.is_none_or(|api| {
430        matches!(
431            api,
432            oxi_sdk::Api::OpenAiCompletions | oxi_sdk::Api::OpenAiResponses
433        )
434    });
435
436    if !is_openai_compatible {
437        tracing::debug!(
438            "Skipping dynamic model fetch for '{}': not OpenAI-compatible",
439            provider_name
440        );
441        return;
442    }
443
444    tracing::info!(
445        "Fetching models from {}/models for provider '{}'...",
446        base_url,
447        provider_name
448    );
449
450    match oxi_sdk::fetch_models_blocking(&base_url, &api_key) {
451        Ok(model_ids) => {
452            tracing::info!(
453                "Fetched {} models from provider '{}'",
454                model_ids.len(),
455                provider_name
456            );
457
458            // Update settings cache
459            if let Ok(mut settings) = crate::store::settings::Settings::load() {
460                settings
461                    .dynamic_models
462                    .insert(provider_name.to_string(), model_ids);
463                if let Err(e) = settings.save() {
464                    tracing::warn!("Failed to save dynamic models cache: {}", e);
465                }
466            }
467        }
468        Err(e) => {
469            tracing::warn!(
470                "Failed to fetch models from provider '{}': {}. \
471                 Falling back to static model list.",
472                provider_name,
473                e
474            );
475        }
476    }
477}
478
479// ── Load theme list ─────────────────────────────────────────────────────────
480
481fn load_themes() -> Vec<String> {
482    // Built-in theme names from oxi-cli theme system
483    vec![
484        "oxi_dark".to_string(),
485        "oxi_light".to_string(),
486        "nord".to_string(),
487        "catppuccin".to_string(),
488        "github_dark".to_string(),
489        "monokai".to_string(),
490    ]
491}
492
493// ── Save auth keys ──────────────────────────────────────────────────────────
494
495// ── Save settings ───────────────────────────────────────────────────────────
496
497/// Save the selected model and theme to settings.
498fn save_settings(
499    model_id: &str,
500    theme_name: &str,
501    custom_base_urls: &[(String, String)],
502) -> Result<()> {
503    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
504
505    // Split "provider/model" and store as last_used
506    if let Some((provider, model_name)) = model_id.split_once('/') {
507        settings.last_used_provider = Some(provider.to_string());
508        settings.last_used_model = Some(model_name.to_string());
509    } else {
510        settings.last_used_model = Some(model_id.to_string());
511    }
512    settings.theme = theme_name.to_string();
513
514    // Ensure custom providers with base_url are registered
515    for (name, base_url) in custom_base_urls {
516        let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
517        if !already_exists {
518            settings
519                .custom_providers
520                .push(crate::store::settings::CustomProvider {
521                    name: name.clone(),
522                    base_url: base_url.clone(),
523                    api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
524                    api: "openai-completions".to_string(),
525                });
526        }
527    }
528
529    settings.save()?;
530    Ok(())
531}
532
533// ── Draw functions ──────────────────────────────────────────────────────────
534
535fn draw_wizard(
536    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
537    state: &mut WizardState,
538) -> Result<()> {
539    terminal.draw(|f| render_wizard(f, state))?;
540    Ok(())
541}
542
543/// Render the wizard into a frame. Split out from `draw_wizard` so the layout
544/// (title bar, persistent step indicator, content, footer) can be exercised
545/// against a `TestBackend` buffer in tests — verifying the step indicator is
546/// visible on every step and that step-specific content lands in the right row.
547fn render_wizard(f: &mut ratatui::Frame, state: &mut WizardState) {
548    let size = f.area();
549
550    // Create outer layout
551    let chunks = Layout::default()
552        .direction(Direction::Vertical)
553        .constraints([
554            Constraint::Length(3), // Title bar
555            Constraint::Length(1), // Step indicator (always visible)
556            Constraint::Min(8),    // Content
557            Constraint::Length(2), // Footer
558        ])
559        .split(size);
560
561    // Title bar
562    let title = Paragraph::new(Line::from(vec![
563        Span::styled(
564            " oxi ",
565            Style::default()
566                .fg(Color::Rgb(255, 165, 0))
567                .add_modifier(Modifier::BOLD),
568        ),
569        Span::styled(
570            "oxi Setup Wizard",
571            Style::default().add_modifier(Modifier::BOLD),
572        ),
573    ]))
574    .block(Block::default().borders(Borders::TOP));
575    f.render_widget(title, chunks[0]);
576
577    // Persistent step indicator — its own dedicated line so it never collides
578    // with the first list item (which hid it when it was a borderless block
579    // `.title()` — the title and item share row 0).
580    f.render_widget(Paragraph::new(build_step_indicator(state.step)), chunks[1]);
581
582    // Content depends on step
583    match state.step {
584        0 => draw_provider_step(f, state, chunks[2]),
585        1 => draw_model_step(f, state, chunks[2]),
586        2 => draw_theme_step(f, state, chunks[2]),
587        3 => draw_done_step(f, state, chunks[2]),
588        _ => {}
589    }
590
591    // Footer
592    let footer_text = match state.step {
593        0 => match &state.input_mode {
594            InputMode::Normal => {
595                if state.provider_searching {
596                    "  Type: filter · ↑/↓ navigate · Enter: select & edit key · Esc/←: close search"
597                        .to_string()
598                } else {
599                    "  ↑/↓ navigate · /: search · Enter: API key · d: delete · →: next · Esc: quit"
600                        .to_string()
601                }
602            }
603            InputMode::EditingApiKey { .. } => "  Enter: save · Esc: cancel".to_string(),
604            InputMode::AddingCustom { .. } => {
605                "  Tab: next field · Enter: save · Esc: cancel".to_string()
606            }
607        },
608        1 => "  Type: filter · ↑/↓ navigate · Enter: select · Esc: clear/back · ←: previous"
609            .to_string(),
610        2 => "  ↑/↓ navigate · Enter: select · Esc/←: previous".to_string(),
611        3 => "  Esc or Enter: quit".to_string(),
612        _ => String::new(),
613    };
614    let footer = Paragraph::new(Line::from(Span::styled(
615        footer_text,
616        Style::default().fg(Color::DarkGray),
617    )));
618    f.render_widget(footer, chunks[3]);
619}
620
621fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
622    match &state.input_mode {
623        InputMode::Normal => draw_provider_list(f, state, area),
624        InputMode::EditingApiKey {
625            provider_name,
626            field_text,
627        } => draw_api_key_dialog(f, provider_name, field_text, area),
628        InputMode::AddingCustom {
629            fields,
630            active_field,
631        } => draw_custom_provider_dialog(f, fields, *active_field, area),
632    }
633}
634
635fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
636    // When filtering, reserve a one-line search input above the list.
637    let (list_area, search_area) = if state.provider_searching {
638        let chunks = Layout::default()
639            .direction(Direction::Vertical)
640            .constraints([Constraint::Length(1), Constraint::Min(1)])
641            .split(area);
642        (chunks[1], Some(chunks[0]))
643    } else {
644        (area, None)
645    };
646
647    // Search input. The solid block cursor sits right after the typed text
648    // (and before the placeholder when empty), mirroring a real text cursor.
649    if let Some(search_rect) = search_area {
650        let mut spans = vec![
651            Span::styled(
652                "  Filter: ",
653                Style::default()
654                    .fg(Color::Yellow)
655                    .add_modifier(Modifier::BOLD),
656            ),
657            Span::styled(
658                &state.provider_filter,
659                Style::default().add_modifier(Modifier::BOLD),
660            ),
661            Span::styled(" ", Style::default().bg(Color::Yellow)),
662        ];
663        if state.provider_filter.is_empty() {
664            spans.push(Span::styled(
665                " type a provider name to filter...",
666                Style::default().fg(Color::DarkGray),
667            ));
668        }
669        f.render_widget(Paragraph::new(Line::from(spans)), search_rect);
670    }
671
672    // Rendered provider indices: filtered while searching, all otherwise.
673    let indices: Vec<usize> = if state.provider_searching {
674        filtered_provider_indices(state)
675    } else {
676        (0..state.providers.len()).collect()
677    };
678
679    let mut items: Vec<ListItem> = indices
680        .iter()
681        .map(|&i| {
682            let p = &state.providers[i];
683            let check = if p.has_key { "[x]" } else { "[ ]" };
684            let key_info = if p.has_key {
685                format!("API key: {}", p.key_masked)
686            } else {
687                "No API key".to_string()
688            };
689            let custom_tag = if p.is_custom { " (custom)" } else { "" };
690
691            let line = Line::from(vec![
692                Span::styled(
693                    format!(" {} ", check),
694                    Style::default().fg(if p.has_key {
695                        Color::Green
696                    } else {
697                        Color::DarkGray
698                    }),
699                ),
700                Span::styled(
701                    format!("{:<14}", p.name),
702                    Style::default().add_modifier(Modifier::BOLD),
703                ),
704                Span::styled(
705                    format!("[{}]", key_info),
706                    Style::default().fg(Color::DarkGray),
707                ),
708                Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
709            ]);
710            ListItem::new(line)
711        })
712        .collect();
713
714    // "Add custom" sentinel row only in the unfiltered view.
715    if !state.provider_searching {
716        items.push(ListItem::new(Line::from(vec![
717            Span::styled("   + ", Style::default().fg(Color::Cyan)),
718            Span::styled("Add custom provider...", Style::default().fg(Color::Cyan)),
719        ])));
720    }
721
722    let list = List::new(items)
723        .block(Block::default().borders(Borders::NONE))
724        .highlight_style(
725            Style::default()
726                .bg(Color::DarkGray)
727                .add_modifier(Modifier::BOLD),
728        )
729        .highlight_symbol("▶ ");
730
731    // Map the absolute selected index onto its position in the rendered list.
732    let selected_pos = if state.provider_searching {
733        indices.iter().position(|&i| i == state.provider_selected)
734    } else {
735        // provider_selected indexes the provider slice directly; the "+ Add
736        // custom" row sits at the end so the absolute index still maps 1:1.
737        Some(state.provider_selected)
738    };
739    state.provider_list_state.select(selected_pos);
740    f.render_stateful_widget(list, list_area, &mut state.provider_list_state);
741}
742
743fn draw_api_key_dialog(f: &mut ratatui::Frame, provider_name: &str, field_text: &str, area: Rect) {
744    // Center the dialog
745    let dialog_height = 7u16;
746    let dialog_width = std::cmp::min(area.width, 60);
747    let x = (area.width.saturating_sub(dialog_width)) / 2;
748    let y = (area.height.saturating_sub(dialog_height)) / 2;
749
750    let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
751
752    let display_text = if field_text.is_empty() {
753        String::new()
754    } else {
755        "*".repeat(field_text.len())
756    };
757
758    let paragraphs = vec![
759        Line::from(""),
760        Line::from(vec![
761            Span::styled("  API Key: ", Style::default().add_modifier(Modifier::BOLD)),
762            Span::styled(
763                format!("[{:<width$}]", display_text, width = 30),
764                Style::default(),
765            ),
766            if field_text.is_empty() {
767                Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
768            } else {
769                Span::raw("")
770            },
771        ]),
772    ];
773
774    let block = Block::default()
775        .borders(Borders::ALL)
776        .title(format!(" {} API Key ", provider_name));
777
778    let para = Paragraph::new(paragraphs).block(block);
779    f.render_widget(para, dialog_area);
780}
781
782fn draw_custom_provider_dialog(
783    f: &mut ratatui::Frame,
784    fields: &[String; 3],
785    active_field: usize,
786    area: Rect,
787) {
788    let dialog_height = 9u16;
789    let dialog_width = std::cmp::min(area.width, 60);
790    let x = (area.width.saturating_sub(dialog_width)) / 2;
791    let y = (area.height.saturating_sub(dialog_height)) / 2;
792
793    let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
794
795    let field_labels = ["Name", "Base URL", "API Key"];
796    let lines: Vec<Line> = std::iter::once(Line::from(""))
797        .chain(field_labels.iter().enumerate().map(|(i, label)| {
798            let display = if i == 2 && !fields[i].is_empty() {
799                "*".repeat(fields[i].len())
800            } else {
801                fields[i].clone()
802            };
803            let is_active = i == active_field;
804            let style = if is_active {
805                Style::default().add_modifier(Modifier::BOLD)
806            } else {
807                Style::default()
808            };
809            Line::from(vec![
810                Span::styled(format!("  {:<10}", format!("{}:", label)), style),
811                Span::styled(format!("[{:<width$}]", display, width = 35), style),
812                if is_active && fields[i].is_empty() {
813                    Span::styled("<enter>", Style::default().fg(Color::DarkGray))
814                } else {
815                    Span::raw("")
816                },
817            ])
818        }))
819        .collect();
820
821    let block = Block::default()
822        .borders(Borders::ALL)
823        .title(" Add Custom Provider ");
824
825    let para = Paragraph::new(lines).block(block);
826    f.render_widget(para, dialog_area);
827}
828
829fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
830    // No configured providers (none with an API key) → nothing to choose among.
831    // Guide the user back to step 1 instead of rendering an empty list.
832    if state.models.is_empty() {
833        let msg = Paragraph::new(vec![
834            Line::from(""),
835            Line::from(Span::styled(
836                "  No providers with an API key configured yet.",
837                Style::default()
838                    .fg(Color::Yellow)
839                    .add_modifier(Modifier::BOLD),
840            )),
841            Line::from(""),
842            Line::from(Span::styled(
843                "  Press Left to go back and add a provider key first.",
844                Style::default().fg(Color::DarkGray),
845            )),
846        ]);
847        f.render_widget(msg, area);
848        return;
849    }
850
851    // Reserve a one-line filter input at the top; the list fills the rest.
852    let chunks = Layout::default()
853        .direction(Direction::Vertical)
854        .constraints([Constraint::Length(1), Constraint::Min(1)])
855        .split(area);
856
857    // Filter input — always visible (the list is filtered live, fzf-style).
858    // The solid block cursor sits right after the typed text (and before the
859    // placeholder when empty), mirroring a real text cursor.
860    let mut spans = vec![
861        Span::styled(
862            "  Filter: ",
863            Style::default()
864                .fg(Color::Yellow)
865                .add_modifier(Modifier::BOLD),
866        ),
867        Span::styled(
868            &state.model_filter,
869            Style::default().add_modifier(Modifier::BOLD),
870        ),
871        Span::styled(" ", Style::default().bg(Color::Yellow)),
872    ];
873    if state.model_filter.is_empty() {
874        spans.push(Span::styled(
875            " type to filter (e.g. 'gpt-4', 'claude', 'gemini')...",
876            Style::default().fg(Color::DarkGray),
877        ));
878    }
879    f.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
880
881    // Filtered model list with highlight + scrolling (handles the huge catalog).
882    let indices = filtered_model_indices(state);
883    let items: Vec<ListItem> = indices
884        .iter()
885        .map(|&i| {
886            let m = &state.models[i];
887            let ctx_str = if m.context_window >= 1_000_000 {
888                format!("{}M ctx", m.context_window / 1_000_000)
889            } else {
890                format!("{}K ctx", m.context_window / 1_000)
891            };
892            ListItem::new(Line::from(vec![
893                Span::styled(format!("{:<40}", m.id), Style::default()),
894                Span::styled(
895                    format!("({})", m.provider),
896                    Style::default().fg(Color::DarkGray),
897                ),
898                Span::styled(
899                    format!(", {}", ctx_str),
900                    Style::default().fg(Color::DarkGray),
901                ),
902            ]))
903        })
904        .collect();
905
906    let list = List::new(items)
907        .block(Block::default().borders(Borders::NONE))
908        .highlight_style(
909            Style::default()
910                .bg(Color::DarkGray)
911                .add_modifier(Modifier::BOLD),
912        )
913        .highlight_symbol("▶ ");
914
915    let selected_pos = indices.iter().position(|&i| i == state.model_selected);
916    state.model_list_state.select(selected_pos);
917    f.render_stateful_widget(list, chunks[1], &mut state.model_list_state);
918
919    // Empty-state hint when the filter matches nothing.
920    if indices.is_empty() {
921        let hint = Paragraph::new(Line::from(Span::styled(
922            "  No models match your filter. Press Esc to clear.",
923            Style::default().fg(Color::DarkGray),
924        )));
925        f.render_widget(hint, chunks[1]);
926    }
927}
928
929fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
930    let items: Vec<ListItem> = state
931        .themes
932        .iter()
933        .map(|t| ListItem::new(Line::from(format!("  {}", t))))
934        .collect();
935
936    let list = List::new(items)
937        .block(Block::default().borders(Borders::NONE))
938        .highlight_style(
939            Style::default()
940                .bg(Color::DarkGray)
941                .add_modifier(Modifier::BOLD),
942        );
943
944    state.theme_list_state.select(Some(state.theme_selected));
945    f.render_stateful_widget(list, area, &mut state.theme_list_state);
946}
947
948fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
949    let settings_path_display = state.settings_path.display().to_string();
950    let auth_path_display = state.auth_path.display().to_string();
951
952    let lines = vec![
953        Line::from(""),
954        Line::from(Span::styled(
955            "  Settings saved!",
956            Style::default()
957                .fg(Color::Green)
958                .add_modifier(Modifier::BOLD),
959        )),
960        Line::from(""),
961        Line::from(Span::styled(
962            format!("  Settings file: {}", settings_path_display),
963            Style::default().fg(Color::DarkGray),
964        )),
965        Line::from(Span::styled(
966            format!("  Auth file: {}", auth_path_display),
967            Style::default().fg(Color::DarkGray),
968        )),
969        Line::from(""),
970        Line::from(Span::styled(
971            "  Run 'oxi' to start.",
972            Style::default().add_modifier(Modifier::BOLD),
973        )),
974    ];
975
976    let block = Block::default().borders(Borders::NONE);
977    let para = Paragraph::new(lines).block(block);
978    f.render_widget(para, area);
979}
980
981fn build_step_indicator(current_step: usize) -> Line<'static> {
982    let steps = [
983        ("1. Provider Setup", 0),
984        ("2. Default Model", 1),
985        ("3. Theme", 2),
986        ("4. Done", 3),
987    ];
988
989    let spans: Vec<Span> = steps
990        .iter()
991        .flat_map(|(label, step)| {
992            let style = if *step == current_step {
993                Style::default()
994                    .add_modifier(Modifier::BOLD)
995                    .fg(Color::Cyan)
996            } else if *step < current_step {
997                Style::default().fg(Color::Green)
998            } else {
999                Style::default().fg(Color::DarkGray)
1000            };
1001            vec![Span::styled(format!("  {}", label), style), Span::raw(" ")]
1002        })
1003        .collect();
1004
1005    Line::from(spans)
1006}
1007
1008// ── Event handling ──────────────────────────────────────────────────────────
1009
1010fn handle_event(
1011    state: &mut WizardState,
1012    event: Event,
1013    auth_store: &crate::store::auth_storage::AuthStorage,
1014) -> Result<bool> {
1015    match state.step {
1016        0 => handle_provider_event(state, event, auth_store),
1017        1 => handle_model_event(state, event),
1018        2 => handle_theme_event(state, event),
1019        3 => handle_done_event(event),
1020        _ => Ok(false),
1021    }
1022}
1023
1024fn handle_provider_event(
1025    state: &mut WizardState,
1026    event: Event,
1027    auth_store: &crate::store::auth_storage::AuthStorage,
1028) -> Result<bool> {
1029    // Provider search mode (only active in Normal input mode). Handled before
1030    // the input_mode match so search keys never collide with key-editing.
1031    if state.provider_searching && matches!(state.input_mode, InputMode::Normal) {
1032        if let Event::Key(key) = event {
1033            match key.code {
1034                KeyCode::Esc => {
1035                    state.provider_searching = false;
1036                    state.provider_filter.clear();
1037                    snap_provider_selection(state);
1038                }
1039                KeyCode::Enter => {
1040                    // Pick the highlighted filtered provider, exit search, and
1041                    // open the API-key dialog for it.
1042                    state.provider_searching = false;
1043                    state.provider_filter.clear();
1044                    if state.provider_selected < state.providers.len() {
1045                        let name = state.providers[state.provider_selected].name.clone();
1046                        state.input_mode = InputMode::EditingApiKey {
1047                            provider_name: name,
1048                            field_text: String::new(),
1049                        };
1050                    }
1051                }
1052                KeyCode::Up => {
1053                    let indices = filtered_provider_indices(state);
1054                    if let Some(pos) = indices.iter().position(|&i| i == state.provider_selected)
1055                        && pos > 0
1056                    {
1057                        state.provider_selected = indices[pos - 1];
1058                    } else if let Some(&first) = indices.first() {
1059                        state.provider_selected = first;
1060                    }
1061                }
1062                KeyCode::Down => {
1063                    let indices = filtered_provider_indices(state);
1064                    if let Some(pos) = indices.iter().position(|&i| i == state.provider_selected)
1065                        && pos + 1 < indices.len()
1066                    {
1067                        state.provider_selected = indices[pos + 1];
1068                    } else if let Some(&first) = indices.first() {
1069                        state.provider_selected = first;
1070                    }
1071                }
1072                KeyCode::Backspace => {
1073                    state.provider_filter.pop();
1074                    snap_provider_selection(state);
1075                }
1076                KeyCode::Char(c) => {
1077                    state.provider_filter.push(c);
1078                    snap_provider_selection(state);
1079                }
1080                KeyCode::Left => {
1081                    state.provider_searching = false;
1082                    state.provider_filter.clear();
1083                    snap_provider_selection(state);
1084                }
1085                _ => {}
1086            }
1087        }
1088        return Ok(false);
1089    }
1090
1091    match &mut state.input_mode {
1092        InputMode::Normal => {
1093            if let Event::Key(key) = event {
1094                match key.code {
1095                    KeyCode::Up if state.provider_selected > 0 => {
1096                        state.provider_selected -= 1;
1097                    }
1098                    KeyCode::Down => {
1099                        // +1 for "add custom" row
1100                        let max = state.providers.len();
1101                        if state.provider_selected < max {
1102                            state.provider_selected += 1;
1103                        }
1104                    }
1105                    KeyCode::Enter => {
1106                        if state.provider_selected == state.providers.len() {
1107                            // Add custom provider
1108                            state.input_mode = InputMode::AddingCustom {
1109                                fields: [String::new(), String::new(), String::new()],
1110                                active_field: 0,
1111                            };
1112                        } else {
1113                            // Edit API key
1114                            let name = state.providers[state.provider_selected].name.clone();
1115                            state.input_mode = InputMode::EditingApiKey {
1116                                provider_name: name,
1117                                field_text: String::new(),
1118                            };
1119                        }
1120                    }
1121                    KeyCode::Char('d') | KeyCode::Delete
1122                        if state.provider_selected < state.providers.len() =>
1123                    {
1124                        let name = state.providers[state.provider_selected].name.clone();
1125                        auth_store.remove(&name);
1126                        state.providers[state.provider_selected].has_key = false;
1127                        state.providers[state.provider_selected].key_masked = String::new();
1128                        state.models_dirty = true;
1129                    }
1130                    KeyCode::Char('/') => {
1131                        state.provider_searching = true;
1132                        state.provider_filter.clear();
1133                        // Snap off the "+ Add custom" sentinel (index ==
1134                        // providers.len()) so a filtered row is highlighted.
1135                        snap_provider_selection(state);
1136                    }
1137                    KeyCode::Right => {
1138                        state.step = 1;
1139                    }
1140                    KeyCode::Esc => {
1141                        return Ok(true); // quit
1142                    }
1143                    _ => {}
1144                }
1145            }
1146        }
1147        InputMode::EditingApiKey {
1148            provider_name,
1149            field_text,
1150        } => {
1151            if let Event::Key(key) = event {
1152                match key.code {
1153                    KeyCode::Esc => {
1154                        state.input_mode = InputMode::Normal;
1155                    }
1156                    KeyCode::Enter => {
1157                        if !field_text.is_empty() {
1158                            auth_store.set_api_key(provider_name, field_text.clone());
1159
1160                            // Update the provider entry
1161                            if let Some(entry) = state
1162                                .providers
1163                                .iter_mut()
1164                                .find(|p| p.name == *provider_name)
1165                            {
1166                                entry.has_key = true;
1167                                entry.key_masked = mask_key(field_text);
1168                            }
1169
1170                            // Try to fetch models dynamically from the provider's /models endpoint
1171                            fetch_and_cache_models(provider_name, &state.providers);
1172
1173                            // Mark models dirty; the main loop rebuilds the list
1174                            // (filtered to configured providers) when the user
1175                            // reaches step 1.
1176                            state.models_dirty = true;
1177                        }
1178                        state.input_mode = InputMode::Normal;
1179                    }
1180                    KeyCode::Backspace => {
1181                        field_text.pop();
1182                    }
1183                    KeyCode::Char(c) => {
1184                        field_text.push(c);
1185                    }
1186                    _ => {}
1187                }
1188            }
1189        }
1190        InputMode::AddingCustom {
1191            fields,
1192            active_field,
1193        } => {
1194            if let Event::Key(key) = event {
1195                match key.code {
1196                    KeyCode::Esc => {
1197                        state.input_mode = InputMode::Normal;
1198                    }
1199                    KeyCode::Tab => {
1200                        *active_field = (*active_field + 1) % 3;
1201                    }
1202                    KeyCode::BackTab => {
1203                        *active_field = (*active_field + 2) % 3;
1204                    }
1205                    KeyCode::Enter => {
1206                        let name = fields[0].trim().to_string();
1207                        let base_url = fields[1].trim().to_string();
1208                        let api_key = fields[2].trim().to_string();
1209
1210                        if !name.is_empty() && !base_url.is_empty() {
1211                            // Save API key
1212                            if !api_key.is_empty() {
1213                                auth_store.set_api_key(&name, api_key.clone());
1214                            }
1215
1216                            // Add to provider list
1217                            let (has_key, key_masked) = if !api_key.is_empty() {
1218                                (true, mask_key(&api_key))
1219                            } else {
1220                                (false, String::new())
1221                            };
1222
1223                            state.providers.push(ProviderEntry {
1224                                name: name.clone(),
1225                                has_key,
1226                                key_masked,
1227                                is_custom: true,
1228                                base_url: Some(base_url),
1229                            });
1230
1231                            // Try to fetch models from this custom provider
1232                            if !api_key.is_empty() {
1233                                fetch_and_cache_models(&name, &state.providers);
1234                            }
1235                            // Rebuild the model list on next step-1 entry when
1236                            // this provider has a key.
1237                            state.models_dirty = has_key;
1238
1239                            // Move back to normal
1240                            state.input_mode = InputMode::Normal;
1241                        }
1242                    }
1243                    KeyCode::Backspace => {
1244                        fields[*active_field].pop();
1245                    }
1246                    KeyCode::Char(c) => {
1247                        fields[*active_field].push(c);
1248                    }
1249                    _ => {}
1250                }
1251            }
1252        }
1253    }
1254    Ok(false)
1255}
1256
1257fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
1258    if let Event::Key(key) = event {
1259        // The model list is always filtered live (fzf-style): every printable
1260        // char extends the filter, Backspace shrinks it. There is no separate
1261        // "search mode" to enter — the filter input is always active.
1262        match key.code {
1263            KeyCode::Char(c) => {
1264                state.model_filter.push(c);
1265                ensure_model_selected_visible(state);
1266            }
1267            KeyCode::Backspace => {
1268                state.model_filter.pop();
1269                ensure_model_selected_visible(state);
1270            }
1271            KeyCode::Up => {
1272                let indices = filtered_model_indices(state);
1273                if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1274                    && pos > 0
1275                {
1276                    state.model_selected = indices[pos - 1];
1277                } else if let Some(&first) = indices.first() {
1278                    state.model_selected = first;
1279                }
1280            }
1281            KeyCode::Down => {
1282                let indices = filtered_model_indices(state);
1283                if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1284                    && pos + 1 < indices.len()
1285                {
1286                    state.model_selected = indices[pos + 1];
1287                } else if let Some(&first) = indices.first() {
1288                    state.model_selected = first;
1289                }
1290            }
1291            KeyCode::Enter => {
1292                // Only advance when the filter yields a selectable model. A
1293                // non-empty filter that matches nothing leaves nothing to
1294                // confirm, so stay put rather than silently carrying over a
1295                // stale selection into the next step.
1296                if !filtered_model_indices(state).is_empty() {
1297                    state.step = 2;
1298                }
1299            }
1300            KeyCode::Esc => {
1301                // Esc backs out one level: clear an active filter, otherwise
1302                // return to the provider step.
1303                if !state.model_filter.is_empty() {
1304                    state.model_filter.clear();
1305                    ensure_model_selected_visible(state);
1306                } else {
1307                    state.step = 0;
1308                }
1309            }
1310            KeyCode::Left => {
1311                state.step = 0;
1312            }
1313            _ => {}
1314        }
1315    }
1316    Ok(false)
1317}
1318
1319fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
1320    if let Event::Key(key) = event {
1321        match key.code {
1322            KeyCode::Up if state.theme_selected > 0 => {
1323                state.theme_selected -= 1;
1324            }
1325            KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
1326                state.theme_selected += 1;
1327            }
1328            KeyCode::Enter => {
1329                // Save everything and go to done
1330                finish_setup(state)?;
1331                state.step = 3;
1332            }
1333            KeyCode::Esc | KeyCode::Left => {
1334                state.step = 1;
1335            }
1336            _ => {}
1337        }
1338    }
1339    Ok(false)
1340}
1341
1342fn handle_done_event(event: Event) -> Result<bool> {
1343    if let Event::Key(key) = event {
1344        match key.code {
1345            KeyCode::Enter | KeyCode::Esc => {
1346                return Ok(true); // quit
1347            }
1348            _ => {}
1349        }
1350    }
1351    Ok(false)
1352}
1353
1354// ── Finish: persist all selections ──────────────────────────────────────────
1355
1356fn finish_setup(state: &mut WizardState) -> Result<()> {
1357    // Get selected model
1358    let model_id = state
1359        .models
1360        .get(state.model_selected)
1361        .map(|m| format!("{}/{}", m.provider, m.id))
1362        .unwrap_or_default();
1363
1364    // Get selected theme
1365    let theme_name = state
1366        .themes
1367        .get(state.theme_selected)
1368        .cloned()
1369        .unwrap_or_else(|| "oxi_dark".to_string());
1370
1371    // Collect custom provider base URLs
1372    let custom_base_urls: Vec<(String, String)> = state
1373        .providers
1374        .iter()
1375        .filter_map(|p| {
1376            if p.is_custom {
1377                p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1378            } else {
1379                None
1380            }
1381        })
1382        .collect();
1383
1384    save_settings(&model_id, &theme_name, &custom_base_urls)?;
1385
1386    Ok(())
1387}
1388
1389// ── Main entry point ────────────────────────────────────────────────────────
1390
1391/// Run the interactive setup wizard.
1392pub async fn run() -> Result<()> {
1393    // Setup terminal
1394    enable_raw_mode()?;
1395    let mut stdout = io::stdout();
1396    execute!(stdout, EnterAlternateScreen)?;
1397    let backend = CrosstermBackend::new(stdout);
1398    let mut terminal = Terminal::new(backend)?;
1399
1400    // Ensure terminal is restored on panic
1401    let panic_hook = std::panic::take_hook();
1402    std::panic::set_hook(Box::new(move |info| {
1403        let _ = disable_raw_mode();
1404        let _ = execute!(io::stdout(), LeaveAlternateScreen);
1405        panic_hook(info);
1406    }));
1407
1408    // Initialize the catalog port for model/provider lookups.
1409    let catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>> = {
1410        let paths = crate::services::OxiPaths::default_paths().ok();
1411        if let Some(paths) = paths {
1412            let config = oxi_sdk::CatalogConfig {
1413                cache_path: paths.home.join("cache").join("models-dev.json"),
1414                etag_path: paths.home.join("cache").join("models-dev.json.etag"),
1415                override_path: paths.home.join("catalog").join("overrides.toml"),
1416                // Don't trigger a network refresh during setup.
1417                fetch_enabled: false,
1418                ..Default::default()
1419            };
1420            oxi_sdk::FileModelCatalog::init(config)
1421                .await
1422                .ok()
1423                .map(|c| c as _)
1424        } else {
1425            None
1426        }
1427    };
1428
1429    // Load data
1430    let auth_store = crate::store::auth_storage::shared_auth_storage();
1431    let providers = load_providers(&auth_store, catalog.as_ref());
1432    let allowed = keyed_provider_names(&providers);
1433    let models = load_models(catalog.as_ref(), Some(&allowed));
1434    let themes = load_themes();
1435
1436    let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1437        dirs::home_dir()
1438            .unwrap_or_default()
1439            .join(".oxi")
1440            .join("auth.json")
1441    });
1442    let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1443        dirs::home_dir()
1444            .unwrap_or_default()
1445            .join(".oxi")
1446            .join("settings.json")
1447    });
1448
1449    // Find the index of the current default model
1450    let current_model = crate::store::settings::Settings::load()
1451        .ok()
1452        .and_then(|s| s.last_used_model.clone())
1453        .unwrap_or_default();
1454
1455    let model_selected = models
1456        .iter()
1457        .position(|m| {
1458            let full_id = format!("{}/{}", m.provider, m.id);
1459            full_id == current_model || m.id == current_model
1460        })
1461        .unwrap_or(0);
1462
1463    // Find the index of the current theme
1464    let current_theme = crate::store::settings::Settings::load()
1465        .ok()
1466        .map(|s| s.theme.clone())
1467        .unwrap_or_else(|| "oxi_dark".to_string());
1468
1469    let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1470
1471    let mut state = WizardState {
1472        step: 0,
1473        providers,
1474        provider_selected: 0,
1475        provider_list_state: ListState::default(),
1476        provider_filter: String::new(),
1477        provider_searching: false,
1478        input_mode: InputMode::Normal,
1479        models,
1480        model_selected,
1481        model_filter: String::new(),
1482        model_list_state: ListState::default(),
1483        models_dirty: false,
1484        themes,
1485        theme_selected,
1486        theme_list_state: ListState::default(),
1487        auth_path,
1488        settings_path,
1489        catalog,
1490    };
1491
1492    // Main loop
1493    loop {
1494        // If a provider key changed, rebuild the model list (restricted to the
1495        // now-configured providers) before drawing — so step 1 never shows
1496        // stale or unconfigured-provider models.
1497        if state.step == 1 && state.models_dirty {
1498            refresh_models(&mut state);
1499            state.models_dirty = false;
1500        }
1501        draw_wizard(&mut terminal, &mut state)?;
1502
1503        if event::poll(std::time::Duration::from_millis(100))?
1504            && let Event::Key(key) = event::read()?
1505        {
1506            // Ctrl+C always quits
1507            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1508                break;
1509            }
1510
1511            let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1512            if should_quit {
1513                break;
1514            }
1515        }
1516    }
1517
1518    // Restore terminal
1519    disable_raw_mode()?;
1520    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1521
1522    Ok(())
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527    use super::*;
1528
1529    fn make_state(providers: Vec<&str>, models: Vec<(&str, &str)>) -> WizardState {
1530        WizardState {
1531            step: 0,
1532            providers: providers
1533                .iter()
1534                .map(|n| ProviderEntry {
1535                    name: n.to_string(),
1536                    has_key: false,
1537                    key_masked: String::new(),
1538                    is_custom: false,
1539                    base_url: None,
1540                })
1541                .collect(),
1542            provider_selected: 0,
1543            provider_list_state: ListState::default(),
1544            provider_filter: String::new(),
1545            provider_searching: false,
1546            input_mode: InputMode::Normal,
1547            models: models
1548                .iter()
1549                .map(|(id, provider)| {
1550                    ModelEntry::new(id.to_string(), provider.to_string(), 128_000)
1551                })
1552                .collect(),
1553            model_selected: 0,
1554            model_filter: String::new(),
1555            model_list_state: ListState::default(),
1556            models_dirty: false,
1557            themes: vec![],
1558            theme_selected: 0,
1559            theme_list_state: ListState::default(),
1560            auth_path: PathBuf::new(),
1561            settings_path: PathBuf::new(),
1562            catalog: None,
1563        }
1564    }
1565
1566    #[test]
1567    fn provider_filter_matches_name_case_insensitive() {
1568        let mut s = make_state(vec!["anthropic", "openai", "google", "mistral"], vec![]);
1569        assert_eq!(filtered_provider_indices(&s), vec![0, 1, 2, 3]);
1570
1571        s.provider_filter = "ANT".to_string();
1572        assert_eq!(filtered_provider_indices(&s), vec![0]); // anthropic
1573
1574        s.provider_filter = "goog".to_string();
1575        assert_eq!(filtered_provider_indices(&s), vec![2]); // google
1576    }
1577
1578    #[test]
1579    fn model_filter_matches_id_or_provider() {
1580        let mut s = make_state(
1581            vec![],
1582            vec![
1583                ("gpt-4o", "openai"),
1584                ("gpt-4-turbo", "openai"),
1585                ("claude-3-opus", "anthropic"),
1586                ("gemini-pro", "google"),
1587            ],
1588        );
1589        assert_eq!(filtered_model_indices(&s), vec![0, 1, 2, 3]);
1590
1591        s.model_filter = "gpt".to_string();
1592        assert_eq!(filtered_model_indices(&s), vec![0, 1]);
1593
1594        s.model_filter = "anthropic".to_string();
1595        assert_eq!(filtered_model_indices(&s), vec![2]); // matched by provider
1596
1597        s.model_filter = "OPUS".to_string();
1598        assert_eq!(filtered_model_indices(&s), vec![2]); // case-insensitive
1599    }
1600
1601    #[test]
1602    fn model_filter_empty_result_yields_no_indices() {
1603        let mut state = make_state(vec![], vec![("gpt-4o", "openai")]);
1604        state.model_filter = "zzz".to_string();
1605        assert!(filtered_model_indices(&state).is_empty());
1606    }
1607
1608    #[test]
1609    fn ensure_model_selected_snaps_to_first_match() {
1610        let mut state = make_state(
1611            vec![],
1612            vec![
1613                ("gpt-4o", "openai"),
1614                ("claude-3", "anthropic"),
1615                ("gpt-3.5", "openai"),
1616            ],
1617        );
1618        // Selection starts at index 0 (gpt-4o).
1619        state.model_filter = "gpt".to_string();
1620        ensure_model_selected_visible(&mut state);
1621        // gpt-4o is in the filtered set {0, 2}, so it stays.
1622        assert_eq!(state.model_selected, 0);
1623
1624        // Now filter to only claude; selection must snap to it.
1625        state.model_filter = "claude".to_string();
1626        ensure_model_selected_visible(&mut state);
1627        assert_eq!(state.model_selected, 1);
1628    }
1629
1630    #[test]
1631    fn snap_provider_selection_into_filtered_set() {
1632        let mut state = make_state(vec!["anthropic", "openai", "google"], vec![]);
1633        state.provider_selected = 2; // google
1634        state.provider_filter = "open".to_string();
1635        snap_provider_selection(&mut state);
1636        assert_eq!(state.provider_selected, 1); // openai
1637    }
1638
1639    #[test]
1640    fn snap_provider_noop_when_filter_empty_matches_all() {
1641        let mut state = make_state(vec!["anthropic", "openai"], vec![]);
1642        state.provider_selected = 1;
1643        state.provider_filter = String::new();
1644        snap_provider_selection(&mut state);
1645        assert_eq!(state.provider_selected, 1); // unchanged
1646    }
1647    #[test]
1648    fn keyed_provider_names_only_includes_configured() {
1649        let providers = vec![
1650            ProviderEntry {
1651                name: "anthropic".to_string(),
1652                has_key: true,
1653                key_masked: "sk-1...abcd".to_string(),
1654                is_custom: false,
1655                base_url: None,
1656            },
1657            ProviderEntry {
1658                name: "openai".to_string(),
1659                has_key: false,
1660                key_masked: String::new(),
1661                is_custom: false,
1662                base_url: None,
1663            },
1664            ProviderEntry {
1665                name: "local".to_string(),
1666                has_key: true,
1667                key_masked: "x...y".to_string(),
1668                is_custom: true,
1669                base_url: Some("http://localhost:11434".to_string()),
1670            },
1671        ];
1672        let set = keyed_provider_names(&providers);
1673        assert!(set.contains("anthropic"));
1674        assert!(set.contains("local"));
1675        assert!(!set.contains("openai"));
1676        assert_eq!(set.len(), 2);
1677    }
1678
1679    #[test]
1680    fn keyed_provider_names_empty_when_none_configured() {
1681        let providers = vec![ProviderEntry {
1682            name: "openai".to_string(),
1683            has_key: false,
1684            key_masked: String::new(),
1685            is_custom: false,
1686            base_url: None,
1687        }];
1688        assert!(keyed_provider_names(&providers).is_empty());
1689    }
1690    /// Render the full wizard into a TestBackend buffer and return the
1691    /// concatenated cell text (rows joined with '\n') for substring assertions.
1692    fn render_to_buffer(step: usize, models: Vec<ModelEntry>) -> String {
1693        use ratatui::backend::TestBackend;
1694        let providers = vec![
1695            ProviderEntry {
1696                name: "openai".to_string(),
1697                has_key: true,
1698                key_masked: "k...1".to_string(),
1699                is_custom: false,
1700                base_url: None,
1701            },
1702            ProviderEntry {
1703                name: "anthropic".to_string(),
1704                has_key: false,
1705                key_masked: String::new(),
1706                is_custom: false,
1707                base_url: None,
1708            },
1709        ];
1710        let mut state = WizardState {
1711            step,
1712            providers,
1713            provider_selected: 0,
1714            provider_list_state: ListState::default(),
1715            provider_filter: String::new(),
1716            provider_searching: false,
1717            input_mode: InputMode::Normal,
1718            models,
1719            model_selected: 0,
1720            model_filter: String::new(),
1721            model_list_state: ListState::default(),
1722            themes: vec!["oxi_dark".to_string()],
1723            theme_selected: 0,
1724            theme_list_state: ListState::default(),
1725            auth_path: PathBuf::new(),
1726            settings_path: PathBuf::new(),
1727            catalog: None,
1728            models_dirty: false,
1729        };
1730        let backend = TestBackend::new(90, 24);
1731        let mut terminal = Terminal::new(backend).unwrap();
1732        terminal.draw(|f| render_wizard(f, &mut state)).unwrap();
1733        let buf = terminal.backend().buffer();
1734        let area = buf.area();
1735        let mut out = String::new();
1736        for y in 0..area.height {
1737            for x in 0..area.width {
1738                out.push_str(buf[(x, y)].symbol());
1739            }
1740            out.push('\n');
1741        }
1742        out
1743    }
1744
1745    #[test]
1746    fn step_indicator_visible_on_every_step() {
1747        // The step indicator must render on its own dedicated line for every
1748        // step — the bug it fixes hid it (borderless-block title collided with
1749        // the first list item).
1750        for (step, label) in [
1751            (0usize, "1. Provider Setup"),
1752            (1, "2. Default Model"),
1753            (2, "3. Theme"),
1754            (3, "4. Done"),
1755        ] {
1756            let models = vec![ModelEntry::new(
1757                "gpt-4o".to_string(),
1758                "openai".to_string(),
1759                128_000,
1760            )];
1761            let rendered = render_to_buffer(step, models);
1762            assert!(
1763                rendered.contains(label),
1764                "step {step}: indicator label {label:?} missing from buffer:\n{rendered}"
1765            );
1766        }
1767    }
1768
1769    #[test]
1770    fn model_step_shows_empty_state_when_no_provider_keyed() {
1771        // With an empty model list (no keyed providers), step 1 must show the
1772        // guidance message, not a bare empty filter list.
1773        let rendered = render_to_buffer(1, vec![]);
1774        assert!(rendered.contains("No providers with an API key configured yet."));
1775        assert!(rendered.contains("Press Left to go back"));
1776    }
1777
1778    #[test]
1779    fn model_step_shows_configured_provider_model() {
1780        // Only models from keyed providers appear. Here the only keyed
1781        // provider is "openai", so a claude model (anthropic, not keyed) must
1782        // NOT show even if it were somehow in the list — but since we pass the
1783        // filtered list directly, we assert the openai model renders.
1784        let models = vec![ModelEntry::new(
1785            "gpt-4o".to_string(),
1786            "openai".to_string(),
1787            128_000,
1788        )];
1789        let rendered = render_to_buffer(1, models);
1790        assert!(rendered.contains("gpt-4o"));
1791    }
1792    // ── Esc-centric key model ──────────────────────────────────────────────
1793    // Esc is the universal "back out one level" key: cancel sub-mode → clear
1794    // filter → previous step → quit. These exercise the handlers directly
1795    // (deterministic, no terminal-timing dependency).
1796
1797    fn esc_event() -> Event {
1798        Event::Key(crossterm::event::KeyEvent::new(
1799            KeyCode::Esc,
1800            KeyModifiers::NONE,
1801        ))
1802    }
1803
1804    #[test]
1805    fn esc_quits_from_provider_step_normal() {
1806        let mut state = make_state(vec!["openai"], vec![]);
1807        state.step = 0;
1808        let auth = crate::store::auth_storage::shared_auth_storage();
1809        let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1810        assert!(quit, "Esc on step 0 Normal should quit");
1811    }
1812
1813    #[test]
1814    fn esc_in_provider_search_closes_search_not_quit() {
1815        let mut state = make_state(vec!["openai", "anthropic"], vec![]);
1816        state.step = 0;
1817        state.provider_searching = true;
1818        state.provider_filter = "anth".to_string();
1819        let auth = crate::store::auth_storage::shared_auth_storage();
1820        let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1821        assert!(!quit, "Esc in search must close search, not quit");
1822        assert!(!state.provider_searching);
1823        assert!(state.provider_filter.is_empty());
1824    }
1825
1826    #[test]
1827    fn esc_backs_out_of_model_step_when_filter_empty() {
1828        let mut state = make_state(vec!["openai"], vec![("gpt-4o", "openai")]);
1829        state.step = 1;
1830        state.model_filter = String::new();
1831        handle_model_event(&mut state, esc_event()).unwrap();
1832        assert_eq!(
1833            state.step, 0,
1834            "Esc with empty filter should return to the provider step"
1835        );
1836    }
1837
1838    #[test]
1839    fn esc_clears_model_filter_when_nonempty() {
1840        let mut state = make_state(
1841            vec!["openai"],
1842            vec![("gpt-4o", "openai"), ("gpt-4", "openai")],
1843        );
1844        state.step = 1;
1845        state.model_filter = "gpt".to_string();
1846        handle_model_event(&mut state, esc_event()).unwrap();
1847        assert_eq!(
1848            state.step, 1,
1849            "Esc with a non-empty filter should stay on the model step"
1850        );
1851        assert!(state.model_filter.is_empty(), "Esc should clear the filter");
1852    }
1853
1854    #[test]
1855    fn esc_backs_out_of_theme_step() {
1856        let mut state = make_state(vec!["openai"], vec![]);
1857        state.step = 2;
1858        state.themes = vec!["oxi_dark".to_string()];
1859        handle_theme_event(&mut state, esc_event()).unwrap();
1860        assert_eq!(state.step, 1);
1861    }
1862
1863    #[test]
1864    fn esc_quits_from_done_step() {
1865        assert!(
1866            handle_done_event(esc_event()).unwrap(),
1867            "Esc on the done step should quit"
1868        );
1869    }
1870}