use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Terminal,
};
use std::io;
use std::path::PathBuf;
struct PresetProvider {
name: &'static str,
env_key: &'static str,
base_url: Option<&'static str>,
}
const PRESET_PROVIDERS: &[PresetProvider] = &[
PresetProvider { name: "anthropic", env_key: "ANTHROPIC_API_KEY", base_url: None },
PresetProvider { name: "openai", env_key: "OPENAI_API_KEY", base_url: None },
PresetProvider { name: "google", env_key: "GOOGLE_API_KEY", base_url: None },
PresetProvider { name: "deepseek", env_key: "DEEPSEEK_API_KEY", base_url: None },
PresetProvider { name: "groq", env_key: "GROQ_API_KEY", base_url: None },
PresetProvider { name: "openrouter", env_key: "OPENROUTER_API_KEY", base_url: None },
PresetProvider { name: "mistral", env_key: "MISTRAL_API_KEY", base_url: None },
PresetProvider { name: "xai", env_key: "XAI_API_KEY", base_url: None },
PresetProvider { name: "minimax", env_key: "MINIMAX_API_KEY", base_url: Some("https://api.minimax.chat/v1") },
PresetProvider { name: "zai", env_key: "ZAI_API_KEY", base_url: Some("https://open.bigmodel.cn/api/paas/v4") },
];
#[derive(Clone)]
struct ProviderEntry {
name: String,
has_key: bool,
key_masked: String,
is_custom: bool,
base_url: Option<String>,
}
#[derive(Clone)]
enum InputMode {
Normal,
EditingApiKey {
provider_name: String,
field_text: String,
},
AddingCustom {
fields: [String; 3], active_field: usize,
},
}
struct WizardState {
step: usize,
providers: Vec<ProviderEntry>,
provider_selected: usize,
provider_list_state: ListState,
input_mode: InputMode,
models: Vec<ModelEntry>,
model_selected: usize,
model_filter: String,
model_searching: bool,
themes: Vec<String>,
theme_selected: usize,
theme_list_state: ListState,
auth_path: PathBuf,
settings_path: PathBuf,
}
#[derive(Clone)]
struct ModelEntry {
id: String,
provider: String,
context_window: u32,
}
fn mask_key(key: &str) -> String {
if key.len() <= 10 {
"*".repeat(key.len())
} else {
format!("{}...{}", &key[..6], &key[key.len() - 4..])
}
}
fn load_providers(auth_store: &crate::auth_storage::AuthStorage) -> Vec<ProviderEntry> {
let mut entries = Vec::new();
for preset in PRESET_PROVIDERS {
let stored_key = auth_store.get_api_key(preset.name);
let env_key = std::env::var(preset.env_key).ok();
let key = stored_key.or(env_key);
let (has_key, key_masked) = match &key {
Some(k) => (true, mask_key(k)),
None => (false, String::new()),
};
entries.push(ProviderEntry {
name: preset.name.to_string(),
has_key,
key_masked,
is_custom: preset.base_url.is_some(),
base_url: preset.base_url.map(|s| s.to_string()),
});
}
if let Ok(settings) = crate::settings::Settings::load() {
let preset_names: Vec<&str> = PRESET_PROVIDERS.iter().map(|p| p.name).collect();
for cp in &settings.custom_providers {
if preset_names.contains(&cp.name.as_str()) {
continue;
}
let key = auth_store.get_api_key(&cp.name);
let env_key = std::env::var(&cp.api_key_env).ok();
let actual_key = key.or(env_key);
let (has_key, key_masked) = match &actual_key {
Some(k) => (true, mask_key(k)),
None => (false, String::new()),
};
entries.push(ProviderEntry {
name: cp.name.clone(),
has_key,
key_masked,
is_custom: true,
base_url: Some(cp.base_url.clone()),
});
}
}
entries
}
fn load_models() -> Vec<ModelEntry> {
let mut models = Vec::new();
for entry in oxi_ai::model_db::get_all_models() {
models.push(ModelEntry {
id: entry.id.to_string(),
provider: entry.provider.to_string(),
context_window: entry.context_window,
});
}
models
}
fn load_themes() -> Vec<String> {
vec![
"oxi_dark".to_string(),
"oxi_light".to_string(),
"nord".to_string(),
"catppuccin".to_string(),
"github_dark".to_string(),
"monokai".to_string(),
]
}
fn save_settings(model_id: &str, theme_name: &str, custom_base_urls: &[(String, String)]) -> Result<()> {
let mut settings = crate::settings::Settings::load().unwrap_or_default();
settings.default_model = Some(model_id.to_string());
settings.theme = theme_name.to_string();
for (name, base_url) in custom_base_urls {
let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
if !already_exists {
settings.custom_providers.push(crate::settings::CustomProvider {
name: name.clone(),
base_url: base_url.clone(),
api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
api: "openai-completions".to_string(),
});
}
}
settings.save()?;
Ok(())
}
fn draw_wizard(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
state: &mut WizardState,
) -> Result<()> {
terminal.draw(|f| {
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(2), ])
.split(size);
let title = Paragraph::new(Line::from(vec![
Span::styled(" 🦊 ", Style::default().fg(Color::Rgb(255, 165, 0))),
Span::styled("oxi 설정 마법사", Style::default().add_modifier(Modifier::BOLD)),
]))
.block(Block::default().borders(Borders::TOP));
f.render_widget(title, chunks[0]);
match state.step {
0 => draw_provider_step(f, state, chunks[1]),
1 => draw_model_step(f, state, chunks[1]),
2 => draw_theme_step(f, state, chunks[1]),
3 => draw_done_step(f, state, chunks[1]),
_ => {}
}
let footer_text = match state.step {
0 => match &state.input_mode {
InputMode::Normal => " ↑/↓ 이동 · Enter: API 키 입력/변경 · d: 삭제 · →: 다음 · q: 종료".to_string(),
InputMode::EditingApiKey { .. } => " Enter: 저장 · Esc: 취소".to_string(),
InputMode::AddingCustom { .. } => " Tab: 다음 필드 · Enter: 저장 · Esc: 취소".to_string(),
},
1 => {
if state.model_searching {
" 입력: 검색 · Esc: 검색 종료 · Enter: 선택 · ←: 이전".to_string()
} else {
" ↑/↓ 이동 · /: 검색 · Enter: 선택 · ←: 이전".to_string()
}
},
2 => " ↑/↓ 이동 · Enter: 선택 · ←: 이전".to_string(),
3 => " Enter: 종료".to_string(),
_ => String::new(),
};
let footer = Paragraph::new(Line::from(Span::styled(
footer_text,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(footer, chunks[2]);
})?;
Ok(())
}
fn draw_provider_step(
f: &mut ratatui::Frame,
state: &mut WizardState,
area: Rect,
) {
match &state.input_mode {
InputMode::Normal => draw_provider_list(f, state, area),
InputMode::EditingApiKey { provider_name, field_text } => {
draw_api_key_dialog(f, provider_name, field_text, area)
}
InputMode::AddingCustom { fields, active_field } => {
draw_custom_provider_dialog(f, fields, *active_field, area)
}
}
}
fn draw_provider_list(
f: &mut ratatui::Frame,
state: &mut WizardState,
area: Rect,
) {
let step_indicator = build_step_indicator(state.step);
let items: Vec<ListItem> = state
.providers
.iter()
.map(|p| {
let check = if p.has_key { "✅" } else { "☐" };
let key_info = if p.has_key {
format!("API 키: {}", p.key_masked)
} else {
"API 키 없음".to_string()
};
let custom_tag = if p.is_custom { " ← 커스텀" } else { "" };
let line = Line::from(vec![
Span::styled(format!(" {} ", check), Style::default().fg(
if p.has_key { Color::Green } else { Color::DarkGray }
)),
Span::styled(format!("{:<14}", p.name), Style::default().add_modifier(Modifier::BOLD)),
Span::styled(format!("[{}]", key_info), Style::default().fg(Color::DarkGray)),
Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
]);
ListItem::new(line)
})
.collect();
let add_custom = ListItem::new(Line::from(vec![
Span::styled(" + ", Style::default().fg(Color::Cyan)),
Span::styled("커스텀 프로바이더 추가...", Style::default().fg(Color::Cyan)),
]));
let mut all_items = items;
all_items.push(add_custom);
let list = List::new(all_items)
.block(
Block::default()
.borders(Borders::NONE)
.title(step_indicator)
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
state.provider_list_state.select(Some(state.provider_selected));
f.render_stateful_widget(list, area, &mut state.provider_list_state);
}
fn draw_api_key_dialog(
f: &mut ratatui::Frame,
provider_name: &str,
field_text: &str,
area: Rect,
) {
let dialog_height = 7u16;
let dialog_width = std::cmp::min(area.width, 60);
let x = (area.width.saturating_sub(dialog_width)) / 2;
let y = (area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect::new(
area.x + x,
area.y + y,
dialog_width,
dialog_height,
);
let display_text = if field_text.is_empty() {
String::new()
} else {
"*".repeat(field_text.len())
};
let paragraphs = vec![
Line::from(""),
Line::from(vec![
Span::styled(" API Key: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(format!("[{:<width$}]", display_text, width = 30), Style::default()),
if field_text.is_empty() {
Span::styled("API 키를 입력하세요", Style::default().fg(Color::DarkGray))
} else {
Span::raw("")
},
]),
];
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} API 키 입력 ", provider_name));
let para = Paragraph::new(paragraphs).block(block);
f.render_widget(para, dialog_area);
}
fn draw_custom_provider_dialog(
f: &mut ratatui::Frame,
fields: &[String; 3],
active_field: usize,
area: Rect,
) {
let dialog_height = 9u16;
let dialog_width = std::cmp::min(area.width, 60);
let x = (area.width.saturating_sub(dialog_width)) / 2;
let y = (area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect::new(
area.x + x,
area.y + y,
dialog_width,
dialog_height,
);
let field_labels = ["이름", "Base URL", "API 키"];
let lines: Vec<Line> = std::iter::once(Line::from(""))
.chain(
field_labels.iter().enumerate().map(|(i, label)| {
let display = if i == 2 && !fields[i].is_empty() {
"*".repeat(fields[i].len())
} else {
fields[i].clone()
};
let is_active = i == active_field;
let style = if is_active {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(vec![
Span::styled(format!(" {:<10}", format!("{}:", label)), style),
Span::styled(format!("[{:<width$}]", display, width = 35), style),
if is_active && fields[i].is_empty() {
Span::styled("← 입력하세요", Style::default().fg(Color::DarkGray))
} else {
Span::raw("")
},
])
}),
)
.collect();
let block = Block::default()
.borders(Borders::ALL)
.title(" 커스텀 프로바이더 추가 ");
let para = Paragraph::new(lines).block(block);
f.render_widget(para, dialog_area);
}
fn draw_model_step(
f: &mut ratatui::Frame,
state: &mut WizardState,
area: Rect,
) {
let step_indicator = build_step_indicator(state.step);
let filtered: Vec<&ModelEntry> = if state.model_filter.is_empty() {
state.models.iter().collect()
} else {
let filter = state.model_filter.to_lowercase();
state
.models
.iter()
.filter(|m| {
m.id.to_lowercase().contains(&filter)
|| m.provider.to_lowercase().contains(&filter)
})
.collect()
};
let mut lines: Vec<Line> = Vec::new();
if state.model_searching {
lines.push(Line::from(vec![
Span::styled(" 검색: ", Style::default().fg(Color::Yellow)),
Span::styled(&state.model_filter, Style::default().add_modifier(Modifier::BOLD)),
Span::raw("_"),
]));
}
lines.push(Line::from(""));
for m in &filtered {
let ctx_str = if m.context_window >= 1_000_000 {
format!("{}M ctx", m.context_window / 1_000_000)
} else {
format!("{}K ctx", m.context_window / 1_000)
};
lines.push(Line::from(vec![
Span::styled(format!(" {:<40}", m.id), Style::default()),
Span::styled(
format!("({})", m.provider),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!(", {}", ctx_str), Style::default().fg(Color::DarkGray)),
]));
}
let block = Block::default()
.borders(Borders::NONE)
.title(step_indicator);
let para = Paragraph::new(lines).block(block);
f.render_widget(para, area);
}
fn draw_theme_step(
f: &mut ratatui::Frame,
state: &mut WizardState,
area: Rect,
) {
let step_indicator = build_step_indicator(state.step);
let items: Vec<ListItem> = state
.themes
.iter()
.map(|t| {
ListItem::new(Line::from(format!(" {}", t)))
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::NONE).title(step_indicator))
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
state.theme_list_state.select(Some(state.theme_selected));
f.render_stateful_widget(list, area, &mut state.theme_list_state);
}
fn draw_done_step(
f: &mut ratatui::Frame,
state: &mut WizardState,
area: Rect,
) {
let settings_path_display = state.settings_path.display().to_string();
let auth_path_display = state.auth_path.display().to_string();
let lines = vec![
Line::from(""),
Line::from(Span::styled(
" ✅ 설정이 저장되었습니다!",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
format!(" 설정 파일: {}", settings_path_display),
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
format!(" 인증 파일: {}", auth_path_display),
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
" 이제 'oxi'를 실행하세요.",
Style::default().add_modifier(Modifier::BOLD)),
),
];
let block = Block::default().borders(Borders::NONE);
let para = Paragraph::new(lines).block(block);
f.render_widget(para, area);
}
fn build_step_indicator(current_step: usize) -> Line<'static> {
let steps = [
("1. 프로바이더 설정", 0),
("2. 기본 모델 선택", 1),
("3. 테마 선택", 2),
("4. 완료", 3),
];
let spans: Vec<Span> = steps
.iter()
.flat_map(|(label, step)| {
let style = if *step == current_step {
Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)
} else if (*step as usize) < current_step {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
vec![
Span::styled(format!(" {}", label), style),
Span::raw(" "),
]
})
.collect();
Line::from(spans)
}
fn handle_event(state: &mut WizardState, event: Event, auth_store: &crate::auth_storage::AuthStorage) -> Result<bool> {
match state.step {
0 => handle_provider_event(state, event, auth_store),
1 => handle_model_event(state, event),
2 => handle_theme_event(state, event),
3 => handle_done_event(event),
_ => Ok(false),
}
}
fn handle_provider_event(
state: &mut WizardState,
event: Event,
auth_store: &crate::auth_storage::AuthStorage,
) -> Result<bool> {
match &mut state.input_mode {
InputMode::Normal => {
if let Event::Key(key) = event {
match key.code {
KeyCode::Up => {
if state.provider_selected > 0 {
state.provider_selected -= 1;
}
}
KeyCode::Down => {
let max = state.providers.len();
if state.provider_selected < max {
state.provider_selected += 1;
}
}
KeyCode::Enter => {
if state.provider_selected == state.providers.len() {
state.input_mode = InputMode::AddingCustom {
fields: [String::new(), String::new(), String::new()],
active_field: 0,
};
} else {
let name = state.providers[state.provider_selected].name.clone();
state.input_mode = InputMode::EditingApiKey {
provider_name: name,
field_text: String::new(),
};
}
}
KeyCode::Char('d') | KeyCode::Delete => {
if state.provider_selected < state.providers.len() {
let name = state.providers[state.provider_selected].name.clone();
auth_store.remove(&name);
state.providers[state.provider_selected].has_key = false;
state.providers[state.provider_selected].key_masked = String::new();
}
}
KeyCode::Right => {
state.step = 1;
}
KeyCode::Char('q') => {
return Ok(true); }
_ => {}
}
}
}
InputMode::EditingApiKey { provider_name, field_text } => {
if let Event::Key(key) = event {
match key.code {
KeyCode::Esc => {
state.input_mode = InputMode::Normal;
}
KeyCode::Enter => {
if !field_text.is_empty() {
auth_store.set_api_key(provider_name, field_text.clone());
if let Some(entry) = state.providers.iter_mut().find(|p| p.name == *provider_name) {
entry.has_key = true;
entry.key_masked = mask_key(field_text);
}
}
state.input_mode = InputMode::Normal;
}
KeyCode::Backspace => {
field_text.pop();
}
KeyCode::Char(c) => {
field_text.push(c);
}
_ => {}
}
}
}
InputMode::AddingCustom { fields, active_field } => {
if let Event::Key(key) = event {
match key.code {
KeyCode::Esc => {
state.input_mode = InputMode::Normal;
}
KeyCode::Tab => {
*active_field = (*active_field + 1) % 3;
}
KeyCode::BackTab => {
*active_field = (*active_field + 2) % 3;
}
KeyCode::Enter => {
let name = fields[0].trim().to_string();
let base_url = fields[1].trim().to_string();
let api_key = fields[2].trim().to_string();
if !name.is_empty() && !base_url.is_empty() {
if !api_key.is_empty() {
auth_store.set_api_key(&name, api_key.clone());
}
let (has_key, key_masked) = if !api_key.is_empty() {
(true, mask_key(&api_key))
} else {
(false, String::new())
};
state.providers.push(ProviderEntry {
name: name.clone(),
has_key,
key_masked,
is_custom: true,
base_url: Some(base_url),
});
state.input_mode = InputMode::Normal;
}
}
KeyCode::Backspace => {
fields[*active_field].pop();
}
KeyCode::Char(c) => {
fields[*active_field].push(c);
}
_ => {}
}
}
}
}
Ok(false)
}
fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
if let Event::Key(key) = event {
if state.model_searching {
match key.code {
KeyCode::Esc => {
state.model_searching = false;
state.model_filter.clear();
}
KeyCode::Enter => {
state.model_searching = false;
select_filtered_model(state);
}
KeyCode::Backspace => {
state.model_filter.pop();
}
KeyCode::Char(c) => {
state.model_filter.push(c);
}
_ => {}
}
} else {
match key.code {
KeyCode::Up => {
if state.model_selected > 0 {
state.model_selected -= 1;
}
}
KeyCode::Down => {
if state.model_selected + 1 < state.models.len() {
state.model_selected += 1;
}
}
KeyCode::Char('/') => {
state.model_searching = true;
state.model_filter.clear();
}
KeyCode::Enter => {
state.step = 2;
}
KeyCode::Left => {
state.step = 0;
}
_ => {}
}
}
}
Ok(false)
}
fn select_filtered_model(state: &mut WizardState) {
if state.model_filter.is_empty() {
state.step = 2;
return;
}
let filter = state.model_filter.to_lowercase();
if let Some(idx) = state.models.iter().position(|m| {
m.id.to_lowercase().contains(&filter)
|| m.provider.to_lowercase().contains(&filter)
}) {
state.model_selected = idx;
}
state.step = 2;
}
fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
if let Event::Key(key) = event {
match key.code {
KeyCode::Up => {
if state.theme_selected > 0 {
state.theme_selected -= 1;
}
}
KeyCode::Down => {
if state.theme_selected + 1 < state.themes.len() {
state.theme_selected += 1;
}
}
KeyCode::Enter => {
finish_setup(state)?;
state.step = 3;
}
KeyCode::Left => {
state.step = 1;
}
_ => {}
}
}
Ok(false)
}
fn handle_done_event(event: Event) -> Result<bool> {
if let Event::Key(key) = event {
match key.code {
KeyCode::Enter | KeyCode::Char('q') => {
return Ok(true); }
_ => {}
}
}
Ok(false)
}
fn finish_setup(state: &mut WizardState) -> Result<()> {
let model_id = state
.models
.get(state.model_selected)
.map(|m| format!("{}/{}", m.provider, m.id))
.unwrap_or_default();
let theme_name = state
.themes
.get(state.theme_selected)
.cloned()
.unwrap_or_else(|| "oxi_dark".to_string());
let custom_base_urls: Vec<(String, String)> = state
.providers
.iter()
.filter_map(|p| {
if p.is_custom {
p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
} else {
None
}
})
.collect();
save_settings(&model_id, &theme_name, &custom_base_urls)?;
Ok(())
}
pub fn run() -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
panic_hook(info);
}));
let auth_store = crate::auth_storage::AuthStorage::new();
let providers = load_providers(&auth_store);
let models = load_models();
let themes = load_themes();
let auth_path = crate::auth_storage::AuthStorage::default_path()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".oxi").join("auth.json"));
let settings_path = crate::settings::Settings::settings_path()
.unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".oxi").join("settings.json"));
let current_model = crate::settings::Settings::load()
.ok()
.and_then(|s| s.default_model.clone())
.unwrap_or_default();
let model_selected = models.iter().position(|m| {
let full_id = format!("{}/{}", m.provider, m.id);
full_id == current_model || m.id == current_model
}).unwrap_or(0);
let current_theme = crate::settings::Settings::load()
.ok()
.map(|s| s.theme.clone())
.unwrap_or_else(|| "oxi_dark".to_string());
let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
let mut state = WizardState {
step: 0,
providers,
provider_selected: 0,
provider_list_state: ListState::default(),
input_mode: InputMode::Normal,
models,
model_selected,
model_filter: String::new(),
model_searching: false,
themes,
theme_selected,
theme_list_state: ListState::default(),
auth_path,
settings_path,
};
loop {
draw_wizard(&mut terminal, &mut state)?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
break;
}
let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
if should_quit {
break;
}
}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
Ok(())
}