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#[derive(Debug, Error)]
12pub enum TuiCommandError {
13 #[error("Unknown command: {0}")]
14 UnknownCommand(String),
15 #[error(transparent)]
16 CoreParseError(#[from] SlashCommandError),
17}
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum TuiCommand {
22 ReloadFiles,
24 Theme(Option<String>),
26 Auth,
28 Help(Option<String>),
30 EditingMode(Option<String>),
32 Mcp,
34 Custom(CustomCommand),
36}
37
38#[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 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 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 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#[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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
147pub enum AppCommand {
148 Tui(TuiCommand),
150 Core(CoreCommand),
152}
153
154impl TuiCommand {
155 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 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 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 pub fn parse(input: &str) -> Result<Self, TuiCommandError> {
212 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 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 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 Err(TuiCommandError::UnknownCommand(command.to_string()))
241 }
242
243 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 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}