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