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 len(&self) -> usize {
192 self.commands.len()
193 }
194
195 pub fn is_empty(&self) -> bool {
197 self.commands.is_empty()
198 }
199}
200
201impl Default for CommandRegistry {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207struct HelpCommand;
210
211impl SlashCommand for HelpCommand {
212 fn name(&self) -> &str {
213 "help"
214 }
215 fn description(&self) -> &str {
216 "List available commands"
217 }
218 fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
219 CommandOutput::text("Use /help to see available commands.")
222 }
223}
224
225struct CompactCommand;
226
227impl SlashCommand for CompactCommand {
228 fn name(&self) -> &str {
229 "compact"
230 }
231 fn description(&self) -> &str {
232 "Manually trigger context compaction"
233 }
234 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
235 CommandOutput::with_action(
236 format!(
237 "Compacting context... ({} messages, {} tokens)",
238 ctx.history_len, ctx.total_tokens
239 ),
240 CommandAction::Compact,
241 )
242 }
243}
244
245struct CostCommand;
246
247impl SlashCommand for CostCommand {
248 fn name(&self) -> &str {
249 "cost"
250 }
251 fn description(&self) -> &str {
252 "Show token usage and estimated cost"
253 }
254 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
255 CommandOutput::text(format!(
256 "Session: {}\n\
257 Model: {}\n\
258 Tokens: {}\n\
259 Cost: ${:.4}",
260 &ctx.session_id[..ctx.session_id.len().min(8)],
261 ctx.model,
262 ctx.total_tokens,
263 ctx.total_cost,
264 ))
265 }
266}
267
268struct ModelCommand;
269
270impl SlashCommand for ModelCommand {
271 fn name(&self) -> &str {
272 "model"
273 }
274 fn description(&self) -> &str {
275 "Show or switch the current model"
276 }
277 fn usage(&self) -> Option<&str> {
278 Some("/model [provider/model]")
279 }
280 fn execute(&self, args: &str, ctx: &CommandContext) -> CommandOutput {
281 if args.is_empty() {
282 CommandOutput::text(format!("Current model: {}", ctx.model))
283 } else if args.contains('/') {
284 CommandOutput::with_action(
285 format!("Switching model to: {args}"),
286 CommandAction::SwitchModel(args.to_string()),
287 )
288 } else {
289 CommandOutput::text(
290 "Usage: /model provider/model (e.g., /model anthropic/claude-sonnet-4-20250514)",
291 )
292 }
293 }
294}
295
296struct ClearCommand;
297
298impl SlashCommand for ClearCommand {
299 fn name(&self) -> &str {
300 "clear"
301 }
302 fn description(&self) -> &str {
303 "Clear conversation history"
304 }
305 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
306 CommandOutput::with_action(
307 format!("Cleared {} messages.", ctx.history_len),
308 CommandAction::ClearHistory,
309 )
310 }
311}
312
313struct HistoryCommand;
314
315impl SlashCommand for HistoryCommand {
316 fn name(&self) -> &str {
317 "history"
318 }
319 fn description(&self) -> &str {
320 "Show conversation stats"
321 }
322 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
323 CommandOutput::text(format!(
324 "Messages: {}\n\
325 Tokens: {}\n\
326 Session: {}",
327 ctx.history_len,
328 ctx.total_tokens,
329 &ctx.session_id[..ctx.session_id.len().min(8)],
330 ))
331 }
332}
333
334struct ToolsCommand;
335
336impl SlashCommand for ToolsCommand {
337 fn name(&self) -> &str {
338 "tools"
339 }
340 fn description(&self) -> &str {
341 "List registered tools"
342 }
343 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
344 if ctx.tool_names.is_empty() {
345 return CommandOutput::text("No tools registered.");
346 }
347 let builtin: Vec<&str> = ctx
348 .tool_names
349 .iter()
350 .filter(|t| !t.starts_with("mcp__"))
351 .map(|s| s.as_str())
352 .collect();
353 let mcp: Vec<&str> = ctx
354 .tool_names
355 .iter()
356 .filter(|t| t.starts_with("mcp__"))
357 .map(|s| s.as_str())
358 .collect();
359
360 let mut out = format!("Tools: {} total\n", ctx.tool_names.len());
361 if !builtin.is_empty() {
362 out.push_str(&format!("\nBuiltin ({}):\n", builtin.len()));
363 for t in &builtin {
364 out.push_str(&format!(" • {t}\n"));
365 }
366 }
367 if !mcp.is_empty() {
368 out.push_str(&format!("\nMCP ({}):\n", mcp.len()));
369 for t in &mcp {
370 out.push_str(&format!(" • {t}\n"));
371 }
372 }
373 CommandOutput::text(out.trim_end())
374 }
375}
376
377struct McpCommand;
378
379impl SlashCommand for McpCommand {
380 fn name(&self) -> &str {
381 "mcp"
382 }
383 fn description(&self) -> &str {
384 "List connected MCP servers and their tools"
385 }
386 fn execute(&self, _args: &str, ctx: &CommandContext) -> CommandOutput {
387 if ctx.mcp_servers.is_empty() {
388 return CommandOutput::text("No MCP servers connected.");
389 }
390 let total_tools: usize = ctx.mcp_servers.iter().map(|(_, c)| c).sum();
391 let mut out = format!(
392 "MCP: {} server(s), {} tool(s)\n",
393 ctx.mcp_servers.len(),
394 total_tools
395 );
396 for (server, count) in &ctx.mcp_servers {
397 out.push_str(&format!("\n {server} ({count} tools)"));
398 let prefix = format!("mcp__{server}__");
400 let server_tools: Vec<&str> = ctx
401 .tool_names
402 .iter()
403 .filter(|t| t.starts_with(&prefix))
404 .map(|s| s.strip_prefix(&prefix).unwrap_or(s))
405 .collect();
406 for t in server_tools {
407 out.push_str(&format!("\n • {t}"));
408 }
409 }
410 CommandOutput::text(out)
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 fn test_ctx() -> CommandContext {
419 CommandContext {
420 session_id: "test-session-123".into(),
421 workspace: "/tmp/test".into(),
422 model: "openai/kimi-k2.5".into(),
423 history_len: 10,
424 total_tokens: 5000,
425 total_cost: 0.0123,
426 tool_names: vec![
427 "read".into(),
428 "write".into(),
429 "bash".into(),
430 "mcp__github__create_issue".into(),
431 "mcp__github__list_repos".into(),
432 ],
433 mcp_servers: vec![("github".into(), 2)],
434 }
435 }
436
437 #[test]
438 fn test_is_command() {
439 assert!(CommandRegistry::is_command("/help"));
440 assert!(CommandRegistry::is_command(" /model foo"));
441 assert!(!CommandRegistry::is_command("hello"));
442 assert!(!CommandRegistry::is_command("not /a command"));
443 }
444
445 #[test]
446 fn test_dispatch_help() {
447 let reg = CommandRegistry::new();
448 let ctx = test_ctx();
449 let out = reg.dispatch("/help", &ctx).unwrap();
450 assert!(!out.text.is_empty());
451 }
452
453 #[test]
454 fn test_dispatch_cost() {
455 let reg = CommandRegistry::new();
456 let ctx = test_ctx();
457 let out = reg.dispatch("/cost", &ctx).unwrap();
458 assert!(out.text.contains("5000"));
459 assert!(out.text.contains("0.0123"));
460 }
461
462 #[test]
463 fn test_dispatch_model_show() {
464 let reg = CommandRegistry::new();
465 let ctx = test_ctx();
466 let out = reg.dispatch("/model", &ctx).unwrap();
467 assert!(out.text.contains("openai/kimi-k2.5"));
468 assert!(out.action.is_none());
469 }
470
471 #[test]
472 fn test_dispatch_model_switch() {
473 let reg = CommandRegistry::new();
474 let ctx = test_ctx();
475 let out = reg
476 .dispatch("/model anthropic/claude-sonnet-4-20250514", &ctx)
477 .unwrap();
478 assert!(matches!(out.action, Some(CommandAction::SwitchModel(_))));
479 }
480
481 #[test]
482 fn test_dispatch_clear() {
483 let reg = CommandRegistry::new();
484 let ctx = test_ctx();
485 let out = reg.dispatch("/clear", &ctx).unwrap();
486 assert!(matches!(out.action, Some(CommandAction::ClearHistory)));
487 assert!(out.text.contains("10"));
488 }
489
490 #[test]
491 fn test_dispatch_compact() {
492 let reg = CommandRegistry::new();
493 let ctx = test_ctx();
494 let out = reg.dispatch("/compact", &ctx).unwrap();
495 assert!(matches!(out.action, Some(CommandAction::Compact)));
496 }
497
498 #[test]
499 fn test_dispatch_unknown() {
500 let reg = CommandRegistry::new();
501 let ctx = test_ctx();
502 let out = reg.dispatch("/foobar", &ctx).unwrap();
503 assert!(out.text.contains("Unknown command"));
504 }
505
506 #[test]
507 fn test_not_a_command() {
508 let reg = CommandRegistry::new();
509 let ctx = test_ctx();
510 assert!(reg.dispatch("hello world", &ctx).is_none());
511 }
512
513 #[test]
514 fn test_custom_command() {
515 struct PingCommand;
516 impl SlashCommand for PingCommand {
517 fn name(&self) -> &str {
518 "ping"
519 }
520 fn description(&self) -> &str {
521 "Pong!"
522 }
523 fn execute(&self, _args: &str, _ctx: &CommandContext) -> CommandOutput {
524 CommandOutput::text("pong")
525 }
526 }
527
528 let mut reg = CommandRegistry::new();
529 let before = reg.len();
530 reg.register(Arc::new(PingCommand));
531 assert_eq!(reg.len(), before + 1);
532
533 let ctx = test_ctx();
534 let out = reg.dispatch("/ping", &ctx).unwrap();
535 assert_eq!(out.text, "pong");
536 }
537
538 #[test]
539 fn test_list_commands() {
540 let reg = CommandRegistry::new();
541 let list = reg.list();
542 assert!(list.len() >= 8);
543 assert!(list.iter().any(|(name, _)| *name == "help"));
544 assert!(list.iter().any(|(name, _)| *name == "compact"));
545 assert!(list.iter().any(|(name, _)| *name == "cost"));
546 assert!(list.iter().any(|(name, _)| *name == "mcp"));
547 }
548
549 #[test]
550 fn test_dispatch_tools() {
551 let reg = CommandRegistry::new();
552 let ctx = test_ctx();
553 let out = reg.dispatch("/tools", &ctx).unwrap();
554 assert!(out.text.contains("5 total"));
555 assert!(out.text.contains("read"));
556 assert!(out.text.contains("mcp__github__create_issue"));
557 }
558
559 #[test]
560 fn test_dispatch_mcp() {
561 let reg = CommandRegistry::new();
562 let ctx = test_ctx();
563 let out = reg.dispatch("/mcp", &ctx).unwrap();
564 assert!(out.text.contains("1 server(s)"));
565 assert!(out.text.contains("github"));
566 assert!(out.text.contains("create_issue"));
567 assert!(out.text.contains("list_repos"));
568 }
569
570 #[test]
571 fn test_dispatch_mcp_empty() {
572 let reg = CommandRegistry::new();
573 let mut ctx = test_ctx();
574 ctx.mcp_servers = vec![];
575 let out = reg.dispatch("/mcp", &ctx).unwrap();
576 assert!(out.text.contains("No MCP servers connected"));
577 }
578}