1use crate::agent::NewArgs;
2use crate::agent::new_agent_wizard::{
3 NewAgentMode, NewAgentOutcome, NewAgentWizard, add_agent, available_prompt_files, detect_mcp_configs,
4 run_wizard_loop, scaffold,
5};
6use crate::error::CliError;
7use llm::LlmModel;
8use llm::catalog::available_models;
9use llm::providers::local::discovery::discover_local_models;
10use std::collections::BTreeMap;
11use std::io;
12use tui::{MouseCapture, TerminalConfig, TerminalRuntime, Theme, terminal_size};
13use wisp::components::model_selector::ModelEntry;
14
15pub async fn run_new(args: NewArgs) -> Result<NewAgentOutcome, CliError> {
16 let project_root = args.path.canonicalize().unwrap_or(args.path);
17 let settings_path = project_root.join(".aether/settings.json");
18 let is_existing = settings_path.is_file();
19 let mode = if is_existing { NewAgentMode::AddAgentToExistingProject } else { NewAgentMode::ScaffoldProject };
20
21 let discovery_handle = tokio::spawn(discover_local_models());
22
23 let size = terminal_size().unwrap_or((80, 24));
24 let mut terminal = TerminalRuntime::new(
25 io::stdout(),
26 Theme::default(),
27 size,
28 TerminalConfig { bracketed_paste: false, mouse_capture: MouseCapture::Enabled },
29 )
30 .map_err(CliError::IoError)?;
31
32 let discovered = discovery_handle.await.unwrap_or_default();
33 let model_entries = build_model_entries(&discovered);
34
35 if !model_entries.iter().any(|e| !e.is_disabled()) {
36 terminal.clear_screen().map_err(CliError::IoError)?;
37 println!("No providers detected. Set an API key environment variable and try again.");
38 return Ok(NewAgentOutcome::Cancelled);
39 }
40
41 let prompt_options = available_prompt_files(&mode, &project_root);
42 let mcp_configs = detect_mcp_configs(&project_root);
43 let mut wizard = NewAgentWizard::new(mode, model_entries, &prompt_options, &mcp_configs);
44
45 let outcome = run_wizard_loop(&mut wizard, &mut terminal).await?;
46 terminal.clear_screen().map_err(CliError::IoError)?;
47
48 if matches!(outcome, NewAgentOutcome::Cancelled) {
49 println!("Cancelled.");
50 return Ok(NewAgentOutcome::Cancelled);
51 }
52
53 let draft = wizard.into_draft();
54
55 if is_existing {
56 add_agent(&settings_path, &draft)?;
57 } else {
58 scaffold(&project_root, &draft)?;
59 }
60
61 Ok(NewAgentOutcome::Applied)
62}
63
64fn build_model_entries(discovered: &[LlmModel]) -> Vec<ModelEntry> {
65 let available: std::collections::HashSet<String> = available_models().iter().map(ToString::to_string).collect();
66
67 let mut entries: Vec<ModelEntry> = available_models()
68 .into_iter()
69 .chain(discovered.iter().cloned())
70 .map(|m| ModelEntry {
71 value: m.to_string(),
72 name: format!("{} / {}", m.provider_display_name(), m.display_name()),
73 reasoning_levels: m.reasoning_levels().to_vec(),
74 supports_image: m.supports_image(),
75 supports_audio: m.supports_audio(),
76 disabled_reason: None,
77 })
78 .collect();
79
80 let mut unavailable_providers: BTreeMap<&str, (usize, &str, Option<&str>)> = BTreeMap::new();
81 for m in LlmModel::all() {
82 if available.contains(&m.to_string()) {
83 continue;
84 }
85 let entry =
86 unavailable_providers.entry(m.provider()).or_insert((0, m.provider_display_name(), m.required_env_var()));
87 entry.0 += 1;
88 }
89 for (provider_key, (count, display, env_var)) in &unavailable_providers {
90 let noun = if *count == 1 { "model" } else { "models" };
91 let reason = env_var.map_or("provider is not configured".to_string(), |var| format!("set {var}"));
92 entries.push(ModelEntry {
93 value: format!("__unavailable:{provider_key}"),
94 name: format!("{display} / {display} ({count} {noun})"),
95 reasoning_levels: vec![],
96 supports_image: false,
97 supports_audio: false,
98 disabled_reason: Some(reason),
99 });
100 }
101
102 entries
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn build_model_entries_includes_available() {
111 let items = build_model_entries(&[]);
112 for item in items.iter().filter(|e| !e.is_disabled()) {
113 let model: LlmModel = item.value.parse().unwrap();
114 assert!(
115 model.required_env_var().is_none_or(|var| std::env::var(var).is_ok()),
116 "model {} should be available",
117 item.value
118 );
119 }
120 }
121
122 #[test]
123 fn build_model_entries_includes_unavailable_providers() {
124 let items = build_model_entries(&[]);
125 let disabled: Vec<_> = items.iter().filter(|e| e.is_disabled()).collect();
126 let available_providers: std::collections::HashSet<&str> =
127 items.iter().filter(|e| !e.is_disabled()).filter_map(|e| e.value.split_once(':').map(|(p, _)| p)).collect();
128
129 for entry in &disabled {
130 assert!(entry.value.starts_with("__unavailable:"), "disabled entry should use __unavailable: prefix");
131 assert!(entry.disabled_reason.is_some(), "disabled entry should have a reason");
132 let provider = entry.value.strip_prefix("__unavailable:").unwrap();
133 assert!(
134 !available_providers.contains(provider),
135 "disabled provider {provider} should not also be in available set"
136 );
137 }
138 }
139}