diff --git a/src/cli.rs b/src/cli.rs index 7a90277..4db5ece 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,9 +3,9 @@ use anyhow::Result; use crate::config::Config; use crate::core::{ create_client, get_provider_for_model, provider::get_all_models, provider::get_supported_models, - provider::is_model_supported, ChatClient, Session, + ChatClient, Session, }; -use crate::utils::{Display, InputHandler}; +use crate::utils::{Display, InputHandler, SessionAction}; pub struct ChatCLI { session: Session, @@ -134,7 +134,7 @@ impl ChatCLI { return Ok(false); } "/model" => { - self.handle_model_command(&parts).await?; + self.model_switcher().await?; } "/models" => { self.list_models(); @@ -145,22 +145,16 @@ impl ChatCLI { "/new" => { self.handle_new_session(&parts)?; } - "/switch" => { - self.handle_switch_session(&parts).await?; + "/switch" | "/sessions" => { + self.session_manager().await?; } "/clear" => { self.session.clear_messages(); self.session.save()?; self.display.print_command_result("Conversation cleared"); } - "/delete" => { - self.handle_delete_session(&parts).await?; - } - "/tool" => { - self.handle_tool_command(&parts)?; - } - "/reasoning" => { - self.handle_reasoning_command(&parts)?; + "/tools" => { + self.tools_manager().await?; } "/effort" => { self.handle_effort_command(&parts)?; @@ -182,42 +176,35 @@ impl ChatCLI { Ok(true) } - async fn handle_model_command(&mut self, parts: &[&str]) -> Result<()> { - if parts.len() == 1 { - let all_models = get_all_models(); - let selection = self.input.select_from_list( - "Select a model:", - &all_models, - Some(&self.session.model), - )?; - - if let Some(model) = selection { - self.session.model = model.to_string(); - let provider = get_provider_for_model(&self.session.model); - self.display.print_command_result(&format!( - "Model switched to {} ({})", - self.session.model, - provider.as_str() - )); - self.client = None; // Force client recreation + async fn model_switcher(&mut self) -> Result<()> { + let all_models = get_all_models(); + let selection = self.input.select_from_list( + "Select a model:", + &all_models, + Some(&self.session.model), + )?; + + match selection { + Some(model) => { + if model.to_string() == self.session.model { + self.display.print_info("Already using that model"); + } else { + self.session.model = model.to_string(); + let provider = get_provider_for_model(&self.session.model); + self.display.print_command_result(&format!( + "Model switched to {} ({})", + self.session.model, + provider.as_str() + )); + self.client = None; // Force client recreation + self.session.save()?; // Save the model change + } } - } else if parts.len() == 2 { - let model = parts[1]; - if !is_model_supported(model) { - self.display.print_error("Unsupported model. Use /models to see the list of supported models."); - } else { - self.session.model = model.to_string(); - let provider = get_provider_for_model(&self.session.model); - self.display.print_command_result(&format!( - "Model switched to {} ({})", - self.session.model, - provider.as_str() - )); - self.client = None; // Force client recreation + None => { + self.display.print_info("Model selection cancelled"); } - } else { - self.display.print_error("Usage: /model [model_name]"); } + Ok(()) } @@ -277,150 +264,188 @@ impl ChatCLI { Ok(()) } - async fn handle_switch_session(&mut self, parts: &[&str]) -> Result<()> { - if parts.len() == 1 { + async fn session_manager(&mut self) -> Result<()> { + loop { let sessions = Session::list_sessions()?; let session_names: Vec = sessions .into_iter() .map(|(name, _)| name) - .filter(|name| name != &self.session.name) .collect(); - if let Some(selection) = self.input.select_from_list( - "Switch to session:", - &session_names, - None, - )? { - self.session.save()?; - match Session::load(&selection) { - Ok(session) => { - self.session = session; - self.display.print_command_result(&format!( - "Switched to session '{}' (model={})", - self.session.name, self.session.model - )); - self.client = None; // Force client recreation - } - Err(e) => { - self.display.print_error(&format!("Failed to load session: {}", e)); - } - } - } - } else if parts.len() == 2 { - let session_name = parts[1]; - self.session.save()?; - match Session::load(session_name) { - Ok(session) => { - self.session = session; - self.display.print_command_result(&format!( - "Switched to session '{}' (model={})", - self.session.name, self.session.model - )); - self.client = None; // Force client recreation - } - Err(e) => { - self.display.print_error(&format!("Failed to load session: {}", e)); - } - } - } else { - self.display.print_error("Usage: /switch [session_name]"); - } - - Ok(()) - } - - async fn handle_delete_session(&mut self, parts: &[&str]) -> Result<()> { - let target = if parts.len() == 1 { - let sessions = Session::list_sessions()?; - let session_names: Vec = sessions - .into_iter() - .map(|(name, _)| name) - .filter(|name| name != &self.session.name) - .collect(); - - self.input.select_from_list("Delete session:", &session_names, None)? - } else if parts.len() == 2 { - Some(parts[1].to_string()) - } else { - self.display.print_error("Usage: /delete [session_name]"); - return Ok(()); - }; - - if let Some(target) = target { - if target == self.session.name { - self.display.print_error( - "Cannot delete the session you are currently using. Switch to another session first." - ); + if session_names.is_empty() { + self.display.print_info("No sessions available"); return Ok(()); } - if self.input.confirm(&format!("Delete session '{}'?", target))? { - match Session::delete_session(&target) { - Ok(()) => { - self.display.print_command_result(&format!("Session '{}' deleted", target)); + 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(()); } - Err(e) => { - self.display.print_error(&format!("Failed to delete session: {}", e)); + + self.session.save()?; + match Session::load(&session_name) { + Ok(session) => { + self.session = session; + self.display.print_command_result(&format!( + "Switched to session '{}' (model={})", + self.session.name, self.session.model + )); + 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 + } } } - } - } - - Ok(()) - } - - fn handle_tool_command(&mut self, parts: &[&str]) -> Result<()> { - if parts.len() != 3 || parts[1].to_lowercase() != "websearch" || !["on", "off"].contains(&parts[2]) { - self.display.print_error("Usage: /tool websearch on|off"); - return Ok(()); - } - - let enable = parts[2] == "on"; - - if enable { - let model = self.session.model.clone(); - if let Ok(client) = self.get_client() { - if !client.supports_feature_for_model("web_search", &model) { - let provider = get_provider_for_model(&model); - self.display.print_warning(&format!( - "Web search is not supported by {} models", - provider.as_str() - )); + 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; + self.display.print_command_result(&format!( + "Switched to session '{}' (model={})", + self.session.name, self.session.model + )); + 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(()); } } } - - self.session.enable_web_search = enable; - let state = if enable { "enabled" } else { "disabled" }; - self.display.print_command_result(&format!("Web search tool {}", state)); - - Ok(()) } - fn handle_reasoning_command(&mut self, parts: &[&str]) -> Result<()> { - if parts.len() != 2 || !["on", "off"].contains(&parts[1]) { - self.display.print_error("Usage: /reasoning on|off"); - return Ok(()); - } - let enable = parts[1] == "on"; - - if enable { + 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(); - if let Ok(client) = self.get_client() { - if !client.supports_feature_for_model("reasoning_summary", &model) { - let provider = get_provider_for_model(&model); - self.display.print_warning(&format!( - "Reasoning summaries are not supported by {} models", - provider.as_str() - )); + 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 web_enabled { + self.display.print_warning("Web search is not supported by Anthropic models"); + } + if reasoning_enabled { + self.display.print_warning("Reasoning summaries are not 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 } - - self.session.enable_reasoning_summary = enable; - let state = if enable { "enabled" } else { "disabled" }; - self.display.print_command_result(&format!("Reasoning summaries {}", state)); Ok(()) } diff --git a/src/utils/display.rs b/src/utils/display.rs index 428cf77..8e9eb28 100644 --- a/src/utils/display.rs +++ b/src/utils/display.rs @@ -70,15 +70,14 @@ impl Display { Available Commands: /help - Show this help message /exit - Exit the CLI - /model [model_name] - Switch model or show interactive picker + /model - Interactive model switcher /models - List all supported models /list - List all saved sessions /new - Create a new session - /switch [session_name] - Switch session or show interactive picker + /switch - Interactive session manager (switch/delete) + /sessions - Alias for /switch /clear - Clear current conversation - /delete [session_name] - Delete a session - /tool websearch on|off - Enable/disable web search (OpenAI only) - /reasoning on|off - Enable/disable reasoning summaries (OpenAI only) + /tools - Interactive tool and feature manager /effort [low|medium|high] - Set reasoning effort level (GPT-5 only) /stats - Show current session statistics /optimize - Optimize session memory usage diff --git a/src/utils/input.rs b/src/utils/input.rs index 01ae1f9..43e4159 100644 --- a/src/utils/input.rs +++ b/src/utils/input.rs @@ -67,13 +67,17 @@ impl InputHandler { 0 }; - let selection = Select::with_theme(&theme) + match Select::with_theme(&theme) .with_prompt(title) .items(items) .default(default_index) - .interact_opt()?; - - Ok(selection.map(|idx| items[idx].clone())) + .interact_opt() { + Ok(selection) => Ok(selection.map(|idx| items[idx].clone())), + Err(_) => { + // Handle any error (ESC, Ctrl+C, etc.) as cancellation + Ok(None) + } + } } pub fn confirm(&self, message: &str) -> Result { @@ -85,6 +89,123 @@ impl InputHandler { Ok(confirmation) } + + /// Interactive session manager with support for switching and deletion + pub fn session_manager( + &mut self, + title: &str, + sessions: &[T], + current_session: Option<&str>, + ) -> Result> { + if sessions.is_empty() { + println!("(no sessions available)"); + return Ok(SessionAction::Cancel); + } + + // Create display items with current session marker + let display_items: Vec = sessions + .iter() + .map(|session| { + let name = session.to_string(); + if Some(name.as_str()) == current_session { + format!("{} (current)", name) + } else { + name + } + }) + .collect(); + + println!("\n{}", title); + println!("Use ↑/↓ arrows to navigate, Enter to switch session, Esc to cancel"); + println!("To delete a session, first switch away from it, then use this menu again.\n"); + + let theme = ColorfulTheme::default(); + + // Find default selection index + let default_index = if let Some(current) = current_session { + sessions.iter().position(|item| item.to_string() == current).unwrap_or(0) + } else { + 0 + }; + + // Create the selection menu + let selection_result = match Select::with_theme(&theme) + .with_prompt("Select session") + .items(&display_items) + .default(default_index) + .interact_opt() { + Ok(selection) => selection, + Err(_) => { + return Ok(SessionAction::Cancel); + } + }; + + match selection_result { + Some(index) => { + let selected_session = sessions[index].clone(); + + // If it's the current session, show options + if Some(selected_session.to_string().as_str()) == current_session { + let options = vec!["Delete this session", "Cancel"]; + let action_result = match Select::with_theme(&theme) + .with_prompt("This is your current session. What would you like to do?") + .items(&options) + .interact_opt() { + Ok(selection) => selection, + Err(_) => { + return Ok(SessionAction::Cancel); + } + }; + + match action_result { + Some(0) => { + if self.confirm(&format!("Delete current session '{}'? You will need to create or switch to another session after deletion.", selected_session.to_string()))? { + return Ok(SessionAction::Delete(selected_session)); + } + return Ok(SessionAction::Cancel); + } + _ => return Ok(SessionAction::Cancel), + } + } else { + // Different session selected - offer to switch or delete + let options = vec![ + format!("Switch to '{}'", selected_session.to_string()), + format!("Delete '{}'", selected_session.to_string()), + "Cancel".to_string() + ]; + + let action_result = match Select::with_theme(&theme) + .with_prompt("What would you like to do?") + .items(&options) + .interact_opt() { + Ok(selection) => selection, + Err(_) => { + return Ok(SessionAction::Cancel); + } + }; + + match action_result { + Some(0) => return Ok(SessionAction::Switch(selected_session)), + Some(1) => { + if self.confirm(&format!("Delete session '{}'?", selected_session.to_string()))? { + return Ok(SessionAction::Delete(selected_session)); + } + return Ok(SessionAction::Cancel); + } + _ => return Ok(SessionAction::Cancel), + } + } + } + None => return Ok(SessionAction::Cancel), + } + } +} + +#[derive(Debug, Clone)] +pub enum SessionAction { + Switch(T), + Delete(T), + Cancel, } impl Default for InputHandler {