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