1use oxicode_vtui::tui::core::{
16 InlineHandle, InlineListItem, InlineListSelection, InlineMessageKind,
17};
18
19use crate::app::agent_session::AgentSessionHandle;
20use crate::tui_vt::main_loop::{RenderState, plain_segment};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) enum SlashOutcome {
25 Handled,
27 Quit,
29 NotHandled,
31}
32
33pub(crate) struct SlashCtx<'a> {
36 pub session: &'a AgentSessionHandle,
37 pub handle: &'a InlineHandle,
38 pub state: &'a mut RenderState,
39}
40
41impl SlashCtx<'_> {
42 pub(crate) fn reply(&self, kind: InlineMessageKind, text: impl Into<String>) {
47 let text = text.into();
48 for line in text.split('\n') {
49 self.handle
50 .append_line(kind, vec![plain_segment(line.to_string())]);
51 }
52 }
53}
54
55pub(crate) trait SlashCommand: Send + Sync {
61 fn name(&self) -> &'static str;
63 fn aliases(&self) -> &'static [&'static str] {
65 &[]
66 }
67 fn description(&self) -> &'static str;
69 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome;
71
72 fn matches(&self, token: &str) -> bool {
74 token.eq_ignore_ascii_case(self.name())
75 || self.aliases().iter().any(|a| token.eq_ignore_ascii_case(a))
76 }
77}
78
79pub struct SlashRegistry {
81 builtins: Vec<Box<dyn SlashCommand>>,
82}
83
84impl SlashRegistry {
85 pub fn builtins() -> Self {
87 let mut registry = SlashRegistry {
88 builtins: Vec::new(),
89 };
90 register_all(&mut registry);
91 registry
92 }
93
94 pub(crate) fn register(&mut self, cmd: Box<dyn SlashCommand>) {
96 self.builtins.push(cmd);
97 }
98
99 pub fn builtin_commands() -> Vec<(&'static str, &'static str, Vec<&'static str>)> {
103 Self::builtins()
104 .builtins
105 .iter()
106 .map(|c| (c.name(), c.description(), c.aliases().to_vec()))
107 .collect()
108 }
109
110 pub(crate) fn dispatch(&self, input: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
114 let trimmed = input.trim();
115 let (cmd_token, arg) = match trimmed.find(' ') {
116 Some(space) => (&trimmed[..space], trimmed[space + 1..].trim()),
117 None => (trimmed, ""),
118 };
119 let token = cmd_token.strip_prefix('/').unwrap_or(cmd_token);
120
121 if matches!(token, "help" | "?" | "commands") {
122 self.render_help(ctx);
123 return SlashOutcome::Handled;
124 }
125
126 for command in &self.builtins {
127 if command.matches(token) {
128 return command.execute(arg, ctx);
129 }
130 }
131 SlashOutcome::NotHandled
132 }
133
134 fn render_help(&self, ctx: &mut SlashCtx<'_>) {
135 let mut items: Vec<InlineListItem> = self
136 .builtins
137 .iter()
138 .map(|c| {
139 let mut title = format!("/{}", c.name());
140 for alias in c.aliases() {
141 title.push_str(&format!(", /{alias}"));
142 }
143 InlineListItem {
144 title,
145 subtitle: Some(c.description().to_string()),
146 badge: None,
147 indent: 0,
148 selection: Some(InlineListSelection::SlashCommand(c.name().to_string())),
149 search_value: None,
150 }
151 })
152 .collect();
153 items.sort_by(|a, b| a.title.cmp(&b.title));
154 ctx.handle.show_list_modal(
155 "Commands".to_string(),
156 vec!["Select a command (Esc to close)".to_string()],
157 items,
158 None,
159 None,
160 );
161 }
162}
163
164struct SettingsCommand;
167
168impl SlashCommand for SettingsCommand {
169 fn name(&self) -> &'static str {
170 "settings"
171 }
172 fn aliases(&self) -> &'static [&'static str] {
173 &["config"]
174 }
175 fn description(&self) -> &'static str {
176 "Show settings overlay (toggle thinking, compaction, advisor)"
177 }
178 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
179 use oxicode_vtui::tui::core::{
180 InlineListItem, InlineListSearchConfig, InlineListSelection,
181 };
182
183 let session = ctx.session;
184 let model = session.model_id();
185 let thinking = session.thinking_level();
186 let auto_compaction = session.auto_compaction_enabled();
187 let auto_retry = session.auto_retry_enabled();
188 let advisor = session.is_advisor_enabled();
189
190 let items = vec![
193 InlineListItem {
194 title: format!("Model: {model}"),
195 subtitle: Some("Use /model to switch".into()),
196 badge: None,
197 indent: 0,
198 selection: None,
199 search_value: Some("model".into()),
200 },
201 InlineListItem {
202 title: format!("Thinking: {thinking:?}"),
203 subtitle: Some("Enter to cycle".into()),
204 badge: None,
205 indent: 0,
206 selection: Some(InlineListSelection::ConfigAction("thinking_level".into())),
207 search_value: Some("thinking".into()),
208 },
209 InlineListItem {
210 title: format!(
211 "Auto-compaction: {}",
212 if auto_compaction { "on" } else { "off" }
213 ),
214 subtitle: Some("Enter to toggle".into()),
215 badge: None,
216 indent: 0,
217 selection: Some(InlineListSelection::ConfigAction("auto_compaction".into())),
218 search_value: Some("compaction".into()),
219 },
220 InlineListItem {
221 title: format!("Auto-retry: {}", if auto_retry { "on" } else { "off" }),
222 subtitle: Some("Enter to toggle".into()),
223 badge: None,
224 indent: 0,
225 selection: Some(InlineListSelection::ConfigAction("auto_retry".into())),
226 search_value: Some("retry".into()),
227 },
228 InlineListItem {
229 title: format!("Advisor: {}", if advisor { "on" } else { "off" }),
230 subtitle: Some("Enter to toggle".into()),
231 badge: None,
232 indent: 0,
233 selection: Some(InlineListSelection::ConfigAction("advisor".into())),
234 search_value: Some("advisor".into()),
235 },
236 ];
237
238 let search = InlineListSearchConfig {
239 label: "Filter settings".into(),
240 placeholder: Some("Type to filter\u{2026}".into()),
241 };
242 ctx.handle.show_list_modal(
243 "Settings".into(),
244 vec!["Select a setting to toggle/cycle (Esc to close)".into()],
245 items,
246 None,
247 Some(search),
248 );
249 SlashOutcome::Handled
250 }
251}
252
253struct SessionsCommand;
256
257impl SlashCommand for SessionsCommand {
258 fn name(&self) -> &'static str {
259 "sessions"
260 }
261 fn aliases(&self) -> &'static [&'static str] {
262 &["resume"]
263 }
264 fn description(&self) -> &'static str {
265 "Browse and resume past sessions"
266 }
267 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
268 use oxicode_vtui::tui::core::{InlineListItem, InlineListSearchConfig};
269
270 let session_dir = dirs::home_dir()
272 .map(|h| h.join(".oxicode").join("sessions"))
273 .unwrap_or_else(|| std::path::PathBuf::from(".oxicode/sessions"));
274
275 let mut entries: Vec<(String, std::time::SystemTime)> = Vec::new();
277 if let Ok(dir) = std::fs::read_dir(&session_dir) {
278 for entry in dir.flatten() {
279 let path = entry.path();
280 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
281 let id = path
282 .file_stem()
283 .map(|s| s.to_string_lossy().to_string())
284 .unwrap_or_default();
285 let mtime = entry
286 .metadata()
287 .ok()
288 .and_then(|m| m.modified().ok())
289 .unwrap_or(std::time::UNIX_EPOCH);
290 entries.push((id, mtime));
291 }
292 }
293 }
294 entries.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
295 entries.truncate(30); if entries.is_empty() {
298 ctx.reply(InlineMessageKind::Info, "No saved sessions found.");
299 return SlashOutcome::Handled;
300 }
301
302 let items: Vec<InlineListItem> = entries
303 .iter()
304 .map(|(id, mtime)| {
305 let time_str = format_relative_time(*mtime);
306 InlineListItem {
307 title: format!("{id} \u{00b7} {time_str}"),
308 subtitle: Some("Enter to resume".into()),
309 badge: None,
310 indent: 0,
311 selection: Some(InlineListSelection::Session(id.clone())),
312 search_value: Some(id.clone()),
313 }
314 })
315 .collect();
316
317 let search = InlineListSearchConfig {
318 label: "Filter sessions".into(),
319 placeholder: Some("Type to filter\u{2026}".into()),
320 };
321 ctx.handle.show_list_modal(
322 "Sessions".into(),
323 vec!["Select a session to resume (Esc to close)".into()],
324 items,
325 None,
326 Some(search),
327 );
328 SlashOutcome::Handled
329 }
330}
331
332fn format_relative_time(t: std::time::SystemTime) -> String {
334 let now = std::time::SystemTime::now();
335 match now.duration_since(t) {
336 Ok(d) => {
337 let mins = d.as_secs() / 60;
338 if mins < 1 {
339 "just now".into()
340 } else if mins < 60 {
341 format!("{mins}m ago")
342 } else if mins < 60 * 24 {
343 format!("{}h ago", mins / 60)
344 } else if mins < 60 * 24 * 7 {
345 format!("{}d ago", mins / (60 * 24))
346 } else {
347 format!("{}w ago", mins / (60 * 24 * 7))
348 }
349 }
350 Err(_) => "unknown".into(),
351 }
352}
353
354fn register_all(registry: &mut SlashRegistry) {
355 registry.register(Box::new(QuitCommand));
356 registry.register(Box::new(ClearCommand));
357 registry.register(Box::new(CompactCommand));
358 registry.register(Box::new(ModelCommand));
359 registry.register(Box::new(CancelCommand));
360 registry.register(Box::new(StatusCommand));
361 registry.register(Box::new(SettingsCommand));
362 registry.register(Box::new(VimCommand));
363 registry.register(Box::new(AgentsCommand));
364 registry.register(Box::new(ThemeCommand));
365 registry.register(Box::new(FindCommand));
366 registry.register(Box::new(SessionsCommand));
367 registry.register(Box::new(ShortcutsCommand));
368}
369
370struct VimCommand;
372
373impl SlashCommand for VimCommand {
374 fn name(&self) -> &'static str {
375 "vim"
376 }
377 fn description(&self) -> &'static str {
378 "Toggle vim mode for prompt editing"
379 }
380 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
381 let enabled = !ctx.state.vim_state.enabled();
382 ctx.state.vim_state.set_enabled(enabled);
383 ctx.reply(
384 InlineMessageKind::Info,
385 if enabled {
386 "Vim mode: ON — press Esc for Normal, i for Insert".to_string()
387 } else {
388 "Vim mode: OFF".to_string()
389 },
390 );
391 SlashOutcome::Handled
392 }
393}
394
395struct AgentsCommand;
397
398impl SlashCommand for AgentsCommand {
399 fn name(&self) -> &'static str {
400 "agents"
401 }
402 fn aliases(&self) -> &'static [&'static str] {
403 &["hub"]
404 }
405 fn description(&self) -> &'static str {
406 "Open the Agent Hub overlay (alias: /hub)"
407 }
408 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
409 ctx.state.agent_hub_open = true;
410 ctx.state.hub_entries = ctx.session.hub().snapshot();
411 SlashOutcome::Handled
412 }
413}
414
415struct QuitCommand;
421
422impl SlashCommand for QuitCommand {
423 fn name(&self) -> &'static str {
424 "quit"
425 }
426 fn aliases(&self) -> &'static [&'static str] {
427 &["exit", "q"]
428 }
429 fn description(&self) -> &'static str {
430 "Quit oxicode (aliases: /exit, /q)"
431 }
432 fn execute(&self, _args: &str, _ctx: &mut SlashCtx<'_>) -> SlashOutcome {
433 SlashOutcome::Quit
434 }
435}
436
437struct ClearCommand;
439
440impl SlashCommand for ClearCommand {
441 fn name(&self) -> &'static str {
442 "clear"
443 }
444 fn aliases(&self) -> &'static [&'static str] {
445 &["cls"]
446 }
447 fn description(&self) -> &'static str {
448 "Clear the conversation and transcript (alias: /cls)"
449 }
450 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
451 if !args.split_whitespace().any(|a| a == "--yes") {
454 ctx.state.confirmation = Some(super::super::main_loop::clear_confirmation());
455 return SlashOutcome::Handled;
456 }
457 ctx.session.reset();
458 ctx.state.transcript.clear();
459 ctx.state.message_buffer.clear();
460 ctx.state.scroll_offset = usize::MAX;
461 ctx.reply(InlineMessageKind::Info, "Conversation cleared.");
462 SlashOutcome::Handled
463 }
464}
465
466struct CompactCommand;
468
469impl SlashCommand for CompactCommand {
470 fn name(&self) -> &'static str {
471 "compact"
472 }
473 fn description(&self) -> &'static str {
474 "Compact the context (optional: /compact <instructions>)"
475 }
476 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
477 let instructions = args.trim();
478 let arg = if instructions.is_empty() {
479 None
480 } else {
481 Some(instructions.to_string())
482 };
483 let session = ctx.session.clone();
484 ctx.reply(InlineMessageKind::Info, "Compacting\u{2026}");
485 tokio::spawn(async move {
486 match session.compact(arg).await {
487 Ok(result) => tracing::info!(?result, "manual compaction complete"),
488 Err(err) => tracing::warn!(%err, "manual compaction failed"),
489 }
490 });
491 SlashOutcome::Handled
492 }
493}
494
495struct ModelCommand;
500
501impl SlashCommand for ModelCommand {
502 fn name(&self) -> &'static str {
503 "model"
504 }
505 fn description(&self) -> &'static str {
506 "Show or switch model (/model [<id>|next])"
507 }
508 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
509 match args.trim() {
510 "" => {
511 let models = ctx.session.scoped_models();
512 if models.is_empty() {
513 ctx.reply(
514 InlineMessageKind::Info,
515 format!("Current model: {}", ctx.session.model_id()),
516 );
517 } else {
518 ctx.state.overlay_model_ids = models
520 .iter()
521 .map(|m| format!("{}/{}", m.provider, m.model_id))
522 .collect();
523 let current = ctx.session.model_id();
524 let items: Vec<InlineListItem> = models
525 .iter()
526 .enumerate()
527 .map(|(i, m)| {
528 let id = format!("{}/{}", m.provider, m.model_id);
529 InlineListItem {
530 title: id.clone(),
531 subtitle: Some(m.provider.clone()),
532 badge: if id == current {
533 Some("active".to_string())
534 } else {
535 None
536 },
537 indent: 0,
538 selection: Some(InlineListSelection::Model(i)),
539 search_value: None,
540 }
541 })
542 .collect();
543 ctx.handle.show_list_modal(
544 "Models".to_string(),
545 vec!["Select a model (Esc to close)".to_string()],
546 items,
547 None,
548 None,
549 );
550 }
551 }
552 "next" | "cycle" => match ctx.session.cycle_model() {
553 Some(new_id) => ctx.reply(InlineMessageKind::Info, format!("Switched to {new_id}")),
554 None => ctx.reply(
555 InlineMessageKind::Warning,
556 "No scoped models configured to cycle.",
557 ),
558 },
559 id => match ctx.session.set_model(id) {
560 Ok(()) => ctx.reply(InlineMessageKind::Info, format!("Switched to {id}")),
561 Err(err) => ctx.reply(
562 InlineMessageKind::Error,
563 format!("Failed to set model {id}: {err}"),
564 ),
565 },
566 }
567 SlashOutcome::Handled
568 }
569}
570
571struct CancelCommand;
573
574impl SlashCommand for CancelCommand {
575 fn name(&self) -> &'static str {
576 "cancel"
577 }
578 fn aliases(&self) -> &'static [&'static str] {
579 &["stop"]
580 }
581 fn description(&self) -> &'static str {
582 "Abort the current run (alias: /stop)"
583 }
584 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
585 let session = ctx.session.clone();
586 tokio::spawn(async move {
587 session.abort().await;
588 });
589 SlashOutcome::Handled
590 }
591}
592
593struct StatusCommand;
595
596impl SlashCommand for StatusCommand {
597 fn name(&self) -> &'static str {
598 "status"
599 }
600 fn description(&self) -> &'static str {
601 "Show model and session stats"
602 }
603 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
604 let stats = ctx.session.session_stats();
605 let model = ctx.session.model_id();
606 ctx.reply(
607 InlineMessageKind::Info,
608 format!(
609 "Model: {model}\n\
610 Messages: {} user / {} assistant\n\
611 Tool calls: {} (results: {})\n\
612 Total: {}",
613 stats.user_messages,
614 stats.assistant_messages,
615 stats.tool_calls,
616 stats.tool_results,
617 stats.total_messages,
618 ),
619 );
620 SlashOutcome::Handled
621 }
622}
623
624struct ThemeCommand;
629
630impl SlashCommand for ThemeCommand {
631 fn name(&self) -> &'static str {
632 "theme"
633 }
634 fn aliases(&self) -> &'static [&'static str] {
635 &["t"]
636 }
637 fn description(&self) -> &'static str {
638 "Cycle or pick a color theme (/theme [name|list])"
639 }
640 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
641 use oxicode_vtui::theme::{
642 active_theme_id, available_themes, set_active_theme, theme_label,
643 };
644 match args.trim() {
645 "" | "next" | "cycle" => {
646 let themes = available_themes();
647 if themes.len() <= 1 {
648 ctx.reply(InlineMessageKind::Info, "Only one theme available.");
649 } else {
650 let current = active_theme_id();
651 let pos = themes.iter().position(|t| *t == current).unwrap_or(0);
652 let next_id = &themes[(pos + 1) % themes.len()];
653 match set_active_theme(next_id) {
654 Ok(()) => {
655 let label = theme_label(next_id).unwrap_or(next_id.as_ref());
656 ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
657 }
658 Err(e) => ctx.reply(
659 InlineMessageKind::Error,
660 format!("Failed to set theme: {e}"),
661 ),
662 }
663 }
664 }
665 "list" | "picker" => {
666 let themes = available_themes();
667 let current = active_theme_id();
668 let items: Vec<InlineListItem> = themes
669 .iter()
670 .map(|id| InlineListItem {
671 title: theme_label(id).unwrap_or(id.as_ref()).to_string(),
672 subtitle: Some(id.to_string()),
673 badge: if *id == current {
674 Some("active".to_string())
675 } else {
676 None
677 },
678 indent: 0,
679 selection: Some(InlineListSelection::Theme(id.to_string())),
680 search_value: Some(id.to_string()),
681 })
682 .collect();
683 ctx.handle.show_list_modal(
684 "Themes".to_string(),
685 vec!["Select a theme (Esc to close, Enter to apply)".to_string()],
686 items,
687 None,
688 None,
689 );
690 }
691 name => match set_active_theme(name) {
692 Ok(()) => {
693 let label = theme_label(name).unwrap_or(name);
694 ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
695 }
696 Err(e) => ctx.reply(
697 InlineMessageKind::Error,
698 format!("Unknown theme '{name}': {e}"),
699 ),
700 },
701 }
702 SlashOutcome::Handled
703 }
704}
705
706struct FindCommand;
710
711impl SlashCommand for FindCommand {
712 fn name(&self) -> &'static str {
713 "find"
714 }
715 fn aliases(&self) -> &'static [&'static str] {
716 &["search", "/"]
717 }
718 fn description(&self) -> &'static str {
719 "Search transcript (/find <query>, n/N to navigate)"
720 }
721 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
722 let query = args.trim();
723 if query.is_empty() {
724 ctx.state.search = None;
725 ctx.reply(InlineMessageKind::Info, "Search cleared.");
726 } else {
727 ctx.state.start_search(query);
728 let count = ctx
729 .state
730 .search
731 .as_ref()
732 .map(|s| s.matches.len())
733 .unwrap_or(0);
734 if count == 0 {
735 ctx.reply(
736 InlineMessageKind::Warning,
737 format!("No matches for '{query}'."),
738 );
739 } else {
740 ctx.reply(
741 InlineMessageKind::Info,
742 format!(
743 "{count} match{} for '{query}'",
744 if count == 1 { "" } else { "es" }
745 ),
746 );
747 }
748 }
749 SlashOutcome::Handled
750 }
751}
752
753struct ShortcutsCommand;
755
756impl SlashCommand for ShortcutsCommand {
757 fn name(&self) -> &'static str {
758 "shortcuts"
759 }
760 fn aliases(&self) -> &'static [&'static str] {
761 &["keys", "cheatsheet"]
762 }
763 fn description(&self) -> &'static str {
764 "Show keyboard shortcuts (alias: ?)"
765 }
766 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
767 ctx.handle
768 .show_modal("Keyboard Shortcuts".to_string(), shortcuts_lines(), None);
769 SlashOutcome::Handled
770 }
771}
772
773fn shortcuts_lines() -> Vec<String> {
775 vec![
776 "".into(),
777 " Navigation".into(),
778 " j / ↓ Scroll down (line)".into(),
779 " k / ↑ Scroll up (line)".into(),
780 " Shift+J Next assistant turn".into(),
781 " Shift+K Previous user turn".into(),
782 " PgDn Scroll down (page)".into(),
783 " PgUp Scroll up (page)".into(),
784 " G Jump to bottom (follow)".into(),
785 " g Jump to top".into(),
786 "".into(),
787 " Blocks".into(),
788 " e Cycle block (collapse/truncate/expand)".into(),
789 " Shift+E Expand all blocks".into(),
790 " Ctrl+E Collapse all blocks".into(),
791 "".into(),
792 " Search".into(),
793 " /find <q> Search transcript".into(),
794 " n Next match".into(),
795 " N Previous match".into(),
796 " Esc Clear search".into(),
797 "".into(),
798 " Input".into(),
799 " Ctrl+M Toggle multiline input".into(),
800 " Ctrl+P Command palette".into(),
801 " Ctrl+Enter Send now (abort + submit)".into(),
802 " Esc Cancel run / quit (y to confirm)".into(),
803 "".into(),
804 " Other".into(),
805 " ? Show this cheatsheet".into(),
806 " /theme Cycle color theme".into(),
807 " /model Pick a model".into(),
808 " /vim Toggle vim mode".into(),
809 " Ctrl+C Cancel run (then y to quit)".into(),
810 "".into(),
811 ]
812}
813#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn builtins_register_expected_commands() {
823 let reg = SlashRegistry::builtins();
824 let names: Vec<&str> = reg.builtins.iter().map(|c| c.name()).collect();
825 assert!(names.contains(&"quit"));
826 assert!(names.contains(&"clear"));
827 assert!(names.contains(&"compact"));
828 assert!(names.contains(&"model"));
829 assert!(names.contains(&"cancel"));
830 assert!(names.contains(&"status"));
831 }
832
833 #[test]
834 fn matches_resolves_aliases_case_insensitively() {
835 let cmd = QuitCommand;
836 assert!(cmd.matches("quit"));
837 assert!(cmd.matches("EXIT"));
838 assert!(cmd.matches("q"));
839 assert!(!cmd.matches("quitter"));
840 }
841
842 #[test]
843 fn builtin_commands_exposes_aliases_for_rpc() {
844 let catalog = SlashRegistry::builtin_commands();
845 let quit = catalog
846 .iter()
847 .find(|(name, _, _)| *name == "quit")
848 .expect("quit command present");
849 assert!(quit.2.contains(&"exit"));
850 assert!(quit.2.contains(&"q"));
851 assert!(!quit.1.is_empty(), "quit has a description");
852 }
853
854 #[test]
855 fn dispatch_help_is_intercepted() {
856 let _reg = SlashRegistry::builtins();
859 assert!(matches!("help".strip_prefix('/').unwrap_or("help"), "help"));
863 }
864}