1use std::collections::HashMap;
37use std::sync::Arc;
38
39#[derive(Debug, Clone)]
41pub struct CommandContext {
42 pub session_id: String,
44 pub workspace: String,
46 pub model: String,
48 pub history_len: usize,
50 pub total_tokens: u64,
52 pub total_cost: f64,
54 pub tool_names: Vec<String>,
56 pub mcp_servers: Vec<(String, usize)>,
58}
59
60#[derive(Debug, Clone)]
62pub struct CommandOutput {
63 pub text: String,
65 pub state_changed: bool,
67 pub action: Option<CommandAction>,
69}
70
71#[derive(Debug, Clone)]
73pub enum CommandAction {
74 Compact,
76 ClearHistory,
78 SwitchModel(String),
80}
81
82impl CommandOutput {
83 pub fn text(msg: impl Into<String>) -> Self {
85 Self {
86 text: msg.into(),
87 state_changed: false,
88 action: None,
89 }
90 }
91
92 pub fn with_action(msg: impl Into<String>, action: CommandAction) -> Self {
94 Self {
95 text: msg.into(),
96 state_changed: true,
97 action: Some(action),
98 }
99 }
100}
101
102pub trait SlashCommand: Send + Sync {
106 fn name(&self) -> &str;
108
109 fn description(&self) -> &str;
111
112 fn usage(&self) -> Option<&str> {
114 None
115 }
116
117 fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput;
119}
120
121pub struct CommandRegistry {
123 commands: HashMap<String, Arc<dyn SlashCommand>>,
124}
125
126impl CommandRegistry {
127 pub fn new() -> Self {
129 let mut registry = Self {
130 commands: HashMap::new(),
131 };
132 registry.register(Arc::new(HelpCommand));
133 registry.register(Arc::new(CompactCommand));
134 registry.register(Arc::new(CostCommand));
135 registry.register(Arc::new(ModelCommand));
136 registry.register(Arc::new(ClearCommand));
137 registry.register(Arc::new(HistoryCommand));
138 registry.register(Arc::new(ToolsCommand));
139 registry.register(Arc::new(McpCommand));
140 registry
141 }
142
143 pub fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
145 self.commands.insert(cmd.name().to_string(), cmd);
146 }
147
148 pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn SlashCommand>> {
150 self.commands.remove(name)
151 }
152
153 pub fn is_command(input: &str) -> bool {
155 input.trim_start().starts_with('/')
156 }
157
158 pub fn dispatch(&self, input: &str, ctx: &CommandContext) -> Option<CommandOutput> {
160 let trimmed = input.trim();
161 if !trimmed.starts_with('/') {
162 return None;
163 }
164
165 let without_slash = &trimmed[1..];
166 let (name, args) = match without_slash.split_once(char::is_whitespace) {
167 Some((n, a)) => (n, a.trim()),
168 None => (without_slash, ""),
169 };
170
171 match self.commands.get(name) {
172 Some(cmd) => Some(cmd.execute(args, ctx)),
173 None => Some(CommandOutput::text(format!(
174 "Unknown command: /{name}\nType /help for available commands."
175 ))),
176 }
177 }
178
179 pub fn list(&self) -> Vec<(&str, &str)> {
181 let mut cmds: Vec<_> = self
182 .commands
183 .values()
184 .map(|c| (c.name(), c.description()))
185 .collect();
186 cmds.sort_by_key(|(name, _)| *name);
187 cmds
188 }
189
190 pub fn list_full(&self) -> Vec<(String, String, Option<String>)> {
192 let mut cmds: Vec<_> = self
193 .commands
194 .values()
195 .map(|c| {
196 (
197 c.name().to_string(),
198 c.description().to_string(),
199 c.usage().map(|s| s.to_string()),
200 )
201 })
202 .collect();
203 cmds.sort_by(|a, b| a.0.cmp(&b.0));
204 cmds
205 }
206
207 pub fn len(&self) -> usize {
209 self.commands.len()
210 }
211
212 pub fn is_empty(&self) -> bool {
214 self.commands.is_empty()
215 }
216}
217
218impl Default for CommandRegistry {
219 fn default() -> Self {
220 Self::new()
221 }
222}
223
224struct HelpCommand;
227
228impl SlashCommand for HelpCommand {
229 fn name(&self) -> &str {
230 "help"
231 }
232 fn description(&self) -> &str {
233 "List available commands"
234 }
235 fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
236 CommandOutput::text("Use /help to see available commands.")
239 }
240}
241
242struct CompactCommand;
243
244impl SlashCommand for CompactCommand {
245 fn name(&self) -> &str {
246 "compact"
247 }
248 fn description(&self) -> &str {
249 "Manually trigger context compaction"
250 }
251 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
252 CommandOutput::with_action(
253 format!(
254 "Compacting context... ({} messages, {} tokens)",
255 ctx.history_len, ctx.total_tokens
256 ),
257 CommandAction::Compact,
258 )
259 }
260}
261
262struct CostCommand;
263
264impl SlashCommand for CostCommand {
265 fn name(&self) -> &str {
266 "cost"
267 }
268 fn description(&self) -> &str {
269 "Show token usage and estimated cost"
270 }
271 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
272 CommandOutput::text(format!(
273 "Session: {}\n\
274 Model: {}\n\
275 Tokens: {}\n\
276 Cost: ${:.4}",
277 &ctx.session_id[..ctx.session_id.len().min(8)],
278 ctx.model,
279 ctx.total_tokens,
280 ctx.total_cost,
281 ))
282 }
283}
284
285struct ModelCommand;
286
287impl SlashCommand for ModelCommand {
288 fn name(&self) -> &str {
289 "model"
290 }
291 fn description(&self) -> &str {
292 "Show or switch the current model"
293 }
294 fn usage(&self) -> Option<&str> {
295 Some("/model [provider/model]")
296 }
297 fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput {
298 if args.is_empty() {
299 CommandOutput::text(format!("Current model: {}", ctx.model))
300 } else if args.contains('/') {
301 CommandOutput::with_action(
302 format!("Switching model to: {args}"),
303 CommandAction::SwitchModel(args.to_string()),
304 )
305 } else {
306 CommandOutput::text(
307 "Usage: /model provider/model (e.g., /model anthropic/claude-sonnet-4-20250514)",
308 )
309 }
310 }
311}
312
313struct ClearCommand;
314
315impl SlashCommand for ClearCommand {
316 fn name(&self) -> &str {
317 "clear"
318 }
319 fn description(&self) -> &str {
320 "Clear conversation history"
321 }
322 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
323 CommandOutput::with_action(
324 format!("Cleared {} messages.", ctx.history_len),
325 CommandAction::ClearHistory,
326 )
327 }
328}
329
330struct HistoryCommand;
331
332impl SlashCommand for HistoryCommand {
333 fn name(&self) -> &str {
334 "history"
335 }
336 fn description(&self) -> &str {
337 "Show conversation stats"
338 }
339 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
340 CommandOutput::text(format!(
341 "Messages: {}\n\
342 Tokens: {}\n\
343 Session: {}",
344 ctx.history_len,
345 ctx.total_tokens,
346 &ctx.session_id[..ctx.session_id.len().min(8)],
347 ))
348 }
349}
350
351struct ToolsCommand;
352
353impl SlashCommand for ToolsCommand {
354 fn name(&self) -> &str {
355 "tools"
356 }
357 fn description(&self) -> &str {
358 "List registered tools"
359 }
360 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
361 if ctx.tool_names.is_empty() {
362 return CommandOutput::text("No tools registered.");
363 }
364 let builtin: Vec<&str> = ctx
365 .tool_names
366 .iter()
367 .filter(|t| !t.starts_with("mcp__"))
368 .map(|s| s.as_str())
369 .collect();
370 let mcp: Vec<&str> = ctx
371 .tool_names
372 .iter()
373 .filter(|t| t.starts_with("mcp__"))
374 .map(|s| s.as_str())
375 .collect();
376
377 let mut out = format!("Tools: {} total\n", ctx.tool_names.len());
378 if !builtin.is_empty() {
379 out.push_str(&format!("\nBuiltin ({}):\n", builtin.len()));
380 for t in &builtin {
381 out.push_str(&format!(" • {t}\n"));
382 }
383 }
384 if !mcp.is_empty() {
385 out.push_str(&format!("\nMCP ({}):\n", mcp.len()));
386 for t in &mcp {
387 out.push_str(&format!(" • {t}\n"));
388 }
389 }
390 CommandOutput::text(out.trim_end())
391 }
392}
393
394struct McpCommand;
395
396impl SlashCommand for McpCommand {
397 fn name(&self) -> &str {
398 "mcp"
399 }
400 fn description(&self) -> &str {
401 "List connected MCP servers and their tools"
402 }
403 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
404 if ctx.mcp_servers.is_empty() {
405 return CommandOutput::text("No MCP servers connected.");
406 }
407 let total_tools: usize = ctx.mcp_servers.iter().map(|(_, c)| c).sum();
408 let mut out = format!(
409 "MCP: {} server(s), {} tool(s)\n",
410 ctx.mcp_servers.len(),
411 total_tools
412 );
413 for (server, count) in &ctx.mcp_servers {
414 out.push_str(&format!("\n {server} ({count} tools)"));
415 let prefix = format!("mcp__{server}__");
417 let server_tools: Vec<&str> = ctx
418 .tool_names
419 .iter()
420 .filter(|t| t.starts_with(&prefix))
421 .map(|s| s.strip_prefix(&prefix).unwrap_or(s))
422 .collect();
423 for t in server_tools {
424 out.push_str(&format!("\n • {t}"));
425 }
426 }
427 CommandOutput::text(out)
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn test_ctx() -> CommandContext {
436 CommandContext {
437 session_id: "test-session-123".into(),
438 workspace: "/tmp/test".into(),
439 model: "openai/kimi-k2.5".into(),
440 history_len: 10,
441 total_tokens: 5000,
442 total_cost: 0.0123,
443 tool_names: vec![
444 "read".into(),
445 "write".into(),
446 "bash".into(),
447 "mcp__github__create_issue".into(),
448 "mcp__github__list_repos".into(),
449 ],
450 mcp_servers: vec![("github".into(), 2)],
451 }
452 }
453
454 #[test]
455 fn test_is_command() {
456 assert!(CommandRegistry::is_command("/help"));
457 assert!(CommandRegistry::is_command(" /model foo"));
458 assert!(!CommandRegistry::is_command("hello"));
459 assert!(!CommandRegistry::is_command("not /a command"));
460 }
461
462 #[test]
463 fn test_dispatch_help() {
464 let reg = CommandRegistry::new();
465 let ctx = test_ctx();
466 let out = reg.dispatch("/help", &ctx).unwrap();
467 assert!(!out.text.is_empty());
468 }
469
470 #[test]
471 fn test_dispatch_cost() {
472 let reg = CommandRegistry::new();
473 let ctx = test_ctx();
474 let out = reg.dispatch("/cost", &ctx).unwrap();
475 assert!(out.text.contains("5000"));
476 assert!(out.text.contains("0.0123"));
477 }
478
479 #[test]
480 fn test_dispatch_model_show() {
481 let reg = CommandRegistry::new();
482 let ctx = test_ctx();
483 let out = reg.dispatch("/model", &ctx).unwrap();
484 assert!(out.text.contains("openai/kimi-k2.5"));
485 assert!(out.action.is_none());
486 }
487
488 #[test]
489 fn test_dispatch_model_switch() {
490 let reg = CommandRegistry::new();
491 let ctx = test_ctx();
492 let out = reg
493 .dispatch("/model anthropic/claude-sonnet-4-20250514", &ctx)
494 .unwrap();
495 assert!(matches!(out.action, Some(CommandAction::SwitchModel(_))));
496 }
497
498 #[test]
499 fn test_dispatch_clear() {
500 let reg = CommandRegistry::new();
501 let ctx = test_ctx();
502 let out = reg.dispatch("/clear", &ctx).unwrap();
503 assert!(matches!(out.action, Some(CommandAction::ClearHistory)));
504 assert!(out.text.contains("10"));
505 }
506
507 #[test]
508 fn test_dispatch_compact() {
509 let reg = CommandRegistry::new();
510 let ctx = test_ctx();
511 let out = reg.dispatch("/compact", &ctx).unwrap();
512 assert!(matches!(out.action, Some(CommandAction::Compact)));
513 }
514
515 #[test]
516 fn test_dispatch_unknown() {
517 let reg = CommandRegistry::new();
518 let ctx = test_ctx();
519 let out = reg.dispatch("/foobar", &ctx).unwrap();
520 assert!(out.text.contains("Unknown command"));
521 }
522
523 #[test]
524 fn test_not_a_command() {
525 let reg = CommandRegistry::new();
526 let ctx = test_ctx();
527 assert!(reg.dispatch("hello world", &ctx).is_none());
528 }
529
530 #[test]
531 fn test_custom_command() {
532 struct PingCommand;
533 impl SlashCommand for PingCommand {
534 fn name(&self) -> &str {
535 "ping"
536 }
537 fn description(&self) -> &str {
538 "Pong!"
539 }
540 fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
541 CommandOutput::text("pong")
542 }
543 }
544
545 let mut reg = CommandRegistry::new();
546 let before = reg.len();
547 reg.register(Arc::new(PingCommand));
548 assert_eq!(reg.len(), before + 1);
549
550 let ctx = test_ctx();
551 let out = reg.dispatch("/ping", &ctx).unwrap();
552 assert_eq!(out.text, "pong");
553 }
554
555 #[test]
556 fn test_list_commands() {
557 let reg = CommandRegistry::new();
558 let list = reg.list();
559 assert!(list.len() >= 8);
560 assert!(list.iter().any(|(name, _)| *name == "help"));
561 assert!(list.iter().any(|(name, _)| *name == "compact"));
562 assert!(list.iter().any(|(name, _)| *name == "cost"));
563 assert!(list.iter().any(|(name, _)| *name == "mcp"));
564 }
565
566 #[test]
567 fn test_dispatch_tools() {
568 let reg = CommandRegistry::new();
569 let ctx = test_ctx();
570 let out = reg.dispatch("/tools", &ctx).unwrap();
571 assert!(out.text.contains("5 total"));
572 assert!(out.text.contains("read"));
573 assert!(out.text.contains("mcp__github__create_issue"));
574 }
575
576 #[test]
577 fn test_dispatch_mcp() {
578 let reg = CommandRegistry::new();
579 let ctx = test_ctx();
580 let out = reg.dispatch("/mcp", &ctx).unwrap();
581 assert!(out.text.contains("1 server(s)"));
582 assert!(out.text.contains("github"));
583 assert!(out.text.contains("create_issue"));
584 assert!(out.text.contains("list_repos"));
585 }
586
587 #[test]
588 fn test_dispatch_mcp_empty() {
589 let reg = CommandRegistry::new();
590 let mut ctx = test_ctx();
591 ctx.mcp_servers = vec![];
592 let out = reg.dispatch("/mcp", &ctx).unwrap();
593 assert!(out.text.contains("No MCP servers connected"));
594 }
595}