1use anyhow::Result;
13use crossterm::{
14 event::{self, Event, KeyCode, KeyModifiers},
15 execute,
16 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
17};
18use ratatui::{
19 Terminal,
20 backend::CrosstermBackend,
21 layout::{Constraint, Direction, Layout, Rect},
22 style::{Color, Modifier, Style},
23 text::{Line, Span},
24 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
25};
26
27use std::io;
28use std::path::PathBuf;
29
30#[derive(Clone)]
36struct ProviderEntry {
37 name: String,
38 has_key: bool,
39 key_masked: String,
40 is_custom: bool,
41 base_url: Option<String>,
42}
43
44#[derive(Clone)]
48enum InputMode {
49 Normal,
51 EditingApiKey {
53 provider_name: String,
54 field_text: String,
55 },
56 AddingCustom {
58 fields: [String; 3], active_field: usize,
60 },
61}
62
63struct WizardState {
67 step: usize,
69 providers: Vec<ProviderEntry>,
71 provider_selected: usize,
73 provider_list_state: ListState,
75 provider_filter: String,
77 on_sentinel: bool,
80 input_mode: InputMode,
82 models: Vec<ModelEntry>,
84 model_selected: usize,
86 model_filter: String,
88 model_list_state: ListState,
90 models_dirty: bool,
94 themes: Vec<String>,
96 theme_selected: usize,
98 theme_list_state: ListState,
100 auth_path: PathBuf,
102 settings_path: PathBuf,
104 catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
106}
107
108#[derive(Clone)]
110struct ModelEntry {
111 id: String,
112 provider: String,
113 context_window: u32,
114 id_lower: String,
117 provider_lower: String,
119}
120
121impl ModelEntry {
122 fn new(id: String, provider: String, context_window: u32) -> Self {
123 let provider_lower = provider.to_lowercase();
124 let id_lower = id.to_lowercase();
125 Self {
126 id,
127 provider,
128 context_window,
129 id_lower,
130 provider_lower,
131 }
132 }
133}
134
135fn mask_key(key: &str) -> String {
139 if key.len() <= 10 {
140 "*".repeat(key.len())
141 } else {
142 format!("{}...{}", &key[..6], &key[key.len() - 4..])
143 }
144}
145
146fn filtered_provider_indices(state: &WizardState) -> Vec<usize> {
151 if state.provider_filter.is_empty() {
152 (0..state.providers.len()).collect()
153 } else {
154 let f = state.provider_filter.to_lowercase();
155 state
156 .providers
157 .iter()
158 .enumerate()
159 .filter(|(_, p)| p.name.to_lowercase().contains(&f))
160 .map(|(i, _)| i)
161 .collect()
162 }
163}
164
165fn filtered_model_indices(state: &WizardState) -> Vec<usize> {
170 if state.model_filter.is_empty() {
171 (0..state.models.len()).collect()
172 } else {
173 let f = state.model_filter.to_lowercase();
174 state
175 .models
176 .iter()
177 .enumerate()
178 .filter(|(_, m)| m.id_lower.contains(&f) || m.provider_lower.contains(&f))
179 .map(|(i, _)| i)
180 .collect()
181 }
182}
183
184fn ensure_model_selected_visible(state: &mut WizardState) {
188 let filtered = filtered_model_indices(state);
189 if filtered.is_empty() {
190 return;
191 }
192 if !filtered.contains(&state.model_selected) {
193 state.model_selected = filtered[0];
194 }
195}
196
197fn snap_provider_selection(state: &mut WizardState) {
202 let indices = filtered_provider_indices(state);
203 if indices.is_empty() {
204 state.on_sentinel = true;
205 return;
206 }
207 if state.on_sentinel || !indices.contains(&state.provider_selected) {
208 state.provider_selected = indices[0];
209 state.on_sentinel = false;
210 }
211}
212
213fn load_providers(
217 auth_store: &crate::store::auth_storage::AuthStorage,
218 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
219) -> Vec<ProviderEntry> {
220 let mut entries = Vec::new();
221
222 let builtin_names: Vec<String> = if let Some(cat) = catalog {
223 cat.list_providers_sync()
224 } else {
225 oxi_sdk::get_builtin_providers()
226 .iter()
227 .map(|p| p.name.to_string())
228 .collect()
229 };
230
231 for name in &builtin_names {
232 let key = auth_store.get_api_key(name);
233
234 let (has_key, key_masked) = match &key {
235 Some(k) => (true, mask_key(k)),
236 None => (false, String::new()),
237 };
238
239 let base_url = if let Some(cat) = catalog {
240 cat.get_provider_sync(name).and_then(|p| p.base_url)
241 } else {
242 oxi_sdk::get_provider_base_url(name)
243 .filter(|s| !s.is_empty())
244 .map(|s| s.to_string())
245 };
246
247 entries.push(ProviderEntry {
248 name: name.clone(),
249 has_key,
250 key_masked,
251 is_custom: false,
252 base_url,
253 });
254 }
255
256 if let Ok(settings) = crate::store::settings::Settings::load() {
258 for cp in &settings.custom_providers {
259 if builtin_names.iter().any(|n| n == &cp.name) {
260 continue;
261 }
262 let actual_key = auth_store.get_api_key(&cp.name);
263
264 let (has_key, key_masked) = match &actual_key {
265 Some(k) => (true, mask_key(k)),
266 None => (false, String::new()),
267 };
268
269 entries.push(ProviderEntry {
270 name: cp.name.clone(),
271 has_key,
272 key_masked,
273 is_custom: true,
274 base_url: Some(cp.base_url.clone()),
275 });
276 }
277 }
278
279 entries
280}
281
282fn load_models(
291 catalog: Option<&std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>>,
292 allowed: Option<&std::collections::HashSet<String>>,
293) -> Vec<ModelEntry> {
294 let permit = |provider: &str| match allowed {
295 None => true,
296 Some(set) => set.contains(provider),
297 };
298
299 let mut models = Vec::new();
300 let mut seen = std::collections::HashSet::new();
301
302 if let Ok(settings) = crate::store::settings::Settings::load() {
304 for (provider, model_ids) in &settings.dynamic_models {
305 if !permit(provider) {
306 continue;
307 }
308 for id in model_ids {
309 let key = format!("{}/{}", provider, id);
310 if seen.insert(key.clone()) {
311 let ctx = if let Some(cat) = catalog {
313 cat.get_model_sync(provider, id)
314 .map(|e| e.context_window)
315 .unwrap_or(128_000)
316 } else {
317 oxi_sdk::get_model_entry(provider, id)
318 .map(|e| e.context_window)
319 .unwrap_or(128_000)
320 };
321 models.push(ModelEntry::new(id.clone(), provider.clone(), ctx));
322 }
323 }
324 }
325 }
326
327 if let Some(cat) = catalog {
329 for entry in cat.search_sync("") {
330 if !permit(&entry.provider) {
331 continue;
332 }
333 let key = format!("{}/{}", entry.provider, entry.model_id);
334 if seen.insert(key) {
335 models.push(ModelEntry::new(
336 entry.model_id,
337 entry.provider,
338 entry.context_window,
339 ));
340 }
341 }
342 } else {
343 for entry in oxi_sdk::get_all_models() {
344 if !permit(entry.provider) {
345 continue;
346 }
347 let key = format!("{}/{}", entry.provider, entry.id);
348 if seen.insert(key) {
349 models.push(ModelEntry::new(
350 entry.id.to_string(),
351 entry.provider.to_string(),
352 entry.context_window,
353 ));
354 }
355 }
356 }
357
358 models
359}
360
361fn keyed_provider_names(providers: &[ProviderEntry]) -> std::collections::HashSet<String> {
365 providers
366 .iter()
367 .filter(|p| p.has_key)
368 .map(|p| p.name.clone())
369 .collect()
370}
371
372fn refresh_models(state: &mut WizardState) {
376 let allowed = keyed_provider_names(&state.providers);
377 let prev = state
378 .models
379 .get(state.model_selected)
380 .map(|m| (m.provider.clone(), m.id.clone()));
381 state.models = load_models(state.catalog.as_ref(), Some(&allowed));
382 state.model_selected = match prev {
383 Some((p, id)) => state
384 .models
385 .iter()
386 .position(|m| m.provider == p && m.id == id)
387 .unwrap_or(0),
388 None => 0,
389 };
390 ensure_model_selected_visible(state);
391}
392
393fn fetch_and_cache_models(provider_name: &str, providers: &[ProviderEntry]) {
401 let base_url = providers
403 .iter()
404 .find(|p| p.name == provider_name)
405 .and_then(|p| p.base_url.clone())
406 .or_else(|| oxi_sdk::get_provider_base_url(provider_name).map(|s| s.to_string()));
407
408 let base_url = match base_url {
409 Some(url) if !url.is_empty() => url,
410 _ => {
411 tracing::debug!(
412 "Skipping dynamic model fetch for '{}': no base_url",
413 provider_name
414 );
415 return;
416 }
417 };
418
419 let auth_store = crate::store::auth_storage::shared_auth_storage();
421 let api_key = match auth_store.get_api_key(provider_name) {
422 Some(key) => key,
423 None => {
424 tracing::debug!(
425 "Skipping dynamic model fetch for '{}': no API key",
426 provider_name
427 );
428 return;
429 }
430 };
431
432 let api_type = oxi_sdk::get_provider_api(provider_name);
434 let is_openai_compatible = api_type.is_none_or(|api| {
435 matches!(
436 api,
437 oxi_sdk::Api::OpenAiCompletions | oxi_sdk::Api::OpenAiResponses
438 )
439 });
440
441 if !is_openai_compatible {
442 tracing::debug!(
443 "Skipping dynamic model fetch for '{}': not OpenAI-compatible",
444 provider_name
445 );
446 return;
447 }
448
449 tracing::info!(
450 "Fetching models from {}/models for provider '{}'...",
451 base_url,
452 provider_name
453 );
454
455 match oxi_sdk::fetch_models_blocking(&base_url, &api_key) {
456 Ok(model_ids) => {
457 tracing::info!(
458 "Fetched {} models from provider '{}'",
459 model_ids.len(),
460 provider_name
461 );
462
463 if let Ok(mut settings) = crate::store::settings::Settings::load() {
465 settings
466 .dynamic_models
467 .insert(provider_name.to_string(), model_ids);
468 if let Err(e) = settings.save() {
469 tracing::warn!("Failed to save dynamic models cache: {}", e);
470 }
471 }
472 }
473 Err(e) => {
474 tracing::warn!(
475 "Failed to fetch models from provider '{}': {}. \
476 Falling back to static model list.",
477 provider_name,
478 e
479 );
480 }
481 }
482}
483
484fn load_themes() -> Vec<String> {
487 oxi_tui::THEME_NAMES.iter().map(|s| s.to_string()).collect()
490}
491
492fn save_settings(
498 model_id: &str,
499 theme_name: &str,
500 custom_base_urls: &[(String, String)],
501) -> Result<()> {
502 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
503
504 if let Some((provider, model_name)) = model_id.split_once('/') {
506 settings.last_used_provider = Some(provider.to_string());
507 settings.last_used_model = Some(model_name.to_string());
508 } else {
509 settings.last_used_model = Some(model_id.to_string());
510 }
511 settings.theme = theme_name.to_string();
512
513 for (name, base_url) in custom_base_urls {
515 let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
516 if !already_exists {
517 settings
518 .custom_providers
519 .push(crate::store::settings::CustomProvider {
520 name: name.clone(),
521 base_url: base_url.clone(),
522 api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
523 api: "openai-completions".to_string(),
524 });
525 }
526 }
527
528 settings.save()?;
529 Ok(())
530}
531
532fn draw_wizard(
535 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
536 state: &mut WizardState,
537) -> Result<()> {
538 terminal.draw(|f| render_wizard(f, state))?;
539 Ok(())
540}
541
542fn wrapped_line_count(text: &str, cols: u16) -> u16 {
546 let max_w = if cols < 2 { 80usize } else { cols as usize };
547 let mut lines: u16 = 1;
548 let mut cur = 0usize;
549 for word in text.split_whitespace() {
550 let wlen = word.chars().count();
551 if cur == 0 {
552 cur = wlen;
553 } else if cur + 1 + wlen <= max_w {
554 cur += 1 + wlen;
555 } else {
556 lines = lines.saturating_add(1);
557 cur = wlen;
558 }
559 }
560 lines.max(1)
561}
562
563fn render_wizard(f: &mut ratatui::Frame, state: &mut WizardState) {
567 let size = f.area();
568
569 let footer_text = match state.step {
571 0 => match &state.input_mode {
572 InputMode::Normal => {
573 " Type to filter · ↑/↓ · Enter: act · → next · Esc back".to_string()
574 }
575 InputMode::EditingApiKey { .. } => {
576 " Enter: save · Ctrl+R: remove (existing) · Esc: cancel".to_string()
577 }
578 InputMode::AddingCustom { .. } => " Tab: next · Enter: save · Esc: cancel".to_string(),
579 },
580 1 => " Type to filter · ↑/↓ · Enter: select · Esc: back · ←: prev".to_string(),
581 2 => " ↑/↓ navigate · Enter: select · Esc/←: back".to_string(),
582 3 => " Esc or Enter: quit".to_string(),
583 _ => String::new(),
584 };
585 let footer_rows = wrapped_line_count(&footer_text, size.width).min(2);
586
587 let chunks = Layout::default()
588 .direction(Direction::Vertical)
589 .constraints([
590 Constraint::Length(3), Constraint::Length(1), Constraint::Min(8), Constraint::Length(footer_rows), ])
595 .split(size);
596
597 let title = Paragraph::new(Line::from(vec![
599 Span::styled(
600 " oxi ",
601 Style::default()
602 .fg(Color::Rgb(255, 165, 0))
603 .add_modifier(Modifier::BOLD),
604 ),
605 Span::styled(
606 "oxi Setup Wizard",
607 Style::default().add_modifier(Modifier::BOLD),
608 ),
609 ]))
610 .block(Block::default().borders(Borders::TOP));
611 f.render_widget(title, chunks[0]);
612
613 f.render_widget(Paragraph::new(build_step_indicator(state.step)), chunks[1]);
617
618 match state.step {
620 0 => draw_provider_step(f, state, chunks[2]),
621 1 => draw_model_step(f, state, chunks[2]),
622 2 => draw_theme_step(f, state, chunks[2]),
623 3 => draw_done_step(f, state, chunks[2]),
624 _ => {}
625 }
626
627 let footer = Paragraph::new(Line::from(Span::styled(
630 footer_text,
631 Style::default().fg(Color::DarkGray),
632 )))
633 .wrap(Wrap { trim: false });
634 f.render_widget(footer, chunks[3]);
635}
636
637fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
638 match &state.input_mode {
639 InputMode::Normal => draw_provider_list(f, state, area),
640 InputMode::EditingApiKey {
641 provider_name,
642 field_text,
643 } => {
644 let has_existing_key = state
647 .providers
648 .iter()
649 .find(|p| p.name == *provider_name)
650 .is_some_and(|p| p.has_key);
651 draw_api_key_dialog(f, provider_name, field_text, has_existing_key, area);
652 }
653 InputMode::AddingCustom {
654 fields,
655 active_field,
656 } => draw_custom_provider_dialog(f, fields, *active_field, area),
657 }
658}
659
660fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
667 let chunks = Layout::default()
669 .direction(Direction::Vertical)
670 .constraints([
671 Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
675 .split(area);
676
677 let mut filter_spans = vec![
681 Span::styled(
682 " Filter: ",
683 Style::default()
684 .fg(Color::Yellow)
685 .add_modifier(Modifier::BOLD),
686 ),
687 Span::styled(
688 &state.provider_filter,
689 Style::default().add_modifier(Modifier::BOLD),
690 ),
691 Span::styled(" ", Style::default().bg(Color::Yellow)),
692 ];
693 if state.provider_filter.is_empty() {
694 filter_spans.push(Span::styled(
695 " type to filter (e.g. 'open', 'anth', 'googl')...",
696 Style::default().fg(Color::DarkGray),
697 ));
698 }
699 f.render_widget(Paragraph::new(Line::from(filter_spans)), chunks[0]);
700
701 let indices = filtered_provider_indices(state);
702
703 let items: Vec<ListItem> = indices
706 .iter()
707 .map(|&i| {
708 let p = &state.providers[i];
709 let check = if p.has_key { "[x]" } else { "[ ]" };
710 let key_info = if p.has_key {
711 format!("API key: {}", p.key_masked)
712 } else {
713 "No API key".to_string()
714 };
715 let custom_tag = if p.is_custom { " (custom)" } else { "" };
716 let line = Line::from(vec![
717 Span::styled(
718 format!(" {} ", check),
719 Style::default().fg(if p.has_key {
720 Color::Green
721 } else {
722 Color::DarkGray
723 }),
724 ),
725 Span::styled(
726 format!("{:<14}", p.name),
727 Style::default().add_modifier(Modifier::BOLD),
728 ),
729 Span::styled(
730 format!("[{}]", key_info),
731 Style::default().fg(Color::DarkGray),
732 ),
733 Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
734 ]);
735 ListItem::new(line)
736 })
737 .collect();
738
739 let list = List::new(items)
740 .block(Block::default().borders(Borders::NONE))
741 .highlight_style(
742 Style::default()
743 .bg(Color::DarkGray)
744 .add_modifier(Modifier::BOLD),
745 )
746 .highlight_symbol("▶ ");
747
748 let list_selected = if state.on_sentinel {
750 None
751 } else {
752 indices.iter().position(|&i| i == state.provider_selected)
753 };
754 state.provider_list_state.select(list_selected);
755 f.render_stateful_widget(list, chunks[1], &mut state.provider_list_state);
756
757 let sentinel = if state.on_sentinel {
761 Line::from(Span::styled(
762 "▶ + Add custom provider…",
763 Style::default()
764 .fg(Color::Cyan)
765 .bg(Color::DarkGray)
766 .add_modifier(Modifier::BOLD),
767 ))
768 } else {
769 Line::from(Span::styled(
770 " + Add custom provider…",
771 Style::default().fg(Color::Cyan),
772 ))
773 };
774 f.render_widget(Paragraph::new(sentinel), chunks[2]);
775}
776
777fn draw_api_key_dialog(
781 f: &mut ratatui::Frame,
782 provider_name: &str,
783 field_text: &str,
784 has_existing_key: bool,
785 area: Rect,
786) {
787 let dialog_height = 8u16;
789 let dialog_width = std::cmp::min(area.width, 60);
790 let x = (area.width.saturating_sub(dialog_width)) / 2;
791 let y = (area.height.saturating_sub(dialog_height)) / 2;
792
793 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
794
795 let display_text = if field_text.is_empty() {
796 String::new()
797 } else {
798 "*".repeat(field_text.len())
799 };
800
801 let mut paragraphs = vec![
802 Line::from(""),
803 Line::from(vec![
804 Span::styled(" API Key: ", Style::default().add_modifier(Modifier::BOLD)),
805 Span::styled(
806 format!("[{:<width$}]", display_text, width = 30),
807 Style::default(),
808 ),
809 if field_text.is_empty() {
810 Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
811 } else {
812 Span::raw("")
813 },
814 ]),
815 ];
816 if has_existing_key {
817 paragraphs.push(Line::from(Span::styled(
818 " (existing key will be replaced)",
819 Style::default().fg(Color::DarkGray),
820 )));
821 } else {
822 paragraphs.push(Line::from(""));
823 }
824 paragraphs.push(Line::from(Span::styled(
825 if has_existing_key {
826 " Enter: save · Ctrl+R: remove · Esc: cancel"
827 } else {
828 " Enter: save · Esc: cancel"
829 },
830 Style::default().fg(Color::DarkGray),
831 )));
832
833 let block = Block::default()
834 .borders(Borders::ALL)
835 .title(format!(" {} API Key ", provider_name));
836
837 let para = Paragraph::new(paragraphs).block(block);
838 f.render_widget(para, dialog_area);
839}
840
841fn draw_custom_provider_dialog(
842 f: &mut ratatui::Frame,
843 fields: &[String; 3],
844 active_field: usize,
845 area: Rect,
846) {
847 let dialog_height = 9u16;
848 let dialog_width = std::cmp::min(area.width, 60);
849 let x = (area.width.saturating_sub(dialog_width)) / 2;
850 let y = (area.height.saturating_sub(dialog_height)) / 2;
851
852 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
853
854 let field_labels = ["Name", "Base URL", "API Key"];
855 let lines: Vec<Line> = std::iter::once(Line::from(""))
856 .chain(field_labels.iter().enumerate().map(|(i, label)| {
857 let display = if i == 2 && !fields[i].is_empty() {
858 "*".repeat(fields[i].len())
859 } else {
860 fields[i].clone()
861 };
862 let is_active = i == active_field;
863 let style = if is_active {
864 Style::default().add_modifier(Modifier::BOLD)
865 } else {
866 Style::default()
867 };
868 Line::from(vec![
869 Span::styled(format!(" {:<10}", format!("{}:", label)), style),
870 Span::styled(format!("[{:<width$}]", display, width = 35), style),
871 if is_active && fields[i].is_empty() {
872 Span::styled("<enter>", Style::default().fg(Color::DarkGray))
873 } else {
874 Span::raw("")
875 },
876 ])
877 }))
878 .collect();
879
880 let block = Block::default()
881 .borders(Borders::ALL)
882 .title(" Add Custom Provider ");
883
884 let para = Paragraph::new(lines).block(block);
885 f.render_widget(para, dialog_area);
886}
887
888fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
889 if state.models.is_empty() {
892 let msg = Paragraph::new(vec![
893 Line::from(""),
894 Line::from(Span::styled(
895 " No providers with an API key configured yet.",
896 Style::default()
897 .fg(Color::Yellow)
898 .add_modifier(Modifier::BOLD),
899 )),
900 Line::from(""),
901 Line::from(Span::styled(
902 " Press Left to go back and add a provider key first.",
903 Style::default().fg(Color::DarkGray),
904 )),
905 ]);
906 f.render_widget(msg, area);
907 return;
908 }
909
910 let chunks = Layout::default()
912 .direction(Direction::Vertical)
913 .constraints([Constraint::Length(1), Constraint::Min(1)])
914 .split(area);
915
916 let mut spans = vec![
920 Span::styled(
921 " Filter: ",
922 Style::default()
923 .fg(Color::Yellow)
924 .add_modifier(Modifier::BOLD),
925 ),
926 Span::styled(
927 &state.model_filter,
928 Style::default().add_modifier(Modifier::BOLD),
929 ),
930 Span::styled(" ", Style::default().bg(Color::Yellow)),
931 ];
932 if state.model_filter.is_empty() {
933 spans.push(Span::styled(
934 " type to filter (e.g. 'gpt-4', 'claude', 'gemini')...",
935 Style::default().fg(Color::DarkGray),
936 ));
937 }
938 f.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
939
940 let indices = filtered_model_indices(state);
942 let items: Vec<ListItem> = indices
943 .iter()
944 .map(|&i| {
945 let m = &state.models[i];
946 let ctx_str = if m.context_window >= 1_000_000 {
947 format!("{}M ctx", m.context_window / 1_000_000)
948 } else {
949 format!("{}K ctx", m.context_window / 1_000)
950 };
951 ListItem::new(Line::from(vec![
952 Span::styled(format!("{:<40}", m.id), Style::default()),
953 Span::styled(
954 format!("({})", m.provider),
955 Style::default().fg(Color::DarkGray),
956 ),
957 Span::styled(
958 format!(", {}", ctx_str),
959 Style::default().fg(Color::DarkGray),
960 ),
961 ]))
962 })
963 .collect();
964
965 let list = List::new(items)
966 .block(Block::default().borders(Borders::NONE))
967 .highlight_style(
968 Style::default()
969 .bg(Color::DarkGray)
970 .add_modifier(Modifier::BOLD),
971 )
972 .highlight_symbol("▶ ");
973
974 let selected_pos = indices.iter().position(|&i| i == state.model_selected);
975 state.model_list_state.select(selected_pos);
976 f.render_stateful_widget(list, chunks[1], &mut state.model_list_state);
977
978 if indices.is_empty() {
980 let hint = Paragraph::new(Line::from(Span::styled(
981 " No models match your filter. Press Esc to clear.",
982 Style::default().fg(Color::DarkGray),
983 )));
984 f.render_widget(hint, chunks[1]);
985 }
986}
987
988fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
989 let items: Vec<ListItem> = state
990 .themes
991 .iter()
992 .map(|t| ListItem::new(Line::from(format!(" {}", t))))
993 .collect();
994
995 let list = List::new(items)
996 .block(Block::default().borders(Borders::NONE))
997 .highlight_style(
998 Style::default()
999 .bg(Color::DarkGray)
1000 .add_modifier(Modifier::BOLD),
1001 );
1002
1003 state.theme_list_state.select(Some(state.theme_selected));
1004 f.render_stateful_widget(list, area, &mut state.theme_list_state);
1005}
1006
1007fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
1008 let settings_path_display = state.settings_path.display().to_string();
1009 let auth_path_display = state.auth_path.display().to_string();
1010
1011 let lines = vec![
1012 Line::from(""),
1013 Line::from(Span::styled(
1014 " Settings saved!",
1015 Style::default()
1016 .fg(Color::Green)
1017 .add_modifier(Modifier::BOLD),
1018 )),
1019 Line::from(""),
1020 Line::from(Span::styled(
1021 format!(" Settings file: {}", settings_path_display),
1022 Style::default().fg(Color::DarkGray),
1023 )),
1024 Line::from(Span::styled(
1025 format!(" Auth file: {}", auth_path_display),
1026 Style::default().fg(Color::DarkGray),
1027 )),
1028 Line::from(""),
1029 Line::from(Span::styled(
1030 " Run 'oxi' to start.",
1031 Style::default().add_modifier(Modifier::BOLD),
1032 )),
1033 ];
1034
1035 let block = Block::default().borders(Borders::NONE);
1036 let para = Paragraph::new(lines).block(block);
1037 f.render_widget(para, area);
1038}
1039
1040fn build_step_indicator(current_step: usize) -> Line<'static> {
1041 let steps = [
1042 ("1. Provider Setup", 0),
1043 ("2. Default Model", 1),
1044 ("3. Theme", 2),
1045 ("4. Done", 3),
1046 ];
1047
1048 let spans: Vec<Span> = steps
1049 .iter()
1050 .flat_map(|(label, step)| {
1051 let style = if *step == current_step {
1052 Style::default()
1053 .add_modifier(Modifier::BOLD)
1054 .fg(Color::Cyan)
1055 } else if *step < current_step {
1056 Style::default().fg(Color::Green)
1057 } else {
1058 Style::default().fg(Color::DarkGray)
1059 };
1060 vec![Span::styled(format!(" {}", label), style), Span::raw(" ")]
1061 })
1062 .collect();
1063
1064 Line::from(spans)
1065}
1066
1067fn handle_event(
1070 state: &mut WizardState,
1071 event: Event,
1072 auth_store: &crate::store::auth_storage::AuthStorage,
1073) -> Result<bool> {
1074 match state.step {
1075 0 => handle_provider_event(state, event, auth_store),
1076 1 => handle_model_event(state, event),
1077 2 => handle_theme_event(state, event),
1078 3 => handle_done_event(event),
1079 _ => Ok(false),
1080 }
1081}
1082
1083fn handle_provider_event(
1084 state: &mut WizardState,
1085 event: Event,
1086 auth_store: &crate::store::auth_storage::AuthStorage,
1087) -> Result<bool> {
1088 match &mut state.input_mode {
1092 InputMode::Normal => {
1093 if let Event::Key(key) = event {
1094 match key.code {
1095 KeyCode::Char(c) => {
1097 state.provider_filter.push(c);
1098 snap_provider_selection(state);
1099 }
1100 KeyCode::Backspace => {
1101 state.provider_filter.pop();
1102 snap_provider_selection(state);
1103 }
1104 KeyCode::Up => {
1108 let indices = filtered_provider_indices(state);
1109 if state.on_sentinel {
1110 if let Some(&last) = indices.last() {
1111 state.provider_selected = last;
1112 state.on_sentinel = false;
1113 }
1114 } else if let Some(pos) =
1115 indices.iter().position(|&i| i == state.provider_selected)
1116 {
1117 if pos > 0 {
1118 state.provider_selected = indices[pos - 1];
1119 }
1120 } else if let Some(&first) = indices.first() {
1121 state.provider_selected = first;
1122 } else {
1123 state.on_sentinel = true;
1124 }
1125 }
1126 KeyCode::Down => {
1127 let indices = filtered_provider_indices(state);
1128 if state.on_sentinel {
1129 } else if let Some(pos) =
1131 indices.iter().position(|&i| i == state.provider_selected)
1132 {
1133 if pos + 1 < indices.len() {
1134 state.provider_selected = indices[pos + 1];
1135 } else {
1136 state.on_sentinel = true;
1138 }
1139 } else if let Some(&first) = indices.first() {
1140 state.provider_selected = first;
1141 } else {
1142 state.on_sentinel = true;
1143 }
1144 }
1145 KeyCode::Enter => {
1146 if state.on_sentinel {
1147 state.input_mode = InputMode::AddingCustom {
1149 fields: [String::new(), String::new(), String::new()],
1150 active_field: 0,
1151 };
1152 } else {
1153 let name = state.providers[state.provider_selected].name.clone();
1154 state.input_mode = InputMode::EditingApiKey {
1155 provider_name: name,
1156 field_text: String::new(),
1157 };
1158 }
1159 }
1160 KeyCode::Esc => {
1161 if !state.provider_filter.is_empty() {
1164 state.provider_filter.clear();
1165 snap_provider_selection(state);
1166 } else {
1167 return Ok(true);
1168 }
1169 }
1170 KeyCode::Right => {
1171 state.step = 1;
1172 }
1173 _ => {}
1174 }
1175 }
1176 }
1177 InputMode::EditingApiKey {
1178 provider_name,
1179 field_text,
1180 } => {
1181 if let Event::Key(key) = event {
1182 match key.code {
1183 KeyCode::Esc => {
1184 state.input_mode = InputMode::Normal;
1185 }
1186 KeyCode::Enter => {
1187 if !field_text.is_empty() {
1188 auth_store.set_api_key(provider_name, field_text.clone());
1189 if let Some(entry) = state
1190 .providers
1191 .iter_mut()
1192 .find(|p| p.name == *provider_name)
1193 {
1194 entry.has_key = true;
1195 entry.key_masked = mask_key(field_text);
1196 }
1197 fetch_and_cache_models(provider_name, &state.providers);
1198 state.models_dirty = true;
1199 }
1200 state.input_mode = InputMode::Normal;
1201 }
1202 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1206 let name = provider_name.clone();
1207 auth_store.remove(&name);
1208 if let Some(entry) = state.providers.iter_mut().find(|p| p.name == name) {
1209 entry.has_key = false;
1210 entry.key_masked = String::new();
1211 }
1212 state.models_dirty = true;
1213 state.input_mode = InputMode::Normal;
1214 }
1215 KeyCode::Backspace => {
1216 field_text.pop();
1217 }
1218 KeyCode::Char(c) => {
1219 field_text.push(c);
1220 }
1221 _ => {}
1222 }
1223 }
1224 }
1225 InputMode::AddingCustom {
1226 fields,
1227 active_field,
1228 } => {
1229 if let Event::Key(key) = event {
1230 match key.code {
1231 KeyCode::Esc => {
1232 state.input_mode = InputMode::Normal;
1233 }
1234 KeyCode::Tab => {
1235 *active_field = (*active_field + 1) % 3;
1236 }
1237 KeyCode::BackTab => {
1238 *active_field = (*active_field + 2) % 3;
1239 }
1240 KeyCode::Enter => {
1241 let name = fields[0].trim().to_string();
1242 let base_url = fields[1].trim().to_string();
1243 let api_key = fields[2].trim().to_string();
1244 if !name.is_empty() && !base_url.is_empty() {
1245 if !api_key.is_empty() {
1246 auth_store.set_api_key(&name, api_key.clone());
1247 }
1248 let (has_key, key_masked) = if !api_key.is_empty() {
1249 (true, mask_key(&api_key))
1250 } else {
1251 (false, String::new())
1252 };
1253 state.providers.push(ProviderEntry {
1254 name: name.clone(),
1255 has_key,
1256 key_masked,
1257 is_custom: true,
1258 base_url: Some(base_url),
1259 });
1260 if !api_key.is_empty() {
1261 fetch_and_cache_models(&name, &state.providers);
1262 }
1263 state.models_dirty = has_key;
1264 state.provider_selected = state.providers.len() - 1;
1267 state.on_sentinel = false;
1268 state.input_mode = InputMode::Normal;
1269 }
1270 }
1271 KeyCode::Backspace => {
1272 fields[*active_field].pop();
1273 }
1274 KeyCode::Char(c) => {
1275 fields[*active_field].push(c);
1276 }
1277 _ => {}
1278 }
1279 }
1280 }
1281 }
1282 Ok(false)
1283}
1284
1285fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
1286 if let Event::Key(key) = event {
1287 match key.code {
1291 KeyCode::Char(c) => {
1292 state.model_filter.push(c);
1293 ensure_model_selected_visible(state);
1294 }
1295 KeyCode::Backspace => {
1296 state.model_filter.pop();
1297 ensure_model_selected_visible(state);
1298 }
1299 KeyCode::Up => {
1300 let indices = filtered_model_indices(state);
1301 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1302 && pos > 0
1303 {
1304 state.model_selected = indices[pos - 1];
1305 } else if let Some(&first) = indices.first() {
1306 state.model_selected = first;
1307 }
1308 }
1309 KeyCode::Down => {
1310 let indices = filtered_model_indices(state);
1311 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1312 && pos + 1 < indices.len()
1313 {
1314 state.model_selected = indices[pos + 1];
1315 } else if let Some(&first) = indices.first() {
1316 state.model_selected = first;
1317 }
1318 }
1319 KeyCode::Enter => {
1320 if !filtered_model_indices(state).is_empty() {
1325 state.step = 2;
1326 }
1327 }
1328 KeyCode::Esc => {
1329 if !state.model_filter.is_empty() {
1332 state.model_filter.clear();
1333 ensure_model_selected_visible(state);
1334 } else {
1335 state.step = 0;
1336 }
1337 }
1338 KeyCode::Left => {
1339 state.step = 0;
1340 }
1341 _ => {}
1342 }
1343 }
1344 Ok(false)
1345}
1346
1347fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
1348 if let Event::Key(key) = event {
1349 match key.code {
1350 KeyCode::Up if state.theme_selected > 0 => {
1351 state.theme_selected -= 1;
1352 }
1353 KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
1354 state.theme_selected += 1;
1355 }
1356 KeyCode::Enter => {
1357 finish_setup(state)?;
1359 state.step = 3;
1360 }
1361 KeyCode::Esc | KeyCode::Left => {
1362 state.step = 1;
1363 }
1364 _ => {}
1365 }
1366 }
1367 Ok(false)
1368}
1369
1370fn handle_done_event(event: Event) -> Result<bool> {
1371 if let Event::Key(key) = event {
1372 match key.code {
1373 KeyCode::Enter | KeyCode::Esc => {
1374 return Ok(true); }
1376 _ => {}
1377 }
1378 }
1379 Ok(false)
1380}
1381
1382fn finish_setup(state: &mut WizardState) -> Result<()> {
1385 let model_id = state
1387 .models
1388 .get(state.model_selected)
1389 .map(|m| format!("{}/{}", m.provider, m.id))
1390 .unwrap_or_default();
1391
1392 let theme_name = state
1394 .themes
1395 .get(state.theme_selected)
1396 .cloned()
1397 .unwrap_or_else(|| "oxi_dark".to_string());
1398
1399 let custom_base_urls: Vec<(String, String)> = state
1401 .providers
1402 .iter()
1403 .filter_map(|p| {
1404 if p.is_custom {
1405 p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1406 } else {
1407 None
1408 }
1409 })
1410 .collect();
1411
1412 save_settings(&model_id, &theme_name, &custom_base_urls)?;
1413
1414 Ok(())
1415}
1416
1417pub async fn run() -> Result<()> {
1421 enable_raw_mode()?;
1423 let mut stdout = io::stdout();
1424 execute!(stdout, EnterAlternateScreen)?;
1425 let backend = CrosstermBackend::new(stdout);
1426 let mut terminal = Terminal::new(backend)?;
1427
1428 let panic_hook = std::panic::take_hook();
1430 std::panic::set_hook(Box::new(move |info| {
1431 let _ = disable_raw_mode();
1432 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1433 panic_hook(info);
1434 }));
1435
1436 let catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>> = {
1438 let paths = crate::services::OxiPaths::default_paths().ok();
1439 if let Some(paths) = paths {
1440 let config = oxi_sdk::CatalogConfig {
1441 cache_path: paths.home.join("cache").join("models-dev.json"),
1442 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
1443 override_path: paths.home.join("catalog").join("overrides.toml"),
1444 fetch_enabled: false,
1446 ..Default::default()
1447 };
1448 oxi_sdk::FileModelCatalog::init(config)
1449 .await
1450 .ok()
1451 .map(|c| c as _)
1452 } else {
1453 None
1454 }
1455 };
1456
1457 let auth_store = crate::store::auth_storage::shared_auth_storage();
1459 let providers = load_providers(&auth_store, catalog.as_ref());
1460 let allowed = keyed_provider_names(&providers);
1461 let models = load_models(catalog.as_ref(), Some(&allowed));
1462 let themes = load_themes();
1463
1464 let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1465 dirs::home_dir()
1466 .unwrap_or_default()
1467 .join(".oxi")
1468 .join("auth.json")
1469 });
1470 let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1471 dirs::home_dir()
1472 .unwrap_or_default()
1473 .join(".oxi")
1474 .join("settings.json")
1475 });
1476
1477 let current_model = crate::store::settings::Settings::load()
1479 .ok()
1480 .and_then(|s| s.last_used_model.clone())
1481 .unwrap_or_default();
1482
1483 let model_selected = models
1484 .iter()
1485 .position(|m| {
1486 let full_id = format!("{}/{}", m.provider, m.id);
1487 full_id == current_model || m.id == current_model
1488 })
1489 .unwrap_or(0);
1490
1491 let current_theme = crate::store::settings::Settings::load()
1493 .ok()
1494 .map(|s| s.theme.clone())
1495 .unwrap_or_else(|| "oxi_dark".to_string());
1496
1497 let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1498
1499 let mut state = WizardState {
1500 step: 0,
1501 providers,
1502 provider_selected: 0,
1503 provider_list_state: ListState::default(),
1504 provider_filter: String::new(),
1505 on_sentinel: false,
1506 input_mode: InputMode::Normal,
1507 models,
1508 model_selected,
1509 model_filter: String::new(),
1510 model_list_state: ListState::default(),
1511 models_dirty: false,
1512 themes,
1513 theme_selected,
1514 theme_list_state: ListState::default(),
1515 auth_path,
1516 settings_path,
1517 catalog,
1518 };
1519
1520 loop {
1522 if state.step == 1 && state.models_dirty {
1526 refresh_models(&mut state);
1527 state.models_dirty = false;
1528 }
1529 draw_wizard(&mut terminal, &mut state)?;
1530
1531 if event::poll(std::time::Duration::from_millis(100))?
1532 && let Event::Key(key) = event::read()?
1533 {
1534 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1536 break;
1537 }
1538
1539 let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1540 if should_quit {
1541 break;
1542 }
1543 }
1544 }
1545
1546 disable_raw_mode()?;
1548 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1549
1550 Ok(())
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555 use super::*;
1556
1557 fn make_state(providers: Vec<&str>, models: Vec<(&str, &str)>) -> WizardState {
1558 WizardState {
1559 step: 0,
1560 providers: providers
1561 .iter()
1562 .map(|n| ProviderEntry {
1563 name: n.to_string(),
1564 has_key: false,
1565 key_masked: String::new(),
1566 is_custom: false,
1567 base_url: None,
1568 })
1569 .collect(),
1570 provider_selected: 0,
1571 provider_list_state: ListState::default(),
1572 provider_filter: String::new(),
1573 on_sentinel: false,
1574 input_mode: InputMode::Normal,
1575 models: models
1576 .iter()
1577 .map(|(id, provider)| {
1578 ModelEntry::new(id.to_string(), provider.to_string(), 128_000)
1579 })
1580 .collect(),
1581 model_selected: 0,
1582 model_filter: String::new(),
1583 model_list_state: ListState::default(),
1584 models_dirty: false,
1585 themes: vec![],
1586 theme_selected: 0,
1587 theme_list_state: ListState::default(),
1588 auth_path: PathBuf::new(),
1589 settings_path: PathBuf::new(),
1590 catalog: None,
1591 }
1592 }
1593
1594 #[test]
1595 fn provider_filter_matches_name_case_insensitive() {
1596 let mut s = make_state(vec!["anthropic", "openai", "google", "mistral"], vec![]);
1597 assert_eq!(filtered_provider_indices(&s), vec![0, 1, 2, 3]);
1598
1599 s.provider_filter = "ANT".to_string();
1600 assert_eq!(filtered_provider_indices(&s), vec![0]); s.provider_filter = "goog".to_string();
1603 assert_eq!(filtered_provider_indices(&s), vec![2]); }
1605
1606 #[test]
1607 fn model_filter_matches_id_or_provider() {
1608 let mut s = make_state(
1609 vec![],
1610 vec![
1611 ("gpt-4o", "openai"),
1612 ("gpt-4-turbo", "openai"),
1613 ("claude-3-opus", "anthropic"),
1614 ("gemini-pro", "google"),
1615 ],
1616 );
1617 assert_eq!(filtered_model_indices(&s), vec![0, 1, 2, 3]);
1618
1619 s.model_filter = "gpt".to_string();
1620 assert_eq!(filtered_model_indices(&s), vec![0, 1]);
1621
1622 s.model_filter = "anthropic".to_string();
1623 assert_eq!(filtered_model_indices(&s), vec![2]); s.model_filter = "OPUS".to_string();
1626 assert_eq!(filtered_model_indices(&s), vec![2]); }
1628
1629 #[test]
1630 fn model_filter_empty_result_yields_no_indices() {
1631 let mut state = make_state(vec![], vec![("gpt-4o", "openai")]);
1632 state.model_filter = "zzz".to_string();
1633 assert!(filtered_model_indices(&state).is_empty());
1634 }
1635
1636 #[test]
1637 fn ensure_model_selected_snaps_to_first_match() {
1638 let mut state = make_state(
1639 vec![],
1640 vec![
1641 ("gpt-4o", "openai"),
1642 ("claude-3", "anthropic"),
1643 ("gpt-3.5", "openai"),
1644 ],
1645 );
1646 state.model_filter = "gpt".to_string();
1648 ensure_model_selected_visible(&mut state);
1649 assert_eq!(state.model_selected, 0);
1651
1652 state.model_filter = "claude".to_string();
1654 ensure_model_selected_visible(&mut state);
1655 assert_eq!(state.model_selected, 1);
1656 }
1657
1658 #[test]
1659 fn snap_provider_selection_into_filtered_set() {
1660 let mut state = make_state(vec!["anthropic", "openai", "google"], vec![]);
1661 state.provider_selected = 2; state.provider_filter = "open".to_string();
1663 snap_provider_selection(&mut state);
1664 assert_eq!(state.provider_selected, 1); }
1666
1667 #[test]
1668 fn snap_provider_noop_when_filter_empty_matches_all() {
1669 let mut state = make_state(vec!["anthropic", "openai"], vec![]);
1670 state.provider_selected = 1;
1671 state.provider_filter = String::new();
1672 snap_provider_selection(&mut state);
1673 assert_eq!(state.provider_selected, 1); }
1675 #[test]
1676 fn keyed_provider_names_only_includes_configured() {
1677 let providers = vec![
1678 ProviderEntry {
1679 name: "anthropic".to_string(),
1680 has_key: true,
1681 key_masked: "sk-1...abcd".to_string(),
1682 is_custom: false,
1683 base_url: None,
1684 },
1685 ProviderEntry {
1686 name: "openai".to_string(),
1687 has_key: false,
1688 key_masked: String::new(),
1689 is_custom: false,
1690 base_url: None,
1691 },
1692 ProviderEntry {
1693 name: "local".to_string(),
1694 has_key: true,
1695 key_masked: "x...y".to_string(),
1696 is_custom: true,
1697 base_url: Some("http://localhost:11434".to_string()),
1698 },
1699 ];
1700 let set = keyed_provider_names(&providers);
1701 assert!(set.contains("anthropic"));
1702 assert!(set.contains("local"));
1703 assert!(!set.contains("openai"));
1704 assert_eq!(set.len(), 2);
1705 }
1706
1707 #[test]
1708 fn keyed_provider_names_empty_when_none_configured() {
1709 let providers = vec![ProviderEntry {
1710 name: "openai".to_string(),
1711 has_key: false,
1712 key_masked: String::new(),
1713 is_custom: false,
1714 base_url: None,
1715 }];
1716 assert!(keyed_provider_names(&providers).is_empty());
1717 }
1718 fn render_to_buffer(step: usize, models: Vec<ModelEntry>) -> String {
1721 use ratatui::backend::TestBackend;
1722 let providers = vec![
1723 ProviderEntry {
1724 name: "openai".to_string(),
1725 has_key: true,
1726 key_masked: "k...1".to_string(),
1727 is_custom: false,
1728 base_url: None,
1729 },
1730 ProviderEntry {
1731 name: "anthropic".to_string(),
1732 has_key: false,
1733 key_masked: String::new(),
1734 is_custom: false,
1735 base_url: None,
1736 },
1737 ];
1738 let mut state = WizardState {
1739 step,
1740 providers,
1741 provider_selected: 0,
1742 provider_list_state: ListState::default(),
1743 provider_filter: String::new(),
1744 on_sentinel: false,
1745 input_mode: InputMode::Normal,
1746 models,
1747 model_selected: 0,
1748 model_filter: String::new(),
1749 model_list_state: ListState::default(),
1750 themes: vec!["oxi_dark".to_string()],
1751 theme_selected: 0,
1752 theme_list_state: ListState::default(),
1753 auth_path: PathBuf::new(),
1754 settings_path: PathBuf::new(),
1755 catalog: None,
1756 models_dirty: false,
1757 };
1758 let backend = TestBackend::new(90, 24);
1759 let mut terminal = Terminal::new(backend).unwrap();
1760 terminal.draw(|f| render_wizard(f, &mut state)).unwrap();
1761 let buf = terminal.backend().buffer();
1762 let area = buf.area();
1763 let mut out = String::new();
1764 for y in 0..area.height {
1765 for x in 0..area.width {
1766 out.push_str(buf[(x, y)].symbol());
1767 }
1768 out.push('\n');
1769 }
1770 out
1771 }
1772
1773 #[test]
1774 fn step_indicator_visible_on_every_step() {
1775 for (step, label) in [
1779 (0usize, "1. Provider Setup"),
1780 (1, "2. Default Model"),
1781 (2, "3. Theme"),
1782 (3, "4. Done"),
1783 ] {
1784 let models = vec![ModelEntry::new(
1785 "gpt-4o".to_string(),
1786 "openai".to_string(),
1787 128_000,
1788 )];
1789 let rendered = render_to_buffer(step, models);
1790 assert!(
1791 rendered.contains(label),
1792 "step {step}: indicator label {label:?} missing from buffer:\n{rendered}"
1793 );
1794 }
1795 }
1796
1797 #[test]
1798 fn model_step_shows_empty_state_when_no_provider_keyed() {
1799 let rendered = render_to_buffer(1, vec![]);
1802 assert!(rendered.contains("No providers with an API key configured yet."));
1803 assert!(rendered.contains("Press Left to go back"));
1804 }
1805
1806 #[test]
1807 fn model_step_shows_configured_provider_model() {
1808 let models = vec![ModelEntry::new(
1813 "gpt-4o".to_string(),
1814 "openai".to_string(),
1815 128_000,
1816 )];
1817 let rendered = render_to_buffer(1, models);
1818 assert!(rendered.contains("gpt-4o"));
1819 }
1820 fn esc_event() -> Event {
1826 Event::Key(crossterm::event::KeyEvent::new(
1827 KeyCode::Esc,
1828 KeyModifiers::NONE,
1829 ))
1830 }
1831
1832 #[test]
1833 fn esc_quits_from_provider_step_normal() {
1834 let mut state = make_state(vec!["openai"], vec![]);
1835 state.step = 0;
1836 let auth = crate::store::auth_storage::shared_auth_storage();
1837 let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1838 assert!(quit, "Esc on step 0 Normal should quit");
1839 }
1840
1841 #[test]
1842 fn esc_clears_provider_filter_without_quitting() {
1843 let mut state = make_state(vec!["openai", "anthropic"], vec![]);
1847 state.step = 0;
1848 state.provider_filter = "anth".to_string();
1849 let auth = crate::store::auth_storage::shared_auth_storage();
1851 let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1852 assert!(!quit, "Esc with a non-empty filter must clear it, not quit");
1853 assert!(state.provider_filter.is_empty());
1854 }
1855
1856 #[test]
1857 fn esc_backs_out_of_model_step_when_filter_empty() {
1858 let mut state = make_state(vec!["openai"], vec![("gpt-4o", "openai")]);
1859 state.step = 1;
1860 state.model_filter = String::new();
1861 handle_model_event(&mut state, esc_event()).unwrap();
1862 assert_eq!(
1863 state.step, 0,
1864 "Esc with empty filter should return to the provider step"
1865 );
1866 }
1867
1868 #[test]
1869 fn esc_clears_model_filter_when_nonempty() {
1870 let mut state = make_state(
1871 vec!["openai"],
1872 vec![("gpt-4o", "openai"), ("gpt-4", "openai")],
1873 );
1874 state.step = 1;
1875 state.model_filter = "gpt".to_string();
1876 handle_model_event(&mut state, esc_event()).unwrap();
1877 assert_eq!(
1878 state.step, 1,
1879 "Esc with a non-empty filter should stay on the model step"
1880 );
1881 assert!(state.model_filter.is_empty(), "Esc should clear the filter");
1882 }
1883
1884 #[test]
1885 fn esc_backs_out_of_theme_step() {
1886 let mut state = make_state(vec!["openai"], vec![]);
1887 state.step = 2;
1888 state.themes = vec!["oxi_dark".to_string()];
1889 handle_theme_event(&mut state, esc_event()).unwrap();
1890 assert_eq!(state.step, 1);
1891 }
1892
1893 #[test]
1894 fn esc_quits_from_done_step() {
1895 assert!(
1896 handle_done_event(esc_event()).unwrap(),
1897 "Esc on the done step should quit"
1898 );
1899 }
1900 #[test]
1901 fn provider_step_renders_filter_and_sentinel() {
1902 let rendered = render_to_buffer(0, vec![]);
1907 assert!(
1908 rendered.contains("Filter:"),
1909 "filter line missing in unfiltered provider step"
1910 );
1911 assert!(
1912 rendered.contains("Add custom provider"),
1913 "sentinel missing in unfiltered provider step"
1914 );
1915 let providers = vec![ProviderEntry {
1917 name: "openai".to_string(),
1918 has_key: true,
1919 key_masked: "k".to_string(),
1920 is_custom: false,
1921 base_url: None,
1922 }];
1923 let mut s = WizardState {
1924 step: 0,
1925 providers,
1926 provider_selected: 0,
1927 provider_list_state: ListState::default(),
1928 provider_filter: "open".to_string(),
1929 on_sentinel: false,
1930 input_mode: InputMode::Normal,
1931 models: vec![],
1932 model_selected: 0,
1933 model_filter: String::new(),
1934 model_list_state: ListState::default(),
1935 themes: vec![],
1936 theme_selected: 0,
1937 theme_list_state: ListState::default(),
1938 auth_path: PathBuf::new(),
1939 settings_path: PathBuf::new(),
1940 catalog: None,
1941 models_dirty: false,
1942 };
1943 use ratatui::backend::TestBackend;
1944 let backend = TestBackend::new(90, 24);
1945 let mut terminal = Terminal::new(backend).unwrap();
1946 terminal.draw(|f| render_wizard(f, &mut s)).unwrap();
1947 let buf = terminal.backend().buffer();
1948 let area = buf.area();
1949 let mut out = String::new();
1950 for y in 0..area.height {
1951 for x in 0..area.width {
1952 out.push_str(buf[(x, y)].symbol());
1953 }
1954 out.push('\n');
1955 }
1956 assert!(out.contains("Filter:"));
1957 assert!(
1958 out.contains("open"),
1959 "typed filter must be shown in the filter line"
1960 );
1961 assert!(
1962 out.contains("Add custom provider"),
1963 "sentinel must remain under a filter"
1964 );
1965 }
1966 #[test]
1967 fn footer_wraps_on_narrow_terminal() {
1968 use ratatui::backend::TestBackend;
1972 let providers = vec![ProviderEntry {
1973 name: "openai".to_string(),
1974 has_key: false,
1975 key_masked: String::new(),
1976 is_custom: false,
1977 base_url: None,
1978 }];
1979 let mut s = WizardState {
1980 step: 0,
1981 providers,
1982 provider_selected: 0,
1983 provider_list_state: ListState::default(),
1984 provider_filter: String::new(),
1985 on_sentinel: false,
1986 input_mode: InputMode::Normal,
1987 models: vec![],
1988 model_selected: 0,
1989 model_filter: String::new(),
1990 model_list_state: ListState::default(),
1991 themes: vec![],
1992 theme_selected: 0,
1993 theme_list_state: ListState::default(),
1994 auth_path: PathBuf::new(),
1995 settings_path: PathBuf::new(),
1996 catalog: None,
1997 models_dirty: false,
1998 };
1999 let backend = TestBackend::new(50, 24);
2000 let mut terminal = Terminal::new(backend).unwrap();
2001 terminal.draw(|f| render_wizard(f, &mut s)).unwrap();
2002 let buf = terminal.backend().buffer();
2003 let area = buf.area();
2004 let mut out = String::new();
2005 for y in 0..area.height {
2006 for x in 0..area.width {
2007 out.push_str(buf[(x, y)].symbol());
2008 }
2009 out.push('\n');
2010 }
2011 for word in [
2014 "Type",
2015 "filter",
2016 "\u{2191}/\u{2193}",
2017 "act",
2018 "next",
2019 "Esc",
2020 "back",
2021 ] {
2022 assert!(
2023 out.contains(word),
2024 "footer word {word:?} missing at 50 cols — footer may be truncated"
2025 );
2026 }
2027 }
2028}