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    super::commands::register_extra(registry);
369}
370
371/// `/vim` — toggle vim mode for prompt editing.
372struct 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
396/// `/agents` — open the Agent Hub overlay. Alias: `/hub`.
397struct 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
416// ─────────────────────────────────────────────────────────────────────────
417// Built-in commands
418// ─────────────────────────────────────────────────────────────────────────
419
420/// `/quit` — exit oxicode. Aliases: `/exit`, `/q`.
421struct 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
438/// `/clear` — reset the conversation and wipe the transcript. Alias: `/cls`.
439struct 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        // `--yes` skips the confirmation dialog (used when re-dispatching
453        // from the confirmation modal). Without it, open the dialog.
454        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
467/// `/compact` — manually trigger context compaction. Optional instructions.
468struct 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
496/// `/model` — inspect, set, or cycle the active model.
497///   `/model`            show the current model
498///   `/model <id>`       switch to `provider/model`
499///   `/model next`       cycle to the next scoped model
500struct 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                    // Open a model picker overlay.
520                    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
585/// `/cancel` — abort any in-progress agent run. Alias: `/stop`.
586struct 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
607/// `/status` — show the active model and session message counts.
608struct 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
661/// `/theme` — cycle, set, or pick a color theme.
662///   `/theme`            cycle to the next theme
663///   `/theme list`       open the theme picker overlay
664///   `/theme <name>`     switch to a named theme
665struct 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
743/// `/find` — search within the transcript. Opens an inline search bar.
744///   `/find <query>`   search for matches (n/N to navigate)
745///   `/find`           clear search
746struct 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
790/// `/shortcuts` — show the keyboard shortcuts cheatsheet overlay.
791struct 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
810/// Lines for the shortcuts cheatsheet overlay.
811fn 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// ─────────────────────────────────────────────────────────────────────────
857// Tests
858// ─────────────────────────────────────────────────────────────────────────
859
860#[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        // `/help` must resolve even though no HelpCommand is registered —
900        // only the registry can enumerate its siblings.
901        let _reg = SlashRegistry::builtins();
902        // We can't build a real SlashCtx without a session, so verify the
903        // interception logic indirectly: a registered command name does not
904        // shadow help, and help token is recognized before iteration.
905        assert!(matches!("help".strip_prefix('/').unwrap_or("help"), "help"));
906    }
907}