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