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, Weak};
38
39#[derive(Debug, thiserror::Error)]
40#[error("projected command name '{name}' conflicts with the compatibility registry")]
41pub(crate) struct CommandRegistrySnapshotError {
42    name: String,
43}
44
45impl CommandRegistrySnapshotError {
46    pub(crate) fn name(&self) -> &str {
47        &self.name
48    }
49}
50
51/// Context passed to every slash command execution.
52#[derive(Debug, Clone)]
53pub struct CommandContext {
54    /// Current session ID.
55    pub session_id: String,
56    /// Workspace path.
57    pub workspace: String,
58    /// Current model identifier (e.g., "openai/kimi-k2.5").
59    pub model: String,
60    /// Number of messages in history.
61    pub history_len: usize,
62    /// Total tokens used in this session.
63    pub total_tokens: u64,
64    /// Estimated cost in USD.
65    pub total_cost: f64,
66    /// Registered tool names (builtin + MCP).
67    pub tool_names: Vec<String>,
68    /// Connected MCP servers and their tool counts: `(server_name, tool_count)`.
69    pub mcp_servers: Vec<(String, usize)>,
70}
71
72/// Result of a slash command execution.
73#[derive(Debug, Clone)]
74pub struct CommandOutput {
75    /// Text output to display to the user.
76    pub text: String,
77    /// Whether the command modified session state (e.g., /clear, /compact).
78    pub state_changed: bool,
79    /// Optional action for the session to perform after the command.
80    pub action: Option<CommandAction>,
81}
82
83/// Post-command actions that the session should perform.
84#[derive(Debug, Clone)]
85pub enum CommandAction {
86    /// Trigger context compaction.
87    Compact,
88    /// Clear conversation history.
89    ClearHistory,
90    /// Switch to a different model.
91    SwitchModel(String),
92}
93
94impl CommandOutput {
95    /// Create a simple text output.
96    pub fn text(msg: impl Into<String>) -> Self {
97        Self {
98            text: msg.into(),
99            state_changed: false,
100            action: None,
101        }
102    }
103
104    /// Create an output with a post-command action.
105    pub fn with_action(msg: impl Into<String>, action: CommandAction) -> Self {
106        Self {
107            text: msg.into(),
108            state_changed: true,
109            action: Some(action),
110        }
111    }
112}
113
114/// Trait for implementing slash commands.
115///
116/// Implement this trait to add custom commands to the session.
117pub trait SlashCommand: Send + Sync {
118    /// Command name (without the leading `/`).
119    fn name(&self) -> &str;
120
121    /// Short description shown in `/help`.
122    fn description(&self) -> &str;
123
124    /// Optional usage hint (e.g., `/model <provider/model>`).
125    fn usage(&self) -> Option<&str> {
126        None
127    }
128
129    /// Execute the command with the given arguments.
130    fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput;
131}
132
133/// Registry of slash commands.
134pub struct CommandRegistry {
135    commands: HashMap<String, Arc<dyn SlashCommand>>,
136    capability_catalog: Option<Weak<crate::capability::CapabilityCatalog>>,
137}
138
139impl CommandRegistry {
140    /// Create a new registry with built-in commands.
141    pub fn new() -> Self {
142        let mut registry = Self {
143            commands: HashMap::new(),
144            capability_catalog: None,
145        };
146        registry.register(Arc::new(HelpCommand));
147        registry.register(Arc::new(CompactCommand));
148        registry.register(Arc::new(CostCommand));
149        registry.register(Arc::new(ModelCommand));
150        registry.register(Arc::new(ClearCommand));
151        registry.register(Arc::new(HistoryCommand));
152        registry.register(Arc::new(ToolsCommand));
153        registry.register(Arc::new(McpCommand));
154        registry
155    }
156
157    pub(crate) fn with_capability_catalog(
158        catalog: &Arc<crate::capability::CapabilityCatalog>,
159    ) -> Self {
160        let mut registry = Self::new();
161        registry.capability_catalog = Some(Arc::downgrade(catalog));
162        registry
163    }
164
165    /// Freeze the compatibility map and merge one projected generation.
166    ///
167    /// The returned registry shares the exact Command [`Arc`] values while
168    /// owning an independent name map.
169    pub(crate) fn snapshot_with_external_commands(
170        &self,
171        external: impl IntoIterator<Item = Arc<dyn SlashCommand>>,
172    ) -> Result<Self, CommandRegistrySnapshotError> {
173        let mut commands = self.commands.clone();
174        for command in external {
175            let name = command.name().to_owned();
176            if commands.contains_key(&name) {
177                return Err(CommandRegistrySnapshotError { name });
178            }
179            commands.insert(name, command);
180        }
181        Ok(Self {
182            commands,
183            capability_catalog: self.capability_catalog.clone(),
184        })
185    }
186
187    /// Register a custom command.
188    pub fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
189        if self.published_projection_owns(cmd.name()) {
190            tracing::warn!(
191                command = cmd.name(),
192                "Rejected command registration because a published projection owns the name"
193            );
194            return;
195        }
196        self.commands.insert(cmd.name().to_string(), cmd);
197    }
198
199    /// Unregister a command by name.
200    pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn SlashCommand>> {
201        self.commands.remove(name)
202    }
203
204    /// Check if input is a slash command.
205    pub fn is_command(input: &str) -> bool {
206        input.trim_start().starts_with('/')
207    }
208
209    /// Parse and execute a slash command. Returns `None` if not a command.
210    pub fn dispatch(&self, input: &str, ctx: &CommandContext) -> Option<CommandOutput> {
211        let trimmed = input.trim();
212        if !trimmed.starts_with('/') {
213            return None;
214        }
215
216        let without_slash = &trimmed[1..];
217        let (name, args) = match without_slash.split_once(char::is_whitespace) {
218            Some((n, a)) => (n, a.trim()),
219            None => (without_slash, ""),
220        };
221
222        match self.commands.get(name) {
223            Some(cmd) => Some(cmd.execute(args, ctx)),
224            None => Some(CommandOutput::text(format!(
225                "Unknown command: /{name}\nType /help for available commands."
226            ))),
227        }
228    }
229
230    /// Get all registered command names and descriptions.
231    pub fn list(&self) -> Vec<(&str, &str)> {
232        let mut cmds: Vec<_> = self
233            .commands
234            .values()
235            .map(|c| (c.name(), c.description()))
236            .collect();
237        cmds.sort_by_key(|(name, _)| *name);
238        cmds
239    }
240
241    /// Get all registered commands with name, description, and optional usage hint.
242    pub fn list_full(&self) -> Vec<(String, String, Option<String>)> {
243        let mut cmds: Vec<_> = self
244            .commands
245            .values()
246            .map(|c| {
247                (
248                    c.name().to_string(),
249                    c.description().to_string(),
250                    c.usage().map(|s| s.to_string()),
251                )
252            })
253            .collect();
254        cmds.sort_by(|a, b| a.0.cmp(&b.0));
255        cmds
256    }
257
258    /// Number of registered commands.
259    pub fn len(&self) -> usize {
260        self.commands.len()
261    }
262
263    /// Whether the registry is empty.
264    pub fn is_empty(&self) -> bool {
265        self.commands.is_empty()
266    }
267
268    fn published_projection_owns(&self, name: &str) -> bool {
269        let Some(catalog) = self
270            .capability_catalog
271            .as_ref()
272            .and_then(std::sync::Weak::upgrade)
273        else {
274            return false;
275        };
276        let projection = catalog.pin();
277        let owns_name = projection.projection().iter().any(|(_, value)| {
278            matches!(
279                value,
280                crate::capability::CapabilityValue::Command(command) if command.name() == name
281            )
282        });
283        owns_name
284    }
285}
286
287impl Default for CommandRegistry {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293// ─── Built-in Commands ──────────────────────────────────────────────
294
295struct HelpCommand;
296
297impl SlashCommand for HelpCommand {
298    fn name(&self) -> &str {
299        "help"
300    }
301    fn description(&self) -> &str {
302        "List available commands"
303    }
304    fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
305        // Help text is generated dynamically by the session using registry.list()
306        // This is a placeholder — the actual help is built in AgentSession::execute_command()
307        CommandOutput::text("Use /help to see available commands.")
308    }
309}
310
311struct CompactCommand;
312
313impl SlashCommand for CompactCommand {
314    fn name(&self) -> &str {
315        "compact"
316    }
317    fn description(&self) -> &str {
318        "Manually trigger context compaction"
319    }
320    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
321        CommandOutput::with_action(
322            format!(
323                "Compacting context... ({} messages, {} tokens)",
324                ctx.history_len, ctx.total_tokens
325            ),
326            CommandAction::Compact,
327        )
328    }
329}
330
331struct CostCommand;
332
333impl SlashCommand for CostCommand {
334    fn name(&self) -> &str {
335        "cost"
336    }
337    fn description(&self) -> &str {
338        "Show token usage and estimated cost"
339    }
340    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
341        CommandOutput::text(format!(
342            "Session: {}\n\
343             Model:   {}\n\
344             Tokens:  {}\n\
345             Cost:    ${:.4}",
346            &ctx.session_id[..ctx.session_id.len().min(8)],
347            ctx.model,
348            ctx.total_tokens,
349            ctx.total_cost,
350        ))
351    }
352}
353
354struct ModelCommand;
355
356impl SlashCommand for ModelCommand {
357    fn name(&self) -> &str {
358        "model"
359    }
360    fn description(&self) -> &str {
361        "Show or switch the current model"
362    }
363    fn usage(&self) -> Option<&str> {
364        Some("/model [provider/model]")
365    }
366    fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput {
367        if args.is_empty() {
368            CommandOutput::text(format!("Current model: {}", ctx.model))
369        } else if args.contains('/') {
370            CommandOutput::with_action(
371                format!("Switching model to: {args}"),
372                CommandAction::SwitchModel(args.to_string()),
373            )
374        } else {
375            CommandOutput::text(
376                "Usage: /model provider/model (e.g., /model anthropic/claude-sonnet-4-20250514)",
377            )
378        }
379    }
380}
381
382struct ClearCommand;
383
384impl SlashCommand for ClearCommand {
385    fn name(&self) -> &str {
386        "clear"
387    }
388    fn description(&self) -> &str {
389        "Clear conversation history"
390    }
391    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
392        CommandOutput::with_action(
393            format!("Cleared {} messages.", ctx.history_len),
394            CommandAction::ClearHistory,
395        )
396    }
397}
398
399struct HistoryCommand;
400
401impl SlashCommand for HistoryCommand {
402    fn name(&self) -> &str {
403        "history"
404    }
405    fn description(&self) -> &str {
406        "Show conversation stats"
407    }
408    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
409        CommandOutput::text(format!(
410            "Messages: {}\n\
411             Tokens:   {}\n\
412             Session:  {}",
413            ctx.history_len,
414            ctx.total_tokens,
415            &ctx.session_id[..ctx.session_id.len().min(8)],
416        ))
417    }
418}
419
420struct ToolsCommand;
421
422impl SlashCommand for ToolsCommand {
423    fn name(&self) -> &str {
424        "tools"
425    }
426    fn description(&self) -> &str {
427        "List registered tools"
428    }
429    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
430        if ctx.tool_names.is_empty() {
431            return CommandOutput::text("No tools registered.");
432        }
433        let builtin: Vec<&str> = ctx
434            .tool_names
435            .iter()
436            .filter(|t| !t.starts_with("mcp__"))
437            .map(|s| s.as_str())
438            .collect();
439        let mcp: Vec<&str> = ctx
440            .tool_names
441            .iter()
442            .filter(|t| t.starts_with("mcp__"))
443            .map(|s| s.as_str())
444            .collect();
445
446        let mut out = format!("Tools: {} total\n", ctx.tool_names.len());
447        if !builtin.is_empty() {
448            out.push_str(&format!("\nBuiltin ({}):\n", builtin.len()));
449            for t in &builtin {
450                out.push_str(&format!("  • {t}\n"));
451            }
452        }
453        if !mcp.is_empty() {
454            out.push_str(&format!("\nMCP ({}):\n", mcp.len()));
455            for t in &mcp {
456                out.push_str(&format!("  • {t}\n"));
457            }
458        }
459        CommandOutput::text(out.trim_end())
460    }
461}
462
463struct McpCommand;
464
465impl SlashCommand for McpCommand {
466    fn name(&self) -> &str {
467        "mcp"
468    }
469    fn description(&self) -> &str {
470        "List connected MCP servers and their tools"
471    }
472    fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
473        if ctx.mcp_servers.is_empty() {
474            return CommandOutput::text("No MCP servers connected.");
475        }
476        let total_tools: usize = ctx.mcp_servers.iter().map(|(_, c)| c).sum();
477        let mut out = format!(
478            "MCP: {} server(s), {} tool(s)\n",
479            ctx.mcp_servers.len(),
480            total_tools
481        );
482        for (server, count) in &ctx.mcp_servers {
483            out.push_str(&format!("\n  {server} ({count} tools)"));
484            // List tools belonging to this server
485            let prefix = format!("mcp__{server}__");
486            let server_tools: Vec<&str> = ctx
487                .tool_names
488                .iter()
489                .filter(|t| t.starts_with(&prefix))
490                .map(|s| s.strip_prefix(&prefix).unwrap_or(s))
491                .collect();
492            for t in server_tools {
493                out.push_str(&format!("\n    • {t}"));
494            }
495        }
496        CommandOutput::text(out)
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    struct NamedCommand {
505        name: &'static str,
506        output: &'static str,
507    }
508
509    impl SlashCommand for NamedCommand {
510        fn name(&self) -> &str {
511            self.name
512        }
513
514        fn description(&self) -> &str {
515            self.output
516        }
517
518        fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
519            CommandOutput::text(self.output)
520        }
521    }
522
523    fn test_ctx() -> CommandContext {
524        CommandContext {
525            session_id: "test-session-123".into(),
526            workspace: "/tmp/test".into(),
527            model: "openai/kimi-k2.5".into(),
528            history_len: 10,
529            total_tokens: 5000,
530            total_cost: 0.0123,
531            tool_names: vec![
532                "read".into(),
533                "write".into(),
534                "bash".into(),
535                "mcp__github__create_issue".into(),
536                "mcp__github__list_repos".into(),
537            ],
538            mcp_servers: vec![("github".into(), 2)],
539        }
540    }
541
542    #[test]
543    fn test_is_command() {
544        assert!(CommandRegistry::is_command("/help"));
545        assert!(CommandRegistry::is_command("  /model foo"));
546        assert!(!CommandRegistry::is_command("hello"));
547        assert!(!CommandRegistry::is_command("not /a command"));
548    }
549
550    #[test]
551    fn test_dispatch_help() {
552        let reg = CommandRegistry::new();
553        let ctx = test_ctx();
554        let out = reg.dispatch("/help", &ctx).unwrap();
555        assert!(!out.text.is_empty());
556    }
557
558    #[test]
559    fn test_dispatch_cost() {
560        let reg = CommandRegistry::new();
561        let ctx = test_ctx();
562        let out = reg.dispatch("/cost", &ctx).unwrap();
563        assert!(out.text.contains("5000"));
564        assert!(out.text.contains("0.0123"));
565    }
566
567    #[test]
568    fn test_dispatch_model_show() {
569        let reg = CommandRegistry::new();
570        let ctx = test_ctx();
571        let out = reg.dispatch("/model", &ctx).unwrap();
572        assert!(out.text.contains("openai/kimi-k2.5"));
573        assert!(out.action.is_none());
574    }
575
576    #[test]
577    fn test_dispatch_model_switch() {
578        let reg = CommandRegistry::new();
579        let ctx = test_ctx();
580        let out = reg
581            .dispatch("/model anthropic/claude-sonnet-4-20250514", &ctx)
582            .unwrap();
583        assert!(matches!(out.action, Some(CommandAction::SwitchModel(_))));
584    }
585
586    #[test]
587    fn test_dispatch_clear() {
588        let reg = CommandRegistry::new();
589        let ctx = test_ctx();
590        let out = reg.dispatch("/clear", &ctx).unwrap();
591        assert!(matches!(out.action, Some(CommandAction::ClearHistory)));
592        assert!(out.text.contains("10"));
593    }
594
595    #[test]
596    fn test_dispatch_compact() {
597        let reg = CommandRegistry::new();
598        let ctx = test_ctx();
599        let out = reg.dispatch("/compact", &ctx).unwrap();
600        assert!(matches!(out.action, Some(CommandAction::Compact)));
601    }
602
603    #[test]
604    fn test_dispatch_unknown() {
605        let reg = CommandRegistry::new();
606        let ctx = test_ctx();
607        let out = reg.dispatch("/foobar", &ctx).unwrap();
608        assert!(out.text.contains("Unknown command"));
609    }
610
611    #[test]
612    fn test_not_a_command() {
613        let reg = CommandRegistry::new();
614        let ctx = test_ctx();
615        assert!(reg.dispatch("hello world", &ctx).is_none());
616    }
617
618    #[test]
619    fn test_custom_command() {
620        struct PingCommand;
621        impl SlashCommand for PingCommand {
622            fn name(&self) -> &str {
623                "ping"
624            }
625            fn description(&self) -> &str {
626                "Pong!"
627            }
628            fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
629                CommandOutput::text("pong")
630            }
631        }
632
633        let mut reg = CommandRegistry::new();
634        let before = reg.len();
635        reg.register(Arc::new(PingCommand));
636        assert_eq!(reg.len(), before + 1);
637
638        let ctx = test_ctx();
639        let out = reg.dispatch("/ping", &ctx).unwrap();
640        assert_eq!(out.text, "pong");
641    }
642
643    #[test]
644    fn projected_snapshot_preserves_identity_and_isolates_later_mutation() {
645        let mut registry = CommandRegistry::new();
646        let projected: Arc<dyn SlashCommand> = Arc::new(NamedCommand {
647            name: "projected",
648            output: "generation one",
649        });
650        let snapshot = registry
651            .snapshot_with_external_commands([Arc::clone(&projected)])
652            .unwrap();
653        assert!(Arc::ptr_eq(
654            snapshot.commands.get("projected").unwrap(),
655            &projected
656        ));
657
658        registry.register(Arc::new(NamedCommand {
659            name: "projected",
660            output: "compatibility mutation",
661        }));
662        let ctx = test_ctx();
663        assert_eq!(
664            snapshot.dispatch("/projected", &ctx).unwrap().text,
665            "generation one"
666        );
667        assert_eq!(
668            registry.dispatch("/projected", &ctx).unwrap().text,
669            "compatibility mutation"
670        );
671    }
672
673    #[test]
674    fn projected_snapshot_rejects_builtin_and_compatibility_name_conflicts() {
675        let mut registry = CommandRegistry::new();
676        registry.register(Arc::new(NamedCommand {
677            name: "compatibility",
678            output: "compatibility",
679        }));
680
681        for name in ["help", "compatibility"] {
682            let error = match registry.snapshot_with_external_commands([Arc::new(NamedCommand {
683                name,
684                output: "projected",
685            })
686                as Arc<dyn SlashCommand>])
687            {
688                Ok(_) => panic!("projected command unexpectedly shadowed '{name}'"),
689                Err(error) => error,
690            };
691            assert_eq!(error.name(), name);
692        }
693    }
694
695    #[test]
696    fn test_list_commands() {
697        let reg = CommandRegistry::new();
698        let list = reg.list();
699        assert!(list.len() >= 8);
700        assert!(list.iter().any(|(name, _)| *name == "help"));
701        assert!(list.iter().any(|(name, _)| *name == "compact"));
702        assert!(list.iter().any(|(name, _)| *name == "cost"));
703        assert!(list.iter().any(|(name, _)| *name == "mcp"));
704    }
705
706    #[test]
707    fn test_dispatch_tools() {
708        let reg = CommandRegistry::new();
709        let ctx = test_ctx();
710        let out = reg.dispatch("/tools", &ctx).unwrap();
711        assert!(out.text.contains("5 total"));
712        assert!(out.text.contains("read"));
713        assert!(out.text.contains("mcp__github__create_issue"));
714    }
715
716    #[test]
717    fn test_dispatch_mcp() {
718        let reg = CommandRegistry::new();
719        let ctx = test_ctx();
720        let out = reg.dispatch("/mcp", &ctx).unwrap();
721        assert!(out.text.contains("1 server(s)"));
722        assert!(out.text.contains("github"));
723        assert!(out.text.contains("create_issue"));
724        assert!(out.text.contains("list_repos"));
725    }
726
727    #[test]
728    fn test_dispatch_mcp_empty() {
729        let reg = CommandRegistry::new();
730        let mut ctx = test_ctx();
731        ctx.mcp_servers = vec![];
732        let out = reg.dispatch("/mcp", &ctx).unwrap();
733        assert!(out.text.contains("No MCP servers connected"));
734    }
735}