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