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