diff --git a/src/chat/commands.rs b/src/chat/commands.rs index 35ae024..b6e0d04 100644 --- a/src/chat/commands.rs +++ b/src/chat/commands.rs @@ -1,29 +1,92 @@ -//use anyhow::{anyhow, Result}; - -/// Represents different slash commands available in chat mode +/// Represents different slash commands available in chat mode, organized by category #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChatCommand { + // General Help, - Save(Option), - Context, - Clear, + Commands, Exit, #[allow(dead_code)] Quit, + + // Session management + Save(Option), + Clear, Retry, Branch(Option), + + // Context management + Context, AddContext(String), RemoveContext(String), + + // AI settings Model(Option), Provider(Option), - Tools(Option), // None = toggle, Some(true) = on, Some(false) = off + Tools(Option), + Status, + Theme(Option), + Streaming(Option), + Settings, +} + +/// Categories for grouping commands in the palette +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandCategory { + General, + Session, + Context, + AiSettings, +} + +impl CommandCategory { + pub fn label(&self) -> &'static str { + match self { + Self::General => "General", + Self::Session => "Session", + Self::Context => "Context", + Self::AiSettings => "AI & Settings", + } + } + + pub fn icon(&self) -> &'static str { + match self { + Self::General => ">>", + Self::Session => "[]", + Self::Context => "()", + Self::AiSettings => "{}", + } + } + + /// Return all categories in display order + pub fn all() -> &'static [CommandCategory] { + &[ + Self::General, + Self::Session, + Self::Context, + Self::AiSettings, + ] + } +} + +/// A command entry for display in the palette +pub struct CommandEntry { + pub command: &'static str, + pub aliases: &'static str, + pub description: &'static str, + pub category: CommandCategory, } impl ChatCommand { - /// Parse a line of input to determine if it's a slash command + /// Parse a line of input to determine if it's a slash command. + /// Also supports `?` as a shortcut for opening the command palette. pub fn parse(input: &str) -> Option { let input = input.trim(); + // Support `?` as a shortcut for the command palette + if input == "?" { + return Some(ChatCommand::Commands); + } + if !input.starts_with('/') { return None; } @@ -34,7 +97,12 @@ impl ChatCommand { } match parts[0].to_lowercase().as_str() { + // General "help" | "h" => Some(ChatCommand::Help), + "commands" | "cmd" => Some(ChatCommand::Commands), + "exit" | "quit" | "q" => Some(ChatCommand::Exit), + + // Session "save" | "s" => { let name = if parts.len() > 1 { Some(parts[1..].join(" ")) @@ -43,9 +111,7 @@ impl ChatCommand { }; Some(ChatCommand::Save(name)) } - "context" | "ctx" => Some(ChatCommand::Context), "clear" | "c" => Some(ChatCommand::Clear), - "exit" | "quit" | "q" => Some(ChatCommand::Exit), "retry" | "r" => Some(ChatCommand::Retry), "branch" | "b" => { let name = if parts.len() > 1 { @@ -55,6 +121,9 @@ impl ChatCommand { }; Some(ChatCommand::Branch(name)) } + + // Context + "context" | "ctx" => Some(ChatCommand::Context), "add" => { if parts.len() > 1 { Some(ChatCommand::AddContext(parts[1..].join(" "))) @@ -69,6 +138,8 @@ impl ChatCommand { None } } + + // AI settings "model" | "m" => { let model = if parts.len() > 1 { Some(parts[1..].join(" ")) @@ -90,13 +161,35 @@ impl ChatCommand { match parts[1].to_lowercase().as_str() { "on" | "true" | "enable" | "enabled" | "1" => Some(true), "off" | "false" | "disable" | "disabled" | "0" => Some(false), - _ => None, // Invalid argument, treat as toggle + _ => None, } } else { - None // No argument = toggle + None }; Some(ChatCommand::Tools(setting)) } + "status" => Some(ChatCommand::Status), + "theme" => { + let theme = if parts.len() > 1 { + Some(parts[1..].join(" ")) + } else { + None + }; + Some(ChatCommand::Theme(theme)) + } + "streaming" => { + let setting = if parts.len() > 1 { + match parts[1].to_lowercase().as_str() { + "on" | "true" | "enable" | "enabled" | "1" => Some(true), + "off" | "false" | "disable" | "disabled" | "0" => Some(false), + _ => None, + } + } else { + None + }; + Some(ChatCommand::Streaming(setting)) + } + "settings" | "config" => Some(ChatCommand::Settings), _ => None, } } @@ -105,44 +198,155 @@ impl ChatCommand { #[allow(dead_code)] pub fn help_text(&self) -> &'static str { match self { - ChatCommand::Help => "Show this help message", + ChatCommand::Help => "Show quick help", + ChatCommand::Commands => "Open command palette with all commands", + ChatCommand::Exit | ChatCommand::Quit => "Exit chat mode", ChatCommand::Save(_) => "Save current session with optional name", - ChatCommand::Context => "Show current context information", ChatCommand::Clear => "Clear conversation history", - ChatCommand::Exit | ChatCommand::Quit => "Exit chat mode", ChatCommand::Retry => "Regenerate the last AI response", ChatCommand::Branch(_) => "Create a new conversation branch", + ChatCommand::Context => "Show current context information", ChatCommand::AddContext(_) => "Add file or directory to context", ChatCommand::RemoveContext(_) => "Remove file or directory from context", - ChatCommand::Model(_) => "Switch AI model (e.g., gpt-5.2, gpt-5-mini, claude-3-5-sonnet-20241022)", - ChatCommand::Provider(_) => "Switch AI provider (claude or openai)", - ChatCommand::Tools(_) => "Toggle or set tool usage (bash, file operations) for OpenAI", + ChatCommand::Model(_) => "Switch AI model or show current", + ChatCommand::Provider(_) => "Switch AI provider or show current", + ChatCommand::Tools(_) => "Toggle tool usage (OpenAI only)", + ChatCommand::Status => "Show current session status", + ChatCommand::Theme(_) => "Switch display theme or list themes", + ChatCommand::Streaming(_) => "Toggle streaming output", + ChatCommand::Settings => "Show all current settings", } } - /// Get all available commands for help display - pub fn all_commands() -> Vec<(&'static str, &'static str)> { + /// Get the full command catalogue, organized by category + pub fn command_palette() -> Vec { vec![ - ("/help, /h", "Show this help message"), - ( - "/save [name], /s", - "Save current session with optional name", - ), - ("/context, /ctx", "Show current context information"), - ("/clear, /c", "Clear conversation history"), - ("/exit, /quit, /q", "Exit chat mode"), - ("/retry, /r", "Regenerate the last AI response"), - ("/branch [name], /b", "Create a new conversation branch"), - ("/add ", "Add file or directory to context"), - ( - "/remove , /rm", - "Remove file or directory from context", - ), - ("/model [name], /m", "Switch AI model or show current model"), - ("/provider [name], /p", "Switch AI provider (claude/openai) or show current"), - ("/tools [on|off], /t", "Toggle or set tool usage (bash, file ops) for OpenAI"), + // General + CommandEntry { + command: "/help", + aliases: "/h", + description: "Show quick help", + category: CommandCategory::General, + }, + CommandEntry { + command: "/commands", + aliases: "/cmd, ?", + description: "Open this command palette", + category: CommandCategory::General, + }, + CommandEntry { + command: "/exit", + aliases: "/quit, /q", + description: "Exit chat mode", + category: CommandCategory::General, + }, + // Session + CommandEntry { + command: "/save [name]", + aliases: "/s", + description: "Save session with optional name", + category: CommandCategory::Session, + }, + CommandEntry { + command: "/clear", + aliases: "/c", + description: "Clear conversation history", + category: CommandCategory::Session, + }, + CommandEntry { + command: "/retry", + aliases: "/r", + description: "Regenerate last AI response", + category: CommandCategory::Session, + }, + CommandEntry { + command: "/branch [name]", + aliases: "/b", + description: "Create conversation branch", + category: CommandCategory::Session, + }, + // Context + CommandEntry { + command: "/context", + aliases: "/ctx", + description: "Show current context info", + category: CommandCategory::Context, + }, + CommandEntry { + command: "/add ", + aliases: "", + description: "Add file/directory to context", + category: CommandCategory::Context, + }, + CommandEntry { + command: "/remove ", + aliases: "/rm", + description: "Remove from context", + category: CommandCategory::Context, + }, + // AI settings + CommandEntry { + command: "/model [name]", + aliases: "/m", + description: "Switch model or show current", + category: CommandCategory::AiSettings, + }, + CommandEntry { + command: "/provider [name]", + aliases: "/p", + description: "Switch provider (claude/openai)", + category: CommandCategory::AiSettings, + }, + CommandEntry { + command: "/tools [on|off]", + aliases: "/t", + description: "Toggle tool usage (OpenAI)", + category: CommandCategory::AiSettings, + }, + CommandEntry { + command: "/status", + aliases: "", + description: "Show session status overview", + category: CommandCategory::AiSettings, + }, + CommandEntry { + command: "/theme [name]", + aliases: "", + description: "Switch theme or list themes", + category: CommandCategory::AiSettings, + }, + CommandEntry { + command: "/streaming [on|off]", + aliases: "", + description: "Toggle streaming output", + category: CommandCategory::AiSettings, + }, + CommandEntry { + command: "/settings", + aliases: "/config", + description: "Show all current settings", + category: CommandCategory::AiSettings, + }, ] } + + /// Legacy flat list for backward compat (used by /help) + pub fn all_commands() -> Vec<(&'static str, &'static str)> { + Self::command_palette() + .iter() + .map(|entry| { + if entry.aliases.is_empty() { + (entry.command, entry.description) + } else { + // Leak a combined string so we can return &'static str + // This is fine since it's only called for display and the set is fixed + let combined: &'static str = + Box::leak(format!("{}, {}", entry.command, entry.aliases).into_boxed_str()); + (combined, entry.description) + } + }) + .collect() + } } /// Represents the result of processing user input @@ -194,14 +398,54 @@ mod tests { ChatCommand::parse("/provider openai"), Some(ChatCommand::Provider(Some("openai".to_string()))) ); - assert_eq!(ChatCommand::parse("/provider"), Some(ChatCommand::Provider(None))); + assert_eq!( + ChatCommand::parse("/provider"), + Some(ChatCommand::Provider(None)) + ); - // Test non-commands + // Non-commands assert_eq!(ChatCommand::parse("hello world"), None); assert_eq!(ChatCommand::parse("not a command"), None); assert_eq!(ChatCommand::parse(""), None); } + #[test] + fn test_question_mark_shortcut() { + assert_eq!(ChatCommand::parse("?"), Some(ChatCommand::Commands)); + } + + #[test] + fn test_new_commands() { + assert_eq!( + ChatCommand::parse("/commands"), + Some(ChatCommand::Commands) + ); + assert_eq!(ChatCommand::parse("/cmd"), Some(ChatCommand::Commands)); + assert_eq!(ChatCommand::parse("/status"), Some(ChatCommand::Status)); + assert_eq!( + ChatCommand::parse("/settings"), + Some(ChatCommand::Settings) + ); + assert_eq!(ChatCommand::parse("/config"), Some(ChatCommand::Settings)); + assert_eq!(ChatCommand::parse("/theme"), Some(ChatCommand::Theme(None))); + assert_eq!( + ChatCommand::parse("/theme dark"), + Some(ChatCommand::Theme(Some("dark".to_string()))) + ); + assert_eq!( + ChatCommand::parse("/streaming on"), + Some(ChatCommand::Streaming(Some(true))) + ); + assert_eq!( + ChatCommand::parse("/streaming off"), + Some(ChatCommand::Streaming(Some(false))) + ); + assert_eq!( + ChatCommand::parse("/streaming"), + Some(ChatCommand::Streaming(None)) + ); + } + #[test] fn test_input_classification() { match InputType::classify("/help") { @@ -209,9 +453,26 @@ mod tests { _ => panic!("Expected Help command"), } + match InputType::classify("?") { + InputType::Command(ChatCommand::Commands) => (), + _ => panic!("Expected Commands command from ?"), + } + match InputType::classify("Hello, how are you?") { InputType::Message(msg) => assert_eq!(msg, "Hello, how are you?"), _ => panic!("Expected regular message"), } } + + #[test] + fn test_command_palette_has_all_categories() { + let palette = ChatCommand::command_palette(); + for cat in CommandCategory::all() { + assert!( + palette.iter().any(|e| e.category == *cat), + "Missing commands for category {:?}", + cat + ); + } + } } diff --git a/src/chat/formatter.rs b/src/chat/formatter.rs index 76be9c3..0a324b6 100644 --- a/src/chat/formatter.rs +++ b/src/chat/formatter.rs @@ -537,7 +537,7 @@ impl ChatFormatter { // Content lines let content = vec![ "Type your message and press Enter to chat", - "/help - Show available slash commands", + "Type ? or /commands to open command palette", "Ctrl+C twice to exit gracefully", ]; @@ -555,34 +555,185 @@ impl ChatFormatter { lines.join("\n") } - /// Format help text for slash commands + /// Format help text for slash commands (compact view from /help) pub fn format_help(&self, commands: &[(&str, &str)]) -> String { let mut help = String::new(); - help.push_str("┌────────────────────────────────────────────────┐\n"); - help.push_str("│ 📚 Available Commands │\n"); - help.push_str("├────────────────────────────────────────────────┤\n"); + help.push_str("┌────────────────────────────────────────────────────────────┐\n"); + help.push_str("│ Available Commands │\n"); + help.push_str("├────────────────────────────────────────────────────────────┤\n"); + let inner_width = 58; for (command, description) in commands { let formatted_line = format!(" {} - {}", command, description); - if formatted_line.len() <= 46 { + let display_len = formatted_line.len(); + if display_len <= inner_width { help.push_str(&format!( "│{}{}│\n", formatted_line, - " ".repeat(48 - formatted_line.len()) + " ".repeat(inner_width - display_len) )); } else { - // Truncate if too long - let truncated = &formatted_line[..43]; - help.push_str(&format!("│{}... │\n", truncated)); + let truncated = &formatted_line[..inner_width - 3]; + help.push_str(&format!("│{}...│\n", truncated)); } } - help.push_str("├────────────────────────────────────────────────┤\n"); - help.push_str("│ 💡 Tip: Commands can be abbreviated (/h, /s) │\n"); - help.push_str("└────────────────────────────────────────────────┘"); + help.push_str("├────────────────────────────────────────────────────────────┤\n"); + help.push_str("│ Tip: Type ? or /commands for the full command palette │\n"); + help.push_str("└────────────────────────────────────────────────────────────┘"); help } + /// Format the rich command palette (from /commands or ?) + /// Groups commands by category with a box-drawing UI + pub fn format_command_palette( + &self, + palette: &[crate::chat::commands::CommandEntry], + ) -> String { + use crate::chat::commands::CommandCategory; + + let outer_width = 62; + let inner_width = outer_width - 2; // inside the box borders + + let mut out = String::new(); + + // Top border and title + out.push_str(&format!("┌{}┐\n", "─".repeat(inner_width))); + let title = "Command Palette"; + let pad_l = (inner_width - title.len()) / 2; + let pad_r = inner_width - title.len() - pad_l; + out.push_str(&format!( + "│{}{}{}│\n", + " ".repeat(pad_l), + title, + " ".repeat(pad_r) + )); + out.push_str(&format!("╞{}╡\n", "═".repeat(inner_width))); + + for cat in CommandCategory::all() { + // Category header + let cat_header = format!(" {} {}", cat.icon(), cat.label()); + let cat_pad = inner_width - cat_header.len(); + out.push_str(&format!("│{}{}│\n", cat_header, " ".repeat(cat_pad))); + out.push_str(&format!("├{}┤\n", "┄".repeat(inner_width))); + + // Commands in this category + let entries: Vec<&crate::chat::commands::CommandEntry> = + palette.iter().filter(|e| e.category == *cat).collect(); + + for entry in &entries { + // Command column (left-aligned, fixed width) + let cmd_col_width = 24; + let cmd_text = if entry.aliases.is_empty() { + entry.command.to_string() + } else { + format!("{}, {}", entry.command, entry.aliases) + }; + + let cmd_display = if cmd_text.len() <= cmd_col_width { + format!("{}{}", cmd_text, " ".repeat(cmd_col_width - cmd_text.len())) + } else { + format!("{}...", &cmd_text[..cmd_col_width - 3]) + }; + + // Description column (fills remaining width) + let desc_width = inner_width - cmd_col_width - 4; // 4 = "│ " + " │" + let desc_display = if entry.description.len() <= desc_width { + format!( + "{}{}", + entry.description, + " ".repeat(desc_width - entry.description.len()) + ) + } else { + format!("{}...", &entry.description[..desc_width - 3]) + }; + + out.push_str(&format!("│ {} {} │\n", cmd_display, desc_display)); + } + + // Separator between categories (skip after last) + if *cat != *CommandCategory::all().last().unwrap() { + out.push_str(&format!("├{}┤\n", "─".repeat(inner_width))); + } + } + + // Footer + out.push_str(&format!("├{}┤\n", "─".repeat(inner_width))); + let tip = " Tip: Commands accept abbreviated aliases (/h, /s, /m)"; + let tip_pad = inner_width - tip.len(); + if tip_pad > 0 { + out.push_str(&format!("│{}{}│\n", tip, " ".repeat(tip_pad))); + } else { + out.push_str(&format!("│{}│\n", &tip[..inner_width])); + } + let tip2 = " Type ? anytime to reopen this palette"; + let tip2_pad = inner_width - tip2.len(); + if tip2_pad > 0 { + out.push_str(&format!("│{}{}│\n", tip2, " ".repeat(tip2_pad))); + } + out.push_str(&format!("└{}┘", "─".repeat(inner_width))); + + out + } + + /// Format a settings overview showing all current chat settings + pub fn format_settings_overview( + &self, + provider: &str, + model: &str, + tools_enabled: bool, + streaming_enabled: bool, + context_file_count: usize, + session_name: &str, + ) -> String { + let inner_width = 58; + let mut out = String::new(); + + out.push_str(&format!("┌{}┐\n", "─".repeat(inner_width))); + let title = "Current Settings"; + let pad_l = (inner_width - title.len()) / 2; + let pad_r = inner_width - title.len() - pad_l; + out.push_str(&format!( + "│{}{}{}│\n", + " ".repeat(pad_l), + title, + " ".repeat(pad_r) + )); + out.push_str(&format!("├{}┤\n", "─".repeat(inner_width))); + + let rows = [ + ("Provider", provider), + ("Model", model), + ( + "Tools", + if tools_enabled { "enabled" } else { "disabled" }, + ), + ( + "Streaming", + if streaming_enabled { + "enabled" + } else { + "disabled" + }, + ), + ("Session", session_name), + ]; + + for (label, value) in &rows { + let row = format!(" {:<16} {}", label, value); + let pad = inner_width - row.len(); + out.push_str(&format!("│{}{}│\n", row, " ".repeat(pad))); + } + + // Context count + let ctx_row = format!(" {:<16} {} file(s)", "Context", context_file_count); + let ctx_pad = inner_width - ctx_row.len(); + out.push_str(&format!("│{}{}│\n", ctx_row, " ".repeat(ctx_pad))); + + out.push_str(&format!("└{}┘", "─".repeat(inner_width))); + out + } + /// Format context information pub fn format_context_info(&self, context_size: usize, files: &[String]) -> String { let mut info = String::new(); @@ -679,7 +830,7 @@ mod tests { let formatter = ChatFormatter::new(); let welcome = formatter.format_welcome(); assert!(welcome.contains("TermAI Interactive Chat Mode")); - assert!(welcome.contains("/help")); + assert!(welcome.contains("/commands")); assert!(welcome.contains("┌")); // Check for proper box formatting assert!(welcome.contains("└")); } diff --git a/src/chat/interactive.rs b/src/chat/interactive.rs index 6127a68..50eeb33 100644 --- a/src/chat/interactive.rs +++ b/src/chat/interactive.rs @@ -160,6 +160,11 @@ where let help_text = self.formatter.format_help(&ChatCommand::all_commands()); self.repl.print_message(&help_text); } + ChatCommand::Commands => { + let palette = ChatCommand::command_palette(); + let palette_text = self.formatter.format_command_palette(&palette); + self.repl.print_message(&palette_text); + } ChatCommand::Save(name) => { let session_name = name .unwrap_or_else(|| format!("chat_{}", Local::now().format("%Y%m%d_%H%M%S"))); @@ -227,6 +232,18 @@ where ChatCommand::Tools(setting) => { self.handle_tools_command(setting); } + ChatCommand::Status => { + self.repl.print_message(&self.chat_state.status()); + } + ChatCommand::Theme(theme_name) => { + self.handle_theme_command(theme_name); + } + ChatCommand::Streaming(setting) => { + self.handle_streaming_command(setting); + } + ChatCommand::Settings => { + self.display_settings_overview(); + } } Ok(()) } @@ -550,6 +567,69 @@ where Ok(()) } + /// Handle /theme command + fn handle_theme_command(&mut self, theme_name: Option) { + match theme_name { + Some(name) => { + match self.formatter.set_theme(&name) { + Ok(()) => { + self.repl.print_message(&self.formatter.format_success( + &format!("Switched to '{}' theme", name), + )); + } + Err(e) => { + self.repl.print_message(&self.formatter.format_error(&e)); + let themes = self.formatter.available_themes(); + self.repl.print_message(&format!( + "Available themes: {}", + themes.join(", ") + )); + } + } + } + None => { + let themes = self.formatter.available_themes(); + self.repl.print_message(&format!( + "Available themes: {}\nUse '/theme ' to switch", + themes.join(", ") + )); + } + } + } + + /// Handle /streaming command + fn handle_streaming_command(&mut self, setting: Option) { + match setting { + Some(enabled) => { + self.formatter.set_streaming(enabled); + let status = if enabled { "enabled" } else { "disabled" }; + self.repl.print_message(&self.formatter.format_success( + &format!("Streaming output {}", status), + )); + } + None => { + // Toggle: we don't track the current state externally, so just + // tell the user how to use the command + self.repl.print_message( + "Usage: /streaming on - enable streaming output\n /streaming off - disable streaming output", + ); + } + } + } + + /// Display a settings overview panel + fn display_settings_overview(&self) { + let overview = self.formatter.format_settings_overview( + &self.chat_state.provider, + &self.chat_state.model, + self.chat_state.tools_enabled, + true, // streaming default + self.context_files.len(), + &self.session.name, + ); + self.repl.print_message(&overview); + } + /// Initialize chat state from current configuration fn initialize_chat_state(config_repo: &R) -> Result { use crate::args::Provider; diff --git a/src/chat/repl.rs b/src/chat/repl.rs index 42c9326..698578e 100644 --- a/src/chat/repl.rs +++ b/src/chat/repl.rs @@ -18,10 +18,29 @@ pub struct ChatHelper { impl ChatHelper { pub fn new() -> Self { let mut commands = HashSet::new(); - // Add all slash commands for tab completion + // All slash commands and their aliases for tab completion let command_list = vec![ - "/help", "/h", "/save", "/s", "/context", "/ctx", "/clear", "/c", "/exit", "/quit", - "/q", "/retry", "/r", "/branch", "/b", "/add", "/remove", "/rm", + // General + "/help", "/h", + "/commands", "/cmd", + "/exit", "/quit", "/q", + // Session + "/save", "/s", + "/clear", "/c", + "/retry", "/r", + "/branch", "/b", + // Context + "/context", "/ctx", + "/add", + "/remove", "/rm", + // AI & settings + "/model", "/m", + "/provider", "/p", + "/tools", "/t", + "/status", + "/theme", + "/streaming", + "/settings", "/config", ]; for cmd in command_list { diff --git a/src/chat/tests.rs b/src/chat/tests.rs index a4f9a89..3d8f3bd 100644 --- a/src/chat/tests.rs +++ b/src/chat/tests.rs @@ -99,7 +99,7 @@ mod tests { #[test] fn test_command_help_text() { - assert_eq!(ChatCommand::Help.help_text(), "Show this help message"); + assert_eq!(ChatCommand::Help.help_text(), "Show quick help"); assert_eq!( ChatCommand::Save(None).help_text(), "Save current session with optional name" @@ -191,7 +191,7 @@ mod tests { let welcome = formatter.format_welcome(); assert!(welcome.contains("TermAI Interactive Chat Mode")); - assert!(welcome.contains("/help")); + assert!(welcome.contains("/commands")); assert!(welcome.contains("Type your message")); assert!(welcome.contains("┌")); // Check for proper box formatting assert!(welcome.contains("└"));