use anyhow::{Context, Result};
use crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use std::collections::HashMap;
use std::io::{self, Stdout};
const PROVIDERS: &[&str] = &["openai", "anthropic", "openrouter", "openai-compatible"];
const OPENAI_FALLBACK_MODELS: &[&str] = &[
"gpt-5.1",
"gpt-5.1-mini",
"gpt-5",
"gpt-5-mini",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4o",
"gpt-4o-mini",
];
const ANTHROPIC_FALLBACK_MODELS: &[&str] =
&["claude-sonnet-4-5", "claude-haiku-4-5", "claude-sonnet-4"];
use crate::semantic::providers::openrouter::OpenRouterModel;
const OPENROUTER_SORT_STRATEGIES: &[(&str, &str)] = &[
("price", "Cheapest provider for the model"),
("latency", "Fastest response time (lowest latency)"),
("throughput", "Highest tokens per second"),
];
#[derive(Debug, Clone, PartialEq)]
enum WizardScreen {
ProviderSelection,
BaseUrlInput,
ApiKeyInput,
FetchingModels,
ModelSelection,
ModelTextInput,
SortStrategySelection,
ConnectivityTest,
Result { success: bool, message: String },
}
fn load_existing_api_key(provider: &str) -> Option<String> {
match crate::semantic::config::get_api_key(provider) {
Ok(key) if !key.is_empty() => {
log::debug!("Found existing API key for {}", provider);
Some(key)
}
_ => {
log::debug!("No existing API key found for {}", provider);
None
}
}
}
fn load_existing_base_url() -> Option<String> {
crate::semantic::config::get_provider_options("openai-compatible")
.and_then(|opts| opts.get("base_url").cloned())
.filter(|s| !s.is_empty())
}
fn load_existing_compatible_model() -> Option<String> {
crate::semantic::config::get_user_model("openai-compatible")
}
fn mask_api_key(key: &str) -> String {
if key.len() <= 11 {
return "*".repeat(key.len());
}
let start = &key[..7];
let end = &key[key.len() - 4..];
format!("{}...{}", start, end)
}
pub struct ConfigWizard {
screen: WizardScreen,
selected_provider_idx: usize,
api_key: String,
api_key_cursor: usize,
selected_model_idx: usize,
selected_sort_idx: usize,
error_message: Option<String>,
existing_api_key: Option<String>,
fetched_models: Vec<OpenRouterModel>,
fetched_dynamic_models: Vec<String>,
model_filter: String,
base_url: String,
base_url_cursor: usize,
model_text: String,
model_text_cursor: usize,
existing_base_url: Option<String>,
existing_compatible_model: Option<String>,
}
impl ConfigWizard {
pub fn new() -> Self {
Self {
screen: WizardScreen::ProviderSelection,
selected_provider_idx: 0,
api_key: String::new(),
api_key_cursor: 0,
selected_model_idx: 0,
selected_sort_idx: 0,
error_message: None,
existing_api_key: None,
fetched_models: Vec::new(),
fetched_dynamic_models: Vec::new(),
model_filter: String::new(),
base_url: String::new(),
base_url_cursor: 0,
model_text: String::new(),
model_text_cursor: 0,
existing_base_url: None,
existing_compatible_model: None,
}
}
fn selected_provider(&self) -> &str {
PROVIDERS[self.selected_provider_idx]
}
fn supports_filter(&self) -> bool {
matches!(
self.selected_provider(),
"openrouter" | "openai" | "anthropic"
)
}
fn static_models(&self) -> &'static [&'static str] {
&[]
}
fn filtered_model_ids(&self) -> Vec<String> {
let filter = self.model_filter.to_lowercase();
match self.selected_provider() {
"openrouter" => self
.fetched_models
.iter()
.filter(|m| {
if filter.is_empty() {
return true;
}
m.id.to_lowercase().contains(&filter) || m.name.to_lowercase().contains(&filter)
})
.map(|m| m.id.clone())
.collect(),
"openai" | "anthropic" => self
.fetched_dynamic_models
.iter()
.filter(|id| filter.is_empty() || id.to_lowercase().contains(&filter))
.cloned()
.collect(),
_ => self.static_models().iter().map(|s| s.to_string()).collect(),
}
}
fn selected_sort(&self) -> &str {
OPENROUTER_SORT_STRATEGIES[self.selected_sort_idx].0
}
fn selected_model(&self) -> String {
let models = self.filtered_model_ids();
if self.selected_model_idx < models.len() {
models[self.selected_model_idx].clone()
} else if !models.is_empty() {
models[0].clone()
} else {
String::new()
}
}
fn filtered_openrouter_model(&self, idx: usize) -> Option<&OpenRouterModel> {
let filter = self.model_filter.to_lowercase();
self.fetched_models
.iter()
.filter(|m| {
if filter.is_empty() {
return true;
}
m.id.to_lowercase().contains(&filter) || m.name.to_lowercase().contains(&filter)
})
.nth(idx)
}
fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(true);
}
match &self.screen {
WizardScreen::ProviderSelection => self.handle_provider_selection_key(key),
WizardScreen::BaseUrlInput => self.handle_base_url_input_key(key),
WizardScreen::ApiKeyInput => self.handle_api_key_input_key(key),
WizardScreen::FetchingModels => Ok(false), WizardScreen::ModelSelection => self.handle_model_selection_key(key),
WizardScreen::ModelTextInput => self.handle_model_text_input_key(key),
WizardScreen::SortStrategySelection => self.handle_sort_strategy_key(key),
WizardScreen::ConnectivityTest => Ok(false), WizardScreen::Result { .. } => {
if key.code == KeyCode::Enter || key.code == KeyCode::Char('q') {
return Ok(true);
}
Ok(false)
}
}
}
fn handle_provider_selection_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_provider_idx > 0 {
self.selected_provider_idx -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_provider_idx < PROVIDERS.len() - 1 {
self.selected_provider_idx += 1;
}
}
KeyCode::Enter => {
self.existing_api_key = load_existing_api_key(self.selected_provider());
if self.selected_provider() == "openai-compatible" {
self.existing_base_url = load_existing_base_url();
self.existing_compatible_model = load_existing_compatible_model();
self.base_url = self.existing_base_url.clone().unwrap_or_default();
self.base_url_cursor = self.base_url.len();
self.model_text = self.existing_compatible_model.clone().unwrap_or_default();
self.model_text_cursor = self.model_text.len();
self.error_message = None;
self.screen = WizardScreen::BaseUrlInput;
} else {
self.screen = WizardScreen::ApiKeyInput;
self.api_key.clear();
self.api_key_cursor = 0;
}
}
KeyCode::Esc | KeyCode::Char('q') => {
return Ok(true); }
_ => {}
}
Ok(false)
}
fn handle_base_url_input_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.base_url.insert(self.base_url_cursor, c);
self.base_url_cursor += 1;
}
KeyCode::Backspace => {
if self.base_url_cursor > 0 {
self.base_url_cursor -= 1;
self.base_url.remove(self.base_url_cursor);
}
}
KeyCode::Delete => {
if self.base_url_cursor < self.base_url.len() {
self.base_url.remove(self.base_url_cursor);
}
}
KeyCode::Left => {
if self.base_url_cursor > 0 {
self.base_url_cursor -= 1;
}
}
KeyCode::Right => {
if self.base_url_cursor < self.base_url.len() {
self.base_url_cursor += 1;
}
}
KeyCode::Home => {
self.base_url_cursor = 0;
}
KeyCode::End => {
self.base_url_cursor = self.base_url.len();
}
KeyCode::Enter => {
let trimmed = self.base_url.trim().trim_end_matches('/');
if trimmed.is_empty() {
self.error_message = Some("Base URL cannot be empty".to_string());
} else if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
self.error_message =
Some("Base URL must start with http:// or https://".to_string());
} else {
self.base_url = trimmed.to_string();
self.base_url_cursor = self.base_url.len();
self.error_message = None;
self.screen = WizardScreen::ApiKeyInput;
self.api_key.clear();
self.api_key_cursor = 0;
}
}
KeyCode::Esc => {
self.error_message = None;
self.screen = WizardScreen::ProviderSelection;
}
_ => {}
}
Ok(false)
}
fn handle_api_key_input_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.api_key.insert(self.api_key_cursor, c);
self.api_key_cursor += 1;
}
KeyCode::Backspace => {
if self.api_key_cursor > 0 {
self.api_key_cursor -= 1;
self.api_key.remove(self.api_key_cursor);
}
}
KeyCode::Delete => {
if self.api_key_cursor < self.api_key.len() {
self.api_key.remove(self.api_key_cursor);
}
}
KeyCode::Left => {
if self.api_key_cursor > 0 {
self.api_key_cursor -= 1;
}
}
KeyCode::Right => {
if self.api_key_cursor < self.api_key.len() {
self.api_key_cursor += 1;
}
}
KeyCode::Home => {
self.api_key_cursor = 0;
}
KeyCode::End => {
self.api_key_cursor = self.api_key.len();
}
KeyCode::Enter => {
let provider = self.selected_provider();
let is_compatible = provider == "openai-compatible";
let next_screen = match provider {
"openrouter" | "openai" | "anthropic" => WizardScreen::FetchingModels,
"openai-compatible" => WizardScreen::ModelTextInput,
_ => WizardScreen::ModelSelection,
};
if self.api_key.is_empty() {
if let Some(ref existing_key) = self.existing_api_key {
log::debug!("Keeping existing API key for {}", provider);
self.api_key = existing_key.clone();
self.error_message = None;
self.selected_model_idx = 0;
self.model_filter.clear();
self.screen = next_screen;
} else if is_compatible {
log::debug!("Proceeding without API key for openai-compatible");
self.error_message = None;
self.selected_model_idx = 0;
self.model_filter.clear();
self.screen = next_screen;
} else {
self.error_message = Some("API key cannot be empty".to_string());
}
} else {
self.error_message = None;
self.selected_model_idx = 0;
self.model_filter.clear();
self.screen = next_screen;
}
}
KeyCode::Esc => {
if self.selected_provider() == "openai-compatible" {
self.screen = WizardScreen::BaseUrlInput;
} else {
self.screen = WizardScreen::ProviderSelection;
}
}
_ => {}
}
Ok(false)
}
fn handle_model_selection_key(&mut self, key: KeyEvent) -> Result<bool> {
let is_openrouter = self.selected_provider() == "openrouter";
let supports_filter = self.supports_filter();
let model_count = self.filtered_model_ids().len();
match key.code {
KeyCode::Up => {
if self.selected_model_idx > 0 {
self.selected_model_idx -= 1;
}
}
KeyCode::Down => {
if model_count > 0 && self.selected_model_idx < model_count - 1 {
self.selected_model_idx += 1;
}
}
KeyCode::Char('k') if !supports_filter => {
if self.selected_model_idx > 0 {
self.selected_model_idx -= 1;
}
}
KeyCode::Char('j') if !supports_filter => {
if model_count > 0 && self.selected_model_idx < model_count - 1 {
self.selected_model_idx += 1;
}
}
KeyCode::Char(c)
if supports_filter && !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.model_filter.push(c);
self.selected_model_idx = 0;
}
KeyCode::Backspace if supports_filter => {
self.model_filter.pop();
self.selected_model_idx = 0;
}
KeyCode::Enter => {
if model_count == 0 {
return Ok(false);
}
if is_openrouter {
self.selected_sort_idx = 0;
self.screen = WizardScreen::SortStrategySelection;
} else {
self.screen = WizardScreen::ConnectivityTest;
}
}
KeyCode::Esc => {
self.model_filter.clear();
self.screen = WizardScreen::ApiKeyInput;
}
_ => {}
}
Ok(false)
}
fn handle_model_text_input_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.model_text.insert(self.model_text_cursor, c);
self.model_text_cursor += 1;
}
KeyCode::Backspace => {
if self.model_text_cursor > 0 {
self.model_text_cursor -= 1;
self.model_text.remove(self.model_text_cursor);
}
}
KeyCode::Delete => {
if self.model_text_cursor < self.model_text.len() {
self.model_text.remove(self.model_text_cursor);
}
}
KeyCode::Left => {
if self.model_text_cursor > 0 {
self.model_text_cursor -= 1;
}
}
KeyCode::Right => {
if self.model_text_cursor < self.model_text.len() {
self.model_text_cursor += 1;
}
}
KeyCode::Home => {
self.model_text_cursor = 0;
}
KeyCode::End => {
self.model_text_cursor = self.model_text.len();
}
KeyCode::Enter => {
if self.model_text.trim().is_empty() {
self.error_message = Some("Model name cannot be empty".to_string());
} else {
self.error_message = None;
self.screen = WizardScreen::ConnectivityTest;
}
}
KeyCode::Esc => {
self.error_message = None;
self.screen = WizardScreen::ApiKeyInput;
}
_ => {}
}
Ok(false)
}
fn handle_sort_strategy_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_sort_idx > 0 {
self.selected_sort_idx -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_sort_idx < OPENROUTER_SORT_STRATEGIES.len() - 1 {
self.selected_sort_idx += 1;
}
}
KeyCode::Enter => {
self.screen = WizardScreen::ConnectivityTest;
}
KeyCode::Esc => {
self.screen = WizardScreen::ModelSelection;
}
_ => {}
}
Ok(false)
}
fn render(&mut self, frame: &mut Frame) {
let screen = self.screen.clone();
match &screen {
WizardScreen::ProviderSelection => self.render_provider_selection(frame),
WizardScreen::BaseUrlInput => self.render_base_url_input(frame),
WizardScreen::ApiKeyInput => self.render_api_key_input(frame),
WizardScreen::FetchingModels => self.render_fetching_models(frame),
WizardScreen::ModelSelection => self.render_model_selection(frame),
WizardScreen::ModelTextInput => self.render_model_text_input(frame),
WizardScreen::SortStrategySelection => self.render_sort_strategy_selection(frame),
WizardScreen::ConnectivityTest => self.render_connectivity_test(frame),
WizardScreen::Result { success, message } => {
self.render_result(frame, *success, message)
}
}
}
fn render_provider_selection(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Reflex AI Configuration Wizard")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let providers: Vec<ListItem> = PROVIDERS
.iter()
.map(|provider| {
let provider_display = match *provider {
"openrouter" => format!("{} (200+ models)", provider),
_ => provider.to_string(),
};
ListItem::new(provider_display)
})
.collect();
let list = List::new(providers)
.block(Block::default().borders(Borders::ALL).title(
"Select AI Provider (↑/↓ to navigate, Enter to select, Esc/q/Ctrl+C to quit)",
))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut list_state = ListState::default().with_selected(Some(self.selected_provider_idx));
frame.render_stateful_widget(list, chunks[1], &mut list_state);
let help = Paragraph::new(
"Use arrow keys or j/k to navigate, Enter to select, Esc/q/Ctrl+C to quit",
)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
fn render_api_key_input(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Length(5),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new(format!("Configure {} API Key", self.selected_provider()))
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let masked_key = "*".repeat(self.api_key.len());
let input_text = if self.api_key_cursor < masked_key.len() {
format!(
"{}â–ˆ{}",
&masked_key[..self.api_key_cursor],
&masked_key[self.api_key_cursor..]
)
} else {
format!("{}â–ˆ", masked_key)
};
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Enter API Key for {}", self.selected_provider())),
);
frame.render_widget(input, chunks[1]);
let message_widget = if let Some(ref error) = self.error_message {
Paragraph::new(error.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center)
} else if let Some(ref existing_key) = self.existing_api_key {
let masked = mask_api_key(existing_key);
Paragraph::new(format!(
"Current API key: {}\n\
Press Enter to keep existing key, or type a new key to replace it\n\
Your API key will be securely stored in ~/.reflex/config.toml",
masked
))
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
} else {
Paragraph::new("Your API key will be securely stored in ~/.reflex/config.toml")
.style(Style::default().fg(Color::Green))
.alignment(Alignment::Center)
};
frame.render_widget(message_widget, chunks[2]);
let help = Paragraph::new("Enter to continue, Esc to go back, Ctrl+C to quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[3]);
}
fn render_model_selection(&mut self, frame: &mut Frame) {
let is_openrouter = self.selected_provider() == "openrouter";
let supports_filter = self.supports_filter();
let filtered = self.filtered_model_ids();
let model_count = filtered.len();
let constraints = if is_openrouter {
vec![
Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]
} else {
vec![
Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints(constraints)
.split(frame.area());
let title_text = if is_openrouter {
format!(
"Select Model for {} ({} models)",
self.selected_provider(),
model_count
)
} else {
format!("Select Model for {}", self.selected_provider())
};
let title = Paragraph::new(title_text)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let (list_chunk, help_chunk) = if is_openrouter {
let filter_text = format!("{}â–ˆ", self.model_filter);
let filter_input = Paragraph::new(filter_text)
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title("Filter (type to search)"),
);
frame.render_widget(filter_input, chunks[1]);
(chunks[2], chunks[3])
} else {
(chunks[1], chunks[2])
};
if model_count == 0 && supports_filter {
let empty_msg = Paragraph::new("No models match filter")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Models"));
frame.render_widget(empty_msg, list_chunk);
} else {
let model_items: Vec<ListItem> = filtered
.iter()
.enumerate()
.map(|(idx, model_id)| {
let model_display = if is_openrouter {
if let Some(m) = self.filtered_openrouter_model(idx) {
format!(
"{} ${:.2} / ${:.2} per 1M tokens",
model_id, m.prompt_price, m.completion_price
)
} else {
model_id.to_string()
}
} else if idx == 0 {
format!("{} (recommended)", model_id)
} else {
model_id.to_string()
};
ListItem::new(model_display)
})
.collect();
let list_title = if supports_filter {
"Models (↑/↓ to navigate, type to filter, Enter to select, Esc to go back)"
} else {
"Select Model (↑/↓ to navigate, Enter to select, Esc to go back, Ctrl+C to quit)"
};
let list = List::new(model_items)
.block(Block::default().borders(Borders::ALL).title(list_title))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut list_state = ListState::default().with_selected(Some(self.selected_model_idx));
frame.render_stateful_widget(list, list_chunk, &mut list_state);
}
let help_text = if supports_filter {
"Type to filter, ↑/↓ to navigate, Enter to select, Esc to go back, Ctrl+C to quit"
} else {
"Use arrow keys or j/k to navigate, Enter to select, Esc to go back, Ctrl+C to quit"
};
let help = Paragraph::new(help_text)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, help_chunk);
}
fn render_base_url_input(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Configure OpenAI-Compatible Endpoint")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let input_text = if self.base_url_cursor < self.base_url.len() {
format!(
"{}â–ˆ{}",
&self.base_url[..self.base_url_cursor],
&self.base_url[self.base_url_cursor..]
)
} else {
format!("{}â–ˆ", self.base_url)
};
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title("Base URL (e.g. http://localhost:1234/v1)"),
);
frame.render_widget(input, chunks[1]);
let message_widget = if let Some(ref error) = self.error_message {
Paragraph::new(error.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center)
} else if let Some(ref existing) = self.existing_base_url {
Paragraph::new(format!(
"Current base URL: {}\n\
Examples: LMStudio http://localhost:1234/v1 · Ollama http://localhost:11434/v1\n\
Press Enter to continue.",
existing
))
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true })
} else {
Paragraph::new(
"Enter the base URL of your OpenAI-compatible endpoint.\n\
Examples: LMStudio http://localhost:1234/v1 · Ollama http://localhost:11434/v1\n\
The /chat/completions path will be appended automatically.",
)
.style(Style::default().fg(Color::Green))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true })
};
frame.render_widget(message_widget, chunks[2]);
let help = Paragraph::new("Enter to continue, Esc to go back, Ctrl+C to quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[3]);
}
fn render_model_text_input(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Specify Model Name")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let input_text = if self.model_text_cursor < self.model_text.len() {
format!(
"{}â–ˆ{}",
&self.model_text[..self.model_text_cursor],
&self.model_text[self.model_text_cursor..]
)
} else {
format!("{}â–ˆ", self.model_text)
};
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title("Model name (as it appears on your endpoint)"),
);
frame.render_widget(input, chunks[1]);
let message_widget = if let Some(ref error) = self.error_message {
Paragraph::new(error.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center)
} else if let Some(ref existing) = self.existing_compatible_model {
Paragraph::new(format!(
"Current model: {}\n\
Type the exact model identifier loaded on your server.",
existing
))
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true })
} else {
Paragraph::new(
"Enter the model name your server hosts.\n\
Examples: qwen2.5-coder-32b-instruct, llama-3.1-8b-instruct, mistral-7b",
)
.style(Style::default().fg(Color::Green))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true })
};
frame.render_widget(message_widget, chunks[2]);
let help = Paragraph::new("Enter to test connection, Esc to go back, Ctrl+C to quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[3]);
}
fn render_fetching_models(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(frame.area());
let title = Paragraph::new("Fetching Available Models...")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let provider_label = match self.selected_provider() {
"openrouter" => "OpenRouter",
"openai" => "OpenAI",
"anthropic" => "Anthropic",
other => other,
};
let body = format!(
"Loading models from {}...\n\nPlease wait...",
provider_label
);
let message = Paragraph::new(body)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(message, chunks[1]);
}
fn render_sort_strategy_selection(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Select Provider Sort Strategy (OpenRouter)")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let strategy_items: Vec<ListItem> = OPENROUTER_SORT_STRATEGIES
.iter()
.enumerate()
.map(|(idx, (name, description))| {
let display = if idx == 0 {
format!("{} - {} (recommended)", name, description)
} else {
format!("{} - {}", name, description)
};
ListItem::new(display)
})
.collect();
let list =
List::new(strategy_items)
.block(Block::default().borders(Borders::ALL).title(
"Select Sort Strategy (↑/↓ to navigate, Enter to select, Esc to go back)",
))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut list_state = ListState::default().with_selected(Some(self.selected_sort_idx));
frame.render_stateful_widget(list, chunks[1], &mut list_state);
let help = Paragraph::new(
"Controls how OpenRouter selects the upstream provider for your chosen model",
)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
fn render_connectivity_test(&mut self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(frame.area());
let title = Paragraph::new("Testing Connection...")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let message = Paragraph::new(format!(
"Testing connection to {}...\n\nPlease wait...",
self.selected_provider()
))
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(message, chunks[1]);
}
fn render_result(&mut self, frame: &mut Frame, success: bool, message: &str) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = if success {
Paragraph::new("Configuration Successful!").style(
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else {
Paragraph::new("Configuration Failed")
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
};
let title = title
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let message_widget = Paragraph::new(message)
.style(if success {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
})
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(message_widget, chunks[1]);
let help = Paragraph::new(if success {
"Press Enter, q, or Ctrl+C to exit"
} else {
"Press Enter, q, or Ctrl+C to exit (configuration not saved)"
})
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode().context("Failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
Terminal::new(backend).context("Failed to create terminal")
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode().context("Failed to disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("Failed to leave alternate screen")?;
terminal.show_cursor().context("Failed to show cursor")?;
Ok(())
}
pub fn run_configure_wizard() -> Result<()> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"The configuration wizard requires an interactive terminal.\n\
\n\
Run `rfx llm config` in an interactive terminal session, or configure\n\
via environment variables instead:\n\
\n\
For OpenAI: export OPENAI_API_KEY=sk-...\n\
For Anthropic: export ANTHROPIC_API_KEY=sk-ant-...\n\
For OpenRouter: export OPENROUTER_API_KEY=sk-or-..."
);
}
let mut terminal = setup_terminal()?;
let mut wizard = ConfigWizard::new();
let result = run_wizard_loop(&mut terminal, &mut wizard);
restore_terminal(&mut terminal)?;
result
}
fn run_wizard_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
wizard: &mut ConfigWizard,
) -> Result<()> {
loop {
terminal.draw(|frame| wizard.render(frame))?;
if wizard.screen == WizardScreen::FetchingModels {
let provider = wizard.selected_provider().to_string();
match provider.as_str() {
"openrouter" => match fetch_openrouter_models(&wizard.api_key) {
Ok(models) => {
wizard.fetched_models = models;
wizard.selected_model_idx = 0;
wizard.model_filter.clear();
wizard.error_message = None;
wizard.screen = WizardScreen::ModelSelection;
}
Err(e) => {
wizard.screen = WizardScreen::Result {
success: false,
message: format!(
"Failed to fetch models from OpenRouter: {}\n\n\
Please check your API key and try again.",
e
),
};
}
},
"openai" => match fetch_openai_models_blocking(&wizard.api_key) {
Ok(ids) => {
wizard.fetched_dynamic_models = ids;
wizard.selected_model_idx = 0;
wizard.model_filter.clear();
wizard.error_message = None;
wizard.screen = WizardScreen::ModelSelection;
}
Err(e) => {
log::warn!("OpenAI /v1/models fetch failed, using fallback list: {}", e);
wizard.fetched_dynamic_models = OPENAI_FALLBACK_MODELS
.iter()
.map(|s| s.to_string())
.collect();
wizard.selected_model_idx = 0;
wizard.model_filter.clear();
wizard.error_message = Some(
"Could not reach api.openai.com — showing recent models. \
Some newer models may be missing."
.to_string(),
);
wizard.screen = WizardScreen::ModelSelection;
}
},
"anthropic" => match fetch_anthropic_models_blocking(&wizard.api_key) {
Ok(ids) => {
wizard.fetched_dynamic_models = ids;
wizard.selected_model_idx = 0;
wizard.model_filter.clear();
wizard.error_message = None;
wizard.screen = WizardScreen::ModelSelection;
}
Err(e) => {
log::warn!(
"Anthropic /v1/models fetch failed, using fallback list: {}",
e
);
wizard.fetched_dynamic_models = ANTHROPIC_FALLBACK_MODELS
.iter()
.map(|s| s.to_string())
.collect();
wizard.selected_model_idx = 0;
wizard.model_filter.clear();
wizard.error_message = Some(
"Could not reach api.anthropic.com — showing recent models. \
Some newer models may be missing."
.to_string(),
);
wizard.screen = WizardScreen::ModelSelection;
}
},
_ => {
wizard.screen = WizardScreen::ModelSelection;
}
}
continue;
}
if wizard.screen == WizardScreen::ConnectivityTest {
let provider = wizard.selected_provider().to_string();
let is_compatible = provider == "openai-compatible";
let selected_model = if is_compatible {
wizard.model_text.clone()
} else {
wizard.selected_model()
};
let options = if is_compatible {
let mut opts = HashMap::new();
opts.insert("base_url".to_string(), wizard.base_url.clone());
Some(opts)
} else {
None
};
let result = test_connectivity(&provider, &wizard.api_key, &selected_model, options);
match result {
Ok(_) => {
let sort = if provider == "openrouter" {
Some(wizard.selected_sort())
} else {
None
};
let base_url = if is_compatible {
Some(wizard.base_url.as_str())
} else {
None
};
if let Err(e) = save_user_config(
&provider,
&wizard.api_key,
&selected_model,
sort,
base_url,
) {
wizard.screen = WizardScreen::Result {
success: false,
message: format!("Failed to save configuration: {}", e),
};
} else {
wizard.screen = WizardScreen::Result {
success: true,
message: format!(
"Configuration saved successfully!\n\n\
Provider: {}\n\
Config file: ~/.reflex/config.toml\n\n\
You can now use 'rfx ask' to query your codebase.",
provider
),
};
}
}
Err(e) => {
wizard.screen = WizardScreen::Result {
success: false,
message: format!(
"Connectivity test failed: {}\n\n\
Please check your endpoint, model, and credentials and try again.",
e
),
};
}
}
continue;
}
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
let should_exit = wizard.handle_key(key)?;
if should_exit {
break;
}
}
}
}
Ok(())
}
fn test_connectivity(
provider_name: &str,
api_key: &str,
model: &str,
options: Option<HashMap<String, String>>,
) -> Result<()> {
let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
runtime.block_on(async {
let model_arg = if model.is_empty() {
None
} else {
Some(model.to_string())
};
let provider = crate::semantic::providers::create_provider(
provider_name,
api_key.to_string(),
model_arg,
options,
crate::semantic::config::SemanticConfig::default().timeout_seconds,
)?;
let test_prompt = "Please respond with valid JSON: {\"status\": \"ok\"}";
provider.complete(test_prompt, true).await?;
Ok::<(), anyhow::Error>(())
})?;
Ok(())
}
fn fetch_openrouter_models(api_key: &str) -> Result<Vec<OpenRouterModel>> {
let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
runtime.block_on(async { crate::semantic::providers::openrouter::fetch_models(api_key).await })
}
fn fetch_openai_models_blocking(api_key: &str) -> Result<Vec<String>> {
let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
runtime.block_on(async { crate::semantic::providers::openai::fetch_models(api_key).await })
}
fn fetch_anthropic_models_blocking(api_key: &str) -> Result<Vec<String>> {
let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
runtime.block_on(async { crate::semantic::providers::anthropic::fetch_models(api_key).await })
}
fn save_user_config(
provider: &str,
api_key: &str,
model: &str,
sort: Option<&str>,
base_url: Option<&str>,
) -> Result<()> {
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Serialize, Deserialize)]
struct UserConfig {
#[serde(default)]
semantic: SemanticSection,
#[serde(default)]
credentials: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SemanticSection {
provider: String,
}
impl Default for SemanticSection {
fn default() -> Self {
Self {
provider: "openai".to_string(),
}
}
}
let home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
let config_dir = home.join(".reflex");
fs::create_dir_all(&config_dir).context("Failed to create ~/.reflex directory")?;
let config_path = config_dir.join("config.toml");
let mut config = if config_path.exists() {
let config_str =
fs::read_to_string(&config_path).context("Failed to read existing config file")?;
toml::from_str::<UserConfig>(&config_str).unwrap_or_else(|_| UserConfig {
semantic: SemanticSection::default(),
credentials: HashMap::new(),
})
} else {
UserConfig {
semantic: SemanticSection::default(),
credentials: HashMap::new(),
}
};
config.semantic.provider = provider.to_string();
let cred_prefix = provider.replace('-', "_");
let key_name = format!("{}_api_key", cred_prefix);
let model_name = format!("{}_model", cred_prefix);
config.credentials.insert(key_name, api_key.to_string());
config.credentials.insert(model_name, model.to_string());
if let Some(sort_value) = sort {
config
.credentials
.insert("openrouter_sort".to_string(), sort_value.to_string());
}
if let Some(url) = base_url {
config
.credentials
.insert(format!("{}_base_url", cred_prefix), url.to_string());
}
let toml_content =
toml::to_string_pretty(&config).context("Failed to serialize config to TOML")?;
let final_content = format!(
"# Reflex User Configuration\n\
# This file stores your AI provider API keys\n\
# Location: ~/.reflex/config.toml\n\
\n\
{}",
toml_content
);
fs::write(&config_path, final_content).context("Failed to write configuration file")?;
log::info!("Configuration saved to {:?}", config_path);
Ok(())
}