1use crate::scheduler::{format_duration, parse_loop_args, CronScheduler};
40use crate::text::truncate_utf8;
41use std::collections::HashMap;
42use std::sync::Arc;
43
44#[derive(Debug, Clone)]
46pub struct CommandContext {
47 pub session_id: String,
49 pub workspace: String,
51 pub model: String,
53 pub history_len: usize,
55 pub total_tokens: u64,
57 pub total_cost: f64,
59 pub tool_names: Vec<String>,
61 pub mcp_servers: Vec<(String, usize)>,
63}
64
65#[derive(Debug, Clone)]
67pub struct CommandOutput {
68 pub text: String,
70 pub state_changed: bool,
72 pub action: Option<CommandAction>,
74}
75
76#[derive(Debug, Clone)]
78pub enum CommandAction {
79 Compact,
81 ClearHistory,
83 SwitchModel(String),
85 BtwQuery(String),
90}
91
92impl CommandOutput {
93 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 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
112pub trait SlashCommand: Send + Sync {
116 fn name(&self) -> &str;
118
119 fn description(&self) -> &str;
121
122 fn usage(&self) -> Option<&str> {
124 None
125 }
126
127 fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput;
129}
130
131pub struct CommandRegistry {
133 commands: HashMap<String, Arc<dyn SlashCommand>>,
134}
135
136impl CommandRegistry {
137 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 pub fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
156 self.commands.insert(cmd.name().to_string(), cmd);
157 }
158
159 pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn SlashCommand>> {
161 self.commands.remove(name)
162 }
163
164 pub fn is_command(input: &str) -> bool {
166 input.trim_start().starts_with('/')
167 }
168
169 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 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 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 pub fn len(&self) -> usize {
220 self.commands.len()
221 }
222
223 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
235struct 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 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 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 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
466pub 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
515pub 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
557pub 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}