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