Skip to main content

a3s_code_core/
commands.rs

1//! Slash Commands — Interactive session commands
2//!
3//! Provides a `/command` system for interactive sessions. Commands are
4//! dispatched before the LLM — if input starts with `/`, it's handled
5//! by the command registry instead of being sent to the model.
6//!
7//! ## Built-in Commands
8//!
9//! | Command | Description |
10//! |---------|-------------|
11//! | `/help` | List available commands |
12//! | `/compact` | Manually trigger context compaction |
13//! | `/cost` | Show token usage and estimated cost |
14//! | `/model` | Show or switch the current model |
15//! | `/clear` | Clear conversation history |
16//! | `/history` | Show conversation turn count and token stats |
17//! | `/tools` | List registered tools |
18//! | `/mcp` | List connected MCP servers and their tools |
19//!
20//! ## Custom Commands
21//!
22//! ```rust,no_run
23//! use a3s_code_core::commands::{SlashCommand, CommandContext, CommandOutput};
24//!
25//! struct MyCommand;
26//!
27//! impl SlashCommand for MyCommand {
28//!     fn name(&self) -> &str { "greet" }
29//!     fn description(&self) -> &str { "Say hello" }
30//!     fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
31//!         CommandOutput::text("Hello from custom command!")
32//!     }
33//! }
34//! ```
35
36use std::collections::HashMap;
37use std::sync::Arc;
38
39/// Context passed to every slash command execution.
40#[derive(Debug, Clone)]
41pub struct CommandContext {
42    /// Current session ID.
43    pub session_id: String,
44    /// Workspace path.
45    pub workspace: String,
46    /// Current model identifier (e.g., "openai/kimi-k2.5").
47    pub model: String,
48    /// Number of messages in history.
49    pub history_len: usize,
50    /// Total tokens used in this session.
51    pub total_tokens: u64,
52    /// Estimated cost in USD.
53    pub total_cost: f64,
54    /// Registered tool names (builtin + MCP).
55    pub tool_names: Vec<String>,
56    /// Connected MCP servers and their tool counts: `(server_name, tool_count)`.
57    pub mcp_servers: Vec<(String, usize)>,
58}
59
60/// Result of a slash command execution.
61#[derive(Debug, Clone)]
62pub struct CommandOutput {
63    /// Text output to display to the user.
64    pub text: String,
65    /// Whether the command modified session state (e.g., /clear, /compact).
66    pub state_changed: bool,
67    /// Optional action for the session to perform after the command.
68    pub action: Option<CommandAction>,
69}
70
71/// Post-command actions that the session should perform.
72#[derive(Debug, Clone)]
73pub enum CommandAction {
74    /// Trigger context compaction.
75    Compact,
76    /// Clear conversation history.
77    ClearHistory,
78    /// Switch to a different model.
79    SwitchModel(String),
80}
81
82impl CommandOutput {
83    /// Create a simple text output.
84    pub fn text(msg: impl Into<String>) -> Self {
85        Self {
86            text: msg.into(),
87            state_changed: false,
88            action: None,
89        }
90    }
91
92    /// Create an output with a post-command action.
93    pub fn with_action(msg: impl Into<String>, action: CommandAction) -> Self {
94        Self {
95            text: msg.into(),
96            state_changed: true,
97            action: Some(action),
98        }
99    }
100}
101
102/// Trait for implementing slash commands.
103///
104/// Implement this trait to add custom commands to the session.
105pub trait SlashCommand: Send + Sync {
106    /// Command name (without the leading `/`).
107    fn name(&self) -> &str;
108
109    /// Short description shown in `/help`.
110    fn description(&self) -> &str;
111
112    /// Optional usage hint (e.g., `/model <provider/model>`).
113    fn usage(&self) -> Option<&str> {
114        None
115    }
116
117    /// Execute the command with the given arguments.
118    fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput;
119}
120
121/// Registry of slash commands.
122pub struct CommandRegistry {
123    commands: HashMap<String, Arc<dyn SlashCommand>>,
124}
125
126impl CommandRegistry {
127    /// Create a new registry with built-in commands.
128    pub fn new() -> Self {
129        let mut registry = Self {
130            commands: HashMap::new(),
131        };
132        registry.register(Arc::new(HelpCommand));
133        registry.register(Arc::new(CompactCommand));
134        registry.register(Arc::new(CostCommand));
135        registry.register(Arc::new(ModelCommand));
136        registry.register(Arc::new(ClearCommand));
137        registry.register(Arc::new(HistoryCommand));
138        registry.register(Arc::new(ToolsCommand));
139        registry.register(Arc::new(McpCommand));
140        registry
141    }
142
143    /// Register a custom command.
144    pub fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
145        self.commands.insert(cmd.name().to_string(), cmd);
146    }
147
148    /// Unregister a command by name.
149    pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn SlashCommand>> {
150        self.commands.remove(name)
151    }
152
153    /// Check if input is a slash command.
154    pub fn is_command(input: &str) -> bool {
155        input.trim_start().starts_with('/')
156    }
157
158    /// Parse and execute a slash command. Returns `None` if not a command.
159    pub fn dispatch(&self, input: &str, ctx: &CommandContext) -> Option<CommandOutput> {
160        let trimmed = input.trim();
161        if !trimmed.starts_with('/') {
162            return None;
163        }
164
165        let without_slash = &trimmed[1..];
166        let (name, args) = match without_slash.split_once(char::is_whitespace) {
167            Some((n, a)) => (n, a.trim()),
168            None => (without_slash, ""),
169        };
170
171        match self.commands.get(name) {
172            Some(cmd) => Some(cmd.execute(args, ctx)),
173            None => Some(CommandOutput::text(format!(
174                "Unknown command: /{name}\nType /help for available commands."
175            ))),
176        }
177    }
178
179    /// Get all registered command names and descriptions.
180    pub fn list(&self) -> Vec<(&str, &str)> {
181        let mut cmds: Vec<_> = self
182            .commands
183            .values()
184            .map(|c| (c.name(), c.description()))
185            .collect();
186        cmds.sort_by_key(|(name, _)| *name);
187        cmds
188    }
189
190    /// Number of registered commands.
191    pub fn len(&self) -> usize {
192        self.commands.len()
193    }
194
195    /// Whether the registry is empty.
196    pub fn is_empty(&self) -> bool {
197        self.commands.is_empty()
198    }
199}
200
201impl Default for CommandRegistry {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207// ─── Built-in Commands ──────────────────────────────────────────────
208
209struct HelpCommand;
210
211impl SlashCommand for HelpCommand {
212    fn name(&self) -> &str {
213        "help"
214    }
215    fn description(&self) -> &str {
216        "List available commands"
217    }
218    fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
219        // Help text is generated dynamically by the session using registry.list()
220        // This is a placeholder — the actual help is built in AgentSession::execute_command()
221        CommandOutput::text("Use /help to see available commands.")
222    }
223}
224
225struct CompactCommand;
226
227impl SlashCommand for CompactCommand {
228    fn name(&self) -> &str {
229        "compact"
230    }
231    fn description(&self) -> &str {
232        "Manually trigger context compaction"
233    }
234    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
235        CommandOutput::with_action(
236            format!(
237                "Compacting context... ({} messages, {} tokens)",
238                ctx.history_len, ctx.total_tokens
239            ),
240            CommandAction::Compact,
241        )
242    }
243}
244
245struct CostCommand;
246
247impl SlashCommand for CostCommand {
248    fn name(&self) -> &str {
249        "cost"
250    }
251    fn description(&self) -> &str {
252        "Show token usage and estimated cost"
253    }
254    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
255        CommandOutput::text(format!(
256            "Session: {}\n\
257             Model:   {}\n\
258             Tokens:  {}\n\
259             Cost:    ${:.4}",
260            &ctx.session_id[..ctx.session_id.len().min(8)],
261            ctx.model,
262            ctx.total_tokens,
263            ctx.total_cost,
264        ))
265    }
266}
267
268struct ModelCommand;
269
270impl SlashCommand for ModelCommand {
271    fn name(&self) -> &str {
272        "model"
273    }
274    fn description(&self) -> &str {
275        "Show or switch the current model"
276    }
277    fn usage(&self) -> Option<&str> {
278        Some("/model [provider/model]")
279    }
280    fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput {
281        if args.is_empty() {
282            CommandOutput::text(format!("Current model: {}", ctx.model))
283        } else if args.contains('/') {
284            CommandOutput::with_action(
285                format!("Switching model to: {args}"),
286                CommandAction::SwitchModel(args.to_string()),
287            )
288        } else {
289            CommandOutput::text(
290                "Usage: /model provider/model (e.g., /model anthropic/claude-sonnet-4-20250514)",
291            )
292        }
293    }
294}
295
296struct ClearCommand;
297
298impl SlashCommand for ClearCommand {
299    fn name(&self) -> &str {
300        "clear"
301    }
302    fn description(&self) -> &str {
303        "Clear conversation history"
304    }
305    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
306        CommandOutput::with_action(
307            format!("Cleared {} messages.", ctx.history_len),
308            CommandAction::ClearHistory,
309        )
310    }
311}
312
313struct HistoryCommand;
314
315impl SlashCommand for HistoryCommand {
316    fn name(&self) -> &str {
317        "history"
318    }
319    fn description(&self) -> &str {
320        "Show conversation stats"
321    }
322    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
323        CommandOutput::text(format!(
324            "Messages: {}\n\
325             Tokens:   {}\n\
326             Session:  {}",
327            ctx.history_len,
328            ctx.total_tokens,
329            &ctx.session_id[..ctx.session_id.len().min(8)],
330        ))
331    }
332}
333
334struct ToolsCommand;
335
336impl SlashCommand for ToolsCommand {
337    fn name(&self) -> &str {
338        "tools"
339    }
340    fn description(&self) -> &str {
341        "List registered tools"
342    }
343    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
344        if ctx.tool_names.is_empty() {
345            return CommandOutput::text("No tools registered.");
346        }
347        let builtin: Vec<&str> = ctx
348            .tool_names
349            .iter()
350            .filter(|t| !t.starts_with("mcp__"))
351            .map(|s| s.as_str())
352            .collect();
353        let mcp: Vec<&str> = ctx
354            .tool_names
355            .iter()
356            .filter(|t| t.starts_with("mcp__"))
357            .map(|s| s.as_str())
358            .collect();
359
360        let mut out = format!("Tools: {} total\n", ctx.tool_names.len());
361        if !builtin.is_empty() {
362            out.push_str(&format!("\nBuiltin ({}):\n", builtin.len()));
363            for t in &builtin {
364                out.push_str(&format!("  • {t}\n"));
365            }
366        }
367        if !mcp.is_empty() {
368            out.push_str(&format!("\nMCP ({}):\n", mcp.len()));
369            for t in &mcp {
370                out.push_str(&format!("  • {t}\n"));
371            }
372        }
373        CommandOutput::text(out.trim_end())
374    }
375}
376
377struct McpCommand;
378
379impl SlashCommand for McpCommand {
380    fn name(&self) -> &str {
381        "mcp"
382    }
383    fn description(&self) -> &str {
384        "List connected MCP servers and their tools"
385    }
386    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
387        if ctx.mcp_servers.is_empty() {
388            return CommandOutput::text("No MCP servers connected.");
389        }
390        let total_tools: usize = ctx.mcp_servers.iter().map(|(_, c)| c).sum();
391        let mut out = format!(
392            "MCP: {} server(s), {} tool(s)\n",
393            ctx.mcp_servers.len(),
394            total_tools
395        );
396        for (server, count) in &ctx.mcp_servers {
397            out.push_str(&format!("\n  {server} ({count} tools)"));
398            // List tools belonging to this server
399            let prefix = format!("mcp__{server}__");
400            let server_tools: Vec<&str> = ctx
401                .tool_names
402                .iter()
403                .filter(|t| t.starts_with(&prefix))
404                .map(|s| s.strip_prefix(&prefix).unwrap_or(s))
405                .collect();
406            for t in server_tools {
407                out.push_str(&format!("\n    • {t}"));
408            }
409        }
410        CommandOutput::text(out)
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    fn test_ctx() -> CommandContext {
419        CommandContext {
420            session_id: "test-session-123".into(),
421            workspace: "/tmp/test".into(),
422            model: "openai/kimi-k2.5".into(),
423            history_len: 10,
424            total_tokens: 5000,
425            total_cost: 0.0123,
426            tool_names: vec![
427                "read".into(),
428                "write".into(),
429                "bash".into(),
430                "mcp__github__create_issue".into(),
431                "mcp__github__list_repos".into(),
432            ],
433            mcp_servers: vec![("github".into(), 2)],
434        }
435    }
436
437    #[test]
438    fn test_is_command() {
439        assert!(CommandRegistry::is_command("/help"));
440        assert!(CommandRegistry::is_command("  /model foo"));
441        assert!(!CommandRegistry::is_command("hello"));
442        assert!(!CommandRegistry::is_command("not /a command"));
443    }
444
445    #[test]
446    fn test_dispatch_help() {
447        let reg = CommandRegistry::new();
448        let ctx = test_ctx();
449        let out = reg.dispatch("/help", &ctx).unwrap();
450        assert!(!out.text.is_empty());
451    }
452
453    #[test]
454    fn test_dispatch_cost() {
455        let reg = CommandRegistry::new();
456        let ctx = test_ctx();
457        let out = reg.dispatch("/cost", &ctx).unwrap();
458        assert!(out.text.contains("5000"));
459        assert!(out.text.contains("0.0123"));
460    }
461
462    #[test]
463    fn test_dispatch_model_show() {
464        let reg = CommandRegistry::new();
465        let ctx = test_ctx();
466        let out = reg.dispatch("/model", &ctx).unwrap();
467        assert!(out.text.contains("openai/kimi-k2.5"));
468        assert!(out.action.is_none());
469    }
470
471    #[test]
472    fn test_dispatch_model_switch() {
473        let reg = CommandRegistry::new();
474        let ctx = test_ctx();
475        let out = reg
476            .dispatch("/model anthropic/claude-sonnet-4-20250514", &ctx)
477            .unwrap();
478        assert!(matches!(out.action, Some(CommandAction::SwitchModel(_))));
479    }
480
481    #[test]
482    fn test_dispatch_clear() {
483        let reg = CommandRegistry::new();
484        let ctx = test_ctx();
485        let out = reg.dispatch("/clear", &ctx).unwrap();
486        assert!(matches!(out.action, Some(CommandAction::ClearHistory)));
487        assert!(out.text.contains("10"));
488    }
489
490    #[test]
491    fn test_dispatch_compact() {
492        let reg = CommandRegistry::new();
493        let ctx = test_ctx();
494        let out = reg.dispatch("/compact", &ctx).unwrap();
495        assert!(matches!(out.action, Some(CommandAction::Compact)));
496    }
497
498    #[test]
499    fn test_dispatch_unknown() {
500        let reg = CommandRegistry::new();
501        let ctx = test_ctx();
502        let out = reg.dispatch("/foobar", &ctx).unwrap();
503        assert!(out.text.contains("Unknown command"));
504    }
505
506    #[test]
507    fn test_not_a_command() {
508        let reg = CommandRegistry::new();
509        let ctx = test_ctx();
510        assert!(reg.dispatch("hello world", &ctx).is_none());
511    }
512
513    #[test]
514    fn test_custom_command() {
515        struct PingCommand;
516        impl SlashCommand for PingCommand {
517            fn name(&self) -> &str {
518                "ping"
519            }
520            fn description(&self) -> &str {
521                "Pong!"
522            }
523            fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
524                CommandOutput::text("pong")
525            }
526        }
527
528        let mut reg = CommandRegistry::new();
529        let before = reg.len();
530        reg.register(Arc::new(PingCommand));
531        assert_eq!(reg.len(), before + 1);
532
533        let ctx = test_ctx();
534        let out = reg.dispatch("/ping", &ctx).unwrap();
535        assert_eq!(out.text, "pong");
536    }
537
538    #[test]
539    fn test_list_commands() {
540        let reg = CommandRegistry::new();
541        let list = reg.list();
542        assert!(list.len() >= 8);
543        assert!(list.iter().any(|(name, _)| *name == "help"));
544        assert!(list.iter().any(|(name, _)| *name == "compact"));
545        assert!(list.iter().any(|(name, _)| *name == "cost"));
546        assert!(list.iter().any(|(name, _)| *name == "mcp"));
547    }
548
549    #[test]
550    fn test_dispatch_tools() {
551        let reg = CommandRegistry::new();
552        let ctx = test_ctx();
553        let out = reg.dispatch("/tools", &ctx).unwrap();
554        assert!(out.text.contains("5 total"));
555        assert!(out.text.contains("read"));
556        assert!(out.text.contains("mcp__github__create_issue"));
557    }
558
559    #[test]
560    fn test_dispatch_mcp() {
561        let reg = CommandRegistry::new();
562        let ctx = test_ctx();
563        let out = reg.dispatch("/mcp", &ctx).unwrap();
564        assert!(out.text.contains("1 server(s)"));
565        assert!(out.text.contains("github"));
566        assert!(out.text.contains("create_issue"));
567        assert!(out.text.contains("list_repos"));
568    }
569
570    #[test]
571    fn test_dispatch_mcp_empty() {
572        let reg = CommandRegistry::new();
573        let mut ctx = test_ctx();
574        ctx.mcp_servers = vec![];
575        let out = reg.dispatch("/mcp", &ctx).unwrap();
576        assert!(out.text.contains("No MCP servers connected"));
577    }
578}