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 super::commands::register_extra(registry);
369}
370
371struct VimCommand;
373
374impl SlashCommand for VimCommand {
375 fn name(&self) -> &'static str {
376 "vim"
377 }
378 fn description(&self) -> &'static str {
379 "Toggle vim mode for prompt editing"
380 }
381 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
382 let enabled = !ctx.state.vim_state.enabled();
383 ctx.state.vim_state.set_enabled(enabled);
384 ctx.reply(
385 InlineMessageKind::Info,
386 if enabled {
387 "Vim mode: ON — press Esc for Normal, i for Insert".to_string()
388 } else {
389 "Vim mode: OFF".to_string()
390 },
391 );
392 SlashOutcome::Handled
393 }
394}
395
396struct AgentsCommand;
398
399impl SlashCommand for AgentsCommand {
400 fn name(&self) -> &'static str {
401 "agents"
402 }
403 fn aliases(&self) -> &'static [&'static str] {
404 &["hub"]
405 }
406 fn description(&self) -> &'static str {
407 "Open the Agent Hub overlay (alias: /hub)"
408 }
409 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
410 ctx.state.agent_hub_open = true;
411 ctx.state.hub_entries = ctx.session.hub().snapshot();
412 SlashOutcome::Handled
413 }
414}
415
416struct QuitCommand;
422
423impl SlashCommand for QuitCommand {
424 fn name(&self) -> &'static str {
425 "quit"
426 }
427 fn aliases(&self) -> &'static [&'static str] {
428 &["exit", "q"]
429 }
430 fn description(&self) -> &'static str {
431 "Quit oxicode (aliases: /exit, /q)"
432 }
433 fn execute(&self, _args: &str, _ctx: &mut SlashCtx<'_>) -> SlashOutcome {
434 SlashOutcome::Quit
435 }
436}
437
438struct ClearCommand;
440
441impl SlashCommand for ClearCommand {
442 fn name(&self) -> &'static str {
443 "clear"
444 }
445 fn aliases(&self) -> &'static [&'static str] {
446 &["cls"]
447 }
448 fn description(&self) -> &'static str {
449 "Clear the conversation and transcript (alias: /cls)"
450 }
451 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
452 if !args.split_whitespace().any(|a| a == "--yes") {
455 ctx.state.confirmation = Some(super::super::main_loop::clear_confirmation());
456 return SlashOutcome::Handled;
457 }
458 ctx.session.reset();
459 ctx.state.transcript.clear();
460 ctx.state.message_buffer.clear();
461 ctx.state.scroll_offset = usize::MAX;
462 ctx.reply(InlineMessageKind::Info, "Conversation cleared.");
463 SlashOutcome::Handled
464 }
465}
466
467struct CompactCommand;
469
470impl SlashCommand for CompactCommand {
471 fn name(&self) -> &'static str {
472 "compact"
473 }
474 fn description(&self) -> &'static str {
475 "Compact the context (optional: /compact <instructions>)"
476 }
477 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
478 let instructions = args.trim();
479 let arg = if instructions.is_empty() {
480 None
481 } else {
482 Some(instructions.to_string())
483 };
484 let session = ctx.session.clone();
485 ctx.reply(InlineMessageKind::Info, "Compacting\u{2026}");
486 tokio::spawn(async move {
487 match session.compact(arg).await {
488 Ok(result) => tracing::info!(?result, "manual compaction complete"),
489 Err(err) => tracing::warn!(%err, "manual compaction failed"),
490 }
491 });
492 SlashOutcome::Handled
493 }
494}
495
496struct ModelCommand;
501
502impl SlashCommand for ModelCommand {
503 fn name(&self) -> &'static str {
504 "model"
505 }
506 fn description(&self) -> &'static str {
507 "Show or switch model (/model [<id>|next])"
508 }
509 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
510 match args.trim() {
511 "" => {
512 let models = ctx.session.scoped_models();
513 if models.is_empty() {
514 ctx.reply(
515 InlineMessageKind::Info,
516 format!("Current model: {}", ctx.session.model_id()),
517 );
518 } else {
519 ctx.state.overlay_model_ids = models
521 .iter()
522 .map(|m| format!("{}/{}", m.provider, m.model_id))
523 .collect();
524 let current = ctx.session.model_id();
525 let items: Vec<InlineListItem> = models
526 .iter()
527 .enumerate()
528 .map(|(i, m)| {
529 let id = format!("{}/{}", m.provider, m.model_id);
530 let sub = ctx
531 .state
532 .catalog
533 .as_ref()
534 .and_then(|c| c.get_model_sync(&m.provider, &m.model_id))
535 .map(|e| {
536 format!(
537 "{} · {}",
538 m.provider,
539 super::commands::fmt_ctx(e.context_window)
540 )
541 })
542 .unwrap_or_else(|| m.provider.clone());
543 InlineListItem {
544 title: id.clone(),
545 subtitle: Some(sub),
546 badge: if id == current {
547 Some("active".to_string())
548 } else {
549 None
550 },
551 indent: 0,
552 selection: Some(InlineListSelection::Model(i)),
553 search_value: None,
554 }
555 })
556 .collect();
557 ctx.handle.show_list_modal(
558 "Models".to_string(),
559 vec!["Select a model (Esc to close)".to_string()],
560 items,
561 None,
562 None,
563 );
564 }
565 }
566 "next" | "cycle" => match ctx.session.cycle_model() {
567 Some(new_id) => ctx.reply(InlineMessageKind::Info, format!("Switched to {new_id}")),
568 None => ctx.reply(
569 InlineMessageKind::Warning,
570 "No scoped models configured to cycle.",
571 ),
572 },
573 id => match ctx.session.set_model(id) {
574 Ok(()) => ctx.reply(InlineMessageKind::Info, format!("Switched to {id}")),
575 Err(err) => ctx.reply(
576 InlineMessageKind::Error,
577 format!("Failed to set model {id}: {err}"),
578 ),
579 },
580 }
581 SlashOutcome::Handled
582 }
583}
584
585struct CancelCommand;
587
588impl SlashCommand for CancelCommand {
589 fn name(&self) -> &'static str {
590 "cancel"
591 }
592 fn aliases(&self) -> &'static [&'static str] {
593 &["stop"]
594 }
595 fn description(&self) -> &'static str {
596 "Abort the current run (alias: /stop)"
597 }
598 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
599 let session = ctx.session.clone();
600 tokio::spawn(async move {
601 session.abort().await;
602 });
603 SlashOutcome::Handled
604 }
605}
606
607struct StatusCommand;
609
610impl SlashCommand for StatusCommand {
611 fn name(&self) -> &'static str {
612 "status"
613 }
614 fn description(&self) -> &'static str {
615 "Show model and session stats"
616 }
617 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
618 let stats = ctx.session.session_stats();
619 let model = ctx.session.model_id();
620 let (provider, model_part) = super::commands::split_model_id(&model);
621 let auth = crate::store::auth_storage::shared_auth_storage();
622 let key = if auth.has(provider) { "set" } else { "missing" };
623 let ctx_win = ctx
624 .state
625 .catalog
626 .as_ref()
627 .and_then(|c| c.get_model_sync(provider, model_part))
628 .map(|e| super::commands::fmt_ctx(e.context_window))
629 .unwrap_or_else(|| "?".to_string());
630 let compaction = if ctx.session.auto_compaction_enabled() {
631 "on"
632 } else {
633 "off"
634 };
635 let advisor = if ctx.session.is_advisor_enabled() {
636 "on"
637 } else {
638 "off"
639 };
640 let thinking = ctx.session.thinking_level();
641 ctx.reply(
642 InlineMessageKind::Info,
643 format!(
644 "Model: {model} (key: {key}, {ctx_win})\n\
645 Provider: {provider} \u{00b7} Thinking: {thinking:?}\n\
646 Compaction: {compaction} \u{00b7} Advisor: {advisor}\n\
647 Messages: {} user / {} assistant\n\
648 Tool calls: {} (results: {})\n\
649 Total: {}",
650 stats.user_messages,
651 stats.assistant_messages,
652 stats.tool_calls,
653 stats.tool_results,
654 stats.total_messages,
655 ),
656 );
657 SlashOutcome::Handled
658 }
659}
660
661struct ThemeCommand;
666
667impl SlashCommand for ThemeCommand {
668 fn name(&self) -> &'static str {
669 "theme"
670 }
671 fn aliases(&self) -> &'static [&'static str] {
672 &["t"]
673 }
674 fn description(&self) -> &'static str {
675 "Cycle or pick a color theme (/theme [name|list])"
676 }
677 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
678 use oxicode_vtui::theme::{
679 active_theme_id, available_themes, set_active_theme, theme_label,
680 };
681 match args.trim() {
682 "" | "next" | "cycle" => {
683 let themes = available_themes();
684 if themes.len() <= 1 {
685 ctx.reply(InlineMessageKind::Info, "Only one theme available.");
686 } else {
687 let current = active_theme_id();
688 let pos = themes.iter().position(|t| *t == current).unwrap_or(0);
689 let next_id = &themes[(pos + 1) % themes.len()];
690 match set_active_theme(next_id) {
691 Ok(()) => {
692 let label = theme_label(next_id).unwrap_or(next_id.as_ref());
693 ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
694 }
695 Err(e) => ctx.reply(
696 InlineMessageKind::Error,
697 format!("Failed to set theme: {e}"),
698 ),
699 }
700 }
701 }
702 "list" | "picker" => {
703 let themes = available_themes();
704 let current = active_theme_id();
705 let items: Vec<InlineListItem> = themes
706 .iter()
707 .map(|id| InlineListItem {
708 title: theme_label(id).unwrap_or(id.as_ref()).to_string(),
709 subtitle: Some(id.to_string()),
710 badge: if *id == current {
711 Some("active".to_string())
712 } else {
713 None
714 },
715 indent: 0,
716 selection: Some(InlineListSelection::Theme(id.to_string())),
717 search_value: Some(id.to_string()),
718 })
719 .collect();
720 ctx.handle.show_list_modal(
721 "Themes".to_string(),
722 vec!["Select a theme (Esc to close, Enter to apply)".to_string()],
723 items,
724 None,
725 None,
726 );
727 }
728 name => match set_active_theme(name) {
729 Ok(()) => {
730 let label = theme_label(name).unwrap_or(name);
731 ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
732 }
733 Err(e) => ctx.reply(
734 InlineMessageKind::Error,
735 format!("Unknown theme '{name}': {e}"),
736 ),
737 },
738 }
739 SlashOutcome::Handled
740 }
741}
742
743struct FindCommand;
747
748impl SlashCommand for FindCommand {
749 fn name(&self) -> &'static str {
750 "find"
751 }
752 fn aliases(&self) -> &'static [&'static str] {
753 &["search", "/"]
754 }
755 fn description(&self) -> &'static str {
756 "Search transcript (/find <query>, n/N to navigate)"
757 }
758 fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
759 let query = args.trim();
760 if query.is_empty() {
761 ctx.state.search = None;
762 ctx.reply(InlineMessageKind::Info, "Search cleared.");
763 } else {
764 ctx.state.start_search(query);
765 let count = ctx
766 .state
767 .search
768 .as_ref()
769 .map(|s| s.matches.len())
770 .unwrap_or(0);
771 if count == 0 {
772 ctx.reply(
773 InlineMessageKind::Warning,
774 format!("No matches for '{query}'."),
775 );
776 } else {
777 ctx.reply(
778 InlineMessageKind::Info,
779 format!(
780 "{count} match{} for '{query}'",
781 if count == 1 { "" } else { "es" }
782 ),
783 );
784 }
785 }
786 SlashOutcome::Handled
787 }
788}
789
790struct ShortcutsCommand;
792
793impl SlashCommand for ShortcutsCommand {
794 fn name(&self) -> &'static str {
795 "shortcuts"
796 }
797 fn aliases(&self) -> &'static [&'static str] {
798 &["keys", "cheatsheet"]
799 }
800 fn description(&self) -> &'static str {
801 "Show keyboard shortcuts (alias: ?)"
802 }
803 fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
804 ctx.handle
805 .show_modal("Keyboard Shortcuts".to_string(), shortcuts_lines(), None);
806 SlashOutcome::Handled
807 }
808}
809
810fn shortcuts_lines() -> Vec<String> {
812 vec![
813 "".into(),
814 " Navigation".into(),
815 " j / ↓ Scroll down (line)".into(),
816 " k / ↑ Scroll up (line)".into(),
817 " Shift+J Next assistant turn".into(),
818 " Shift+K Previous user turn".into(),
819 " PgDn Scroll down (page)".into(),
820 " PgUp Scroll up (page)".into(),
821 " G Jump to bottom (follow)".into(),
822 " g Jump to top".into(),
823 "".into(),
824 " Blocks".into(),
825 " e Cycle block (collapse/truncate/expand)".into(),
826 " Shift+E Expand all blocks".into(),
827 " Ctrl+E Collapse all blocks".into(),
828 "".into(),
829 " Search".into(),
830 " /find <q> Search transcript".into(),
831 " n Next match".into(),
832 " N Previous match".into(),
833 " Esc Clear search".into(),
834 "".into(),
835 " Input".into(),
836 " Ctrl+M Toggle multiline input".into(),
837 " Ctrl+P Command palette".into(),
838 " Ctrl+Enter Send now (abort + submit)".into(),
839 " Esc Cancel run / quit (y to confirm)".into(),
840 "".into(),
841 " Other".into(),
842 " ? Show this cheatsheet".into(),
843 " /theme Cycle color theme".into(),
844 " /model Pick a model".into(),
845 " /models Browse all models".into(),
846 " /providers Manage API keys".into(),
847 " /tools List tools".into(),
848 " /mcp MCP status".into(),
849 " /info Diagnostics".into(),
850 " /export Save as HTML".into(),
851 " /vim Toggle vim mode".into(),
852 " Ctrl+C Cancel run (then y to quit)".into(),
853 "".into(),
854 ]
855}
856#[cfg(test)]
861mod tests {
862 use super::*;
863
864 #[test]
865 fn builtins_register_expected_commands() {
866 let reg = SlashRegistry::builtins();
867 let names: Vec<&str> = reg.builtins.iter().map(|c| c.name()).collect();
868 assert!(names.contains(&"quit"));
869 assert!(names.contains(&"clear"));
870 assert!(names.contains(&"compact"));
871 assert!(names.contains(&"model"));
872 assert!(names.contains(&"cancel"));
873 assert!(names.contains(&"status"));
874 }
875
876 #[test]
877 fn matches_resolves_aliases_case_insensitively() {
878 let cmd = QuitCommand;
879 assert!(cmd.matches("quit"));
880 assert!(cmd.matches("EXIT"));
881 assert!(cmd.matches("q"));
882 assert!(!cmd.matches("quitter"));
883 }
884
885 #[test]
886 fn builtin_commands_exposes_aliases_for_rpc() {
887 let catalog = SlashRegistry::builtin_commands();
888 let quit = catalog
889 .iter()
890 .find(|(name, _, _)| *name == "quit")
891 .expect("quit command present");
892 assert!(quit.2.contains(&"exit"));
893 assert!(quit.2.contains(&"q"));
894 assert!(!quit.1.is_empty(), "quit has a description");
895 }
896
897 #[test]
898 fn dispatch_help_is_intercepted() {
899 let _reg = SlashRegistry::builtins();
902 assert!(matches!("help".strip_prefix('/').unwrap_or("help"), "help"));
906 }
907}