Skip to main content

steer_tui/tui/
commands.rs

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