1use 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#[derive(Debug, Clone)]
53pub struct CommandContext {
54 pub session_id: String,
56 pub workspace: String,
58 pub model: String,
60 pub history_len: usize,
62 pub total_tokens: u64,
64 pub total_cost: f64,
66 pub tool_names: Vec<String>,
68 pub mcp_servers: Vec<(String, usize)>,
70}
71
72#[derive(Debug, Clone)]
74pub struct CommandOutput {
75 pub text: String,
77 pub state_changed: bool,
79 pub action: Option<CommandAction>,
81}
82
83#[derive(Debug, Clone)]
85pub enum CommandAction {
86 Compact,
88 ClearHistory,
90 SwitchModel(String),
92}
93
94impl CommandOutput {
95 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 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
114pub trait SlashCommand: Send + Sync {
118 fn name(&self) -> &str;
120
121 fn description(&self) -> &str;
123
124 fn usage(&self) -> Option<&str> {
126 None
127 }
128
129 fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput;
131}
132
133pub struct CommandRegistry {
135 commands: HashMap<String, Arc<dyn SlashCommand>>,
136 capability_catalog: Option<Weak<crate::capability::CapabilityCatalog>>,
137}
138
139impl CommandRegistry {
140 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 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 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 pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn SlashCommand>> {
201 self.commands.remove(name)
202 }
203
204 pub fn is_command(input: &str) -> bool {
206 input.trim_start().starts_with('/')
207 }
208
209 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 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 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 pub fn len(&self) -> usize {
260 self.commands.len()
261 }
262
263 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
293struct 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 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 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}