syncable_cli/agent/
session.rs

1//! Interactive chat session with /model and /provider commands
2//!
3//! Provides a rich REPL experience similar to Claude Code with:
4//! - `/model` - Select from available models based on configured API keys
5//! - `/provider` - Switch provider (prompts for API key if not set)
6//! - `/help` - Show available commands
7//! - `/clear` - Clear conversation history
8//! - `/exit` or `/quit` - Exit the session
9
10use crate::agent::{AgentError, AgentResult, ProviderType};
11use crate::config::{load_agent_config, save_agent_config};
12use colored::Colorize;
13use std::io::{self, Write};
14use std::path::Path;
15
16const ROBOT: &str = "🤖";
17
18/// Available models per provider
19pub fn get_available_models(provider: ProviderType) -> Vec<(&'static str, &'static str)> {
20    match provider {
21        ProviderType::OpenAI => vec![
22            ("gpt-5.2", "GPT-5.2 - Latest reasoning model (Dec 2025)"),
23            ("gpt-5.2-mini", "GPT-5.2 Mini - Fast and affordable"),
24            ("gpt-4o", "GPT-4o - Multimodal workhorse"),
25            ("o1-preview", "o1-preview - Advanced reasoning"),
26        ],
27        ProviderType::Anthropic => vec![
28            ("claude-sonnet-4-20250514", "Claude 4 Sonnet - Latest (May 2025)"),
29            ("claude-3-5-sonnet-latest", "Claude 3.5 Sonnet - Previous gen"),
30            ("claude-3-opus-latest", "Claude 3 Opus - Most capable"),
31            ("claude-3-haiku-latest", "Claude 3 Haiku - Fast and cheap"),
32        ],
33    }
34}
35
36/// Chat session state
37pub struct ChatSession {
38    pub provider: ProviderType,
39    pub model: String,
40    pub project_path: std::path::PathBuf,
41    pub history: Vec<(String, String)>, // (role, content)
42}
43
44impl ChatSession {
45    pub fn new(project_path: &Path, provider: ProviderType, model: Option<String>) -> Self {
46        let default_model = match provider {
47            ProviderType::OpenAI => "gpt-5.2".to_string(),
48            ProviderType::Anthropic => "claude-sonnet-4-20250514".to_string(),
49        };
50        
51        Self {
52            provider,
53            model: model.unwrap_or(default_model),
54            project_path: project_path.to_path_buf(),
55            history: Vec::new(),
56        }
57    }
58
59    /// Check if API key is configured for a provider (env var OR config file)
60    pub fn has_api_key(provider: ProviderType) -> bool {
61        // Check environment variable first
62        let env_key = match provider {
63            ProviderType::OpenAI => std::env::var("OPENAI_API_KEY").ok(),
64            ProviderType::Anthropic => std::env::var("ANTHROPIC_API_KEY").ok(),
65        };
66        
67        if env_key.is_some() {
68            return true;
69        }
70        
71        // Check config file
72        let agent_config = load_agent_config();
73        match provider {
74            ProviderType::OpenAI => agent_config.openai_api_key.is_some(),
75            ProviderType::Anthropic => agent_config.anthropic_api_key.is_some(),
76        }
77    }
78    
79    /// Load API key from config if not in env, and set it in env for use
80    pub fn load_api_key_to_env(provider: ProviderType) {
81        let env_var = match provider {
82            ProviderType::OpenAI => "OPENAI_API_KEY",
83            ProviderType::Anthropic => "ANTHROPIC_API_KEY",
84        };
85        
86        // If already in env, do nothing
87        if std::env::var(env_var).is_ok() {
88            return;
89        }
90        
91        // Load from config and set in env
92        let agent_config = load_agent_config();
93        let key = match provider {
94            ProviderType::OpenAI => agent_config.openai_api_key,
95            ProviderType::Anthropic => agent_config.anthropic_api_key,
96        };
97        
98        if let Some(key) = key {
99            // SAFETY: Single-threaded CLI context during initialization
100            unsafe {
101                std::env::set_var(env_var, &key);
102            }
103        }
104    }
105
106    /// Get configured providers (those with API keys)
107    pub fn get_configured_providers() -> Vec<ProviderType> {
108        let mut providers = Vec::new();
109        if Self::has_api_key(ProviderType::OpenAI) {
110            providers.push(ProviderType::OpenAI);
111        }
112        if Self::has_api_key(ProviderType::Anthropic) {
113            providers.push(ProviderType::Anthropic);
114        }
115        providers
116    }
117
118    /// Prompt user to enter API key for a provider
119    pub fn prompt_api_key(provider: ProviderType) -> AgentResult<String> {
120        let env_var = match provider {
121            ProviderType::OpenAI => "OPENAI_API_KEY",
122            ProviderType::Anthropic => "ANTHROPIC_API_KEY",
123        };
124        
125        println!("\n{}", format!("🔑 No API key found for {}", provider).yellow());
126        println!("Please enter your {} API key:", provider);
127        print!("> ");
128        io::stdout().flush().unwrap();
129        
130        let mut key = String::new();
131        io::stdin().read_line(&mut key).map_err(|e| AgentError::ToolError(e.to_string()))?;
132        let key = key.trim().to_string();
133        
134        if key.is_empty() {
135            return Err(AgentError::MissingApiKey(env_var.to_string()));
136        }
137        
138        // Set for current session
139        // SAFETY: We're in a single-threaded CLI context during initialization
140        unsafe {
141            std::env::set_var(env_var, &key);
142        }
143        
144        // Save to config file for persistence
145        let mut agent_config = load_agent_config();
146        match provider {
147            ProviderType::OpenAI => agent_config.openai_api_key = Some(key.clone()),
148            ProviderType::Anthropic => agent_config.anthropic_api_key = Some(key.clone()),
149        }
150        
151        if let Err(e) = save_agent_config(&agent_config) {
152            eprintln!("{}", format!("Warning: Could not save config: {}", e).yellow());
153        } else {
154            println!("{}", "✓ API key saved to ~/.syncable.toml".green());
155        }
156        
157        Ok(key)
158    }
159
160    /// Handle /model command - interactive model selection
161    pub fn handle_model_command(&mut self) -> AgentResult<()> {
162        let models = get_available_models(self.provider);
163        
164        println!("\n{}", format!("📋 Available models for {}:", self.provider).cyan().bold());
165        println!();
166        
167        for (i, (id, desc)) in models.iter().enumerate() {
168            let marker = if *id == self.model { "→ " } else { "  " };
169            let num = format!("[{}]", i + 1);
170            println!("  {} {} {} - {}", marker, num.dimmed(), id.white().bold(), desc.dimmed());
171        }
172        
173        println!();
174        println!("Enter number to select, or press Enter to keep current:");
175        print!("> ");
176        io::stdout().flush().unwrap();
177        
178        let mut input = String::new();
179        io::stdin().read_line(&mut input).ok();
180        let input = input.trim();
181        
182        if input.is_empty() {
183            println!("{}", format!("Keeping model: {}", self.model).dimmed());
184            return Ok(());
185        }
186        
187        if let Ok(num) = input.parse::<usize>() {
188            if num >= 1 && num <= models.len() {
189                let (id, desc) = models[num - 1];
190                self.model = id.to_string();
191                println!("{}", format!("✓ Switched to {} - {}", id, desc).green());
192            } else {
193                println!("{}", "Invalid selection".red());
194            }
195        } else {
196            // Allow direct model name input
197            self.model = input.to_string();
198            println!("{}", format!("✓ Set model to: {}", input).green());
199        }
200        
201        Ok(())
202    }
203
204    /// Handle /provider command - switch provider with API key prompt if needed
205    pub fn handle_provider_command(&mut self) -> AgentResult<()> {
206        let providers = [ProviderType::OpenAI, ProviderType::Anthropic];
207        
208        println!("\n{}", "🔄 Available providers:".cyan().bold());
209        println!();
210        
211        for (i, provider) in providers.iter().enumerate() {
212            let marker = if *provider == self.provider { "→ " } else { "  " };
213            let has_key = if Self::has_api_key(*provider) {
214                "✓ API key configured".green()
215            } else {
216                "⚠ No API key".yellow()
217            };
218            let num = format!("[{}]", i + 1);
219            println!("  {} {} {} - {}", marker, num.dimmed(), provider.to_string().white().bold(), has_key);
220        }
221        
222        println!();
223        println!("Enter number to select:");
224        print!("> ");
225        io::stdout().flush().unwrap();
226        
227        let mut input = String::new();
228        io::stdin().read_line(&mut input).ok();
229        let input = input.trim();
230        
231        if let Ok(num) = input.parse::<usize>() {
232            if num >= 1 && num <= providers.len() {
233                let new_provider = providers[num - 1];
234                
235                // Check if API key exists, prompt if not
236                if !Self::has_api_key(new_provider) {
237                    Self::prompt_api_key(new_provider)?;
238                }
239                
240                self.provider = new_provider;
241                
242                // Set default model for new provider
243                let default_model = match new_provider {
244                    ProviderType::OpenAI => "gpt-5.2",
245                    ProviderType::Anthropic => "claude-sonnet-4-20250514",
246                };
247                self.model = default_model.to_string();
248                
249                println!("{}", format!("✓ Switched to {} with model {}", new_provider, default_model).green());
250            } else {
251                println!("{}", "Invalid selection".red());
252            }
253        }
254        
255        Ok(())
256    }
257
258    /// Handle /help command
259    pub fn print_help() {
260        println!();
261        println!("{}", "📖 Available Commands:".cyan().bold());
262        println!();
263        println!("  {}  - Select a different AI model", "/model".white().bold());
264        println!("  {} - Switch provider (OpenAI/Anthropic)", "/provider".white().bold());
265        println!("  {}  - Clear conversation history", "/clear".white().bold());
266        println!("  {}   - Show this help message", "/help".white().bold());
267        println!("  {}   - Exit the chat", "/exit".white().bold());
268        println!();
269        println!("{}", "Just type your message and press Enter to chat!".dimmed());
270        println!();
271    }
272
273
274    /// Print session banner with colorful SYNCABLE ASCII art
275    pub fn print_logo() {
276    // Colors matching the logo gradient: purple → orange → pink
277    // Using ANSI 256 colors for better gradient
278
279        // Purple shades for S, y
280        let purple = "\x1b[38;5;141m";  // Light purple
281        // Orange shades for n, c  
282        let orange = "\x1b[38;5;216m";  // Peach/orange
283        // Pink shades for a, b, l, e
284        let pink = "\x1b[38;5;212m";    // Hot pink
285        let magenta = "\x1b[38;5;207m"; // Magenta
286        let reset = "\x1b[0m";
287
288        println!();
289        println!(
290            "{}  ███████╗{}{} ██╗   ██╗{}{}███╗   ██╗{}{} ██████╗{}{}  █████╗ {}{}██████╗ {}{}██╗     {}{}███████╗{}",
291            purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset
292        );
293        println!(
294            "{}  ██╔════╝{}{} ╚██╗ ██╔╝{}{}████╗  ██║{}{} ██╔════╝{}{} ██╔══██╗{}{}██╔══██╗{}{}██║     {}{}██╔════╝{}",
295            purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset
296        );
297        println!(
298            "{}  ███████╗{}{}  ╚████╔╝ {}{}██╔██╗ ██║{}{} ██║     {}{} ███████║{}{}██████╔╝{}{}██║     {}{}█████╗  {}",
299            purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset
300        );
301        println!(
302            "{}  ╚════██║{}{}   ╚██╔╝  {}{}██║╚██╗██║{}{} ██║     {}{} ██╔══██║{}{}██╔══██╗{}{}██║     {}{}██╔══╝  {}",
303            purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset
304        );
305        println!(
306            "{}  ███████║{}{}    ██║   {}{}██║ ╚████║{}{} ╚██████╗{}{} ██║  ██║{}{}██████╔╝{}{}███████╗{}{}███████╗{}",
307            purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset
308        );
309        println!(
310            "{}  ╚══════╝{}{}    ╚═╝   {}{}╚═╝  ╚═══╝{}{}  ╚═════╝{}{} ╚═╝  ╚═╝{}{}╚═════╝ {}{}╚══════╝{}{}╚══════╝{}",
311            purple, reset, purple, reset, orange, reset, orange, reset, pink, reset, pink, reset, magenta, reset, magenta, reset
312        );
313        println!();
314    }
315
316    /// Print the welcome banner
317    pub fn print_banner(&self) {
318        // Print the gradient ASCII logo
319        Self::print_logo();
320
321        // Print agent info
322        println!(
323            "  {} {} powered by {}: {}",
324            ROBOT,
325            "Syncable Agent".white().bold(),
326            self.provider.to_string().cyan(),
327            self.model.cyan()
328        );
329        println!(
330            "  {}",
331            "Your AI-powered code analysis assistant".dimmed()
332        );
333        println!();
334        println!(
335            "  {} Type your questions. Use {} to exit.\n",
336            "→".cyan(),
337            "exit".yellow().bold()
338        );
339    }
340
341
342    /// Process a command (returns true if should continue, false if should exit)
343    pub fn process_command(&mut self, input: &str) -> AgentResult<bool> {
344        let cmd = input.trim().to_lowercase();
345        
346        match cmd.as_str() {
347            "/exit" | "/quit" | "/q" => {
348                println!("\n{}", "👋 Goodbye!".green());
349                return Ok(false);
350            }
351            "/help" | "/h" | "/?" => {
352                Self::print_help();
353            }
354            "/model" | "/m" => {
355                self.handle_model_command()?;
356            }
357            "/provider" | "/p" => {
358                self.handle_provider_command()?;
359            }
360            "/clear" | "/c" => {
361                self.history.clear();
362                println!("{}", "✓ Conversation history cleared".green());
363            }
364            _ => {
365                if cmd.starts_with('/') {
366                    println!("{}", format!("Unknown command: {}. Type /help for available commands.", cmd).yellow());
367                }
368            }
369        }
370        
371        Ok(true)
372    }
373
374    /// Check if input is a command
375    pub fn is_command(input: &str) -> bool {
376        input.trim().starts_with('/')
377    }
378
379    /// Read user input with prompt
380    pub fn read_input(&self) -> io::Result<String> {
381        print!("{}", "You: ".green().bold());
382        io::stdout().flush()?;
383        
384        let mut input = String::new();
385        io::stdin().read_line(&mut input)?;
386        Ok(input.trim().to_string())
387    }
388}