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