use anyhow::Result; use crate::config::Config; use crate::core::{ create_client, get_provider_for_model, provider::{get_model_info_list, get_display_name_for_model, get_model_id_from_display_name}, ChatClient, Session, }; use crate::utils::{Display, InputHandler, SessionAction}; use std::future::Future; use std::pin::Pin; pub struct ChatCLI { session: Session, client: Option, current_model: Option, display: Display, input: InputHandler, config: Config, } impl ChatCLI { pub fn new(session: Session, config: Config) -> Result { Ok(Self { session, client: None, current_model: None, display: Display::new(), input: InputHandler::new()?, config, }) } fn get_client(&mut self) -> Result<&ChatClient> { if self.client.is_none() || self.current_model.as_ref() != Some(&self.session.model) { let client = create_client(&self.session.model, &self.config)?; self.current_model = Some(self.session.model.clone()); self.client = Some(client); } Ok(self.client.as_ref().unwrap()) } pub async fn run(&mut self) -> Result<()> { self.display.print_header(); self.display.print_info("Type your message and press Enter. Commands start with '/'."); self.display.print_info("Type /help for help."); let provider = get_provider_for_model(&self.session.model); let display_name = get_display_name_for_model(&self.session.model); self.display.print_model_info(&display_name, provider.as_str()); self.display.print_session_info(&self.session.name); println!(); loop { match self.input.read_line("👤> ")? { Some(line) => { let line = line.trim(); if line.is_empty() { continue; } if line.starts_with('/') { if !self.handle_command(line).await? { break; } } else { if let Err(e) = self.handle_user_message(line).await { self.display.print_error(&format!("Error: {}", e)); } } } None => { self.display.print_info("Goodbye!"); break; } } } self.session.save()?; self.input.save_history()?; Ok(()) } async fn handle_user_message(&mut self, message: &str) -> Result<()> { self.session.add_user_message(message.to_string()); self.session.save()?; // Clone data needed for the API call before getting mutable client reference let model = self.session.model.clone(); let messages = self.session.messages.clone(); let enable_web_search = self.session.enable_web_search; let enable_reasoning_summary = self.session.enable_reasoning_summary; let reasoning_effort = self.session.reasoning_effort.clone(); // Check if we should use streaming before getting client let should_use_streaming = { let client = self.get_client()?; client.supports_streaming() }; if should_use_streaming { print!("{} ", console::style("🤖").magenta()); use std::io::{self, Write}; io::stdout().flush().ok(); let stream_callback = { use crate::core::StreamCallback; Box::new(move |chunk: &str| { print!("{}", chunk); use std::io::{self, Write}; io::stdout().flush().ok(); Box::pin(async move {}) as Pin + Send>> }) as StreamCallback }; let client = self.get_client()?; match client .chat_completion_stream( &model, &messages, enable_web_search, enable_reasoning_summary, &reasoning_effort, stream_callback, ) .await { Ok(response) => { println!(); // Add newline after streaming self.session.add_assistant_message(response); self.session.save()?; } Err(e) => { println!(); // Add newline after failed streaming self.display.print_error(&format!("Streaming failed: {}", e)); return Err(e); } } } else { // Fallback to non-streaming let spinner = self.display.show_spinner("Thinking"); let client = self.get_client()?; match client .chat_completion( &model, &messages, enable_web_search, enable_reasoning_summary, &reasoning_effort, ) .await { Ok(response) => { spinner.finish("Done"); self.display.print_assistant_response(&response); self.session.add_assistant_message(response); self.session.save()?; } Err(e) => { spinner.finish_with_error("Failed"); return Err(e); } } } Ok(()) } async fn handle_command(&mut self, command: &str) -> Result { let parts: Vec<&str> = command.split_whitespace().collect(); if parts.is_empty() { return Ok(true); } match parts[0].to_lowercase().as_str() { "/help" => { self.display.print_help(); } "/exit" => { self.session.save()?; self.display.print_info("Session saved. Goodbye!"); return Ok(false); } "/model" => { self.model_switcher().await?; } "/new" => { self.handle_new_session(&parts)?; } "/switch" => { self.session_manager().await?; } "/clear" => { self.session.clear_messages(); self.session.save()?; self.display.print_command_result("Conversation cleared"); } "/tools" => { self.tools_manager().await?; } "/history" => { self.handle_history_command(&parts)?; } "/export" => { self.handle_export_command(&parts)?; } "/save" => { self.handle_save_command(&parts)?; } _ => { self.display.print_error(&format!("Unknown command: {} (see /help)", parts[0])); } } Ok(true) } async fn model_switcher(&mut self) -> Result<()> { let model_info_list = get_model_info_list(); let display_names: Vec = model_info_list.iter().map(|info| info.display_name.to_string()).collect(); let current_display_name = get_display_name_for_model(&self.session.model); let selection = self.input.select_from_list( "Select a model:", &display_names, Some(¤t_display_name), )?; match selection { Some(display_name) => { if let Some(model_id) = get_model_id_from_display_name(&display_name) { if model_id == self.session.model { self.display.print_info("Already using that model"); } else { self.session.model = model_id.clone(); let provider = get_provider_for_model(&self.session.model); self.display.print_command_result(&format!( "Model switched to {} ({})", display_name, provider.as_str() )); self.client = None; // Force client recreation self.session.save()?; // Save the model change } } else { self.display.print_error("Invalid model selection"); } } None => { self.display.print_info("Model selection cancelled"); } } Ok(()) } fn handle_new_session(&mut self, parts: &[&str]) -> Result<()> { if parts.len() != 2 { self.display.print_error("Usage: /new "); return Ok(()); } self.session.save()?; let new_session = Session::new(parts[1].to_string(), self.session.model.clone()); self.session = new_session; self.display.print_command_result(&format!("New session '{}' started", self.session.name)); Ok(()) } async fn session_manager(&mut self) -> Result<()> { loop { let sessions = Session::list_sessions()?; let session_names: Vec = sessions .into_iter() .map(|(name, _)| name) .collect(); if session_names.is_empty() { self.display.print_info("No sessions available"); return Ok(()); } let action = self.input.session_manager( "Session Manager:", &session_names, Some(&self.session.name), )?; match action { SessionAction::Switch(session_name) => { if session_name == self.session.name { self.display.print_info("Already in that session"); return Ok(()); } self.session.save()?; match Session::load(&session_name) { Ok(session) => { self.session = session; let display_name = get_display_name_for_model(&self.session.model); self.display.print_command_result(&format!( "Switched to session '{}' (model={})", self.session.name, display_name )); self.client = None; // Force client recreation return Ok(()); } Err(e) => { self.display.print_error(&format!("Failed to load session: {}", e)); // Don't return, allow user to try again or cancel } } } SessionAction::Delete(session_name) => { match Session::delete_session(&session_name) { Ok(()) => { self.display.print_command_result(&format!("Session '{}' deleted", session_name)); // If we deleted the current session, we need to handle this specially if session_name == self.session.name { // Try to switch to another session or create a default one let remaining_sessions = Session::list_sessions()?; let remaining_names: Vec = remaining_sessions .into_iter() .map(|(name, _)| name) .collect(); if remaining_names.is_empty() { // No sessions left, create a default one self.session = Session::new("default".to_string(), self.session.model.clone()); self.display.print_command_result("Created new default session"); return Ok(()); } else { // Switch to the first available session match Session::load(&remaining_names[0]) { Ok(session) => { self.session = session; let display_name = get_display_name_for_model(&self.session.model); self.display.print_command_result(&format!( "Switched to session '{}' (model={})", self.session.name, display_name )); self.client = None; return Ok(()); } Err(e) => { self.display.print_error(&format!("Failed to load fallback session: {}", e)); // Create a new default session as fallback self.session = Session::new("default".to_string(), self.session.model.clone()); self.display.print_command_result("Created new default session"); return Ok(()); } } } } // Continue to show updated session list if we didn't delete current session } Err(e) => { self.display.print_error(&format!("Failed to delete session: {}", e)); // Continue to allow retry } } } SessionAction::Cancel => { return Ok(()); } } } } async fn tools_manager(&mut self) -> Result<()> { loop { // Show current tool status self.display.print_info("Tool Management:"); let web_status = if self.session.enable_web_search { "✓ enabled" } else { "✗ disabled" }; let reasoning_status = if self.session.enable_reasoning_summary { "✓ enabled" } else { "✗ disabled" }; println!(" Web Search: {}", web_status); println!(" Reasoning Summaries: {}", reasoning_status); println!(" Reasoning Effort: {}", self.session.reasoning_effort); // Check model compatibility let model = self.session.model.clone(); let provider = get_provider_for_model(&model); let web_enabled = self.session.enable_web_search; let reasoning_enabled = self.session.enable_reasoning_summary; // Show compatibility warnings based on provider match provider { crate::core::provider::Provider::Anthropic => { if reasoning_enabled { self.display.print_warning("Reasoning summaries are not supported by Anthropic models"); } // Web search is now supported by Anthropic models } crate::core::provider::Provider::OpenAI => { // OpenAI models generally support these features } } // Tool management options let options = vec![ "Toggle Web Search", "Toggle Reasoning Summaries", "Set Reasoning Effort", "Done" ]; let selection = self.input.select_from_list( "Select an option:", &options, None, )?; match selection.as_deref() { Some("Toggle Web Search") => { self.session.enable_web_search = !self.session.enable_web_search; let state = if self.session.enable_web_search { "enabled" } else { "disabled" }; self.display.print_command_result(&format!("Web search {}", state)); } Some("Toggle Reasoning Summaries") => { self.session.enable_reasoning_summary = !self.session.enable_reasoning_summary; let state = if self.session.enable_reasoning_summary { "enabled" } else { "disabled" }; self.display.print_command_result(&format!("Reasoning summaries {}", state)); } Some("Set Reasoning Effort") => { let effort_options = vec!["low", "medium", "high"]; if let Some(effort) = self.input.select_from_list( "Select reasoning effort:", &effort_options, Some(&self.session.reasoning_effort), )? { self.session.reasoning_effort = effort.to_string(); self.display.print_command_result(&format!("Reasoning effort set to {}", effort)); if !self.session.model.starts_with("gpt-5") { self.display.print_warning("Reasoning effort is only supported by GPT-5 models"); } } } Some("Done") | None => { break; } _ => {} } self.session.save()?; // Save changes after each modification println!(); // Add spacing } Ok(()) } fn handle_history_command(&mut self, parts: &[&str]) -> Result<()> { let mut filter_role: Option<&str> = None; let mut limit: Option = None; // Parse parameters for &part in parts.iter().skip(1) { match part { "user" | "assistant" => filter_role = Some(part), _ => { if let Ok(num) = part.parse::() { limit = Some(num); } else { self.display.print_error(&format!("Invalid parameter: {}", part)); self.display.print_info("Usage: /history [user|assistant] [number]"); return Ok(()); } } } } // Filter messages (skip system prompt at index 0) let mut messages: Vec<(usize, &crate::core::Message)> = self.session.messages .iter() .enumerate() .skip(1) // Skip system prompt .collect(); // Apply role filter if let Some(role) = filter_role { messages.retain(|(_, msg)| msg.role == role); } // Apply limit if let Some(limit_count) = limit { let start_index = messages.len().saturating_sub(limit_count); messages = messages[start_index..].to_vec(); } if messages.is_empty() { self.display.print_info("No messages to display"); return Ok(()); } // Format and display self.display.print_conversation_history(&messages); Ok(()) } fn handle_export_command(&mut self, parts: &[&str]) -> Result<()> { let format = if parts.len() > 1 { parts[1].to_lowercase() } else { // Default to markdown "markdown".to_string() }; let valid_formats = ["markdown", "md", "json", "txt"]; if !valid_formats.contains(&format.as_str()) { self.display.print_error(&format!( "Invalid format '{}'. Supported formats: markdown, json, txt", format )); self.display.print_info("Usage: /export [format]"); return Ok(()); } // Generate filename with timestamp let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let extension = match format.as_str() { "markdown" | "md" => "md", "json" => "json", "txt" => "txt", _ => "md", }; // Create exports directory if it doesn't exist let exports_dir = std::path::Path::new("exports"); if !exports_dir.exists() { if let Err(e) = std::fs::create_dir(exports_dir) { self.display.print_error(&format!("Failed to create exports directory: {}", e)); return Ok(()); } } let filename = format!("{}_{}.{}", self.session.name, now, extension); let file_path = exports_dir.join(&filename); match self.session.export(&format, file_path.to_str().unwrap_or(&filename)) { Ok(()) => { self.display.print_command_result(&format!( "Conversation exported to '{}'", file_path.display() )); } Err(e) => { self.display.print_error(&format!("Export failed: {}", e)); } } Ok(()) } fn handle_save_command(&mut self, parts: &[&str]) -> Result<()> { if parts.len() != 2 { self.display.print_error("Usage: /save "); self.display.print_info("Example: /save my_important_conversation"); return Ok(()); } let new_session_name = parts[1]; // Validate session name if new_session_name.is_empty() { self.display.print_error("Session name cannot be empty"); return Ok(()); } // Check if session already exists if let Ok(sessions) = Session::list_sessions() { if sessions.iter().any(|(name, _)| name == new_session_name) { self.display.print_error(&format!( "Session '{}' already exists. Choose a different name.", new_session_name )); return Ok(()); } } // Save the current session first to ensure it's up to date self.session.save()?; // Copy the session with the new name match self.session.save_as(new_session_name) { Ok(()) => { self.display.print_command_result(&format!( "Current session saved as '{}' ({} messages copied)", new_session_name, self.session.messages.len().saturating_sub(1) // Exclude system prompt )); self.display.print_info(&format!( "Use '/switch' to switch to the new session, or continue with current session '{}'", self.session.name )); } Err(e) => { self.display.print_error(&format!("Failed to save session: {}", e)); } } Ok(()) } }