Skip to main content

reflex/semantic/
configure.rs

1//! Interactive TUI configuration wizard for AI provider setup
2
3use anyhow::{Context, Result};
4use crossterm::{
5    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
6    execute,
7    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
8};
9use ratatui::{
10    Frame, Terminal,
11    backend::CrosstermBackend,
12    layout::{Alignment, Constraint, Direction, Layout},
13    style::{Color, Modifier, Style},
14    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
15};
16use std::collections::HashMap;
17use std::io::{self, Stdout};
18
19/// Available AI providers
20const PROVIDERS: &[&str] = &["openai", "anthropic", "openrouter", "openai-compatible"];
21
22// OpenAI and Anthropic model lists are fetched dynamically from each provider's
23// /v1/models endpoint at wizard runtime (see fetch_openai_models_blocking,
24// fetch_anthropic_models_blocking). These small fallback lists are only used
25// when the live fetch fails (offline, key revoked, regional outage), so the
26// wizard remains usable without a network connection.
27const OPENAI_FALLBACK_MODELS: &[&str] = &[
28    "gpt-5.1",
29    "gpt-5.1-mini",
30    "gpt-5",
31    "gpt-5-mini",
32    "gpt-4.1",
33    "gpt-4.1-mini",
34    "gpt-4o",
35    "gpt-4o-mini",
36];
37const ANTHROPIC_FALLBACK_MODELS: &[&str] =
38    &["claude-sonnet-4-5", "claude-haiku-4-5", "claude-sonnet-4"];
39use crate::semantic::providers::openrouter::OpenRouterModel;
40
41/// Sort strategies for OpenRouter provider routing
42const OPENROUTER_SORT_STRATEGIES: &[(&str, &str)] = &[
43    ("price", "Cheapest provider for the model"),
44    ("latency", "Fastest response time (lowest latency)"),
45    ("throughput", "Highest tokens per second"),
46];
47
48/// Wizard screen states
49#[derive(Debug, Clone, PartialEq)]
50enum WizardScreen {
51    ProviderSelection,
52    BaseUrlInput,
53    ApiKeyInput,
54    FetchingModels,
55    ModelSelection,
56    ModelTextInput,
57    SortStrategySelection,
58    ConnectivityTest,
59    Result { success: bool, message: String },
60}
61
62/// Load existing API key for a provider from ~/.reflex/config.toml
63fn load_existing_api_key(provider: &str) -> Option<String> {
64    match crate::semantic::config::get_api_key(provider) {
65        Ok(key) if !key.is_empty() => {
66            log::debug!("Found existing API key for {}", provider);
67            Some(key)
68        }
69        _ => {
70            log::debug!("No existing API key found for {}", provider);
71            None
72        }
73    }
74}
75
76/// Load existing base URL for the openai-compatible provider
77fn load_existing_base_url() -> Option<String> {
78    crate::semantic::config::get_provider_options("openai-compatible")
79        .and_then(|opts| opts.get("base_url").cloned())
80        .filter(|s| !s.is_empty())
81}
82
83/// Load existing model preference for the openai-compatible provider
84fn load_existing_compatible_model() -> Option<String> {
85    crate::semantic::config::get_user_model("openai-compatible")
86}
87
88/// Mask API key for display (show first 7 and last 4 characters)
89fn mask_api_key(key: &str) -> String {
90    if key.len() <= 11 {
91        // Too short to mask meaningfully
92        return "*".repeat(key.len());
93    }
94
95    let start = &key[..7];
96    let end = &key[key.len() - 4..];
97    format!("{}...{}", start, end)
98}
99
100/// Main configuration wizard state
101pub struct ConfigWizard {
102    screen: WizardScreen,
103    selected_provider_idx: usize,
104    api_key: String,
105    api_key_cursor: usize,
106    selected_model_idx: usize,
107    selected_sort_idx: usize,
108    error_message: Option<String>,
109    existing_api_key: Option<String>,
110    /// Dynamically fetched models (OpenRouter)
111    fetched_models: Vec<OpenRouterModel>,
112    /// Dynamically fetched plain model IDs (OpenAI, Anthropic)
113    fetched_dynamic_models: Vec<String>,
114    /// Current search/filter text for model selection
115    model_filter: String,
116    /// Base URL for openai-compatible endpoints
117    base_url: String,
118    base_url_cursor: usize,
119    /// Free-text model name for openai-compatible (since we cannot enumerate)
120    model_text: String,
121    model_text_cursor: usize,
122    /// Previously-saved base URL (used to pre-populate the wizard on re-run)
123    existing_base_url: Option<String>,
124    /// Previously-saved openai-compatible model name
125    existing_compatible_model: Option<String>,
126}
127
128impl ConfigWizard {
129    pub fn new() -> Self {
130        Self {
131            screen: WizardScreen::ProviderSelection,
132            selected_provider_idx: 0,
133            api_key: String::new(),
134            api_key_cursor: 0,
135            selected_model_idx: 0,
136            selected_sort_idx: 0,
137            error_message: None,
138            existing_api_key: None,
139            fetched_models: Vec::new(),
140            fetched_dynamic_models: Vec::new(),
141            model_filter: String::new(),
142            base_url: String::new(),
143            base_url_cursor: 0,
144            model_text: String::new(),
145            model_text_cursor: 0,
146            existing_base_url: None,
147            existing_compatible_model: None,
148        }
149    }
150
151    /// Get the currently selected provider
152    fn selected_provider(&self) -> &str {
153        PROVIDERS[self.selected_provider_idx]
154    }
155
156    /// True for providers whose model list supports a typed text filter.
157    /// OpenAI/Anthropic/OpenRouter are dynamically fetched and may be long;
158    /// type-to-filter narrows the displayed list.
159    fn supports_filter(&self) -> bool {
160        matches!(
161            self.selected_provider(),
162            "openrouter" | "openai" | "anthropic"
163        )
164    }
165
166    /// Get available static models for the current provider.
167    ///
168    /// Returns empty for every provider since openai/anthropic/openrouter all
169    /// fetch dynamically and openai-compatible uses free-text input. Kept as a
170    /// hook in case a future provider ships with a static catalog.
171    fn static_models(&self) -> &'static [&'static str] {
172        &[]
173    }
174
175    /// Get filtered model IDs for display (applies search filter for dynamic providers).
176    fn filtered_model_ids(&self) -> Vec<String> {
177        let filter = self.model_filter.to_lowercase();
178        match self.selected_provider() {
179            "openrouter" => self
180                .fetched_models
181                .iter()
182                .filter(|m| {
183                    if filter.is_empty() {
184                        return true;
185                    }
186                    m.id.to_lowercase().contains(&filter) || m.name.to_lowercase().contains(&filter)
187                })
188                .map(|m| m.id.clone())
189                .collect(),
190            "openai" | "anthropic" => self
191                .fetched_dynamic_models
192                .iter()
193                .filter(|id| filter.is_empty() || id.to_lowercase().contains(&filter))
194                .cloned()
195                .collect(),
196            _ => self.static_models().iter().map(|s| s.to_string()).collect(),
197        }
198    }
199
200    /// Get the currently selected sort strategy (OpenRouter only)
201    fn selected_sort(&self) -> &str {
202        OPENROUTER_SORT_STRATEGIES[self.selected_sort_idx].0
203    }
204
205    /// Get the currently selected model
206    fn selected_model(&self) -> String {
207        let models = self.filtered_model_ids();
208        if self.selected_model_idx < models.len() {
209            models[self.selected_model_idx].clone()
210        } else if !models.is_empty() {
211            models[0].clone()
212        } else {
213            String::new()
214        }
215    }
216
217    /// Get the OpenRouterModel info for a model ID in the filtered list
218    fn filtered_openrouter_model(&self, idx: usize) -> Option<&OpenRouterModel> {
219        let filter = self.model_filter.to_lowercase();
220        self.fetched_models
221            .iter()
222            .filter(|m| {
223                if filter.is_empty() {
224                    return true;
225                }
226                m.id.to_lowercase().contains(&filter) || m.name.to_lowercase().contains(&filter)
227            })
228            .nth(idx)
229    }
230
231    /// Handle keyboard input based on current screen
232    fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
233        // Handle Ctrl+C globally to exit wizard
234        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
235            return Ok(true);
236        }
237
238        match &self.screen {
239            WizardScreen::ProviderSelection => self.handle_provider_selection_key(key),
240            WizardScreen::BaseUrlInput => self.handle_base_url_input_key(key),
241            WizardScreen::ApiKeyInput => self.handle_api_key_input_key(key),
242            WizardScreen::FetchingModels => Ok(false), // No input during fetch
243            WizardScreen::ModelSelection => self.handle_model_selection_key(key),
244            WizardScreen::ModelTextInput => self.handle_model_text_input_key(key),
245            WizardScreen::SortStrategySelection => self.handle_sort_strategy_key(key),
246            WizardScreen::ConnectivityTest => Ok(false), // No input during test
247            WizardScreen::Result { .. } => {
248                // Any key exits on result screen
249                if key.code == KeyCode::Enter || key.code == KeyCode::Char('q') {
250                    return Ok(true);
251                }
252                Ok(false)
253            }
254        }
255    }
256
257    /// Handle keys for provider selection screen
258    fn handle_provider_selection_key(&mut self, key: KeyEvent) -> Result<bool> {
259        match key.code {
260            KeyCode::Up | KeyCode::Char('k') => {
261                if self.selected_provider_idx > 0 {
262                    self.selected_provider_idx -= 1;
263                }
264            }
265            KeyCode::Down | KeyCode::Char('j') => {
266                if self.selected_provider_idx < PROVIDERS.len() - 1 {
267                    self.selected_provider_idx += 1;
268                }
269            }
270            KeyCode::Enter => {
271                // Check if API key already exists for this provider
272                self.existing_api_key = load_existing_api_key(self.selected_provider());
273
274                if self.selected_provider() == "openai-compatible" {
275                    // Pre-populate base URL and model from any prior config
276                    self.existing_base_url = load_existing_base_url();
277                    self.existing_compatible_model = load_existing_compatible_model();
278                    self.base_url = self.existing_base_url.clone().unwrap_or_default();
279                    self.base_url_cursor = self.base_url.len();
280                    self.model_text = self.existing_compatible_model.clone().unwrap_or_default();
281                    self.model_text_cursor = self.model_text.len();
282                    self.error_message = None;
283                    self.screen = WizardScreen::BaseUrlInput;
284                } else {
285                    // Move to API key input for the standard providers
286                    self.screen = WizardScreen::ApiKeyInput;
287                    self.api_key.clear();
288                    self.api_key_cursor = 0;
289                }
290            }
291            KeyCode::Esc | KeyCode::Char('q') => {
292                return Ok(true); // Exit wizard
293            }
294            _ => {}
295        }
296        Ok(false)
297    }
298
299    /// Handle keys for base URL input screen (openai-compatible only)
300    fn handle_base_url_input_key(&mut self, key: KeyEvent) -> Result<bool> {
301        match key.code {
302            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
303                self.base_url.insert(self.base_url_cursor, c);
304                self.base_url_cursor += 1;
305            }
306            KeyCode::Backspace => {
307                if self.base_url_cursor > 0 {
308                    self.base_url_cursor -= 1;
309                    self.base_url.remove(self.base_url_cursor);
310                }
311            }
312            KeyCode::Delete => {
313                if self.base_url_cursor < self.base_url.len() {
314                    self.base_url.remove(self.base_url_cursor);
315                }
316            }
317            KeyCode::Left => {
318                if self.base_url_cursor > 0 {
319                    self.base_url_cursor -= 1;
320                }
321            }
322            KeyCode::Right => {
323                if self.base_url_cursor < self.base_url.len() {
324                    self.base_url_cursor += 1;
325                }
326            }
327            KeyCode::Home => {
328                self.base_url_cursor = 0;
329            }
330            KeyCode::End => {
331                self.base_url_cursor = self.base_url.len();
332            }
333            KeyCode::Enter => {
334                let trimmed = self.base_url.trim().trim_end_matches('/');
335                if trimmed.is_empty() {
336                    self.error_message = Some("Base URL cannot be empty".to_string());
337                } else if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
338                    self.error_message =
339                        Some("Base URL must start with http:// or https://".to_string());
340                } else {
341                    self.base_url = trimmed.to_string();
342                    self.base_url_cursor = self.base_url.len();
343                    self.error_message = None;
344                    self.screen = WizardScreen::ApiKeyInput;
345                    self.api_key.clear();
346                    self.api_key_cursor = 0;
347                }
348            }
349            KeyCode::Esc => {
350                self.error_message = None;
351                self.screen = WizardScreen::ProviderSelection;
352            }
353            _ => {}
354        }
355        Ok(false)
356    }
357
358    /// Handle keys for API key input screen
359    fn handle_api_key_input_key(&mut self, key: KeyEvent) -> Result<bool> {
360        match key.code {
361            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
362                self.api_key.insert(self.api_key_cursor, c);
363                self.api_key_cursor += 1;
364            }
365            KeyCode::Backspace => {
366                if self.api_key_cursor > 0 {
367                    self.api_key_cursor -= 1;
368                    self.api_key.remove(self.api_key_cursor);
369                }
370            }
371            KeyCode::Delete => {
372                if self.api_key_cursor < self.api_key.len() {
373                    self.api_key.remove(self.api_key_cursor);
374                }
375            }
376            KeyCode::Left => {
377                if self.api_key_cursor > 0 {
378                    self.api_key_cursor -= 1;
379                }
380            }
381            KeyCode::Right => {
382                if self.api_key_cursor < self.api_key.len() {
383                    self.api_key_cursor += 1;
384                }
385            }
386            KeyCode::Home => {
387                self.api_key_cursor = 0;
388            }
389            KeyCode::End => {
390                self.api_key_cursor = self.api_key.len();
391            }
392            KeyCode::Enter => {
393                let provider = self.selected_provider();
394                let is_compatible = provider == "openai-compatible";
395
396                // Determine the next screen for the chosen provider
397                let next_screen = match provider {
398                    "openrouter" | "openai" | "anthropic" => WizardScreen::FetchingModels,
399                    "openai-compatible" => WizardScreen::ModelTextInput,
400                    _ => WizardScreen::ModelSelection,
401                };
402
403                if self.api_key.is_empty() {
404                    if let Some(ref existing_key) = self.existing_api_key {
405                        log::debug!("Keeping existing API key for {}", provider);
406                        self.api_key = existing_key.clone();
407                        self.error_message = None;
408                        self.selected_model_idx = 0;
409                        self.model_filter.clear();
410                        self.screen = next_screen;
411                    } else if is_compatible {
412                        // Local servers (LMStudio, Ollama, llama.cpp) often don't need a key
413                        log::debug!("Proceeding without API key for openai-compatible");
414                        self.error_message = None;
415                        self.selected_model_idx = 0;
416                        self.model_filter.clear();
417                        self.screen = next_screen;
418                    } else {
419                        self.error_message = Some("API key cannot be empty".to_string());
420                    }
421                } else {
422                    self.error_message = None;
423                    self.selected_model_idx = 0;
424                    self.model_filter.clear();
425                    self.screen = next_screen;
426                }
427            }
428            KeyCode::Esc => {
429                // openai-compatible has BaseUrlInput as the previous screen
430                if self.selected_provider() == "openai-compatible" {
431                    self.screen = WizardScreen::BaseUrlInput;
432                } else {
433                    self.screen = WizardScreen::ProviderSelection;
434                }
435            }
436            _ => {}
437        }
438        Ok(false)
439    }
440
441    /// Handle keys for model selection screen
442    fn handle_model_selection_key(&mut self, key: KeyEvent) -> Result<bool> {
443        let is_openrouter = self.selected_provider() == "openrouter";
444        let supports_filter = self.supports_filter();
445        let model_count = self.filtered_model_ids().len();
446
447        match key.code {
448            KeyCode::Up => {
449                if self.selected_model_idx > 0 {
450                    self.selected_model_idx -= 1;
451                }
452            }
453            KeyCode::Down => {
454                if model_count > 0 && self.selected_model_idx < model_count - 1 {
455                    self.selected_model_idx += 1;
456                }
457            }
458            // j/k vim navigation only works when typing-to-filter is disabled,
459            // otherwise those characters would always be swallowed as filter input.
460            KeyCode::Char('k') if !supports_filter => {
461                if self.selected_model_idx > 0 {
462                    self.selected_model_idx -= 1;
463                }
464            }
465            KeyCode::Char('j') if !supports_filter => {
466                if model_count > 0 && self.selected_model_idx < model_count - 1 {
467                    self.selected_model_idx += 1;
468                }
469            }
470            KeyCode::Char(c)
471                if supports_filter && !key.modifiers.contains(KeyModifiers::CONTROL) =>
472            {
473                self.model_filter.push(c);
474                self.selected_model_idx = 0;
475            }
476            KeyCode::Backspace if supports_filter => {
477                self.model_filter.pop();
478                self.selected_model_idx = 0;
479            }
480            KeyCode::Enter => {
481                if model_count == 0 {
482                    // No models to select
483                    return Ok(false);
484                }
485                if is_openrouter {
486                    self.selected_sort_idx = 0;
487                    self.screen = WizardScreen::SortStrategySelection;
488                } else {
489                    self.screen = WizardScreen::ConnectivityTest;
490                }
491            }
492            KeyCode::Esc => {
493                self.model_filter.clear();
494                self.screen = WizardScreen::ApiKeyInput;
495            }
496            _ => {}
497        }
498        Ok(false)
499    }
500
501    /// Handle keys for free-text model input (openai-compatible only)
502    fn handle_model_text_input_key(&mut self, key: KeyEvent) -> Result<bool> {
503        match key.code {
504            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
505                self.model_text.insert(self.model_text_cursor, c);
506                self.model_text_cursor += 1;
507            }
508            KeyCode::Backspace => {
509                if self.model_text_cursor > 0 {
510                    self.model_text_cursor -= 1;
511                    self.model_text.remove(self.model_text_cursor);
512                }
513            }
514            KeyCode::Delete => {
515                if self.model_text_cursor < self.model_text.len() {
516                    self.model_text.remove(self.model_text_cursor);
517                }
518            }
519            KeyCode::Left => {
520                if self.model_text_cursor > 0 {
521                    self.model_text_cursor -= 1;
522                }
523            }
524            KeyCode::Right => {
525                if self.model_text_cursor < self.model_text.len() {
526                    self.model_text_cursor += 1;
527                }
528            }
529            KeyCode::Home => {
530                self.model_text_cursor = 0;
531            }
532            KeyCode::End => {
533                self.model_text_cursor = self.model_text.len();
534            }
535            KeyCode::Enter => {
536                if self.model_text.trim().is_empty() {
537                    self.error_message = Some("Model name cannot be empty".to_string());
538                } else {
539                    self.error_message = None;
540                    self.screen = WizardScreen::ConnectivityTest;
541                }
542            }
543            KeyCode::Esc => {
544                self.error_message = None;
545                self.screen = WizardScreen::ApiKeyInput;
546            }
547            _ => {}
548        }
549        Ok(false)
550    }
551
552    /// Handle keys for sort strategy selection screen (OpenRouter only)
553    fn handle_sort_strategy_key(&mut self, key: KeyEvent) -> Result<bool> {
554        match key.code {
555            KeyCode::Up | KeyCode::Char('k') => {
556                if self.selected_sort_idx > 0 {
557                    self.selected_sort_idx -= 1;
558                }
559            }
560            KeyCode::Down | KeyCode::Char('j') => {
561                if self.selected_sort_idx < OPENROUTER_SORT_STRATEGIES.len() - 1 {
562                    self.selected_sort_idx += 1;
563                }
564            }
565            KeyCode::Enter => {
566                self.screen = WizardScreen::ConnectivityTest;
567            }
568            KeyCode::Esc => {
569                // Go back to model selection
570                self.screen = WizardScreen::ModelSelection;
571            }
572            _ => {}
573        }
574        Ok(false)
575    }
576
577    /// Render the current screen
578    fn render(&mut self, frame: &mut Frame) {
579        // Clone screen to avoid borrow conflict with &mut self render methods
580        let screen = self.screen.clone();
581        match &screen {
582            WizardScreen::ProviderSelection => self.render_provider_selection(frame),
583            WizardScreen::BaseUrlInput => self.render_base_url_input(frame),
584            WizardScreen::ApiKeyInput => self.render_api_key_input(frame),
585            WizardScreen::FetchingModels => self.render_fetching_models(frame),
586            WizardScreen::ModelSelection => self.render_model_selection(frame),
587            WizardScreen::ModelTextInput => self.render_model_text_input(frame),
588            WizardScreen::SortStrategySelection => self.render_sort_strategy_selection(frame),
589            WizardScreen::ConnectivityTest => self.render_connectivity_test(frame),
590            WizardScreen::Result { success, message } => {
591                self.render_result(frame, *success, message)
592            }
593        }
594    }
595
596    /// Render provider selection screen
597    fn render_provider_selection(&mut self, frame: &mut Frame) {
598        let chunks = Layout::default()
599            .direction(Direction::Vertical)
600            .margin(2)
601            .constraints([
602                Constraint::Length(3),
603                Constraint::Min(0),
604                Constraint::Length(3),
605            ])
606            .split(frame.area());
607
608        // Title
609        let title = Paragraph::new("Reflex AI Configuration Wizard")
610            .style(
611                Style::default()
612                    .fg(Color::Cyan)
613                    .add_modifier(Modifier::BOLD),
614            )
615            .alignment(Alignment::Center)
616            .block(Block::default().borders(Borders::ALL));
617        frame.render_widget(title, chunks[0]);
618
619        // Provider list
620        let providers: Vec<ListItem> = PROVIDERS
621            .iter()
622            .map(|provider| {
623                let provider_display = match *provider {
624                    "openrouter" => format!("{} (200+ models)", provider),
625                    _ => provider.to_string(),
626                };
627
628                ListItem::new(provider_display)
629            })
630            .collect();
631
632        let list = List::new(providers)
633            .block(Block::default().borders(Borders::ALL).title(
634                "Select AI Provider (↑/↓ to navigate, Enter to select, Esc/q/Ctrl+C to quit)",
635            ))
636            .highlight_style(
637                Style::default()
638                    .fg(Color::Yellow)
639                    .add_modifier(Modifier::BOLD),
640            )
641            .highlight_symbol("> ");
642
643        let mut list_state = ListState::default().with_selected(Some(self.selected_provider_idx));
644        frame.render_stateful_widget(list, chunks[1], &mut list_state);
645
646        // Help text
647        let help = Paragraph::new(
648            "Use arrow keys or j/k to navigate, Enter to select, Esc/q/Ctrl+C to quit",
649        )
650        .style(Style::default().fg(Color::DarkGray))
651        .alignment(Alignment::Center);
652        frame.render_widget(help, chunks[2]);
653    }
654
655    /// Render API key input screen
656    fn render_api_key_input(&mut self, frame: &mut Frame) {
657        let chunks = Layout::default()
658            .direction(Direction::Vertical)
659            .margin(2)
660            .constraints([
661                Constraint::Length(3),
662                Constraint::Length(5),
663                Constraint::Min(0),
664                Constraint::Length(3),
665            ])
666            .split(frame.area());
667
668        // Title
669        let title = Paragraph::new(format!("Configure {} API Key", self.selected_provider()))
670            .style(
671                Style::default()
672                    .fg(Color::Cyan)
673                    .add_modifier(Modifier::BOLD),
674            )
675            .alignment(Alignment::Center)
676            .block(Block::default().borders(Borders::ALL));
677        frame.render_widget(title, chunks[0]);
678
679        // API key input (masked)
680        let masked_key = "*".repeat(self.api_key.len());
681        let input_text = if self.api_key_cursor < masked_key.len() {
682            format!(
683                "{}█{}",
684                &masked_key[..self.api_key_cursor],
685                &masked_key[self.api_key_cursor..]
686            )
687        } else {
688            format!("{}█", masked_key)
689        };
690
691        let input = Paragraph::new(input_text)
692            .style(Style::default().fg(Color::Yellow))
693            .block(
694                Block::default()
695                    .borders(Borders::ALL)
696                    .title(format!("Enter API Key for {}", self.selected_provider())),
697            );
698        frame.render_widget(input, chunks[1]);
699
700        // Error message or instructions
701        let message_widget = if let Some(ref error) = self.error_message {
702            Paragraph::new(error.as_str())
703                .style(Style::default().fg(Color::Red))
704                .alignment(Alignment::Center)
705        } else if let Some(ref existing_key) = self.existing_api_key {
706            // Show masked existing key
707            let masked = mask_api_key(existing_key);
708            Paragraph::new(format!(
709                "Current API key: {}\n\
710                Press Enter to keep existing key, or type a new key to replace it\n\
711                Your API key will be securely stored in ~/.reflex/config.toml",
712                masked
713            ))
714            .style(Style::default().fg(Color::Yellow))
715            .alignment(Alignment::Center)
716        } else {
717            Paragraph::new("Your API key will be securely stored in ~/.reflex/config.toml")
718                .style(Style::default().fg(Color::Green))
719                .alignment(Alignment::Center)
720        };
721        frame.render_widget(message_widget, chunks[2]);
722
723        // Help text
724        let help = Paragraph::new("Enter to continue, Esc to go back, Ctrl+C to quit")
725            .style(Style::default().fg(Color::DarkGray))
726            .alignment(Alignment::Center);
727        frame.render_widget(help, chunks[3]);
728    }
729
730    /// Render model selection screen
731    fn render_model_selection(&mut self, frame: &mut Frame) {
732        let is_openrouter = self.selected_provider() == "openrouter";
733        let supports_filter = self.supports_filter();
734        let filtered = self.filtered_model_ids();
735        let model_count = filtered.len();
736
737        let constraints = if is_openrouter {
738            vec![
739                Constraint::Length(3), // Title
740                Constraint::Length(3), // Filter input
741                Constraint::Min(0),    // Model list
742                Constraint::Length(3), // Help text
743            ]
744        } else {
745            vec![
746                Constraint::Length(3), // Title
747                Constraint::Min(0),    // Model list
748                Constraint::Length(3), // Help text
749            ]
750        };
751
752        let chunks = Layout::default()
753            .direction(Direction::Vertical)
754            .margin(2)
755            .constraints(constraints)
756            .split(frame.area());
757
758        // Title
759        let title_text = if is_openrouter {
760            format!(
761                "Select Model for {} ({} models)",
762                self.selected_provider(),
763                model_count
764            )
765        } else {
766            format!("Select Model for {}", self.selected_provider())
767        };
768        let title = Paragraph::new(title_text)
769            .style(
770                Style::default()
771                    .fg(Color::Cyan)
772                    .add_modifier(Modifier::BOLD),
773            )
774            .alignment(Alignment::Center)
775            .block(Block::default().borders(Borders::ALL));
776        frame.render_widget(title, chunks[0]);
777
778        // Filter input (OpenRouter only)
779        let (list_chunk, help_chunk) = if is_openrouter {
780            let filter_text = format!("{}█", self.model_filter);
781            let filter_input = Paragraph::new(filter_text)
782                .style(Style::default().fg(Color::Yellow))
783                .block(
784                    Block::default()
785                        .borders(Borders::ALL)
786                        .title("Filter (type to search)"),
787                );
788            frame.render_widget(filter_input, chunks[1]);
789            (chunks[2], chunks[3])
790        } else {
791            (chunks[1], chunks[2])
792        };
793
794        // Model list
795        if model_count == 0 && supports_filter {
796            let empty_msg = Paragraph::new("No models match filter")
797                .style(Style::default().fg(Color::DarkGray))
798                .alignment(Alignment::Center)
799                .block(Block::default().borders(Borders::ALL).title("Models"));
800            frame.render_widget(empty_msg, list_chunk);
801        } else {
802            let model_items: Vec<ListItem> = filtered
803                .iter()
804                .enumerate()
805                .map(|(idx, model_id)| {
806                    let model_display = if is_openrouter {
807                        if let Some(m) = self.filtered_openrouter_model(idx) {
808                            format!(
809                                "{}  ${:.2} / ${:.2} per 1M tokens",
810                                model_id, m.prompt_price, m.completion_price
811                            )
812                        } else {
813                            model_id.to_string()
814                        }
815                    } else if idx == 0 {
816                        format!("{} (recommended)", model_id)
817                    } else {
818                        model_id.to_string()
819                    };
820
821                    ListItem::new(model_display)
822                })
823                .collect();
824
825            let list_title = if supports_filter {
826                "Models (↑/↓ to navigate, type to filter, Enter to select, Esc to go back)"
827            } else {
828                "Select Model (↑/↓ to navigate, Enter to select, Esc to go back, Ctrl+C to quit)"
829            };
830            let list = List::new(model_items)
831                .block(Block::default().borders(Borders::ALL).title(list_title))
832                .highlight_style(
833                    Style::default()
834                        .fg(Color::Yellow)
835                        .add_modifier(Modifier::BOLD),
836                )
837                .highlight_symbol("> ");
838
839            let mut list_state = ListState::default().with_selected(Some(self.selected_model_idx));
840            frame.render_stateful_widget(list, list_chunk, &mut list_state);
841        }
842
843        // Help text
844        let help_text = if supports_filter {
845            "Type to filter, ↑/↓ to navigate, Enter to select, Esc to go back, Ctrl+C to quit"
846        } else {
847            "Use arrow keys or j/k to navigate, Enter to select, Esc to go back, Ctrl+C to quit"
848        };
849        let help = Paragraph::new(help_text)
850            .style(Style::default().fg(Color::DarkGray))
851            .alignment(Alignment::Center);
852        frame.render_widget(help, help_chunk);
853    }
854
855    /// Render base URL input screen (openai-compatible only)
856    fn render_base_url_input(&mut self, frame: &mut Frame) {
857        let chunks = Layout::default()
858            .direction(Direction::Vertical)
859            .margin(2)
860            .constraints([
861                Constraint::Length(3),
862                Constraint::Length(3),
863                Constraint::Min(0),
864                Constraint::Length(3),
865            ])
866            .split(frame.area());
867
868        let title = Paragraph::new("Configure OpenAI-Compatible Endpoint")
869            .style(
870                Style::default()
871                    .fg(Color::Cyan)
872                    .add_modifier(Modifier::BOLD),
873            )
874            .alignment(Alignment::Center)
875            .block(Block::default().borders(Borders::ALL));
876        frame.render_widget(title, chunks[0]);
877
878        let input_text = if self.base_url_cursor < self.base_url.len() {
879            format!(
880                "{}█{}",
881                &self.base_url[..self.base_url_cursor],
882                &self.base_url[self.base_url_cursor..]
883            )
884        } else {
885            format!("{}█", self.base_url)
886        };
887
888        let input = Paragraph::new(input_text)
889            .style(Style::default().fg(Color::Yellow))
890            .block(
891                Block::default()
892                    .borders(Borders::ALL)
893                    .title("Base URL (e.g. http://localhost:1234/v1)"),
894            );
895        frame.render_widget(input, chunks[1]);
896
897        let message_widget = if let Some(ref error) = self.error_message {
898            Paragraph::new(error.as_str())
899                .style(Style::default().fg(Color::Red))
900                .alignment(Alignment::Center)
901        } else if let Some(ref existing) = self.existing_base_url {
902            Paragraph::new(format!(
903                "Current base URL: {}\n\
904                Examples: LMStudio http://localhost:1234/v1 · Ollama http://localhost:11434/v1\n\
905                Press Enter to continue.",
906                existing
907            ))
908            .style(Style::default().fg(Color::Yellow))
909            .alignment(Alignment::Center)
910            .wrap(Wrap { trim: true })
911        } else {
912            Paragraph::new(
913                "Enter the base URL of your OpenAI-compatible endpoint.\n\
914                Examples: LMStudio http://localhost:1234/v1 · Ollama http://localhost:11434/v1\n\
915                The /chat/completions path will be appended automatically.",
916            )
917            .style(Style::default().fg(Color::Green))
918            .alignment(Alignment::Center)
919            .wrap(Wrap { trim: true })
920        };
921        frame.render_widget(message_widget, chunks[2]);
922
923        let help = Paragraph::new("Enter to continue, Esc to go back, Ctrl+C to quit")
924            .style(Style::default().fg(Color::DarkGray))
925            .alignment(Alignment::Center);
926        frame.render_widget(help, chunks[3]);
927    }
928
929    /// Render free-text model input screen (openai-compatible only)
930    fn render_model_text_input(&mut self, frame: &mut Frame) {
931        let chunks = Layout::default()
932            .direction(Direction::Vertical)
933            .margin(2)
934            .constraints([
935                Constraint::Length(3),
936                Constraint::Length(3),
937                Constraint::Min(0),
938                Constraint::Length(3),
939            ])
940            .split(frame.area());
941
942        let title = Paragraph::new("Specify Model Name")
943            .style(
944                Style::default()
945                    .fg(Color::Cyan)
946                    .add_modifier(Modifier::BOLD),
947            )
948            .alignment(Alignment::Center)
949            .block(Block::default().borders(Borders::ALL));
950        frame.render_widget(title, chunks[0]);
951
952        let input_text = if self.model_text_cursor < self.model_text.len() {
953            format!(
954                "{}█{}",
955                &self.model_text[..self.model_text_cursor],
956                &self.model_text[self.model_text_cursor..]
957            )
958        } else {
959            format!("{}█", self.model_text)
960        };
961
962        let input = Paragraph::new(input_text)
963            .style(Style::default().fg(Color::Yellow))
964            .block(
965                Block::default()
966                    .borders(Borders::ALL)
967                    .title("Model name (as it appears on your endpoint)"),
968            );
969        frame.render_widget(input, chunks[1]);
970
971        let message_widget = if let Some(ref error) = self.error_message {
972            Paragraph::new(error.as_str())
973                .style(Style::default().fg(Color::Red))
974                .alignment(Alignment::Center)
975        } else if let Some(ref existing) = self.existing_compatible_model {
976            Paragraph::new(format!(
977                "Current model: {}\n\
978                Type the exact model identifier loaded on your server.",
979                existing
980            ))
981            .style(Style::default().fg(Color::Yellow))
982            .alignment(Alignment::Center)
983            .wrap(Wrap { trim: true })
984        } else {
985            Paragraph::new(
986                "Enter the model name your server hosts.\n\
987                Examples: qwen2.5-coder-32b-instruct, llama-3.1-8b-instruct, mistral-7b",
988            )
989            .style(Style::default().fg(Color::Green))
990            .alignment(Alignment::Center)
991            .wrap(Wrap { trim: true })
992        };
993        frame.render_widget(message_widget, chunks[2]);
994
995        let help = Paragraph::new("Enter to test connection, Esc to go back, Ctrl+C to quit")
996            .style(Style::default().fg(Color::DarkGray))
997            .alignment(Alignment::Center);
998        frame.render_widget(help, chunks[3]);
999    }
1000
1001    /// Render fetching models loading screen
1002    fn render_fetching_models(&mut self, frame: &mut Frame) {
1003        let chunks = Layout::default()
1004            .direction(Direction::Vertical)
1005            .margin(2)
1006            .constraints([Constraint::Length(3), Constraint::Min(0)])
1007            .split(frame.area());
1008
1009        // Title
1010        let title = Paragraph::new("Fetching Available Models...")
1011            .style(
1012                Style::default()
1013                    .fg(Color::Cyan)
1014                    .add_modifier(Modifier::BOLD),
1015            )
1016            .alignment(Alignment::Center)
1017            .block(Block::default().borders(Borders::ALL));
1018        frame.render_widget(title, chunks[0]);
1019
1020        // Loading message — name the actual provider being queried
1021        let provider_label = match self.selected_provider() {
1022            "openrouter" => "OpenRouter",
1023            "openai" => "OpenAI",
1024            "anthropic" => "Anthropic",
1025            other => other,
1026        };
1027        let body = format!(
1028            "Loading models from {}...\n\nPlease wait...",
1029            provider_label
1030        );
1031        let message = Paragraph::new(body)
1032            .style(Style::default().fg(Color::Yellow))
1033            .alignment(Alignment::Center)
1034            .wrap(Wrap { trim: true });
1035        frame.render_widget(message, chunks[1]);
1036    }
1037
1038    /// Render sort strategy selection screen (OpenRouter only)
1039    fn render_sort_strategy_selection(&mut self, frame: &mut Frame) {
1040        let chunks = Layout::default()
1041            .direction(Direction::Vertical)
1042            .margin(2)
1043            .constraints([
1044                Constraint::Length(3),
1045                Constraint::Min(0),
1046                Constraint::Length(3),
1047            ])
1048            .split(frame.area());
1049
1050        // Title
1051        let title = Paragraph::new("Select Provider Sort Strategy (OpenRouter)")
1052            .style(
1053                Style::default()
1054                    .fg(Color::Cyan)
1055                    .add_modifier(Modifier::BOLD),
1056            )
1057            .alignment(Alignment::Center)
1058            .block(Block::default().borders(Borders::ALL));
1059        frame.render_widget(title, chunks[0]);
1060
1061        // Sort strategy list
1062        let strategy_items: Vec<ListItem> = OPENROUTER_SORT_STRATEGIES
1063            .iter()
1064            .enumerate()
1065            .map(|(idx, (name, description))| {
1066                let display = if idx == 0 {
1067                    format!("{} - {} (recommended)", name, description)
1068                } else {
1069                    format!("{} - {}", name, description)
1070                };
1071
1072                ListItem::new(display)
1073            })
1074            .collect();
1075
1076        let list =
1077            List::new(strategy_items)
1078                .block(Block::default().borders(Borders::ALL).title(
1079                    "Select Sort Strategy (↑/↓ to navigate, Enter to select, Esc to go back)",
1080                ))
1081                .highlight_style(
1082                    Style::default()
1083                        .fg(Color::Yellow)
1084                        .add_modifier(Modifier::BOLD),
1085                )
1086                .highlight_symbol("> ");
1087
1088        let mut list_state = ListState::default().with_selected(Some(self.selected_sort_idx));
1089        frame.render_stateful_widget(list, chunks[1], &mut list_state);
1090
1091        // Help text
1092        let help = Paragraph::new(
1093            "Controls how OpenRouter selects the upstream provider for your chosen model",
1094        )
1095        .style(Style::default().fg(Color::DarkGray))
1096        .alignment(Alignment::Center);
1097        frame.render_widget(help, chunks[2]);
1098    }
1099
1100    /// Render connectivity test screen
1101    fn render_connectivity_test(&mut self, frame: &mut Frame) {
1102        let chunks = Layout::default()
1103            .direction(Direction::Vertical)
1104            .margin(2)
1105            .constraints([Constraint::Length(3), Constraint::Min(0)])
1106            .split(frame.area());
1107
1108        // Title
1109        let title = Paragraph::new("Testing Connection...")
1110            .style(
1111                Style::default()
1112                    .fg(Color::Cyan)
1113                    .add_modifier(Modifier::BOLD),
1114            )
1115            .alignment(Alignment::Center)
1116            .block(Block::default().borders(Borders::ALL));
1117        frame.render_widget(title, chunks[0]);
1118
1119        // Loading message
1120        let message = Paragraph::new(format!(
1121            "Testing connection to {}...\n\nPlease wait...",
1122            self.selected_provider()
1123        ))
1124        .style(Style::default().fg(Color::Yellow))
1125        .alignment(Alignment::Center)
1126        .wrap(Wrap { trim: true });
1127        frame.render_widget(message, chunks[1]);
1128    }
1129
1130    /// Render result screen
1131    fn render_result(&mut self, frame: &mut Frame, success: bool, message: &str) {
1132        let chunks = Layout::default()
1133            .direction(Direction::Vertical)
1134            .margin(2)
1135            .constraints([
1136                Constraint::Length(3),
1137                Constraint::Min(0),
1138                Constraint::Length(3),
1139            ])
1140            .split(frame.area());
1141
1142        // Title
1143        let title = if success {
1144            Paragraph::new("Configuration Successful!").style(
1145                Style::default()
1146                    .fg(Color::Green)
1147                    .add_modifier(Modifier::BOLD),
1148            )
1149        } else {
1150            Paragraph::new("Configuration Failed")
1151                .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
1152        };
1153        let title = title
1154            .alignment(Alignment::Center)
1155            .block(Block::default().borders(Borders::ALL));
1156        frame.render_widget(title, chunks[0]);
1157
1158        // Message
1159        let message_widget = Paragraph::new(message)
1160            .style(if success {
1161                Style::default().fg(Color::Green)
1162            } else {
1163                Style::default().fg(Color::Red)
1164            })
1165            .alignment(Alignment::Center)
1166            .wrap(Wrap { trim: true });
1167        frame.render_widget(message_widget, chunks[1]);
1168
1169        // Help text
1170        let help = Paragraph::new(if success {
1171            "Press Enter, q, or Ctrl+C to exit"
1172        } else {
1173            "Press Enter, q, or Ctrl+C to exit (configuration not saved)"
1174        })
1175        .style(Style::default().fg(Color::DarkGray))
1176        .alignment(Alignment::Center);
1177        frame.render_widget(help, chunks[2]);
1178    }
1179}
1180
1181/// Setup terminal for TUI
1182fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
1183    enable_raw_mode().context("Failed to enable raw mode")?;
1184    let mut stdout = io::stdout();
1185    execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
1186    let backend = CrosstermBackend::new(stdout);
1187    Terminal::new(backend).context("Failed to create terminal")
1188}
1189
1190/// Restore terminal to normal mode
1191fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
1192    disable_raw_mode().context("Failed to disable raw mode")?;
1193    execute!(terminal.backend_mut(), LeaveAlternateScreen)
1194        .context("Failed to leave alternate screen")?;
1195    terminal.show_cursor().context("Failed to show cursor")?;
1196    Ok(())
1197}
1198
1199/// Run the configuration wizard
1200pub fn run_configure_wizard() -> Result<()> {
1201    use std::io::IsTerminal;
1202    if !std::io::stdin().is_terminal() {
1203        anyhow::bail!(
1204            "The configuration wizard requires an interactive terminal.\n\
1205             \n\
1206             Run `rfx llm config` in an interactive terminal session, or configure\n\
1207             via environment variables instead:\n\
1208             \n\
1209             For OpenAI:     export OPENAI_API_KEY=sk-...\n\
1210             For Anthropic:  export ANTHROPIC_API_KEY=sk-ant-...\n\
1211             For OpenRouter: export OPENROUTER_API_KEY=sk-or-..."
1212        );
1213    }
1214    let mut terminal = setup_terminal()?;
1215    let mut wizard = ConfigWizard::new();
1216
1217    let result = run_wizard_loop(&mut terminal, &mut wizard);
1218
1219    // Always restore terminal
1220    restore_terminal(&mut terminal)?;
1221
1222    result
1223}
1224
1225/// Main wizard event loop
1226fn run_wizard_loop(
1227    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
1228    wizard: &mut ConfigWizard,
1229) -> Result<()> {
1230    loop {
1231        // Render current screen
1232        terminal.draw(|frame| wizard.render(frame))?;
1233
1234        // Dispatch /v1/models fetch by provider. OpenRouter dead-ends on error
1235        // because pricing data is critical to its UX; OpenAI and Anthropic fall
1236        // back to a small offline list so the wizard remains usable.
1237        if wizard.screen == WizardScreen::FetchingModels {
1238            let provider = wizard.selected_provider().to_string();
1239            match provider.as_str() {
1240                "openrouter" => match fetch_openrouter_models(&wizard.api_key) {
1241                    Ok(models) => {
1242                        wizard.fetched_models = models;
1243                        wizard.selected_model_idx = 0;
1244                        wizard.model_filter.clear();
1245                        wizard.error_message = None;
1246                        wizard.screen = WizardScreen::ModelSelection;
1247                    }
1248                    Err(e) => {
1249                        wizard.screen = WizardScreen::Result {
1250                            success: false,
1251                            message: format!(
1252                                "Failed to fetch models from OpenRouter: {}\n\n\
1253                                Please check your API key and try again.",
1254                                e
1255                            ),
1256                        };
1257                    }
1258                },
1259                "openai" => match fetch_openai_models_blocking(&wizard.api_key) {
1260                    Ok(ids) => {
1261                        wizard.fetched_dynamic_models = ids;
1262                        wizard.selected_model_idx = 0;
1263                        wizard.model_filter.clear();
1264                        wizard.error_message = None;
1265                        wizard.screen = WizardScreen::ModelSelection;
1266                    }
1267                    Err(e) => {
1268                        log::warn!("OpenAI /v1/models fetch failed, using fallback list: {}", e);
1269                        wizard.fetched_dynamic_models = OPENAI_FALLBACK_MODELS
1270                            .iter()
1271                            .map(|s| s.to_string())
1272                            .collect();
1273                        wizard.selected_model_idx = 0;
1274                        wizard.model_filter.clear();
1275                        wizard.error_message = Some(
1276                            "Could not reach api.openai.com — showing recent models. \
1277                            Some newer models may be missing."
1278                                .to_string(),
1279                        );
1280                        wizard.screen = WizardScreen::ModelSelection;
1281                    }
1282                },
1283                "anthropic" => match fetch_anthropic_models_blocking(&wizard.api_key) {
1284                    Ok(ids) => {
1285                        wizard.fetched_dynamic_models = ids;
1286                        wizard.selected_model_idx = 0;
1287                        wizard.model_filter.clear();
1288                        wizard.error_message = None;
1289                        wizard.screen = WizardScreen::ModelSelection;
1290                    }
1291                    Err(e) => {
1292                        log::warn!(
1293                            "Anthropic /v1/models fetch failed, using fallback list: {}",
1294                            e
1295                        );
1296                        wizard.fetched_dynamic_models = ANTHROPIC_FALLBACK_MODELS
1297                            .iter()
1298                            .map(|s| s.to_string())
1299                            .collect();
1300                        wizard.selected_model_idx = 0;
1301                        wizard.model_filter.clear();
1302                        wizard.error_message = Some(
1303                            "Could not reach api.anthropic.com — showing recent models. \
1304                            Some newer models may be missing."
1305                                .to_string(),
1306                        );
1307                        wizard.screen = WizardScreen::ModelSelection;
1308                    }
1309                },
1310                _ => {
1311                    // Unexpected provider in FetchingModels; fall through.
1312                    wizard.screen = WizardScreen::ModelSelection;
1313                }
1314            }
1315            continue;
1316        }
1317
1318        // Handle connectivity test asynchronously
1319        if wizard.screen == WizardScreen::ConnectivityTest {
1320            let provider = wizard.selected_provider().to_string();
1321            let is_compatible = provider == "openai-compatible";
1322
1323            // openai-compatible uses free-text model input; others use list selection
1324            let selected_model = if is_compatible {
1325                wizard.model_text.clone()
1326            } else {
1327                wizard.selected_model()
1328            };
1329
1330            // Build provider options. openai-compatible needs base_url here, or
1331            // the factory will bail and the connectivity test would never reach
1332            // the network. OpenRouter passes sort via save (not test).
1333            let options = if is_compatible {
1334                let mut opts = HashMap::new();
1335                opts.insert("base_url".to_string(), wizard.base_url.clone());
1336                Some(opts)
1337            } else {
1338                None
1339            };
1340
1341            let result = test_connectivity(&provider, &wizard.api_key, &selected_model, options);
1342            match result {
1343                Ok(_) => {
1344                    // Save configuration
1345                    let sort = if provider == "openrouter" {
1346                        Some(wizard.selected_sort())
1347                    } else {
1348                        None
1349                    };
1350                    let base_url = if is_compatible {
1351                        Some(wizard.base_url.as_str())
1352                    } else {
1353                        None
1354                    };
1355                    if let Err(e) = save_user_config(
1356                        &provider,
1357                        &wizard.api_key,
1358                        &selected_model,
1359                        sort,
1360                        base_url,
1361                    ) {
1362                        wizard.screen = WizardScreen::Result {
1363                            success: false,
1364                            message: format!("Failed to save configuration: {}", e),
1365                        };
1366                    } else {
1367                        wizard.screen = WizardScreen::Result {
1368                            success: true,
1369                            message: format!(
1370                                "Configuration saved successfully!\n\n\
1371                                Provider: {}\n\
1372                                Config file: ~/.reflex/config.toml\n\n\
1373                                You can now use 'rfx ask' to query your codebase.",
1374                                provider
1375                            ),
1376                        };
1377                    }
1378                }
1379                Err(e) => {
1380                    wizard.screen = WizardScreen::Result {
1381                        success: false,
1382                        message: format!(
1383                            "Connectivity test failed: {}\n\n\
1384                            Please check your endpoint, model, and credentials and try again.",
1385                            e
1386                        ),
1387                    };
1388                }
1389            }
1390            continue;
1391        }
1392
1393        // Handle keyboard input
1394        if event::poll(std::time::Duration::from_millis(100))? {
1395            if let Event::Key(key) = event::read()? {
1396                let should_exit = wizard.handle_key(key)?;
1397                if should_exit {
1398                    break;
1399                }
1400            }
1401        }
1402    }
1403
1404    Ok(())
1405}
1406
1407/// Test connectivity to the selected provider
1408fn test_connectivity(
1409    provider_name: &str,
1410    api_key: &str,
1411    model: &str,
1412    options: Option<HashMap<String, String>>,
1413) -> Result<()> {
1414    // Create a tokio runtime for async operations
1415    let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
1416
1417    runtime.block_on(async {
1418        // openai-compatible needs the model passed through; other providers
1419        // can fall back to their built-in defaults if no model is given.
1420        let model_arg = if model.is_empty() {
1421            None
1422        } else {
1423            Some(model.to_string())
1424        };
1425
1426        // Create provider instance
1427        let provider = crate::semantic::providers::create_provider(
1428            provider_name,
1429            api_key.to_string(),
1430            model_arg,
1431            options,
1432            crate::semantic::config::SemanticConfig::default().timeout_seconds,
1433        )?;
1434
1435        // Try to make a simple API call to test connectivity
1436        // Note: Must contain "json" for OpenAI structured output requirement
1437        let test_prompt = "Please respond with valid JSON: {\"status\": \"ok\"}";
1438
1439        // Call complete method (json_mode: true for test). Some local servers
1440        // do not honor response_format, but the call should still complete.
1441        provider.complete(test_prompt, true).await?;
1442
1443        Ok::<(), anyhow::Error>(())
1444    })?;
1445
1446    Ok(())
1447}
1448
1449/// Fetch models from OpenRouter API (blocking wrapper)
1450fn fetch_openrouter_models(api_key: &str) -> Result<Vec<OpenRouterModel>> {
1451    let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
1452    runtime.block_on(async { crate::semantic::providers::openrouter::fetch_models(api_key).await })
1453}
1454
1455/// Fetch chat models from OpenAI's /v1/models (blocking wrapper)
1456fn fetch_openai_models_blocking(api_key: &str) -> Result<Vec<String>> {
1457    let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
1458    runtime.block_on(async { crate::semantic::providers::openai::fetch_models(api_key).await })
1459}
1460
1461/// Fetch chat models from Anthropic's /v1/models (blocking wrapper)
1462fn fetch_anthropic_models_blocking(api_key: &str) -> Result<Vec<String>> {
1463    let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
1464    runtime.block_on(async { crate::semantic::providers::anthropic::fetch_models(api_key).await })
1465}
1466
1467/// Save user configuration to ~/.reflex/config.toml
1468fn save_user_config(
1469    provider: &str,
1470    api_key: &str,
1471    model: &str,
1472    sort: Option<&str>,
1473    base_url: Option<&str>,
1474) -> Result<()> {
1475    use serde::{Deserialize, Serialize};
1476    use std::fs;
1477
1478    #[derive(Debug, Serialize, Deserialize)]
1479    struct UserConfig {
1480        #[serde(default)]
1481        semantic: SemanticSection,
1482        #[serde(default)]
1483        credentials: HashMap<String, String>,
1484    }
1485
1486    #[derive(Debug, Serialize, Deserialize)]
1487    struct SemanticSection {
1488        provider: String,
1489    }
1490
1491    impl Default for SemanticSection {
1492        fn default() -> Self {
1493            Self {
1494                provider: "openai".to_string(),
1495            }
1496        }
1497    }
1498
1499    let home =
1500        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
1501
1502    let config_dir = home.join(".reflex");
1503    fs::create_dir_all(&config_dir).context("Failed to create ~/.reflex directory")?;
1504
1505    let config_path = config_dir.join("config.toml");
1506
1507    // Load existing config if it exists
1508    let mut config = if config_path.exists() {
1509        let config_str =
1510            fs::read_to_string(&config_path).context("Failed to read existing config file")?;
1511        toml::from_str::<UserConfig>(&config_str).unwrap_or_else(|_| UserConfig {
1512            semantic: SemanticSection::default(),
1513            credentials: HashMap::new(),
1514        })
1515    } else {
1516        UserConfig {
1517            semantic: SemanticSection::default(),
1518            credentials: HashMap::new(),
1519        }
1520    };
1521
1522    // The [semantic] provider value stays in its user-facing kebab-case form
1523    // (e.g. "openai-compatible"), but credential field names use underscores
1524    // to match the serde fields on the Credentials struct.
1525    config.semantic.provider = provider.to_string();
1526    let cred_prefix = provider.replace('-', "_");
1527
1528    // Update the specific provider's key and model in credentials
1529    let key_name = format!("{}_api_key", cred_prefix);
1530    let model_name = format!("{}_model", cred_prefix);
1531    config.credentials.insert(key_name, api_key.to_string());
1532    config.credentials.insert(model_name, model.to_string());
1533
1534    // Save sort strategy for OpenRouter
1535    if let Some(sort_value) = sort {
1536        config
1537            .credentials
1538            .insert("openrouter_sort".to_string(), sort_value.to_string());
1539    }
1540
1541    // Save base URL for openai-compatible
1542    if let Some(url) = base_url {
1543        config
1544            .credentials
1545            .insert(format!("{}_base_url", cred_prefix), url.to_string());
1546    }
1547
1548    // Serialize to TOML
1549    let toml_content =
1550        toml::to_string_pretty(&config).context("Failed to serialize config to TOML")?;
1551
1552    // Prepend comment header
1553    let final_content = format!(
1554        "# Reflex User Configuration\n\
1555         # This file stores your AI provider API keys\n\
1556         # Location: ~/.reflex/config.toml\n\
1557         \n\
1558         {}",
1559        toml_content
1560    );
1561
1562    fs::write(&config_path, final_content).context("Failed to write configuration file")?;
1563
1564    log::info!("Configuration saved to {:?}", config_path);
1565
1566    Ok(())
1567}