greppy/cli/
model.rs

1//! Interactive AI model switcher
2
3use crate::ai::ollama::OllamaClient;
4use crate::core::config::{AiProfile, AiProvider, Config};
5use crate::core::error::{Error, Result};
6use dialoguer::{theme::ColorfulTheme, Input, Select};
7
8/// Run the interactive model switcher
9pub async fn run() -> Result<()> {
10    let mut config = Config::load()?;
11
12    // Show current model
13    println!();
14    println!(" Current: {}", format_current(&config));
15    println!();
16
17    // Build menu options (only selectable items)
18    let mut options = Vec::new();
19    let mut actions = Vec::new();
20
21    // === Saved Profiles ===
22    let mut profile_names: Vec<_> = config.ai.profiles.keys().cloned().collect();
23    profile_names.sort();
24
25    for name in &profile_names {
26        if let Some(profile) = config.ai.profiles.get(name) {
27            let label = format_profile_label(name, profile);
28            let is_active = is_profile_active(&config, name);
29            if is_active {
30                options.push(format!("{} ✓", label));
31            } else {
32                options.push(label);
33            }
34            actions.push(Action::SwitchProfile(name.clone()));
35        }
36    }
37
38    // === Cloud Providers ===
39    let has_claude = config.ai.anthropic_token.is_some();
40    let has_gemini = config.ai.google_token.is_some();
41
42    if has_claude {
43        let active = config.ai.provider == AiProvider::Claude;
44        let label = "Claude (Anthropic)".to_string();
45        if active {
46            options.push(format!("{} ✓", label));
47        } else {
48            options.push(label);
49        }
50        actions.push(Action::SwitchProvider(AiProvider::Claude));
51    }
52
53    if has_gemini {
54        let active = config.ai.provider == AiProvider::Gemini;
55        let label = "Gemini (Google)".to_string();
56        if active {
57            options.push(format!("{} ✓", label));
58        } else {
59            options.push(label);
60        }
61        actions.push(Action::SwitchProvider(AiProvider::Gemini));
62    }
63
64    // === Ollama Models ===
65    let ollama_client = OllamaClient::new();
66    let ollama_available = ollama_client.is_available().await;
67
68    if ollama_available {
69        if let Ok(models) = ollama_client.list_models().await {
70            for model in models {
71                let is_active = config.ai.provider == AiProvider::Ollama
72                    && config.ai.ollama_model == model.name;
73                let label = format!("Ollama: {}", model.name);
74                if is_active {
75                    options.push(format!("{} ✓", label));
76                } else {
77                    options.push(label);
78                }
79                actions.push(Action::SwitchOllama(model.name));
80            }
81        }
82    }
83
84    // === Actions ===
85    options.push("+ Add account (login)".to_string());
86    actions.push(Action::AddAccount);
87
88    options.push("+ Save as profile".to_string());
89    actions.push(Action::SaveProfile);
90
91    if !profile_names.is_empty() {
92        options.push("- Delete profile".to_string());
93        actions.push(Action::DeleteProfile);
94    }
95
96    // Show selection menu
97    let selection = Select::with_theme(&ColorfulTheme::default())
98        .with_prompt("Switch to")
99        .items(&options)
100        .default(0)
101        .interact()
102        .map_err(|e| Error::DaemonError {
103            message: format!("Selection failed: {}", e),
104        })?;
105
106    // Handle selection
107    match &actions[selection] {
108        Action::SwitchProfile(name) => {
109            if let Some(profile) = config.ai.profiles.get(name).cloned() {
110                apply_profile(&mut config, &profile);
111                config.save()?;
112                println!("\n✓ Switched to '{}'", name);
113            }
114        }
115        Action::SwitchProvider(provider) => {
116            config.ai.provider = provider.clone();
117            config.save()?;
118            println!("\n✓ Switched to {}", format_provider_name(provider));
119        }
120        Action::SwitchOllama(model) => {
121            config.ai.provider = AiProvider::Ollama;
122            config.ai.ollama_model = model.clone();
123            config.save()?;
124            println!("\n✓ Switched to Ollama '{}'", model);
125        }
126        Action::SaveProfile => {
127            save_current_as_profile(&mut config).await?;
128        }
129        Action::AddAccount => {
130            // Run login flow
131            drop(config);
132            crate::cli::login::run().await?;
133        }
134        Action::DeleteProfile => {
135            delete_profile(&mut config).await?;
136        }
137    }
138
139    Ok(())
140}
141
142#[derive(Debug)]
143enum Action {
144    SwitchProfile(String),
145    SwitchProvider(AiProvider),
146    SwitchOllama(String),
147    SaveProfile,
148    AddAccount,
149    DeleteProfile,
150}
151
152fn format_current(config: &Config) -> String {
153    match config.ai.provider {
154        AiProvider::Claude => "Claude (Anthropic)".to_string(),
155        AiProvider::Gemini => "Gemini (Google)".to_string(),
156        AiProvider::Ollama => format!("Ollama ({})", config.ai.ollama_model),
157    }
158}
159
160fn format_provider_name(provider: &AiProvider) -> &'static str {
161    match provider {
162        AiProvider::Claude => "Claude (Anthropic)",
163        AiProvider::Gemini => "Gemini (Google)",
164        AiProvider::Ollama => "Ollama",
165    }
166}
167
168fn format_profile_label(name: &str, profile: &AiProfile) -> String {
169    match profile.provider {
170        AiProvider::Claude => format!("[{}] Claude", name),
171        AiProvider::Gemini => format!("[{}] Gemini", name),
172        AiProvider::Ollama => {
173            let model = profile.ollama_model.as_deref().unwrap_or("default");
174            format!("[{}] Ollama: {}", name, model)
175        }
176    }
177}
178
179fn is_profile_active(config: &Config, name: &str) -> bool {
180    if let Some(profile) = config.ai.profiles.get(name) {
181        if config.ai.provider != profile.provider {
182            return false;
183        }
184        match config.ai.provider {
185            AiProvider::Ollama => profile.ollama_model.as_deref() == Some(&config.ai.ollama_model),
186            AiProvider::Claude => {
187                profile.anthropic_token.is_some()
188                    && profile.anthropic_token == config.ai.anthropic_token
189            }
190            AiProvider::Gemini => {
191                profile.google_token.is_some() && profile.google_token == config.ai.google_token
192            }
193        }
194    } else {
195        false
196    }
197}
198
199fn apply_profile(config: &mut Config, profile: &AiProfile) {
200    config.ai.provider = profile.provider.clone();
201
202    if let Some(model) = &profile.ollama_model {
203        config.ai.ollama_model = model.clone();
204    }
205    if let Some(url) = &profile.ollama_url {
206        config.ai.ollama_url = url.clone();
207    }
208    if let Some(token) = &profile.google_token {
209        config.ai.google_token = Some(token.clone());
210    }
211    if let Some(token) = &profile.anthropic_token {
212        config.ai.anthropic_token = Some(token.clone());
213    }
214}
215
216async fn save_current_as_profile(config: &mut Config) -> Result<()> {
217    let name: String = Input::with_theme(&ColorfulTheme::default())
218        .with_prompt("Profile name")
219        .interact_text()
220        .map_err(|e| Error::DaemonError {
221            message: format!("Input failed: {}", e),
222        })?;
223
224    let profile = AiProfile {
225        provider: config.ai.provider.clone(),
226        ollama_model: if config.ai.provider == AiProvider::Ollama {
227            Some(config.ai.ollama_model.clone())
228        } else {
229            None
230        },
231        ollama_url: if config.ai.provider == AiProvider::Ollama {
232            Some(config.ai.ollama_url.clone())
233        } else {
234            None
235        },
236        google_token: if config.ai.provider == AiProvider::Gemini {
237            config.ai.google_token.clone()
238        } else {
239            None
240        },
241        anthropic_token: if config.ai.provider == AiProvider::Claude {
242            config.ai.anthropic_token.clone()
243        } else {
244            None
245        },
246    };
247
248    config.ai.profiles.insert(name.clone(), profile);
249    config.save()?;
250    println!("\n✓ Saved profile '{}'", name);
251
252    Ok(())
253}
254
255async fn delete_profile(config: &mut Config) -> Result<()> {
256    let profile_names: Vec<_> = config.ai.profiles.keys().cloned().collect();
257
258    if profile_names.is_empty() {
259        println!("No profiles to delete.");
260        return Ok(());
261    }
262
263    let selection = Select::with_theme(&ColorfulTheme::default())
264        .with_prompt("Delete which profile?")
265        .items(&profile_names)
266        .interact()
267        .map_err(|e| Error::DaemonError {
268            message: format!("Selection failed: {}", e),
269        })?;
270
271    let name = &profile_names[selection];
272    config.ai.profiles.remove(name);
273    config.save()?;
274    println!("\n✓ Deleted profile '{}'", name);
275
276    Ok(())
277}