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