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