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    /// Ask an ephemeral side question (handled async by the session).
81    ///
82    /// The session makes a separate LLM call with the current history snapshot
83    /// and returns the answer without modifying conversation history.
84    BtwQuery(String),
85}
86
87impl CommandOutput {
88    /// Create a simple text output.
89    pub fn text(msg: impl Into<String>) -> Self {
90        Self {
91            text: msg.into(),
92            state_changed: false,
93            action: None,
94        }
95    }
96
97    /// Create an output with a post-command action.
98    pub fn with_action(msg: impl Into<String>, action: CommandAction) -> Self {
99        Self {
100            text: msg.into(),
101            state_changed: true,
102            action: Some(action),
103        }
104    }
105}
106
107/// Trait for implementing slash commands.
108///
109/// Implement this trait to add custom commands to the session.
110pub trait SlashCommand: Send + Sync {
111    /// Command name (without the leading `/`).
112    fn name(&self) -> &str;
113
114    /// Short description shown in `/help`.
115    fn description(&self) -> &str;
116
117    /// Optional usage hint (e.g., `/model <provider/model>`).
118    fn usage(&self) -> Option<&str> {
119        None
120    }
121
122    /// Execute the command with the given arguments.
123    fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput;
124}
125
126/// Registry of slash commands.
127pub struct CommandRegistry {
128    commands: HashMap<String, Arc<dyn SlashCommand>>,
129}
130
131impl CommandRegistry {
132    /// Create a new registry with built-in commands.
133    pub fn new() -> Self {
134        let mut registry = Self {
135            commands: HashMap::new(),
136        };
137        registry.register(Arc::new(HelpCommand));
138        registry.register(Arc::new(BtwCommand));
139        registry.register(Arc::new(CompactCommand));
140        registry.register(Arc::new(CostCommand));
141        registry.register(Arc::new(ModelCommand));
142        registry.register(Arc::new(ClearCommand));
143        registry.register(Arc::new(HistoryCommand));
144        registry.register(Arc::new(ToolsCommand));
145        registry.register(Arc::new(McpCommand));
146        registry
147    }
148
149    /// Register a custom command.
150    pub fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
151        self.commands.insert(cmd.name().to_string(), cmd);
152    }
153
154    /// Unregister a command by name.
155    pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn SlashCommand>> {
156        self.commands.remove(name)
157    }
158
159    /// Check if input is a slash command.
160    pub fn is_command(input: &str) -> bool {
161        input.trim_start().starts_with('/')
162    }
163
164    /// Parse and execute a slash command. Returns `None` if not a command.
165    pub fn dispatch(&self, input: &str, ctx: &CommandContext) -> Option<CommandOutput> {
166        let trimmed = input.trim();
167        if !trimmed.starts_with('/') {
168            return None;
169        }
170
171        let without_slash = &trimmed[1..];
172        let (name, args) = match without_slash.split_once(char::is_whitespace) {
173            Some((n, a)) => (n, a.trim()),
174            None => (without_slash, ""),
175        };
176
177        match self.commands.get(name) {
178            Some(cmd) => Some(cmd.execute(args, ctx)),
179            None => Some(CommandOutput::text(format!(
180                "Unknown command: /{name}\nType /help for available commands."
181            ))),
182        }
183    }
184
185    /// Get all registered command names and descriptions.
186    pub fn list(&self) -> Vec<(&str, &str)> {
187        let mut cmds: Vec<_> = self
188            .commands
189            .values()
190            .map(|c| (c.name(), c.description()))
191            .collect();
192        cmds.sort_by_key(|(name, _)| *name);
193        cmds
194    }
195
196    /// Get all registered commands with name, description, and optional usage hint.
197    pub fn list_full(&self) -> Vec<(String, String, Option<String>)> {
198        let mut cmds: Vec<_> = self
199            .commands
200            .values()
201            .map(|c| {
202                (
203                    c.name().to_string(),
204                    c.description().to_string(),
205                    c.usage().map(|s| s.to_string()),
206                )
207            })
208            .collect();
209        cmds.sort_by(|a, b| a.0.cmp(&b.0));
210        cmds
211    }
212
213    /// Number of registered commands.
214    pub fn len(&self) -> usize {
215        self.commands.len()
216    }
217
218    /// Whether the registry is empty.
219    pub fn is_empty(&self) -> bool {
220        self.commands.is_empty()
221    }
222}
223
224impl Default for CommandRegistry {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230// ─── Built-in Commands ──────────────────────────────────────────────
231
232struct BtwCommand;
233
234impl SlashCommand for BtwCommand {
235    fn name(&self) -> &str {
236        "btw"
237    }
238    fn description(&self) -> &str {
239        "Ask a side question without affecting conversation history"
240    }
241    fn usage(&self) -> Option<&str> {
242        Some("/btw <question>")
243    }
244    fn execute(&self, args: &str, _ctx: &CommandContext) -> CommandOutput {
245        let question = args.trim();
246        if question.is_empty() {
247            return CommandOutput::text(
248                "Usage: /btw <question>\nExample: /btw what file was that error in?",
249            );
250        }
251        // The actual LLM call is async — signal the session to handle it.
252        CommandOutput::with_action(String::new(), CommandAction::BtwQuery(question.to_string()))
253    }
254}
255
256struct HelpCommand;
257
258impl SlashCommand for HelpCommand {
259    fn name(&self) -> &str {
260        "help"
261    }
262    fn description(&self) -> &str {
263        "List available commands"
264    }
265    fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
266        // Help text is generated dynamically by the session using registry.list()
267        // This is a placeholder — the actual help is built in AgentSession::execute_command()
268        CommandOutput::text("Use /help to see available commands.")
269    }
270}
271
272struct CompactCommand;
273
274impl SlashCommand for CompactCommand {
275    fn name(&self) -> &str {
276        "compact"
277    }
278    fn description(&self) -> &str {
279        "Manually trigger context compaction"
280    }
281    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
282        CommandOutput::with_action(
283            format!(
284                "Compacting context... ({} messages, {} tokens)",
285                ctx.history_len, ctx.total_tokens
286            ),
287            CommandAction::Compact,
288        )
289    }
290}
291
292struct CostCommand;
293
294impl SlashCommand for CostCommand {
295    fn name(&self) -> &str {
296        "cost"
297    }
298    fn description(&self) -> &str {
299        "Show token usage and estimated cost"
300    }
301    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
302        CommandOutput::text(format!(
303            "Session: {}\n\
304             Model:   {}\n\
305             Tokens:  {}\n\
306             Cost:    ${:.4}",
307            &ctx.session_id[..ctx.session_id.len().min(8)],
308            ctx.model,
309            ctx.total_tokens,
310            ctx.total_cost,
311        ))
312    }
313}
314
315struct ModelCommand;
316
317impl SlashCommand for ModelCommand {
318    fn name(&self) -> &str {
319        "model"
320    }
321    fn description(&self) -> &str {
322        "Show or switch the current model"
323    }
324    fn usage(&self) -> Option<&str> {
325        Some("/model [provider/model]")
326    }
327    fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput {
328        if args.is_empty() {
329            CommandOutput::text(format!("Current model: {}", ctx.model))
330        } else if args.contains('/') {
331            CommandOutput::with_action(
332                format!("Switching model to: {args}"),
333                CommandAction::SwitchModel(args.to_string()),
334            )
335        } else {
336            CommandOutput::text(
337                "Usage: /model provider/model (e.g., /model anthropic/claude-sonnet-4-20250514)",
338            )
339        }
340    }
341}
342
343struct ClearCommand;
344
345impl SlashCommand for ClearCommand {
346    fn name(&self) -> &str {
347        "clear"
348    }
349    fn description(&self) -> &str {
350        "Clear conversation history"
351    }
352    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
353        CommandOutput::with_action(
354            format!("Cleared {} messages.", ctx.history_len),
355            CommandAction::ClearHistory,
356        )
357    }
358}
359
360struct HistoryCommand;
361
362impl SlashCommand for HistoryCommand {
363    fn name(&self) -> &str {
364        "history"
365    }
366    fn description(&self) -> &str {
367        "Show conversation stats"
368    }
369    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
370        CommandOutput::text(format!(
371            "Messages: {}\n\
372             Tokens:   {}\n\
373             Session:  {}",
374            ctx.history_len,
375            ctx.total_tokens,
376            &ctx.session_id[..ctx.session_id.len().min(8)],
377        ))
378    }
379}
380
381struct ToolsCommand;
382
383impl SlashCommand for ToolsCommand {
384    fn name(&self) -> &str {
385        "tools"
386    }
387    fn description(&self) -> &str {
388        "List registered tools"
389    }
390    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
391        if ctx.tool_names.is_empty() {
392            return CommandOutput::text("No tools registered.");
393        }
394        let builtin: Vec<&str> = ctx
395            .tool_names
396            .iter()
397            .filter(|t| !t.starts_with("mcp__"))
398            .map(|s| s.as_str())
399            .collect();
400        let mcp: Vec<&str> = ctx
401            .tool_names
402            .iter()
403            .filter(|t| t.starts_with("mcp__"))
404            .map(|s| s.as_str())
405            .collect();
406
407        let mut out = format!("Tools: {} total\n", ctx.tool_names.len());
408        if !builtin.is_empty() {
409            out.push_str(&format!("\nBuiltin ({}):\n", builtin.len()));
410            for t in &builtin {
411                out.push_str(&format!("  • {t}\n"));
412            }
413        }
414        if !mcp.is_empty() {
415            out.push_str(&format!("\nMCP ({}):\n", mcp.len()));
416            for t in &mcp {
417                out.push_str(&format!("  • {t}\n"));
418            }
419        }
420        CommandOutput::text(out.trim_end())
421    }
422}
423
424struct McpCommand;
425
426impl SlashCommand for McpCommand {
427    fn name(&self) -> &str {
428        "mcp"
429    }
430    fn description(&self) -> &str {
431        "List connected MCP servers and their tools"
432    }
433    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
434        if ctx.mcp_servers.is_empty() {
435            return CommandOutput::text("No MCP servers connected.");
436        }
437        let total_tools: usize = ctx.mcp_servers.iter().map(|(_, c)| c).sum();
438        let mut out = format!(
439            "MCP: {} server(s), {} tool(s)\n",
440            ctx.mcp_servers.len(),
441            total_tools
442        );
443        for (server, count) in &ctx.mcp_servers {
444            out.push_str(&format!("\n  {server} ({count} tools)"));
445            // List tools belonging to this server
446            let prefix = format!("mcp__{server}__");
447            let server_tools: Vec<&str> = ctx
448                .tool_names
449                .iter()
450                .filter(|t| t.starts_with(&prefix))
451                .map(|s| s.strip_prefix(&prefix).unwrap_or(s))
452                .collect();
453            for t in server_tools {
454                out.push_str(&format!("\n    • {t}"));
455            }
456        }
457        CommandOutput::text(out)
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    fn test_ctx() -> CommandContext {
466        CommandContext {
467            session_id: "test-session-123".into(),
468            workspace: "/tmp/test".into(),
469            model: "openai/kimi-k2.5".into(),
470            history_len: 10,
471            total_tokens: 5000,
472            total_cost: 0.0123,
473            tool_names: vec![
474                "read".into(),
475                "write".into(),
476                "bash".into(),
477                "mcp__github__create_issue".into(),
478                "mcp__github__list_repos".into(),
479            ],
480            mcp_servers: vec![("github".into(), 2)],
481        }
482    }
483
484    #[test]
485    fn test_is_command() {
486        assert!(CommandRegistry::is_command("/help"));
487        assert!(CommandRegistry::is_command("  /model foo"));
488        assert!(!CommandRegistry::is_command("hello"));
489        assert!(!CommandRegistry::is_command("not /a command"));
490    }
491
492    #[test]
493    fn test_dispatch_help() {
494        let reg = CommandRegistry::new();
495        let ctx = test_ctx();
496        let out = reg.dispatch("/help", &ctx).unwrap();
497        assert!(!out.text.is_empty());
498    }
499
500    #[test]
501    fn test_dispatch_cost() {
502        let reg = CommandRegistry::new();
503        let ctx = test_ctx();
504        let out = reg.dispatch("/cost", &ctx).unwrap();
505        assert!(out.text.contains("5000"));
506        assert!(out.text.contains("0.0123"));
507    }
508
509    #[test]
510    fn test_dispatch_model_show() {
511        let reg = CommandRegistry::new();
512        let ctx = test_ctx();
513        let out = reg.dispatch("/model", &ctx).unwrap();
514        assert!(out.text.contains("openai/kimi-k2.5"));
515        assert!(out.action.is_none());
516    }
517
518    #[test]
519    fn test_dispatch_model_switch() {
520        let reg = CommandRegistry::new();
521        let ctx = test_ctx();
522        let out = reg
523            .dispatch("/model anthropic/claude-sonnet-4-20250514", &ctx)
524            .unwrap();
525        assert!(matches!(out.action, Some(CommandAction::SwitchModel(_))));
526    }
527
528    #[test]
529    fn test_dispatch_clear() {
530        let reg = CommandRegistry::new();
531        let ctx = test_ctx();
532        let out = reg.dispatch("/clear", &ctx).unwrap();
533        assert!(matches!(out.action, Some(CommandAction::ClearHistory)));
534        assert!(out.text.contains("10"));
535    }
536
537    #[test]
538    fn test_dispatch_compact() {
539        let reg = CommandRegistry::new();
540        let ctx = test_ctx();
541        let out = reg.dispatch("/compact", &ctx).unwrap();
542        assert!(matches!(out.action, Some(CommandAction::Compact)));
543    }
544
545    #[test]
546    fn test_dispatch_unknown() {
547        let reg = CommandRegistry::new();
548        let ctx = test_ctx();
549        let out = reg.dispatch("/foobar", &ctx).unwrap();
550        assert!(out.text.contains("Unknown command"));
551    }
552
553    #[test]
554    fn test_not_a_command() {
555        let reg = CommandRegistry::new();
556        let ctx = test_ctx();
557        assert!(reg.dispatch("hello world", &ctx).is_none());
558    }
559
560    #[test]
561    fn test_custom_command() {
562        struct PingCommand;
563        impl SlashCommand for PingCommand {
564            fn name(&self) -> &str {
565                "ping"
566            }
567            fn description(&self) -> &str {
568                "Pong!"
569            }
570            fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
571                CommandOutput::text("pong")
572            }
573        }
574
575        let mut reg = CommandRegistry::new();
576        let before = reg.len();
577        reg.register(Arc::new(PingCommand));
578        assert_eq!(reg.len(), before + 1);
579
580        let ctx = test_ctx();
581        let out = reg.dispatch("/ping", &ctx).unwrap();
582        assert_eq!(out.text, "pong");
583    }
584
585    #[test]
586    fn test_list_commands() {
587        let reg = CommandRegistry::new();
588        let list = reg.list();
589        assert!(list.len() >= 8);
590        assert!(list.iter().any(|(name, _)| *name == "help"));
591        assert!(list.iter().any(|(name, _)| *name == "compact"));
592        assert!(list.iter().any(|(name, _)| *name == "cost"));
593        assert!(list.iter().any(|(name, _)| *name == "mcp"));
594    }
595
596    #[test]
597    fn test_dispatch_tools() {
598        let reg = CommandRegistry::new();
599        let ctx = test_ctx();
600        let out = reg.dispatch("/tools", &ctx).unwrap();
601        assert!(out.text.contains("5 total"));
602        assert!(out.text.contains("read"));
603        assert!(out.text.contains("mcp__github__create_issue"));
604    }
605
606    #[test]
607    fn test_dispatch_mcp() {
608        let reg = CommandRegistry::new();
609        let ctx = test_ctx();
610        let out = reg.dispatch("/mcp", &ctx).unwrap();
611        assert!(out.text.contains("1 server(s)"));
612        assert!(out.text.contains("github"));
613        assert!(out.text.contains("create_issue"));
614        assert!(out.text.contains("list_repos"));
615    }
616
617    #[test]
618    fn test_dispatch_mcp_empty() {
619        let reg = CommandRegistry::new();
620        let mut ctx = test_ctx();
621        ctx.mcp_servers = vec![];
622        let out = reg.dispatch("/mcp", &ctx).unwrap();
623        assert!(out.text.contains("No MCP servers connected"));
624    }
625}