1use crate::scheduler::{format_duration, parse_loop_args, CronScheduler};
40use std::collections::HashMap;
41use std::sync::Arc;
42
43#[derive(Debug, Clone)]
45pub struct CommandContext {
46 pub session_id: String,
48 pub workspace: String,
50 pub model: String,
52 pub history_len: usize,
54 pub total_tokens: u64,
56 pub total_cost: f64,
58 pub tool_names: Vec<String>,
60 pub mcp_servers: Vec<(String, usize)>,
62}
63
64#[derive(Debug, Clone)]
66pub struct CommandOutput {
67 pub text: String,
69 pub state_changed: bool,
71 pub action: Option<CommandAction>,
73}
74
75#[derive(Debug, Clone)]
77pub enum CommandAction {
78 Compact,
80 ClearHistory,
82 SwitchModel(String),
84 BtwQuery(String),
89}
90
91impl CommandOutput {
92 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 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
111pub trait SlashCommand: Send + Sync {
115 fn name(&self) -> &str;
117
118 fn description(&self) -> &str;
120
121 fn usage(&self) -> Option<&str> {
123 None
124 }
125
126 fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput;
128}
129
130pub struct CommandRegistry {
132 commands: HashMap<String, Arc<dyn SlashCommand>>,
133}
134
135impl CommandRegistry {
136 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 pub fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
155 self.commands.insert(cmd.name().to_string(), cmd);
156 }
157
158 pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn SlashCommand>> {
160 self.commands.remove(name)
161 }
162
163 pub fn is_command(input: &str) -> bool {
165 input.trim_start().starts_with('/')
166 }
167
168 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 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 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 pub fn len(&self) -> usize {
219 self.commands.len()
220 }
221
222 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
234struct 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 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 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 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
465pub 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
514pub 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
556pub 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}