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