Skip to main content

oxicode/tui_vt/slash/
registry.rs

1//! Slash command registry for the VT TUI harness.
2//!
3//! Parsed in `crate::tui_vt::main_loop::handle_inline_event` when the
4//! submitted text begins with `/`. Each command owns its name, aliases, and
5//! execution. Adding a command = implement `SlashCommand` + register it in
6//! [`SlashRegistry::builtins`].
7//!
8//! Slash commands for the VT TUI harness. Each command owns its name, aliases,
9//! and execution; adding one = implement `SlashCommand` + register it in
10//! [`SlashRegistry::builtins`] (`register_all`). Overlay-driving commands
11//! (`/model`, `/settings`, `/sessions`, `/theme`) build an `InlineListSelection`
12//! modal whose submission is handled in `main_loop.rs`'s overlay-submission arm.
13//! `/issue` is not yet wired (no issue overlay in this harness).
14
15use 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/// Outcome of dispatching a slash command.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub(crate) enum SlashOutcome {
25    /// Command handled; remain in the event loop.
26    Handled,
27    /// Request application shutdown (`/quit`).
28    Quit,
29    /// No command matched; caller surfaces an "unknown command" notice.
30    NotHandled,
31}
32
33/// Everything a slash command execution needs, bundled so adding a
34/// dependency never changes every command's signature.
35pub(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    /// Append one or more transcript lines via the harness command channel —
43    /// the single source of truth (`apply_command` applies it to `RenderState`
44    /// on the next loop iteration, so we must NOT also mutate `state` here).
45    /// Newlines split into separate transcript lines.
46    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
55/// One slash command owns its definition and execution.
56///
57/// Adding a command = implementing this trait + registering in `builtins()`.
58/// Aliases live alongside the handler so the old "keep the table in sync with
59/// the match" drift cannot recur.
60pub(crate) trait SlashCommand: Send + Sync {
61    /// Canonical name, no leading `/` (e.g. `"quit"`).
62    fn name(&self) -> &'static str;
63    /// Alternative names resolved alongside `name()` (e.g. `["exit", "q"]`).
64    fn aliases(&self) -> &'static [&'static str] {
65        &[]
66    }
67    /// Short description shown in `/help` and RPC `get_commands`.
68    fn description(&self) -> &'static str;
69    /// Run the command. `args` is the trimmed text after the command token.
70    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome;
71
72    /// Whether `token` (no leading `/`) names this command (case-insensitive).
73    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
79/// Central registry of all built-in slash commands.
80pub struct SlashRegistry {
81    builtins: Vec<Box<dyn SlashCommand>>,
82}
83
84impl SlashRegistry {
85    /// Assemble all built-in commands.
86    pub fn builtins() -> Self {
87        let mut registry = SlashRegistry {
88            builtins: Vec::new(),
89        };
90        register_all(&mut registry);
91        registry
92    }
93
94    /// Register one command.
95    pub(crate) fn register(&mut self, cmd: Box<dyn SlashCommand>) {
96        self.builtins.push(cmd);
97    }
98
99    /// Static catalog for RPC `get_commands`: `(name, description, aliases)`.
100    /// Kept as a standalone associated fn so RPC can enumerate without
101    /// constructing a session/handle context.
102    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    /// Try to dispatch `input` (full `/cmd args…`) to a command. `/help` is
111    /// intercepted here because only the registry can enumerate its siblings.
112    /// Returns [`SlashOutcome::NotHandled`] if nothing matches.
113    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
164/// `/settings` — open a settings overlay showing current configuration.
165/// Selecting a toggleable item cycles/toggles its value through the session.
166struct 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        // Build setting items. Items with a `selection` are interactive;
191        // items without are read-only display.
192        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
253/// `/sessions` — open a session picker overlay listing recent sessions.
254/// Selecting a session fills `/resume <id>` into the prompt.
255struct 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        // Find the sessions directory.
271        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        // Scan session files synchronously, sorted by mtime desc.
276        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); // cap at 30 most recent
296
297        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
332/// Format a `SystemTime` as a human-readable relative time (e.g. "2h ago").
333fn 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
370/// `/vim` — toggle vim mode for prompt editing.
371struct 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
395/// `/agents` — open the Agent Hub overlay. Alias: `/hub`.
396struct 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
415// ─────────────────────────────────────────────────────────────────────────
416// Built-in commands
417// ─────────────────────────────────────────────────────────────────────────
418
419/// `/quit` — exit oxicode. Aliases: `/exit`, `/q`.
420struct 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
437/// `/clear` — reset the conversation and wipe the transcript. Alias: `/cls`.
438struct 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        // `--yes` skips the confirmation dialog (used when re-dispatching
452        // from the confirmation modal). Without it, open the dialog.
453        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
466/// `/compact` — manually trigger context compaction. Optional instructions.
467struct 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
495/// `/model` — inspect, set, or cycle the active model.
496///   `/model`            show the current model
497///   `/model <id>`       switch to `provider/model`
498///   `/model next`       cycle to the next scoped model
499struct 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                    // Open a model picker overlay.
519                    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
571/// `/cancel` — abort any in-progress agent run. Alias: `/stop`.
572struct 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
593/// `/status` — show the active model and session message counts.
594struct 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
624/// `/theme` — cycle, set, or pick a color theme.
625///   `/theme`            cycle to the next theme
626///   `/theme list`       open the theme picker overlay
627///   `/theme <name>`     switch to a named theme
628struct 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
706/// `/find` — search within the transcript. Opens an inline search bar.
707///   `/find <query>`   search for matches (n/N to navigate)
708///   `/find`           clear search
709struct 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
753/// `/shortcuts` — show the keyboard shortcuts cheatsheet overlay.
754struct 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
773/// Lines for the shortcuts cheatsheet overlay.
774fn 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// ─────────────────────────────────────────────────────────────────────────
814// Tests
815// ─────────────────────────────────────────────────────────────────────────
816
817#[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        // `/help` must resolve even though no HelpCommand is registered —
857        // only the registry can enumerate its siblings.
858        let _reg = SlashRegistry::builtins();
859        // We can't build a real SlashCtx without a session, so verify the
860        // interception logic indirectly: a registered command name does not
861        // shadow help, and help token is recognized before iteration.
862        assert!(matches!("help".strip_prefix('/').unwrap_or("help"), "help"));
863    }
864}