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