steer_tui/tui/
commands.rs

1pub mod registry;
2
3use crate::tui::custom_commands::CustomCommand;
4use std::fmt;
5use std::str::FromStr;
6use steer_core::app::conversation::{AppCommandType as CoreCommand, SlashCommandError};
7use strum::{Display, EnumIter, IntoEnumIterator};
8use thiserror::Error;
9
10/// Errors that can occur when parsing TUI commands
11#[derive(Debug, Error)]
12pub enum TuiCommandError {
13    #[error("Unknown command: {0}")]
14    UnknownCommand(String),
15    #[error(transparent)]
16    CoreParseError(#[from] SlashCommandError),
17}
18
19/// TUI-specific commands that don't belong in the core
20#[derive(Debug, Clone, PartialEq)]
21pub enum TuiCommand {
22    /// Reload files in the TUI
23    ReloadFiles,
24    /// Change or list themes
25    Theme(Option<String>),
26    /// Launch authentication setup
27    Auth,
28    /// Show help for commands
29    Help(Option<String>),
30    /// Switch editing mode
31    EditingMode(Option<String>),
32    /// Show MCP server connection status
33    Mcp,
34    /// Custom user-defined command
35    Custom(CustomCommand),
36}
37
38/// Enum representing all TUI command types (without parameters)
39/// This is used for exhaustive iteration and type-safe handling
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter, Display)]
41#[strum(serialize_all = "kebab-case")]
42pub enum TuiCommandType {
43    ReloadFiles,
44    Theme,
45    Auth,
46    Help,
47    EditingMode,
48    Mcp,
49}
50
51impl TuiCommandType {
52    /// Get the command name as it appears in slash commands
53    pub fn command_name(&self) -> String {
54        match self {
55            TuiCommandType::ReloadFiles => self.to_string(),
56            TuiCommandType::Theme => self.to_string(),
57            TuiCommandType::Auth => self.to_string(),
58            TuiCommandType::Help => self.to_string(),
59            TuiCommandType::EditingMode => self.to_string(),
60            TuiCommandType::Mcp => self.to_string(),
61        }
62    }
63
64    /// Get the command description
65    pub fn description(&self) -> &'static str {
66        match self {
67            TuiCommandType::ReloadFiles => "Reload file cache in the TUI",
68            TuiCommandType::Theme => "Change or list available themes",
69            TuiCommandType::Auth => "Manage authentication settings",
70            TuiCommandType::Help => "Show help information",
71            TuiCommandType::EditingMode => "Switch between editing modes (simple/vim)",
72            TuiCommandType::Mcp => "Show MCP server connection status",
73        }
74    }
75
76    /// Get the command usage
77    pub fn usage(&self) -> String {
78        match self {
79            TuiCommandType::ReloadFiles => format!("/{}", self.command_name()),
80            TuiCommandType::Theme => format!("/{} [theme_name]", self.command_name()),
81            TuiCommandType::Auth => format!("/{}", self.command_name()),
82            TuiCommandType::Help => format!("/{} [command]", self.command_name()),
83            TuiCommandType::EditingMode => format!("/{} [simple|vim]", self.command_name()),
84            TuiCommandType::Mcp => format!("/{}", self.command_name()),
85        }
86    }
87}
88
89/// Enum representing all Core command types (without parameters)
90/// This mirrors steer_core::app::conversation::AppCommandType
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter, Display)]
92#[strum(serialize_all = "kebab-case")]
93pub enum CoreCommandType {
94    Model,
95    Clear,
96    Compact,
97}
98
99impl CoreCommandType {
100    /// Get the command name as it appears in slash commands
101    pub fn command_name(&self) -> String {
102        match self {
103            CoreCommandType::Model => self.to_string(),
104            CoreCommandType::Clear => self.to_string(),
105            CoreCommandType::Compact => self.to_string(),
106        }
107    }
108
109    /// Get the command description
110    pub fn description(&self) -> &'static str {
111        match self {
112            CoreCommandType::Model => "Show or change the current model",
113            CoreCommandType::Clear => "Clear conversation history and tool approvals",
114            CoreCommandType::Compact => "Summarize the current conversation",
115        }
116    }
117
118    /// Get the command usage
119    pub fn usage(&self) -> String {
120        match self {
121            CoreCommandType::Model => format!("/{} [model_name]", self.command_name()),
122            CoreCommandType::Clear => format!("/{}", self.command_name()),
123            CoreCommandType::Compact => format!("/{}", self.command_name()),
124        }
125    }
126
127    /// Convert to the actual core AppCommandType
128    /// Returns None if the command requires parameters that aren't provided
129    pub fn to_core_command(&self, args: &[&str]) -> Option<CoreCommand> {
130        match self {
131            CoreCommandType::Model => {
132                let target = if args.is_empty() {
133                    None
134                } else {
135                    Some(args.join(" "))
136                };
137                Some(CoreCommand::Model { target })
138            }
139            CoreCommandType::Clear => Some(CoreCommand::Clear),
140            CoreCommandType::Compact => Some(CoreCommand::Compact),
141        }
142    }
143}
144
145/// Unified command type that can represent either TUI or Core commands
146#[derive(Debug, Clone, PartialEq)]
147pub enum AppCommand {
148    /// A TUI-specific command
149    Tui(TuiCommand),
150    /// A core command that gets passed down
151    Core(CoreCommand),
152}
153
154impl TuiCommand {
155    /// Parse a command string into a TuiCommand (without leading slash)
156    fn parse_without_slash(command: &str) -> Result<Self, TuiCommandError> {
157        let parts: Vec<&str> = command.split_whitespace().collect();
158        let cmd_name = parts.first().copied().unwrap_or("");
159
160        // Try to match against all TuiCommandType variants
161        for cmd_type in TuiCommandType::iter() {
162            if cmd_name == cmd_type.command_name() {
163                return match cmd_type {
164                    TuiCommandType::ReloadFiles => Ok(TuiCommand::ReloadFiles),
165                    TuiCommandType::Theme => {
166                        let theme_name = parts.get(1).map(|s| s.to_string());
167                        Ok(TuiCommand::Theme(theme_name))
168                    }
169                    TuiCommandType::Auth => Ok(TuiCommand::Auth),
170                    TuiCommandType::Help => {
171                        let command_name = parts.get(1).map(|s| s.to_string());
172                        Ok(TuiCommand::Help(command_name))
173                    }
174                    TuiCommandType::EditingMode => {
175                        let mode_name = parts.get(1).map(|s| s.to_string());
176                        Ok(TuiCommand::EditingMode(mode_name))
177                    }
178                    TuiCommandType::Mcp => Ok(TuiCommand::Mcp),
179                };
180            }
181        }
182
183        Err(TuiCommandError::UnknownCommand(command.to_string()))
184    }
185
186    /// Convert the command to its string representation (without leading slash)
187    pub fn as_command_str(&self) -> String {
188        match self {
189            TuiCommand::ReloadFiles => TuiCommandType::ReloadFiles.command_name().to_string(),
190            TuiCommand::Theme(None) => TuiCommandType::Theme.command_name().to_string(),
191            TuiCommand::Theme(Some(name)) => {
192                format!("{} {}", TuiCommandType::Theme.command_name(), name)
193            }
194            TuiCommand::Auth => TuiCommandType::Auth.command_name().to_string(),
195            TuiCommand::Help(None) => TuiCommandType::Help.command_name().to_string(),
196            TuiCommand::Help(Some(cmd)) => {
197                format!("{} {}", TuiCommandType::Help.command_name(), cmd)
198            }
199            TuiCommand::EditingMode(None) => TuiCommandType::EditingMode.command_name().to_string(),
200            TuiCommand::EditingMode(Some(mode)) => {
201                format!("{} {}", TuiCommandType::EditingMode.command_name(), mode)
202            }
203            TuiCommand::Mcp => TuiCommandType::Mcp.command_name().to_string(),
204            TuiCommand::Custom(cmd) => cmd.name().to_string(),
205        }
206    }
207}
208
209impl AppCommand {
210    /// Parse a command string into an AppCommand
211    pub fn parse(input: &str) -> Result<Self, TuiCommandError> {
212        // Trim whitespace and remove leading slash if present
213        let command = input.trim();
214        let command = command.strip_prefix('/').unwrap_or(command);
215
216        let parts: Vec<&str> = command.split_whitespace().collect();
217        let cmd_name = parts.first().copied().unwrap_or("");
218
219        // First try to parse as a TUI command
220        for tui_type in TuiCommandType::iter() {
221            if cmd_name == tui_type.command_name() {
222                return TuiCommand::parse_without_slash(command).map(AppCommand::Tui);
223            }
224        }
225
226        // Then try to parse as a Core command
227        for core_type in CoreCommandType::iter() {
228            if cmd_name == core_type.command_name() {
229                let args: Vec<&str> = parts.into_iter().skip(1).collect();
230                if let Some(core_cmd) = core_type.to_core_command(&args) {
231                    return Ok(AppCommand::Core(core_cmd));
232                } else {
233                    return Err(TuiCommandError::UnknownCommand(command.to_string()));
234                }
235            }
236        }
237
238        // Note: Custom commands will be resolved by the caller using the registry
239        // since we can't access the registry from here
240        Err(TuiCommandError::UnknownCommand(command.to_string()))
241    }
242
243    /// Convert the command back to its string representation (with leading slash)
244    pub fn as_command_str(&self) -> String {
245        match self {
246            AppCommand::Tui(tui_cmd) => format!("/{}", tui_cmd.as_command_str()),
247            AppCommand::Core(core_cmd) => core_cmd.to_string(),
248        }
249    }
250}
251
252impl fmt::Display for TuiCommand {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        write!(f, "/{}", self.as_command_str())
255    }
256}
257
258impl fmt::Display for AppCommand {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        write!(f, "{}", self.as_command_str())
261    }
262}
263
264impl FromStr for AppCommand {
265    type Err = TuiCommandError;
266
267    fn from_str(s: &str) -> Result<Self, Self::Err> {
268        Self::parse(s)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_parse_tui_commands() {
278        assert!(matches!(
279            AppCommand::parse("/reload-files").unwrap(),
280            AppCommand::Tui(TuiCommand::ReloadFiles)
281        ));
282        assert!(matches!(
283            AppCommand::parse("/theme").unwrap(),
284            AppCommand::Tui(TuiCommand::Theme(None))
285        ));
286        assert!(matches!(
287            AppCommand::parse("/theme gruvbox").unwrap(),
288            AppCommand::Tui(TuiCommand::Theme(Some(_)))
289        ));
290        assert!(matches!(
291            AppCommand::parse("/mcp").unwrap(),
292            AppCommand::Tui(TuiCommand::Mcp)
293        ));
294    }
295
296    #[test]
297    fn test_parse_core_commands() {
298        assert!(matches!(
299            AppCommand::parse("/help").unwrap(),
300            AppCommand::Tui(TuiCommand::Help(None))
301        ));
302        assert!(matches!(
303            AppCommand::parse("/clear").unwrap(),
304            AppCommand::Core(CoreCommand::Clear)
305        ));
306        assert!(matches!(
307            AppCommand::parse("/model opus").unwrap(),
308            AppCommand::Core(CoreCommand::Model { .. })
309        ));
310    }
311
312    #[test]
313    fn test_display() {
314        assert_eq!(
315            AppCommand::Tui(TuiCommand::ReloadFiles).to_string(),
316            "/reload-files"
317        );
318        assert_eq!(AppCommand::Tui(TuiCommand::Help(None)).to_string(), "/help");
319    }
320
321    #[test]
322    fn test_error_formatting() {
323        // Test TUI unknown command error
324        let err = AppCommand::parse("/unknown-tui-cmd").unwrap_err();
325        assert_eq!(err.to_string(), "Unknown command: unknown-tui-cmd");
326    }
327
328    #[test]
329    fn test_tui_command_from_str() {
330        let cmd = "/reload-files".parse::<AppCommand>().unwrap();
331        assert!(matches!(cmd, AppCommand::Tui(TuiCommand::ReloadFiles)));
332    }
333}