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    /// Get all registered commands with name, description, and optional usage hint.
191    pub fn list_full(&self) -> Vec<(String, String, Option<String>)> {
192        let mut cmds: Vec<_> = self
193            .commands
194            .values()
195            .map(|c| {
196                (
197                    c.name().to_string(),
198                    c.description().to_string(),
199                    c.usage().map(|s| s.to_string()),
200                )
201            })
202            .collect();
203        cmds.sort_by(|a, b| a.0.cmp(&b.0));
204        cmds
205    }
206
207    /// Number of registered commands.
208    pub fn len(&self) -> usize {
209        self.commands.len()
210    }
211
212    /// Whether the registry is empty.
213    pub fn is_empty(&self) -> bool {
214        self.commands.is_empty()
215    }
216}
217
218impl Default for CommandRegistry {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224// ─── Built-in Commands ──────────────────────────────────────────────
225
226struct HelpCommand;
227
228impl SlashCommand for HelpCommand {
229    fn name(&self) -> &str {
230        "help"
231    }
232    fn description(&self) -> &str {
233        "List available commands"
234    }
235    fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
236        // Help text is generated dynamically by the session using registry.list()
237        // This is a placeholder — the actual help is built in AgentSession::execute_command()
238        CommandOutput::text("Use /help to see available commands.")
239    }
240}
241
242struct CompactCommand;
243
244impl SlashCommand for CompactCommand {
245    fn name(&self) -> &str {
246        "compact"
247    }
248    fn description(&self) -> &str {
249        "Manually trigger context compaction"
250    }
251    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
252        CommandOutput::with_action(
253            format!(
254                "Compacting context... ({} messages, {} tokens)",
255                ctx.history_len, ctx.total_tokens
256            ),
257            CommandAction::Compact,
258        )
259    }
260}
261
262struct CostCommand;
263
264impl SlashCommand for CostCommand {
265    fn name(&self) -> &str {
266        "cost"
267    }
268    fn description(&self) -> &str {
269        "Show token usage and estimated cost"
270    }
271    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
272        CommandOutput::text(format!(
273            "Session: {}\n\
274             Model:   {}\n\
275             Tokens:  {}\n\
276             Cost:    ${:.4}",
277            &ctx.session_id[..ctx.session_id.len().min(8)],
278            ctx.model,
279            ctx.total_tokens,
280            ctx.total_cost,
281        ))
282    }
283}
284
285struct ModelCommand;
286
287impl SlashCommand for ModelCommand {
288    fn name(&self) -> &str {
289        "model"
290    }
291    fn description(&self) -> &str {
292        "Show or switch the current model"
293    }
294    fn usage(&self) -> Option<&str> {
295        Some("/model [provider/model]")
296    }
297    fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput {
298        if args.is_empty() {
299            CommandOutput::text(format!("Current model: {}", ctx.model))
300        } else if args.contains('/') {
301            CommandOutput::with_action(
302                format!("Switching model to: {args}"),
303                CommandAction::SwitchModel(args.to_string()),
304            )
305        } else {
306            CommandOutput::text(
307                "Usage: /model provider/model (e.g., /model anthropic/claude-sonnet-4-20250514)",
308            )
309        }
310    }
311}
312
313struct ClearCommand;
314
315impl SlashCommand for ClearCommand {
316    fn name(&self) -> &str {
317        "clear"
318    }
319    fn description(&self) -> &str {
320        "Clear conversation history"
321    }
322    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
323        CommandOutput::with_action(
324            format!("Cleared {} messages.", ctx.history_len),
325            CommandAction::ClearHistory,
326        )
327    }
328}
329
330struct HistoryCommand;
331
332impl SlashCommand for HistoryCommand {
333    fn name(&self) -> &str {
334        "history"
335    }
336    fn description(&self) -> &str {
337        "Show conversation stats"
338    }
339    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
340        CommandOutput::text(format!(
341            "Messages: {}\n\
342             Tokens:   {}\n\
343             Session:  {}",
344            ctx.history_len,
345            ctx.total_tokens,
346            &ctx.session_id[..ctx.session_id.len().min(8)],
347        ))
348    }
349}
350
351struct ToolsCommand;
352
353impl SlashCommand for ToolsCommand {
354    fn name(&self) -> &str {
355        "tools"
356    }
357    fn description(&self) -> &str {
358        "List registered tools"
359    }
360    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
361        if ctx.tool_names.is_empty() {
362            return CommandOutput::text("No tools registered.");
363        }
364        let builtin: Vec<&str> = ctx
365            .tool_names
366            .iter()
367            .filter(|t| !t.starts_with("mcp__"))
368            .map(|s| s.as_str())
369            .collect();
370        let mcp: Vec<&str> = ctx
371            .tool_names
372            .iter()
373            .filter(|t| t.starts_with("mcp__"))
374            .map(|s| s.as_str())
375            .collect();
376
377        let mut out = format!("Tools: {} total\n", ctx.tool_names.len());
378        if !builtin.is_empty() {
379            out.push_str(&format!("\nBuiltin ({}):\n", builtin.len()));
380            for t in &builtin {
381                out.push_str(&format!("  • {t}\n"));
382            }
383        }
384        if !mcp.is_empty() {
385            out.push_str(&format!("\nMCP ({}):\n", mcp.len()));
386            for t in &mcp {
387                out.push_str(&format!("  • {t}\n"));
388            }
389        }
390        CommandOutput::text(out.trim_end())
391    }
392}
393
394struct McpCommand;
395
396impl SlashCommand for McpCommand {
397    fn name(&self) -> &str {
398        "mcp"
399    }
400    fn description(&self) -> &str {
401        "List connected MCP servers and their tools"
402    }
403    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
404        if ctx.mcp_servers.is_empty() {
405            return CommandOutput::text("No MCP servers connected.");
406        }
407        let total_tools: usize = ctx.mcp_servers.iter().map(|(_, c)| c).sum();
408        let mut out = format!(
409            "MCP: {} server(s), {} tool(s)\n",
410            ctx.mcp_servers.len(),
411            total_tools
412        );
413        for (server, count) in &ctx.mcp_servers {
414            out.push_str(&format!("\n  {server} ({count} tools)"));
415            // List tools belonging to this server
416            let prefix = format!("mcp__{server}__");
417            let server_tools: Vec<&str> = ctx
418                .tool_names
419                .iter()
420                .filter(|t| t.starts_with(&prefix))
421                .map(|s| s.strip_prefix(&prefix).unwrap_or(s))
422                .collect();
423            for t in server_tools {
424                out.push_str(&format!("\n    • {t}"));
425            }
426        }
427        CommandOutput::text(out)
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn test_ctx() -> CommandContext {
436        CommandContext {
437            session_id: "test-session-123".into(),
438            workspace: "/tmp/test".into(),
439            model: "openai/kimi-k2.5".into(),
440            history_len: 10,
441            total_tokens: 5000,
442            total_cost: 0.0123,
443            tool_names: vec![
444                "read".into(),
445                "write".into(),
446                "bash".into(),
447                "mcp__github__create_issue".into(),
448                "mcp__github__list_repos".into(),
449            ],
450            mcp_servers: vec![("github".into(), 2)],
451        }
452    }
453
454    #[test]
455    fn test_is_command() {
456        assert!(CommandRegistry::is_command("/help"));
457        assert!(CommandRegistry::is_command("  /model foo"));
458        assert!(!CommandRegistry::is_command("hello"));
459        assert!(!CommandRegistry::is_command("not /a command"));
460    }
461
462    #[test]
463    fn test_dispatch_help() {
464        let reg = CommandRegistry::new();
465        let ctx = test_ctx();
466        let out = reg.dispatch("/help", &ctx).unwrap();
467        assert!(!out.text.is_empty());
468    }
469
470    #[test]
471    fn test_dispatch_cost() {
472        let reg = CommandRegistry::new();
473        let ctx = test_ctx();
474        let out = reg.dispatch("/cost", &ctx).unwrap();
475        assert!(out.text.contains("5000"));
476        assert!(out.text.contains("0.0123"));
477    }
478
479    #[test]
480    fn test_dispatch_model_show() {
481        let reg = CommandRegistry::new();
482        let ctx = test_ctx();
483        let out = reg.dispatch("/model", &ctx).unwrap();
484        assert!(out.text.contains("openai/kimi-k2.5"));
485        assert!(out.action.is_none());
486    }
487
488    #[test]
489    fn test_dispatch_model_switch() {
490        let reg = CommandRegistry::new();
491        let ctx = test_ctx();
492        let out = reg
493            .dispatch("/model anthropic/claude-sonnet-4-20250514", &ctx)
494            .unwrap();
495        assert!(matches!(out.action, Some(CommandAction::SwitchModel(_))));
496    }
497
498    #[test]
499    fn test_dispatch_clear() {
500        let reg = CommandRegistry::new();
501        let ctx = test_ctx();
502        let out = reg.dispatch("/clear", &ctx).unwrap();
503        assert!(matches!(out.action, Some(CommandAction::ClearHistory)));
504        assert!(out.text.contains("10"));
505    }
506
507    #[test]
508    fn test_dispatch_compact() {
509        let reg = CommandRegistry::new();
510        let ctx = test_ctx();
511        let out = reg.dispatch("/compact", &ctx).unwrap();
512        assert!(matches!(out.action, Some(CommandAction::Compact)));
513    }
514
515    #[test]
516    fn test_dispatch_unknown() {
517        let reg = CommandRegistry::new();
518        let ctx = test_ctx();
519        let out = reg.dispatch("/foobar", &ctx).unwrap();
520        assert!(out.text.contains("Unknown command"));
521    }
522
523    #[test]
524    fn test_not_a_command() {
525        let reg = CommandRegistry::new();
526        let ctx = test_ctx();
527        assert!(reg.dispatch("hello world", &ctx).is_none());
528    }
529
530    #[test]
531    fn test_custom_command() {
532        struct PingCommand;
533        impl SlashCommand for PingCommand {
534            fn name(&self) -> &str {
535                "ping"
536            }
537            fn description(&self) -> &str {
538                "Pong!"
539            }
540            fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
541                CommandOutput::text("pong")
542            }
543        }
544
545        let mut reg = CommandRegistry::new();
546        let before = reg.len();
547        reg.register(Arc::new(PingCommand));
548        assert_eq!(reg.len(), before + 1);
549
550        let ctx = test_ctx();
551        let out = reg.dispatch("/ping", &ctx).unwrap();
552        assert_eq!(out.text, "pong");
553    }
554
555    #[test]
556    fn test_list_commands() {
557        let reg = CommandRegistry::new();
558        let list = reg.list();
559        assert!(list.len() >= 8);
560        assert!(list.iter().any(|(name, _)| *name == "help"));
561        assert!(list.iter().any(|(name, _)| *name == "compact"));
562        assert!(list.iter().any(|(name, _)| *name == "cost"));
563        assert!(list.iter().any(|(name, _)| *name == "mcp"));
564    }
565
566    #[test]
567    fn test_dispatch_tools() {
568        let reg = CommandRegistry::new();
569        let ctx = test_ctx();
570        let out = reg.dispatch("/tools", &ctx).unwrap();
571        assert!(out.text.contains("5 total"));
572        assert!(out.text.contains("read"));
573        assert!(out.text.contains("mcp__github__create_issue"));
574    }
575
576    #[test]
577    fn test_dispatch_mcp() {
578        let reg = CommandRegistry::new();
579        let ctx = test_ctx();
580        let out = reg.dispatch("/mcp", &ctx).unwrap();
581        assert!(out.text.contains("1 server(s)"));
582        assert!(out.text.contains("github"));
583        assert!(out.text.contains("create_issue"));
584        assert!(out.text.contains("list_repos"));
585    }
586
587    #[test]
588    fn test_dispatch_mcp_empty() {
589        let reg = CommandRegistry::new();
590        let mut ctx = test_ctx();
591        ctx.mcp_servers = vec![];
592        let out = reg.dispatch("/mcp", &ctx).unwrap();
593        assert!(out.text.contains("No MCP servers connected"));
594    }
595}