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