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 vec![
489 "oxi_dark".to_string(),
490 "oxi_light".to_string(),
491 "nord".to_string(),
492 "catppuccin".to_string(),
493 "github_dark".to_string(),
494 "monokai".to_string(),
495 ]
496}
497
498fn save_settings(
504 model_id: &str,
505 theme_name: &str,
506 custom_base_urls: &[(String, String)],
507) -> Result<()> {
508 let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
509
510 if let Some((provider, model_name)) = model_id.split_once('/') {
512 settings.last_used_provider = Some(provider.to_string());
513 settings.last_used_model = Some(model_name.to_string());
514 } else {
515 settings.last_used_model = Some(model_id.to_string());
516 }
517 settings.theme = theme_name.to_string();
518
519 for (name, base_url) in custom_base_urls {
521 let already_exists = settings.custom_providers.iter().any(|cp| cp.name == *name);
522 if !already_exists {
523 settings
524 .custom_providers
525 .push(crate::store::settings::CustomProvider {
526 name: name.clone(),
527 base_url: base_url.clone(),
528 api_key_env: format!("{}_API_KEY", name.to_uppercase().replace('-', "_")),
529 api: "openai-completions".to_string(),
530 });
531 }
532 }
533
534 settings.save()?;
535 Ok(())
536}
537
538fn draw_wizard(
541 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
542 state: &mut WizardState,
543) -> Result<()> {
544 terminal.draw(|f| render_wizard(f, state))?;
545 Ok(())
546}
547
548fn wrapped_line_count(text: &str, cols: u16) -> u16 {
552 let max_w = if cols < 2 { 80usize } else { cols as usize };
553 let mut lines: u16 = 1;
554 let mut cur = 0usize;
555 for word in text.split_whitespace() {
556 let wlen = word.chars().count();
557 if cur == 0 {
558 cur = wlen;
559 } else if cur + 1 + wlen <= max_w {
560 cur += 1 + wlen;
561 } else {
562 lines = lines.saturating_add(1);
563 cur = wlen;
564 }
565 }
566 lines.max(1)
567}
568
569fn render_wizard(f: &mut ratatui::Frame, state: &mut WizardState) {
573 let size = f.area();
574
575 let footer_text = match state.step {
577 0 => match &state.input_mode {
578 InputMode::Normal => {
579 " Type to filter · ↑/↓ · Enter: act · → next · Esc back".to_string()
580 }
581 InputMode::EditingApiKey { .. } => {
582 " Enter: save · Ctrl+R: remove (existing) · Esc: cancel".to_string()
583 }
584 InputMode::AddingCustom { .. } => " Tab: next · Enter: save · Esc: cancel".to_string(),
585 },
586 1 => " Type to filter · ↑/↓ · Enter: select · Esc: back · ←: prev".to_string(),
587 2 => " ↑/↓ navigate · Enter: select · Esc/←: back".to_string(),
588 3 => " Esc or Enter: quit".to_string(),
589 _ => String::new(),
590 };
591 let footer_rows = wrapped_line_count(&footer_text, size.width).min(2);
592
593 let chunks = Layout::default()
594 .direction(Direction::Vertical)
595 .constraints([
596 Constraint::Length(3), Constraint::Length(1), Constraint::Min(8), Constraint::Length(footer_rows), ])
601 .split(size);
602
603 let title = Paragraph::new(Line::from(vec![
605 Span::styled(
606 " oxi ",
607 Style::default()
608 .fg(Color::Rgb(255, 165, 0))
609 .add_modifier(Modifier::BOLD),
610 ),
611 Span::styled(
612 "oxi Setup Wizard",
613 Style::default().add_modifier(Modifier::BOLD),
614 ),
615 ]))
616 .block(Block::default().borders(Borders::TOP));
617 f.render_widget(title, chunks[0]);
618
619 f.render_widget(Paragraph::new(build_step_indicator(state.step)), chunks[1]);
623
624 match state.step {
626 0 => draw_provider_step(f, state, chunks[2]),
627 1 => draw_model_step(f, state, chunks[2]),
628 2 => draw_theme_step(f, state, chunks[2]),
629 3 => draw_done_step(f, state, chunks[2]),
630 _ => {}
631 }
632
633 let footer = Paragraph::new(Line::from(Span::styled(
636 footer_text,
637 Style::default().fg(Color::DarkGray),
638 )))
639 .wrap(Wrap { trim: false });
640 f.render_widget(footer, chunks[3]);
641}
642
643fn draw_provider_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
644 match &state.input_mode {
645 InputMode::Normal => draw_provider_list(f, state, area),
646 InputMode::EditingApiKey {
647 provider_name,
648 field_text,
649 } => {
650 let has_existing_key = state
653 .providers
654 .iter()
655 .find(|p| p.name == *provider_name)
656 .is_some_and(|p| p.has_key);
657 draw_api_key_dialog(f, provider_name, field_text, has_existing_key, area);
658 }
659 InputMode::AddingCustom {
660 fields,
661 active_field,
662 } => draw_custom_provider_dialog(f, fields, *active_field, area),
663 }
664}
665
666fn draw_provider_list(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
673 let chunks = Layout::default()
675 .direction(Direction::Vertical)
676 .constraints([
677 Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
681 .split(area);
682
683 let mut filter_spans = vec![
687 Span::styled(
688 " Filter: ",
689 Style::default()
690 .fg(Color::Yellow)
691 .add_modifier(Modifier::BOLD),
692 ),
693 Span::styled(
694 &state.provider_filter,
695 Style::default().add_modifier(Modifier::BOLD),
696 ),
697 Span::styled(" ", Style::default().bg(Color::Yellow)),
698 ];
699 if state.provider_filter.is_empty() {
700 filter_spans.push(Span::styled(
701 " type to filter (e.g. 'open', 'anth', 'googl')...",
702 Style::default().fg(Color::DarkGray),
703 ));
704 }
705 f.render_widget(Paragraph::new(Line::from(filter_spans)), chunks[0]);
706
707 let indices = filtered_provider_indices(state);
708
709 let items: Vec<ListItem> = indices
712 .iter()
713 .map(|&i| {
714 let p = &state.providers[i];
715 let check = if p.has_key { "[x]" } else { "[ ]" };
716 let key_info = if p.has_key {
717 format!("API key: {}", p.key_masked)
718 } else {
719 "No API key".to_string()
720 };
721 let custom_tag = if p.is_custom { " (custom)" } else { "" };
722 let line = Line::from(vec![
723 Span::styled(
724 format!(" {} ", check),
725 Style::default().fg(if p.has_key {
726 Color::Green
727 } else {
728 Color::DarkGray
729 }),
730 ),
731 Span::styled(
732 format!("{:<14}", p.name),
733 Style::default().add_modifier(Modifier::BOLD),
734 ),
735 Span::styled(
736 format!("[{}]", key_info),
737 Style::default().fg(Color::DarkGray),
738 ),
739 Span::styled(custom_tag.to_string(), Style::default().fg(Color::Yellow)),
740 ]);
741 ListItem::new(line)
742 })
743 .collect();
744
745 let list = List::new(items)
746 .block(Block::default().borders(Borders::NONE))
747 .highlight_style(
748 Style::default()
749 .bg(Color::DarkGray)
750 .add_modifier(Modifier::BOLD),
751 )
752 .highlight_symbol("▶ ");
753
754 let list_selected = if state.on_sentinel {
756 None
757 } else {
758 indices.iter().position(|&i| i == state.provider_selected)
759 };
760 state.provider_list_state.select(list_selected);
761 f.render_stateful_widget(list, chunks[1], &mut state.provider_list_state);
762
763 let sentinel = if state.on_sentinel {
767 Line::from(Span::styled(
768 "▶ + Add custom provider…",
769 Style::default()
770 .fg(Color::Cyan)
771 .bg(Color::DarkGray)
772 .add_modifier(Modifier::BOLD),
773 ))
774 } else {
775 Line::from(Span::styled(
776 " + Add custom provider…",
777 Style::default().fg(Color::Cyan),
778 ))
779 };
780 f.render_widget(Paragraph::new(sentinel), chunks[2]);
781}
782
783fn draw_api_key_dialog(
787 f: &mut ratatui::Frame,
788 provider_name: &str,
789 field_text: &str,
790 has_existing_key: bool,
791 area: Rect,
792) {
793 let dialog_height = 8u16;
795 let dialog_width = std::cmp::min(area.width, 60);
796 let x = (area.width.saturating_sub(dialog_width)) / 2;
797 let y = (area.height.saturating_sub(dialog_height)) / 2;
798
799 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
800
801 let display_text = if field_text.is_empty() {
802 String::new()
803 } else {
804 "*".repeat(field_text.len())
805 };
806
807 let mut paragraphs = vec![
808 Line::from(""),
809 Line::from(vec![
810 Span::styled(" API Key: ", Style::default().add_modifier(Modifier::BOLD)),
811 Span::styled(
812 format!("[{:<width$}]", display_text, width = 30),
813 Style::default(),
814 ),
815 if field_text.is_empty() {
816 Span::styled("Enter your API key", Style::default().fg(Color::DarkGray))
817 } else {
818 Span::raw("")
819 },
820 ]),
821 ];
822 if has_existing_key {
823 paragraphs.push(Line::from(Span::styled(
824 " (existing key will be replaced)",
825 Style::default().fg(Color::DarkGray),
826 )));
827 } else {
828 paragraphs.push(Line::from(""));
829 }
830 paragraphs.push(Line::from(Span::styled(
831 if has_existing_key {
832 " Enter: save · Ctrl+R: remove · Esc: cancel"
833 } else {
834 " Enter: save · Esc: cancel"
835 },
836 Style::default().fg(Color::DarkGray),
837 )));
838
839 let block = Block::default()
840 .borders(Borders::ALL)
841 .title(format!(" {} API Key ", provider_name));
842
843 let para = Paragraph::new(paragraphs).block(block);
844 f.render_widget(para, dialog_area);
845}
846
847fn draw_custom_provider_dialog(
848 f: &mut ratatui::Frame,
849 fields: &[String; 3],
850 active_field: usize,
851 area: Rect,
852) {
853 let dialog_height = 9u16;
854 let dialog_width = std::cmp::min(area.width, 60);
855 let x = (area.width.saturating_sub(dialog_width)) / 2;
856 let y = (area.height.saturating_sub(dialog_height)) / 2;
857
858 let dialog_area = Rect::new(area.x + x, area.y + y, dialog_width, dialog_height);
859
860 let field_labels = ["Name", "Base URL", "API Key"];
861 let lines: Vec<Line> = std::iter::once(Line::from(""))
862 .chain(field_labels.iter().enumerate().map(|(i, label)| {
863 let display = if i == 2 && !fields[i].is_empty() {
864 "*".repeat(fields[i].len())
865 } else {
866 fields[i].clone()
867 };
868 let is_active = i == active_field;
869 let style = if is_active {
870 Style::default().add_modifier(Modifier::BOLD)
871 } else {
872 Style::default()
873 };
874 Line::from(vec![
875 Span::styled(format!(" {:<10}", format!("{}:", label)), style),
876 Span::styled(format!("[{:<width$}]", display, width = 35), style),
877 if is_active && fields[i].is_empty() {
878 Span::styled("<enter>", Style::default().fg(Color::DarkGray))
879 } else {
880 Span::raw("")
881 },
882 ])
883 }))
884 .collect();
885
886 let block = Block::default()
887 .borders(Borders::ALL)
888 .title(" Add Custom Provider ");
889
890 let para = Paragraph::new(lines).block(block);
891 f.render_widget(para, dialog_area);
892}
893
894fn draw_model_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
895 if state.models.is_empty() {
898 let msg = Paragraph::new(vec![
899 Line::from(""),
900 Line::from(Span::styled(
901 " No providers with an API key configured yet.",
902 Style::default()
903 .fg(Color::Yellow)
904 .add_modifier(Modifier::BOLD),
905 )),
906 Line::from(""),
907 Line::from(Span::styled(
908 " Press Left to go back and add a provider key first.",
909 Style::default().fg(Color::DarkGray),
910 )),
911 ]);
912 f.render_widget(msg, area);
913 return;
914 }
915
916 let chunks = Layout::default()
918 .direction(Direction::Vertical)
919 .constraints([Constraint::Length(1), Constraint::Min(1)])
920 .split(area);
921
922 let mut spans = vec![
926 Span::styled(
927 " Filter: ",
928 Style::default()
929 .fg(Color::Yellow)
930 .add_modifier(Modifier::BOLD),
931 ),
932 Span::styled(
933 &state.model_filter,
934 Style::default().add_modifier(Modifier::BOLD),
935 ),
936 Span::styled(" ", Style::default().bg(Color::Yellow)),
937 ];
938 if state.model_filter.is_empty() {
939 spans.push(Span::styled(
940 " type to filter (e.g. 'gpt-4', 'claude', 'gemini')...",
941 Style::default().fg(Color::DarkGray),
942 ));
943 }
944 f.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
945
946 let indices = filtered_model_indices(state);
948 let items: Vec<ListItem> = indices
949 .iter()
950 .map(|&i| {
951 let m = &state.models[i];
952 let ctx_str = if m.context_window >= 1_000_000 {
953 format!("{}M ctx", m.context_window / 1_000_000)
954 } else {
955 format!("{}K ctx", m.context_window / 1_000)
956 };
957 ListItem::new(Line::from(vec![
958 Span::styled(format!("{:<40}", m.id), Style::default()),
959 Span::styled(
960 format!("({})", m.provider),
961 Style::default().fg(Color::DarkGray),
962 ),
963 Span::styled(
964 format!(", {}", ctx_str),
965 Style::default().fg(Color::DarkGray),
966 ),
967 ]))
968 })
969 .collect();
970
971 let list = List::new(items)
972 .block(Block::default().borders(Borders::NONE))
973 .highlight_style(
974 Style::default()
975 .bg(Color::DarkGray)
976 .add_modifier(Modifier::BOLD),
977 )
978 .highlight_symbol("▶ ");
979
980 let selected_pos = indices.iter().position(|&i| i == state.model_selected);
981 state.model_list_state.select(selected_pos);
982 f.render_stateful_widget(list, chunks[1], &mut state.model_list_state);
983
984 if indices.is_empty() {
986 let hint = Paragraph::new(Line::from(Span::styled(
987 " No models match your filter. Press Esc to clear.",
988 Style::default().fg(Color::DarkGray),
989 )));
990 f.render_widget(hint, chunks[1]);
991 }
992}
993
994fn draw_theme_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
995 let items: Vec<ListItem> = state
996 .themes
997 .iter()
998 .map(|t| ListItem::new(Line::from(format!(" {}", t))))
999 .collect();
1000
1001 let list = List::new(items)
1002 .block(Block::default().borders(Borders::NONE))
1003 .highlight_style(
1004 Style::default()
1005 .bg(Color::DarkGray)
1006 .add_modifier(Modifier::BOLD),
1007 );
1008
1009 state.theme_list_state.select(Some(state.theme_selected));
1010 f.render_stateful_widget(list, area, &mut state.theme_list_state);
1011}
1012
1013fn draw_done_step(f: &mut ratatui::Frame, state: &mut WizardState, area: Rect) {
1014 let settings_path_display = state.settings_path.display().to_string();
1015 let auth_path_display = state.auth_path.display().to_string();
1016
1017 let lines = vec![
1018 Line::from(""),
1019 Line::from(Span::styled(
1020 " Settings saved!",
1021 Style::default()
1022 .fg(Color::Green)
1023 .add_modifier(Modifier::BOLD),
1024 )),
1025 Line::from(""),
1026 Line::from(Span::styled(
1027 format!(" Settings file: {}", settings_path_display),
1028 Style::default().fg(Color::DarkGray),
1029 )),
1030 Line::from(Span::styled(
1031 format!(" Auth file: {}", auth_path_display),
1032 Style::default().fg(Color::DarkGray),
1033 )),
1034 Line::from(""),
1035 Line::from(Span::styled(
1036 " Run 'oxi' to start.",
1037 Style::default().add_modifier(Modifier::BOLD),
1038 )),
1039 ];
1040
1041 let block = Block::default().borders(Borders::NONE);
1042 let para = Paragraph::new(lines).block(block);
1043 f.render_widget(para, area);
1044}
1045
1046fn build_step_indicator(current_step: usize) -> Line<'static> {
1047 let steps = [
1048 ("1. Provider Setup", 0),
1049 ("2. Default Model", 1),
1050 ("3. Theme", 2),
1051 ("4. Done", 3),
1052 ];
1053
1054 let spans: Vec<Span> = steps
1055 .iter()
1056 .flat_map(|(label, step)| {
1057 let style = if *step == current_step {
1058 Style::default()
1059 .add_modifier(Modifier::BOLD)
1060 .fg(Color::Cyan)
1061 } else if *step < current_step {
1062 Style::default().fg(Color::Green)
1063 } else {
1064 Style::default().fg(Color::DarkGray)
1065 };
1066 vec![Span::styled(format!(" {}", label), style), Span::raw(" ")]
1067 })
1068 .collect();
1069
1070 Line::from(spans)
1071}
1072
1073fn handle_event(
1076 state: &mut WizardState,
1077 event: Event,
1078 auth_store: &crate::store::auth_storage::AuthStorage,
1079) -> Result<bool> {
1080 match state.step {
1081 0 => handle_provider_event(state, event, auth_store),
1082 1 => handle_model_event(state, event),
1083 2 => handle_theme_event(state, event),
1084 3 => handle_done_event(event),
1085 _ => Ok(false),
1086 }
1087}
1088
1089fn handle_provider_event(
1090 state: &mut WizardState,
1091 event: Event,
1092 auth_store: &crate::store::auth_storage::AuthStorage,
1093) -> Result<bool> {
1094 match &mut state.input_mode {
1098 InputMode::Normal => {
1099 if let Event::Key(key) = event {
1100 match key.code {
1101 KeyCode::Char(c) => {
1103 state.provider_filter.push(c);
1104 snap_provider_selection(state);
1105 }
1106 KeyCode::Backspace => {
1107 state.provider_filter.pop();
1108 snap_provider_selection(state);
1109 }
1110 KeyCode::Up => {
1114 let indices = filtered_provider_indices(state);
1115 if state.on_sentinel {
1116 if let Some(&last) = indices.last() {
1117 state.provider_selected = last;
1118 state.on_sentinel = false;
1119 }
1120 } else if let Some(pos) =
1121 indices.iter().position(|&i| i == state.provider_selected)
1122 {
1123 if pos > 0 {
1124 state.provider_selected = indices[pos - 1];
1125 }
1126 } else if let Some(&first) = indices.first() {
1127 state.provider_selected = first;
1128 } else {
1129 state.on_sentinel = true;
1130 }
1131 }
1132 KeyCode::Down => {
1133 let indices = filtered_provider_indices(state);
1134 if state.on_sentinel {
1135 } else if let Some(pos) =
1137 indices.iter().position(|&i| i == state.provider_selected)
1138 {
1139 if pos + 1 < indices.len() {
1140 state.provider_selected = indices[pos + 1];
1141 } else {
1142 state.on_sentinel = true;
1144 }
1145 } else if let Some(&first) = indices.first() {
1146 state.provider_selected = first;
1147 } else {
1148 state.on_sentinel = true;
1149 }
1150 }
1151 KeyCode::Enter => {
1152 if state.on_sentinel {
1153 state.input_mode = InputMode::AddingCustom {
1155 fields: [String::new(), String::new(), String::new()],
1156 active_field: 0,
1157 };
1158 } else {
1159 let name = state.providers[state.provider_selected].name.clone();
1160 state.input_mode = InputMode::EditingApiKey {
1161 provider_name: name,
1162 field_text: String::new(),
1163 };
1164 }
1165 }
1166 KeyCode::Esc => {
1167 if !state.provider_filter.is_empty() {
1170 state.provider_filter.clear();
1171 snap_provider_selection(state);
1172 } else {
1173 return Ok(true);
1174 }
1175 }
1176 KeyCode::Right => {
1177 state.step = 1;
1178 }
1179 _ => {}
1180 }
1181 }
1182 }
1183 InputMode::EditingApiKey {
1184 provider_name,
1185 field_text,
1186 } => {
1187 if let Event::Key(key) = event {
1188 match key.code {
1189 KeyCode::Esc => {
1190 state.input_mode = InputMode::Normal;
1191 }
1192 KeyCode::Enter => {
1193 if !field_text.is_empty() {
1194 auth_store.set_api_key(provider_name, field_text.clone());
1195 if let Some(entry) = state
1196 .providers
1197 .iter_mut()
1198 .find(|p| p.name == *provider_name)
1199 {
1200 entry.has_key = true;
1201 entry.key_masked = mask_key(field_text);
1202 }
1203 fetch_and_cache_models(provider_name, &state.providers);
1204 state.models_dirty = true;
1205 }
1206 state.input_mode = InputMode::Normal;
1207 }
1208 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1212 let name = provider_name.clone();
1213 auth_store.remove(&name);
1214 if let Some(entry) = state.providers.iter_mut().find(|p| p.name == name) {
1215 entry.has_key = false;
1216 entry.key_masked = String::new();
1217 }
1218 state.models_dirty = true;
1219 state.input_mode = InputMode::Normal;
1220 }
1221 KeyCode::Backspace => {
1222 field_text.pop();
1223 }
1224 KeyCode::Char(c) => {
1225 field_text.push(c);
1226 }
1227 _ => {}
1228 }
1229 }
1230 }
1231 InputMode::AddingCustom {
1232 fields,
1233 active_field,
1234 } => {
1235 if let Event::Key(key) = event {
1236 match key.code {
1237 KeyCode::Esc => {
1238 state.input_mode = InputMode::Normal;
1239 }
1240 KeyCode::Tab => {
1241 *active_field = (*active_field + 1) % 3;
1242 }
1243 KeyCode::BackTab => {
1244 *active_field = (*active_field + 2) % 3;
1245 }
1246 KeyCode::Enter => {
1247 let name = fields[0].trim().to_string();
1248 let base_url = fields[1].trim().to_string();
1249 let api_key = fields[2].trim().to_string();
1250 if !name.is_empty() && !base_url.is_empty() {
1251 if !api_key.is_empty() {
1252 auth_store.set_api_key(&name, api_key.clone());
1253 }
1254 let (has_key, key_masked) = if !api_key.is_empty() {
1255 (true, mask_key(&api_key))
1256 } else {
1257 (false, String::new())
1258 };
1259 state.providers.push(ProviderEntry {
1260 name: name.clone(),
1261 has_key,
1262 key_masked,
1263 is_custom: true,
1264 base_url: Some(base_url),
1265 });
1266 if !api_key.is_empty() {
1267 fetch_and_cache_models(&name, &state.providers);
1268 }
1269 state.models_dirty = has_key;
1270 state.provider_selected = state.providers.len() - 1;
1273 state.on_sentinel = false;
1274 state.input_mode = InputMode::Normal;
1275 }
1276 }
1277 KeyCode::Backspace => {
1278 fields[*active_field].pop();
1279 }
1280 KeyCode::Char(c) => {
1281 fields[*active_field].push(c);
1282 }
1283 _ => {}
1284 }
1285 }
1286 }
1287 }
1288 Ok(false)
1289}
1290
1291fn handle_model_event(state: &mut WizardState, event: Event) -> Result<bool> {
1292 if let Event::Key(key) = event {
1293 match key.code {
1297 KeyCode::Char(c) => {
1298 state.model_filter.push(c);
1299 ensure_model_selected_visible(state);
1300 }
1301 KeyCode::Backspace => {
1302 state.model_filter.pop();
1303 ensure_model_selected_visible(state);
1304 }
1305 KeyCode::Up => {
1306 let indices = filtered_model_indices(state);
1307 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1308 && pos > 0
1309 {
1310 state.model_selected = indices[pos - 1];
1311 } else if let Some(&first) = indices.first() {
1312 state.model_selected = first;
1313 }
1314 }
1315 KeyCode::Down => {
1316 let indices = filtered_model_indices(state);
1317 if let Some(pos) = indices.iter().position(|&i| i == state.model_selected)
1318 && pos + 1 < indices.len()
1319 {
1320 state.model_selected = indices[pos + 1];
1321 } else if let Some(&first) = indices.first() {
1322 state.model_selected = first;
1323 }
1324 }
1325 KeyCode::Enter => {
1326 if !filtered_model_indices(state).is_empty() {
1331 state.step = 2;
1332 }
1333 }
1334 KeyCode::Esc => {
1335 if !state.model_filter.is_empty() {
1338 state.model_filter.clear();
1339 ensure_model_selected_visible(state);
1340 } else {
1341 state.step = 0;
1342 }
1343 }
1344 KeyCode::Left => {
1345 state.step = 0;
1346 }
1347 _ => {}
1348 }
1349 }
1350 Ok(false)
1351}
1352
1353fn handle_theme_event(state: &mut WizardState, event: Event) -> Result<bool> {
1354 if let Event::Key(key) = event {
1355 match key.code {
1356 KeyCode::Up if state.theme_selected > 0 => {
1357 state.theme_selected -= 1;
1358 }
1359 KeyCode::Down if state.theme_selected + 1 < state.themes.len() => {
1360 state.theme_selected += 1;
1361 }
1362 KeyCode::Enter => {
1363 finish_setup(state)?;
1365 state.step = 3;
1366 }
1367 KeyCode::Esc | KeyCode::Left => {
1368 state.step = 1;
1369 }
1370 _ => {}
1371 }
1372 }
1373 Ok(false)
1374}
1375
1376fn handle_done_event(event: Event) -> Result<bool> {
1377 if let Event::Key(key) = event {
1378 match key.code {
1379 KeyCode::Enter | KeyCode::Esc => {
1380 return Ok(true); }
1382 _ => {}
1383 }
1384 }
1385 Ok(false)
1386}
1387
1388fn finish_setup(state: &mut WizardState) -> Result<()> {
1391 let model_id = state
1393 .models
1394 .get(state.model_selected)
1395 .map(|m| format!("{}/{}", m.provider, m.id))
1396 .unwrap_or_default();
1397
1398 let theme_name = state
1400 .themes
1401 .get(state.theme_selected)
1402 .cloned()
1403 .unwrap_or_else(|| "oxi_dark".to_string());
1404
1405 let custom_base_urls: Vec<(String, String)> = state
1407 .providers
1408 .iter()
1409 .filter_map(|p| {
1410 if p.is_custom {
1411 p.base_url.as_ref().map(|url| (p.name.clone(), url.clone()))
1412 } else {
1413 None
1414 }
1415 })
1416 .collect();
1417
1418 save_settings(&model_id, &theme_name, &custom_base_urls)?;
1419
1420 Ok(())
1421}
1422
1423pub async fn run() -> Result<()> {
1427 enable_raw_mode()?;
1429 let mut stdout = io::stdout();
1430 execute!(stdout, EnterAlternateScreen)?;
1431 let backend = CrosstermBackend::new(stdout);
1432 let mut terminal = Terminal::new(backend)?;
1433
1434 let panic_hook = std::panic::take_hook();
1436 std::panic::set_hook(Box::new(move |info| {
1437 let _ = disable_raw_mode();
1438 let _ = execute!(io::stdout(), LeaveAlternateScreen);
1439 panic_hook(info);
1440 }));
1441
1442 let catalog: Option<std::sync::Arc<dyn oxi_sdk::ports::catalog::ModelCatalog>> = {
1444 let paths = crate::services::OxiPaths::default_paths().ok();
1445 if let Some(paths) = paths {
1446 let config = oxi_sdk::CatalogConfig {
1447 cache_path: paths.home.join("cache").join("models-dev.json"),
1448 etag_path: paths.home.join("cache").join("models-dev.json.etag"),
1449 override_path: paths.home.join("catalog").join("overrides.toml"),
1450 fetch_enabled: false,
1452 ..Default::default()
1453 };
1454 oxi_sdk::FileModelCatalog::init(config)
1455 .await
1456 .ok()
1457 .map(|c| c as _)
1458 } else {
1459 None
1460 }
1461 };
1462
1463 let auth_store = crate::store::auth_storage::shared_auth_storage();
1465 let providers = load_providers(&auth_store, catalog.as_ref());
1466 let allowed = keyed_provider_names(&providers);
1467 let models = load_models(catalog.as_ref(), Some(&allowed));
1468 let themes = load_themes();
1469
1470 let auth_path = crate::store::auth_storage::AuthStorage::default_path().unwrap_or_else(|| {
1471 dirs::home_dir()
1472 .unwrap_or_default()
1473 .join(".oxi")
1474 .join("auth.json")
1475 });
1476 let settings_path = crate::store::settings::Settings::settings_path().unwrap_or_else(|_| {
1477 dirs::home_dir()
1478 .unwrap_or_default()
1479 .join(".oxi")
1480 .join("settings.json")
1481 });
1482
1483 let current_model = crate::store::settings::Settings::load()
1485 .ok()
1486 .and_then(|s| s.last_used_model.clone())
1487 .unwrap_or_default();
1488
1489 let model_selected = models
1490 .iter()
1491 .position(|m| {
1492 let full_id = format!("{}/{}", m.provider, m.id);
1493 full_id == current_model || m.id == current_model
1494 })
1495 .unwrap_or(0);
1496
1497 let current_theme = crate::store::settings::Settings::load()
1499 .ok()
1500 .map(|s| s.theme.clone())
1501 .unwrap_or_else(|| "oxi_dark".to_string());
1502
1503 let theme_selected = themes.iter().position(|t| *t == current_theme).unwrap_or(0);
1504
1505 let mut state = WizardState {
1506 step: 0,
1507 providers,
1508 provider_selected: 0,
1509 provider_list_state: ListState::default(),
1510 provider_filter: String::new(),
1511 on_sentinel: false,
1512 input_mode: InputMode::Normal,
1513 models,
1514 model_selected,
1515 model_filter: String::new(),
1516 model_list_state: ListState::default(),
1517 models_dirty: false,
1518 themes,
1519 theme_selected,
1520 theme_list_state: ListState::default(),
1521 auth_path,
1522 settings_path,
1523 catalog,
1524 };
1525
1526 loop {
1528 if state.step == 1 && state.models_dirty {
1532 refresh_models(&mut state);
1533 state.models_dirty = false;
1534 }
1535 draw_wizard(&mut terminal, &mut state)?;
1536
1537 if event::poll(std::time::Duration::from_millis(100))?
1538 && let Event::Key(key) = event::read()?
1539 {
1540 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1542 break;
1543 }
1544
1545 let should_quit = handle_event(&mut state, Event::Key(key), &auth_store)?;
1546 if should_quit {
1547 break;
1548 }
1549 }
1550 }
1551
1552 disable_raw_mode()?;
1554 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
1555
1556 Ok(())
1557}
1558
1559#[cfg(test)]
1560mod tests {
1561 use super::*;
1562
1563 fn make_state(providers: Vec<&str>, models: Vec<(&str, &str)>) -> WizardState {
1564 WizardState {
1565 step: 0,
1566 providers: providers
1567 .iter()
1568 .map(|n| ProviderEntry {
1569 name: n.to_string(),
1570 has_key: false,
1571 key_masked: String::new(),
1572 is_custom: false,
1573 base_url: None,
1574 })
1575 .collect(),
1576 provider_selected: 0,
1577 provider_list_state: ListState::default(),
1578 provider_filter: String::new(),
1579 on_sentinel: false,
1580 input_mode: InputMode::Normal,
1581 models: models
1582 .iter()
1583 .map(|(id, provider)| {
1584 ModelEntry::new(id.to_string(), provider.to_string(), 128_000)
1585 })
1586 .collect(),
1587 model_selected: 0,
1588 model_filter: String::new(),
1589 model_list_state: ListState::default(),
1590 models_dirty: false,
1591 themes: vec![],
1592 theme_selected: 0,
1593 theme_list_state: ListState::default(),
1594 auth_path: PathBuf::new(),
1595 settings_path: PathBuf::new(),
1596 catalog: None,
1597 }
1598 }
1599
1600 #[test]
1601 fn provider_filter_matches_name_case_insensitive() {
1602 let mut s = make_state(vec!["anthropic", "openai", "google", "mistral"], vec![]);
1603 assert_eq!(filtered_provider_indices(&s), vec![0, 1, 2, 3]);
1604
1605 s.provider_filter = "ANT".to_string();
1606 assert_eq!(filtered_provider_indices(&s), vec![0]); s.provider_filter = "goog".to_string();
1609 assert_eq!(filtered_provider_indices(&s), vec![2]); }
1611
1612 #[test]
1613 fn model_filter_matches_id_or_provider() {
1614 let mut s = make_state(
1615 vec![],
1616 vec![
1617 ("gpt-4o", "openai"),
1618 ("gpt-4-turbo", "openai"),
1619 ("claude-3-opus", "anthropic"),
1620 ("gemini-pro", "google"),
1621 ],
1622 );
1623 assert_eq!(filtered_model_indices(&s), vec![0, 1, 2, 3]);
1624
1625 s.model_filter = "gpt".to_string();
1626 assert_eq!(filtered_model_indices(&s), vec![0, 1]);
1627
1628 s.model_filter = "anthropic".to_string();
1629 assert_eq!(filtered_model_indices(&s), vec![2]); s.model_filter = "OPUS".to_string();
1632 assert_eq!(filtered_model_indices(&s), vec![2]); }
1634
1635 #[test]
1636 fn model_filter_empty_result_yields_no_indices() {
1637 let mut state = make_state(vec![], vec![("gpt-4o", "openai")]);
1638 state.model_filter = "zzz".to_string();
1639 assert!(filtered_model_indices(&state).is_empty());
1640 }
1641
1642 #[test]
1643 fn ensure_model_selected_snaps_to_first_match() {
1644 let mut state = make_state(
1645 vec![],
1646 vec![
1647 ("gpt-4o", "openai"),
1648 ("claude-3", "anthropic"),
1649 ("gpt-3.5", "openai"),
1650 ],
1651 );
1652 state.model_filter = "gpt".to_string();
1654 ensure_model_selected_visible(&mut state);
1655 assert_eq!(state.model_selected, 0);
1657
1658 state.model_filter = "claude".to_string();
1660 ensure_model_selected_visible(&mut state);
1661 assert_eq!(state.model_selected, 1);
1662 }
1663
1664 #[test]
1665 fn snap_provider_selection_into_filtered_set() {
1666 let mut state = make_state(vec!["anthropic", "openai", "google"], vec![]);
1667 state.provider_selected = 2; state.provider_filter = "open".to_string();
1669 snap_provider_selection(&mut state);
1670 assert_eq!(state.provider_selected, 1); }
1672
1673 #[test]
1674 fn snap_provider_noop_when_filter_empty_matches_all() {
1675 let mut state = make_state(vec!["anthropic", "openai"], vec![]);
1676 state.provider_selected = 1;
1677 state.provider_filter = String::new();
1678 snap_provider_selection(&mut state);
1679 assert_eq!(state.provider_selected, 1); }
1681 #[test]
1682 fn keyed_provider_names_only_includes_configured() {
1683 let providers = vec![
1684 ProviderEntry {
1685 name: "anthropic".to_string(),
1686 has_key: true,
1687 key_masked: "sk-1...abcd".to_string(),
1688 is_custom: false,
1689 base_url: None,
1690 },
1691 ProviderEntry {
1692 name: "openai".to_string(),
1693 has_key: false,
1694 key_masked: String::new(),
1695 is_custom: false,
1696 base_url: None,
1697 },
1698 ProviderEntry {
1699 name: "local".to_string(),
1700 has_key: true,
1701 key_masked: "x...y".to_string(),
1702 is_custom: true,
1703 base_url: Some("http://localhost:11434".to_string()),
1704 },
1705 ];
1706 let set = keyed_provider_names(&providers);
1707 assert!(set.contains("anthropic"));
1708 assert!(set.contains("local"));
1709 assert!(!set.contains("openai"));
1710 assert_eq!(set.len(), 2);
1711 }
1712
1713 #[test]
1714 fn keyed_provider_names_empty_when_none_configured() {
1715 let providers = vec![ProviderEntry {
1716 name: "openai".to_string(),
1717 has_key: false,
1718 key_masked: String::new(),
1719 is_custom: false,
1720 base_url: None,
1721 }];
1722 assert!(keyed_provider_names(&providers).is_empty());
1723 }
1724 fn render_to_buffer(step: usize, models: Vec<ModelEntry>) -> String {
1727 use ratatui::backend::TestBackend;
1728 let providers = vec![
1729 ProviderEntry {
1730 name: "openai".to_string(),
1731 has_key: true,
1732 key_masked: "k...1".to_string(),
1733 is_custom: false,
1734 base_url: None,
1735 },
1736 ProviderEntry {
1737 name: "anthropic".to_string(),
1738 has_key: false,
1739 key_masked: String::new(),
1740 is_custom: false,
1741 base_url: None,
1742 },
1743 ];
1744 let mut state = WizardState {
1745 step,
1746 providers,
1747 provider_selected: 0,
1748 provider_list_state: ListState::default(),
1749 provider_filter: String::new(),
1750 on_sentinel: false,
1751 input_mode: InputMode::Normal,
1752 models,
1753 model_selected: 0,
1754 model_filter: String::new(),
1755 model_list_state: ListState::default(),
1756 themes: vec!["oxi_dark".to_string()],
1757 theme_selected: 0,
1758 theme_list_state: ListState::default(),
1759 auth_path: PathBuf::new(),
1760 settings_path: PathBuf::new(),
1761 catalog: None,
1762 models_dirty: false,
1763 };
1764 let backend = TestBackend::new(90, 24);
1765 let mut terminal = Terminal::new(backend).unwrap();
1766 terminal.draw(|f| render_wizard(f, &mut state)).unwrap();
1767 let buf = terminal.backend().buffer();
1768 let area = buf.area();
1769 let mut out = String::new();
1770 for y in 0..area.height {
1771 for x in 0..area.width {
1772 out.push_str(buf[(x, y)].symbol());
1773 }
1774 out.push('\n');
1775 }
1776 out
1777 }
1778
1779 #[test]
1780 fn step_indicator_visible_on_every_step() {
1781 for (step, label) in [
1785 (0usize, "1. Provider Setup"),
1786 (1, "2. Default Model"),
1787 (2, "3. Theme"),
1788 (3, "4. Done"),
1789 ] {
1790 let models = vec![ModelEntry::new(
1791 "gpt-4o".to_string(),
1792 "openai".to_string(),
1793 128_000,
1794 )];
1795 let rendered = render_to_buffer(step, models);
1796 assert!(
1797 rendered.contains(label),
1798 "step {step}: indicator label {label:?} missing from buffer:\n{rendered}"
1799 );
1800 }
1801 }
1802
1803 #[test]
1804 fn model_step_shows_empty_state_when_no_provider_keyed() {
1805 let rendered = render_to_buffer(1, vec![]);
1808 assert!(rendered.contains("No providers with an API key configured yet."));
1809 assert!(rendered.contains("Press Left to go back"));
1810 }
1811
1812 #[test]
1813 fn model_step_shows_configured_provider_model() {
1814 let models = vec![ModelEntry::new(
1819 "gpt-4o".to_string(),
1820 "openai".to_string(),
1821 128_000,
1822 )];
1823 let rendered = render_to_buffer(1, models);
1824 assert!(rendered.contains("gpt-4o"));
1825 }
1826 fn esc_event() -> Event {
1832 Event::Key(crossterm::event::KeyEvent::new(
1833 KeyCode::Esc,
1834 KeyModifiers::NONE,
1835 ))
1836 }
1837
1838 #[test]
1839 fn esc_quits_from_provider_step_normal() {
1840 let mut state = make_state(vec!["openai"], vec![]);
1841 state.step = 0;
1842 let auth = crate::store::auth_storage::shared_auth_storage();
1843 let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1844 assert!(quit, "Esc on step 0 Normal should quit");
1845 }
1846
1847 #[test]
1848 fn esc_clears_provider_filter_without_quitting() {
1849 let mut state = make_state(vec!["openai", "anthropic"], vec![]);
1853 state.step = 0;
1854 state.provider_filter = "anth".to_string();
1855 let auth = crate::store::auth_storage::shared_auth_storage();
1857 let quit = handle_provider_event(&mut state, esc_event(), &auth).unwrap();
1858 assert!(!quit, "Esc with a non-empty filter must clear it, not quit");
1859 assert!(state.provider_filter.is_empty());
1860 }
1861
1862 #[test]
1863 fn esc_backs_out_of_model_step_when_filter_empty() {
1864 let mut state = make_state(vec!["openai"], vec![("gpt-4o", "openai")]);
1865 state.step = 1;
1866 state.model_filter = String::new();
1867 handle_model_event(&mut state, esc_event()).unwrap();
1868 assert_eq!(
1869 state.step, 0,
1870 "Esc with empty filter should return to the provider step"
1871 );
1872 }
1873
1874 #[test]
1875 fn esc_clears_model_filter_when_nonempty() {
1876 let mut state = make_state(
1877 vec!["openai"],
1878 vec![("gpt-4o", "openai"), ("gpt-4", "openai")],
1879 );
1880 state.step = 1;
1881 state.model_filter = "gpt".to_string();
1882 handle_model_event(&mut state, esc_event()).unwrap();
1883 assert_eq!(
1884 state.step, 1,
1885 "Esc with a non-empty filter should stay on the model step"
1886 );
1887 assert!(state.model_filter.is_empty(), "Esc should clear the filter");
1888 }
1889
1890 #[test]
1891 fn esc_backs_out_of_theme_step() {
1892 let mut state = make_state(vec!["openai"], vec![]);
1893 state.step = 2;
1894 state.themes = vec!["oxi_dark".to_string()];
1895 handle_theme_event(&mut state, esc_event()).unwrap();
1896 assert_eq!(state.step, 1);
1897 }
1898
1899 #[test]
1900 fn esc_quits_from_done_step() {
1901 assert!(
1902 handle_done_event(esc_event()).unwrap(),
1903 "Esc on the done step should quit"
1904 );
1905 }
1906 #[test]
1907 fn provider_step_renders_filter_and_sentinel() {
1908 let rendered = render_to_buffer(0, vec![]);
1913 assert!(
1914 rendered.contains("Filter:"),
1915 "filter line missing in unfiltered provider step"
1916 );
1917 assert!(
1918 rendered.contains("Add custom provider"),
1919 "sentinel missing in unfiltered provider step"
1920 );
1921 let providers = vec![ProviderEntry {
1923 name: "openai".to_string(),
1924 has_key: true,
1925 key_masked: "k".to_string(),
1926 is_custom: false,
1927 base_url: None,
1928 }];
1929 let mut s = WizardState {
1930 step: 0,
1931 providers,
1932 provider_selected: 0,
1933 provider_list_state: ListState::default(),
1934 provider_filter: "open".to_string(),
1935 on_sentinel: false,
1936 input_mode: InputMode::Normal,
1937 models: vec![],
1938 model_selected: 0,
1939 model_filter: String::new(),
1940 model_list_state: ListState::default(),
1941 themes: vec![],
1942 theme_selected: 0,
1943 theme_list_state: ListState::default(),
1944 auth_path: PathBuf::new(),
1945 settings_path: PathBuf::new(),
1946 catalog: None,
1947 models_dirty: false,
1948 };
1949 use ratatui::backend::TestBackend;
1950 let backend = TestBackend::new(90, 24);
1951 let mut terminal = Terminal::new(backend).unwrap();
1952 terminal.draw(|f| render_wizard(f, &mut s)).unwrap();
1953 let buf = terminal.backend().buffer();
1954 let area = buf.area();
1955 let mut out = String::new();
1956 for y in 0..area.height {
1957 for x in 0..area.width {
1958 out.push_str(buf[(x, y)].symbol());
1959 }
1960 out.push('\n');
1961 }
1962 assert!(out.contains("Filter:"));
1963 assert!(
1964 out.contains("open"),
1965 "typed filter must be shown in the filter line"
1966 );
1967 assert!(
1968 out.contains("Add custom provider"),
1969 "sentinel must remain under a filter"
1970 );
1971 }
1972 #[test]
1973 fn footer_wraps_on_narrow_terminal() {
1974 use ratatui::backend::TestBackend;
1978 let providers = vec![ProviderEntry {
1979 name: "openai".to_string(),
1980 has_key: false,
1981 key_masked: String::new(),
1982 is_custom: false,
1983 base_url: None,
1984 }];
1985 let mut s = WizardState {
1986 step: 0,
1987 providers,
1988 provider_selected: 0,
1989 provider_list_state: ListState::default(),
1990 provider_filter: String::new(),
1991 on_sentinel: false,
1992 input_mode: InputMode::Normal,
1993 models: vec![],
1994 model_selected: 0,
1995 model_filter: String::new(),
1996 model_list_state: ListState::default(),
1997 themes: vec![],
1998 theme_selected: 0,
1999 theme_list_state: ListState::default(),
2000 auth_path: PathBuf::new(),
2001 settings_path: PathBuf::new(),
2002 catalog: None,
2003 models_dirty: false,
2004 };
2005 let backend = TestBackend::new(50, 24);
2006 let mut terminal = Terminal::new(backend).unwrap();
2007 terminal.draw(|f| render_wizard(f, &mut s)).unwrap();
2008 let buf = terminal.backend().buffer();
2009 let area = buf.area();
2010 let mut out = String::new();
2011 for y in 0..area.height {
2012 for x in 0..area.width {
2013 out.push_str(buf[(x, y)].symbol());
2014 }
2015 out.push('\n');
2016 }
2017 for word in [
2020 "Type",
2021 "filter",
2022 "\u{2191}/\u{2193}",
2023 "act",
2024 "next",
2025 "Esc",
2026 "back",
2027 ] {
2028 assert!(
2029 out.contains(word),
2030 "footer word {word:?} missing at 50 cols — footer may be truncated"
2031 );
2032 }
2033 }
2034}