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` opens the issues panel (see `commands.rs::IssueCommand`).
14
15use oxicode_vtui::tui::core::{
16    InlineHandle, InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
17};
18
19use crate::app::agent_session::AgentSessionHandle;
20use crate::store::settings::Settings;
21use crate::tui_vt::main_loop::{RenderState, plain_segment};
22use crate::tui_vt::settings_defs::{SettingWidget, SettingsTab, defs_for_tab, get_display_value};
23
24/// Outcome of dispatching a slash command.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(crate) enum SlashOutcome {
27    /// Command handled; remain in the event loop.
28    Handled,
29    /// Request application shutdown (`/quit`).
30    Quit,
31    /// No command matched; caller surfaces an "unknown command" notice.
32    NotHandled,
33}
34
35/// Everything a slash command execution needs, bundled so adding a
36/// dependency never changes every command's signature.
37pub(crate) struct SlashCtx<'a> {
38    pub session: &'a AgentSessionHandle,
39    pub handle: &'a InlineHandle,
40    pub state: &'a mut RenderState,
41}
42
43impl SlashCtx<'_> {
44    /// Append one or more transcript lines via the harness command channel —
45    /// the single source of truth (`apply_command` applies it to `RenderState`
46    /// on the next loop iteration, so we must NOT also mutate `state` here).
47    /// Newlines split into separate transcript lines.
48    pub(crate) fn reply(&self, kind: InlineMessageKind, text: impl Into<String>) {
49        let text = text.into();
50        for line in text.split('\n') {
51            self.handle
52                .append_line(kind, vec![plain_segment(line.to_string())]);
53        }
54    }
55}
56
57/// One slash command owns its definition and execution.
58///
59/// Adding a command = implementing this trait + registering in `builtins()`.
60/// Aliases live alongside the handler so the old "keep the table in sync with
61/// the match" drift cannot recur.
62pub(crate) trait SlashCommand: Send + Sync {
63    /// Canonical name, no leading `/` (e.g. `"quit"`).
64    fn name(&self) -> &'static str;
65    /// Alternative names resolved alongside `name()` (e.g. `["exit", "q"]`).
66    fn aliases(&self) -> &'static [&'static str] {
67        &[]
68    }
69    /// Short description shown in `/help` and RPC `get_commands`.
70    fn description(&self) -> &'static str;
71    /// Run the command. `args` is the trimmed text after the command token.
72    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome;
73
74    /// Whether `token` (no leading `/`) names this command (case-insensitive).
75    fn matches(&self, token: &str) -> bool {
76        token.eq_ignore_ascii_case(self.name())
77            || self.aliases().iter().any(|a| token.eq_ignore_ascii_case(a))
78    }
79}
80
81/// Central registry of all built-in slash commands.
82pub struct SlashRegistry {
83    builtins: Vec<Box<dyn SlashCommand>>,
84}
85
86impl SlashRegistry {
87    /// Assemble all built-in commands.
88    pub fn builtins() -> Self {
89        let mut registry = SlashRegistry {
90            builtins: Vec::new(),
91        };
92        register_all(&mut registry);
93        registry
94    }
95
96    /// Register one command.
97    pub(crate) fn register(&mut self, cmd: Box<dyn SlashCommand>) {
98        self.builtins.push(cmd);
99    }
100
101    /// Static catalog for RPC `get_commands`: `(name, description, aliases)`.
102    /// Kept as a standalone associated fn so RPC can enumerate without
103    /// constructing a session/handle context.
104    pub fn builtin_commands() -> Vec<(&'static str, &'static str, Vec<&'static str>)> {
105        Self::builtins()
106            .builtins
107            .iter()
108            .map(|c| (c.name(), c.description(), c.aliases().to_vec()))
109            .collect()
110    }
111
112    /// Try to dispatch `input` (full `/cmd args…`) to a command. `/help` is
113    /// intercepted here because only the registry can enumerate its siblings.
114    /// Returns [`SlashOutcome::NotHandled`] if nothing matches.
115    pub(crate) fn dispatch(&self, input: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
116        let trimmed = input.trim();
117        let (cmd_token, arg) = match trimmed.find(' ') {
118            Some(space) => (&trimmed[..space], trimmed[space + 1..].trim()),
119            None => (trimmed, ""),
120        };
121        let token = cmd_token.strip_prefix('/').unwrap_or(cmd_token);
122
123        if matches!(token, "help" | "?" | "commands") {
124            self.render_help(ctx);
125            return SlashOutcome::Handled;
126        }
127
128        for command in &self.builtins {
129            if command.matches(token) {
130                return command.execute(arg, ctx);
131            }
132        }
133        SlashOutcome::NotHandled
134    }
135
136    fn render_help(&self, ctx: &mut SlashCtx<'_>) {
137        let mut items: Vec<InlineListItem> = self
138            .builtins
139            .iter()
140            .map(|c| {
141                let mut title = format!("/{}", c.name());
142                for alias in c.aliases() {
143                    title.push_str(&format!(", /{alias}"));
144                }
145                InlineListItem {
146                    title,
147                    subtitle: Some(c.description().to_string()),
148                    badge: None,
149                    indent: 0,
150                    selection: Some(InlineListSelection::SlashCommand(c.name().to_string())),
151                    search_value: None,
152                }
153            })
154            .collect();
155        items.sort_by(|a, b| a.title.cmp(&b.title));
156        ctx.handle.show_list_modal(
157            "Commands".to_string(),
158            vec!["Browse commands and insert one into the composer.".to_string()],
159            items,
160            None,
161            None,
162        );
163    }
164}
165
166/// Build the `/settings` overlay rows for one tab from the
167/// [`SETTING_DEFS`](crate::tui_vt::settings_defs::SETTING_DEFS) table —
168/// never a hand-written list; adding a def is all it takes to show up.
169///
170/// Emits a non-interactive heading row (`selection: None`, no subtitle
171/// or badge — the same convention the old read-only "Model" row used)
172/// whenever the group label changes, then one row per def with its live
173/// value from [`get_display_value`] as the badge. `Toggle` and `Cycle`
174/// rows submit a `ConfigAction` carrying the `SettingKey` Debug name;
175/// `Text` / `SubmenuSelect` / `Multiselect` / `Pointer` rows stay
176/// read-only here (their editors route through the overlay-event path
177/// in `main_loop.rs`).
178///
179/// The two `MapEditor` defs expand in place into per-entry rows:
180/// - `Keybindings` → one action row per `GlobalAction` (Enter opens the
181///   key-capture submenu) plus one indented row per bound combo.
182/// - `ModelRoles` → one row per role.
183///
184/// Returns the items plus a parallel `SettingsMapRow` table (index
185/// aligned, `None` for ordinary rows) that the settings panel's input
186/// handling consults for `Enter` / `d` / `n` on map rows.
187pub(crate) fn settings_overlay_items(
188    tab: SettingsTab,
189    settings: &Settings,
190) -> (
191    Vec<InlineListItem>,
192    Vec<Option<crate::tui_vt::settings_defs::SettingsMapRow>>,
193) {
194    use crate::tui_vt::settings_defs::{SettingKey, SettingsMapRow};
195    let mut items: Vec<InlineListItem> = Vec::new();
196    let mut rows: Vec<Option<SettingsMapRow>> = Vec::new();
197    let mut last_group: Option<&'static str> = None;
198    for def in defs_for_tab(tab, settings) {
199        if last_group != Some(def.group) {
200            items.push(InlineListItem {
201                title: def.group.to_string(),
202                subtitle: None,
203                badge: None,
204                indent: 0,
205                selection: None,
206                search_value: None,
207            });
208            rows.push(None);
209            last_group = Some(def.group);
210        }
211        // Map editors expand into per-entry rows; the generic def row
212        // never renders for them.
213        match (def.widget, def.key) {
214            (SettingWidget::MapEditor, SettingKey::Keybindings) => {
215                expand_keybinding_rows(settings, &mut items, &mut rows);
216                continue;
217            }
218            (SettingWidget::MapEditor, SettingKey::ModelRoles) => {
219                expand_model_role_rows(settings, &mut items, &mut rows);
220                continue;
221            }
222            _ => {}
223        }
224        let selection = match def.widget {
225            // Toggle/Cycle commit through the ConfigAction path (the
226            // overlay-submission arm routes the SettingKey Debug name).
227            SettingWidget::Toggle | SettingWidget::Cycle => {
228                Some(InlineListSelection::ConfigAction(format!("{:?}", def.key)))
229            }
230            // Text/SubmenuSelect/Multiselect each get their own
231            // selection variant: the editor opens on Enter with the
232            // SettingKey Debug name as the payload, routed via the
233            // matching arm in `handle_inline_event`.
234            SettingWidget::Text => Some(InlineListSelection::SettingTextEdit(format!(
235                "{:?}",
236                def.key
237            ))),
238            SettingWidget::SubmenuSelect(_) => Some(InlineListSelection::SettingSubmenuOpen(
239                format!("{:?}", def.key),
240            )),
241            SettingWidget::Multiselect => Some(InlineListSelection::SettingMultiselect(format!(
242                "{:?}",
243                def.key
244            ))),
245            // Pointer + MapEditor rows stay read-only here (MapEditor
246            // is expanded above into per-entry rows with their own
247            // selection variants; Pointer rows are owned by
248            // slash commands).
249            SettingWidget::MapEditor | SettingWidget::Pointer => None,
250        };
251        items.push(InlineListItem {
252            title: def.label.to_string(),
253            subtitle: Some(def.description.to_string()),
254            badge: Some(get_display_value(def.key, settings)),
255            indent: 0,
256            selection,
257            search_value: Some(format!("{} {} {:?}", def.label, def.description, def.key)),
258        });
259        rows.push(None);
260    }
261    (items, rows)
262}
263
264/// Expand the `Keybindings` MapEditor def: one action row per
265/// `GlobalAction` (Enter → key-capture submenu, selection carries the
266/// action name) followed by one indented row per bound combo. The
267/// combo list comes from a keymap hydrated exactly like the live one
268/// (defaults + user overrides), so the rows mirror what the next
269/// keystroke actually resolves.
270fn expand_keybinding_rows(
271    settings: &Settings,
272    items: &mut Vec<InlineListItem>,
273    rows: &mut Vec<Option<crate::tui_vt::settings_defs::SettingsMapRow>>,
274) {
275    use crate::tui_vt::settings_defs::SettingsMapRow;
276    let keymap = crate::tui_vt::keymap::Keymap::from_settings(&settings.keybindings);
277    for action in crate::tui_vt::keymap::GlobalAction::all() {
278        let name = action.name();
279        items.push(InlineListItem {
280            title: name.to_string(),
281            subtitle: Some("Enter: capture a new combo".into()),
282            badge: None,
283            indent: 0,
284            selection: Some(InlineListSelection::SettingKeyCapture(name.to_string())),
285            search_value: Some(name.to_string()),
286        });
287        rows.push(Some(SettingsMapRow::KeybindingAction(action)));
288        for combo in keymap.action_combos(action) {
289            let combo = combo.to_string();
290            items.push(InlineListItem {
291                title: combo.clone(),
292                subtitle: Some("d: remove".into()),
293                badge: None,
294                indent: 1,
295                selection: None,
296                search_value: Some(format!("{name} {combo}")),
297            });
298            rows.push(Some(SettingsMapRow::KeybindingCombo(action, combo)));
299        }
300    }
301}
302
303/// Expand the `ModelRoles` MapEditor def into one row per role
304/// (sorted by role so the list — and tests — stay deterministic):
305/// Enter edits the value, `d` deletes the role, `n` anywhere on the
306/// Model tab starts a new role. An empty map renders a hint row.
307fn expand_model_role_rows(
308    settings: &Settings,
309    items: &mut Vec<InlineListItem>,
310    rows: &mut Vec<Option<crate::tui_vt::settings_defs::SettingsMapRow>>,
311) {
312    use crate::tui_vt::settings_defs::SettingsMapRow;
313    if settings.model_roles.is_empty() {
314        items.push(InlineListItem {
315            title: "(no model roles)".into(),
316            subtitle: Some("n: add a role".into()),
317            badge: None,
318            indent: 1,
319            selection: None,
320            search_value: Some("model roles".into()),
321        });
322        rows.push(None);
323        return;
324    }
325    let mut roles: Vec<(&String, &String)> = settings.model_roles.iter().collect();
326    roles.sort();
327    for (role, model) in roles {
328        items.push(InlineListItem {
329            title: role.clone(),
330            subtitle: Some("Enter: edit \u{b7} d: delete".into()),
331            badge: Some(model.clone()),
332            indent: 0,
333            selection: None,
334            search_value: Some(format!("{role} {model}")),
335        });
336        rows.push(Some(SettingsMapRow::ModelRole(role.clone())));
337    }
338}
339
340/// `/settings` — open the settings panel overlay.
341///
342/// Rows come from the declarative settings table: a heading per group,
343/// one row per setting, live values as badges. Selecting a `Toggle` or
344/// `Cycle` row submits its `SettingKey` for an immediate apply.
345struct SettingsCommand;
346
347impl SlashCommand for SettingsCommand {
348    fn name(&self) -> &'static str {
349        "settings"
350    }
351    fn aliases(&self) -> &'static [&'static str] {
352        &["config"]
353    }
354    fn description(&self) -> &'static str {
355        "Open the settings panel"
356    }
357    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
358        let settings = Settings::load().unwrap_or_default();
359        // `handle_inline_event`'s ShowOverlay hydration replaces this
360        // flat list with the full tabbed panel (on the live tab); this
361        // request just carries the first tab's rows for harnesses that
362        // render the request verbatim.
363        let (items, _rows) = settings_overlay_items(SettingsTab::General, &settings);
364
365        let search = InlineListSearchConfig {
366            label: "Filter settings".into(),
367            placeholder: Some("Type to filter".into()),
368        };
369        ctx.handle.show_list_modal(
370            "Settings".into(),
371            vec!["Browse settings by group; filter with the search bar.".into()],
372            items,
373            None,
374            Some(search),
375        );
376        SlashOutcome::Handled
377    }
378}
379
380/// `/sessions` — open a session picker overlay listing recent sessions.
381/// Selecting a session enqueues a resume that fires on the next Enter.
382struct SessionsCommand;
383
384/// Resolve the TUI's session storage directory (mirrors the CLI's canonical
385/// `sessions/` dir, with the legacy `~/.oxicode/sessions/` read-only fallback
386/// so pre-migration sessions still show up in the picker).
387pub(crate) fn sessions_dir() -> std::path::PathBuf {
388    oxicode_catalog::oxi_home::read_path(std::path::Path::new("sessions"))
389        .unwrap_or_else(|| std::path::PathBuf::from(".oxicode/sessions"))
390}
391
392impl SessionsCommand {
393    fn open_picker(&self, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
394        use oxicode_vtui::tui::core::{InlineListItem, InlineListSearchConfig};
395
396        let session_dir = sessions_dir();
397        let mut entries: Vec<(String, std::time::SystemTime)> = Vec::new();
398        if let Ok(dir) = std::fs::read_dir(session_dir) {
399            for entry in dir.flatten() {
400                let path = entry.path();
401                if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
402                    let id = path
403                        .file_stem()
404                        .map(|s| s.to_string_lossy().to_string())
405                        .unwrap_or_default();
406                    let mtime = entry
407                        .metadata()
408                        .ok()
409                        .and_then(|m| m.modified().ok())
410                        .unwrap_or(std::time::UNIX_EPOCH);
411                    entries.push((id, mtime));
412                }
413            }
414        }
415        entries.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
416        entries.truncate(30);
417
418        if entries.is_empty() {
419            ctx.reply(InlineMessageKind::Info, "No saved sessions found.");
420            return SlashOutcome::Handled;
421        }
422
423        let items: Vec<InlineListItem> = entries
424            .iter()
425            .map(|(id, mtime)| {
426                let time_str = format_relative_time(*mtime);
427                InlineListItem {
428                    title: format!("{id}  \u{00b7}  {time_str}"),
429                    subtitle: Some("Enter to resume".into()),
430                    badge: None,
431                    indent: 0,
432                    selection: Some(InlineListSelection::Session(id.clone())),
433                    search_value: Some(id.clone()),
434                }
435            })
436            .collect();
437
438        let search = InlineListSearchConfig {
439            label: "Filter sessions".into(),
440            placeholder: Some("Type to filter\u{2026}".into()),
441        };
442        ctx.handle.show_list_modal(
443            "Sessions".into(),
444            vec!["Select a session to resume (Esc to close)".into()],
445            items,
446            None,
447            Some(search),
448        );
449        SlashOutcome::Handled
450    }
451}
452
453impl SlashCommand for SessionsCommand {
454    fn name(&self) -> &'static str {
455        "sessions"
456    }
457    fn aliases(&self) -> &'static [&'static str] {
458        &["resume"]
459    }
460    fn description(&self) -> &'static str {
461        "Browse and resume past sessions"
462    }
463    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
464        let arg = args.trim();
465        if arg.is_empty() {
466            return self.open_picker(ctx);
467        }
468
469        if ctx.session.is_streaming() {
470            ctx.reply(
471                InlineMessageKind::Error,
472                "Cannot resume while agent is running. Use /cancel first.",
473            );
474            return SlashOutcome::Handled;
475        }
476        let path = sessions_dir().join(format!("{arg}.jsonl"));
477        if !path.is_file() {
478            ctx.reply(
479                InlineMessageKind::Error,
480                format!("No session file: {}", path.display()),
481            );
482            return SlashOutcome::Handled;
483        }
484        ctx.state.pending_resume = Some(path);
485        ctx.reply(InlineMessageKind::Info, format!("Resuming {arg}…"));
486        SlashOutcome::Handled
487    }
488}
489/// Format a `SystemTime` as a human-readable relative time (e.g. "2h ago").
490fn format_relative_time(t: std::time::SystemTime) -> String {
491    let now = std::time::SystemTime::now();
492    match now.duration_since(t) {
493        Ok(d) => {
494            let mins = d.as_secs() / 60;
495            if mins < 1 {
496                "just now".into()
497            } else if mins < 60 {
498                format!("{mins}m ago")
499            } else if mins < 60 * 24 {
500                format!("{}h ago", mins / 60)
501            } else if mins < 60 * 24 * 7 {
502                format!("{}d ago", mins / (60 * 24))
503            } else {
504                format!("{}w ago", mins / (60 * 24 * 7))
505            }
506        }
507        Err(_) => "unknown".into(),
508    }
509}
510
511fn register_all(registry: &mut SlashRegistry) {
512    registry.register(Box::new(QuitCommand));
513    registry.register(Box::new(ClearCommand));
514    registry.register(Box::new(CompactCommand));
515    registry.register(Box::new(ModelCommand));
516    registry.register(Box::new(CancelCommand));
517    registry.register(Box::new(StatusCommand));
518    registry.register(Box::new(SettingsCommand));
519    registry.register(Box::new(VimCommand));
520    registry.register(Box::new(AgentsCommand));
521    registry.register(Box::new(ThemeCommand));
522    registry.register(Box::new(FindCommand));
523    registry.register(Box::new(SessionsCommand));
524    registry.register(Box::new(ShortcutsCommand));
525    registry.register(Box::new(HandoffCommand));
526    registry.register(Box::new(MemoryCommand));
527    registry.register(Box::new(super::todo_command::TodoCommand));
528    super::commands::register_extra(registry);
529}
530
531/// `/vim` — toggle vim mode for prompt editing.
532struct VimCommand;
533
534impl SlashCommand for VimCommand {
535    fn name(&self) -> &'static str {
536        "vim"
537    }
538    fn description(&self) -> &'static str {
539        "Toggle vim mode for prompt editing"
540    }
541    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
542        let enabled = !ctx.state.vim_state.enabled();
543        ctx.state.vim_state.set_enabled(enabled);
544        ctx.reply(
545            InlineMessageKind::Info,
546            if enabled {
547                "Vim mode: ON — press Esc for Normal, i for Insert".to_string()
548            } else {
549                "Vim mode: OFF".to_string()
550            },
551        );
552        SlashOutcome::Handled
553    }
554}
555
556/// `/memory` — oxibrain durable-memory status: daemon health, space stats,
557/// and recovery hints. Aliases: `/brain`, `/mem`.
558struct MemoryCommand;
559
560impl SlashCommand for MemoryCommand {
561    fn name(&self) -> &'static str {
562        "memory"
563    }
564    fn aliases(&self) -> &'static [&'static str] {
565        &["brain", "mem"]
566    }
567    fn description(&self) -> &'static str {
568        "Show oxibrain memory status (health, stats, recovery hints)"
569    }
570    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
571        let args = _args.to_string();
572        let handle = ctx.handle.clone();
573        // The command runs inside the TUI's tokio runtime, so the daemon
574        // round-trips go through `tokio::spawn`; replies land on the
575        // transcript via the cloned `InlineHandle`.
576        ctx.reply(InlineMessageKind::Info, "Brain memory — querying daemon…");
577        tokio::spawn(async move {
578            fn append(handle: &oxicode_vtui::tui::core::InlineHandle, text: String) {
579                for line in text.split('\n') {
580                    handle.append_line(
581                        InlineMessageKind::Info,
582                        vec![plain_segment(line.to_string())],
583                    );
584                }
585            }
586
587            let socket = crate::foundation::brain::default_socket_path();
588            let backend = crate::foundation::brain::BrainMemoryBackend::new(socket.clone());
589            let enabled = crate::store::settings::Settings::load()
590                .map(|s| s.memory_enabled)
591                .unwrap_or(true);
592            let mut out = format!("socket:    {}", socket.display());
593            out.push_str(if enabled {
594                "\ntools:     enabled (memory_enabled)"
595            } else {
596                "\ntools:     disabled (memory_enabled = false in settings)"
597            });
598            let restart = args.trim().eq_ignore_ascii_case("restart");
599            if restart {
600                match crate::foundation::brain_control::revive().await {
601                    Ok(msg) => out.push_str(&format!("\nrestart:   {msg}")),
602                    Err(err) => out.push_str(&format!("\nrestart:   {err}")),
603                }
604            }
605            match backend.ping().await {
606                Ok(()) => {
607                    out.push_str("\nhealth:    ok — oxibrain daemon connected");
608                    if let Ok(stats) = backend.stats().await {
609                        out.push_str(&format!(
610                            "\nstats:     episodes {} · entities {} · statements {} · contradictions {}",
611                            stats.get("episodes").and_then(|v| v.as_i64()).unwrap_or(-1),
612                            stats.get("entities").and_then(|v| v.as_i64()).unwrap_or(-1),
613                            stats.get("statements").and_then(|v| v.as_i64()).unwrap_or(-1),
614                            stats.get("contradictions").and_then(|v| v.as_i64()).unwrap_or(-1),
615                        ));
616                    }
617                    out.push_str(
618                        "\nhints:     chat tools memory_retain / memory_recall / memory_reflect \
619                         / memory_edit read+write this daemon",
620                    );
621                }
622                Err(e) => {
623                    out.push_str(&format!("\nhealth:    degraded — {e}"));
624                    // Installed but stopped? Revive instead of just
625                    // hinting: launchd bootstrap/kickstart when
626                    // supervised, detached spawn otherwise.
627                    match crate::foundation::brain_control::revive().await {
628                        Ok(msg) => {
629                            out.push_str(&format!("\nrevive:    {msg}"));
630                            out.push_str("\n            the health chip refreshes within ~20s");
631                        }
632                        Err(err) => out.push_str(&format!("\nrevive:    {err}")),
633                    }
634                    out.push_str(
635                        "\nhints:     set OXIBRAIN_SOCKET if the daemon lives elsewhere; \
636                         no local fallback exists by design",
637                    );
638                }
639            }
640            if crate::foundation::migrate::default_legacy_path().exists() {
641                out.push_str(
642                    "\nlegacy:    a legacy local store exists — run `oxicode migrate brain` \
643                     to move it into the daemon",
644                );
645            }
646            append(&handle, out);
647        });
648        SlashOutcome::Handled
649    }
650}
651
652/// `/agents` — open the Agent Hub overlay. Alias: `/hub`.
653struct AgentsCommand;
654
655impl SlashCommand for AgentsCommand {
656    fn name(&self) -> &'static str {
657        "agents"
658    }
659    fn aliases(&self) -> &'static [&'static str] {
660        &["hub"]
661    }
662    fn description(&self) -> &'static str {
663        "Open the Agent Hub overlay (alias: /hub)"
664    }
665    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
666        ctx.state.agent_hub_open = true;
667        ctx.state.hub_entries = ctx.session.hub().snapshot();
668        SlashOutcome::Handled
669    }
670}
671
672// ─────────────────────────────────────────────────────────────────────────
673// Built-in commands
674// ─────────────────────────────────────────────────────────────────────────
675
676/// `/quit` — exit oxicode. Aliases: `/exit`, `/q`.
677struct QuitCommand;
678
679impl SlashCommand for QuitCommand {
680    fn name(&self) -> &'static str {
681        "quit"
682    }
683    fn aliases(&self) -> &'static [&'static str] {
684        &["exit", "q"]
685    }
686    fn description(&self) -> &'static str {
687        "Quit oxicode (aliases: /exit, /q)"
688    }
689    fn execute(&self, _args: &str, _ctx: &mut SlashCtx<'_>) -> SlashOutcome {
690        SlashOutcome::Quit
691    }
692}
693
694/// `/clear` — reset the conversation and wipe the transcript. Alias: `/cls`.
695struct ClearCommand;
696
697impl SlashCommand for ClearCommand {
698    fn name(&self) -> &'static str {
699        "clear"
700    }
701    fn aliases(&self) -> &'static [&'static str] {
702        &["cls"]
703    }
704    fn description(&self) -> &'static str {
705        "Clear the conversation and transcript (alias: /cls)"
706    }
707    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
708        // `--yes` skips the confirmation dialog (used when re-dispatching
709        // from the confirmation modal). Without it, open the dialog.
710        if !args.split_whitespace().any(|a| a == "--yes") {
711            ctx.state.confirmation = Some(super::super::main_loop::clear_confirmation());
712            return SlashOutcome::Handled;
713        }
714        ctx.session.reset();
715        ctx.state.transcript.clear();
716        ctx.state.message_buffer.clear();
717        ctx.state.scroll_offset = usize::MAX;
718        ctx.reply(InlineMessageKind::Info, "Conversation cleared.");
719        SlashOutcome::Handled
720    }
721}
722
723/// `/compact` — manually trigger context compaction. Optional instructions.
724struct CompactCommand;
725
726impl SlashCommand for CompactCommand {
727    fn name(&self) -> &'static str {
728        "compact"
729    }
730    fn description(&self) -> &'static str {
731        "Compact the context (optional: /compact <instructions>)"
732    }
733    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
734        let instructions = args.trim();
735        let arg = if instructions.is_empty() {
736            None
737        } else {
738            Some(instructions.to_string())
739        };
740        let session = ctx.session.clone();
741        ctx.reply(InlineMessageKind::Info, "Compacting\u{2026}");
742        tokio::spawn(async move {
743            match session.compact(arg).await {
744                Ok(result) => tracing::info!(?result, "manual compaction complete"),
745                Err(err) => tracing::warn!(%err, "manual compaction failed"),
746            }
747        });
748        SlashOutcome::Handled
749    }
750}
751
752/// `/handoff` — generate a handoff document, start a fresh session, and
753/// optionally auto-continue. Alias: `/hd`.
754///
755///   `/handoff`                generate + new session + auto-continue
756///   `/handoff --review`       generate + new session, wait for user
757///   `/handoff --dry-run`      generate doc only, don't start new session
758///   `/handoff <slug>`         generate with a custom filename slug
759struct HandoffCommand;
760
761impl SlashCommand for HandoffCommand {
762    fn name(&self) -> &'static str {
763        "handoff"
764    }
765    fn aliases(&self) -> &'static [&'static str] {
766        &["hd"]
767    }
768    fn description(&self) -> &'static str {
769        "Generate a handoff doc and start a fresh session (alias: /hd)"
770    }
771    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
772        use crate::app::handoff::{HandoffOptions, generate_and_apply_handoff};
773
774        // Parse flags.
775        let mut auto_continue = true;
776        let mut dry_run = false;
777        let mut slug = None;
778        for arg in args.split_whitespace() {
779            match arg {
780                "--review" => auto_continue = false,
781                "--dry-run" => dry_run = true,
782                s if !s.starts_with('-') => slug = Some(s.to_string()),
783                _ => {}
784            }
785        }
786
787        // Gate: cannot hand off while agent is running.
788        if ctx.session.is_streaming() {
789            ctx.reply(
790                InlineMessageKind::Error,
791                "Cannot hand off while agent is running. Use /cancel first.",
792            );
793            return SlashOutcome::Handled;
794        }
795
796        // Gate: need enough conversation.
797        let msg_count = ctx.session.messages().len();
798        if msg_count < 2 {
799            ctx.reply(
800                InlineMessageKind::Error,
801                "Not enough conversation to hand off (need at least 2 messages).",
802            );
803            return SlashOutcome::Handled;
804        }
805
806        let opts = HandoffOptions {
807            slug,
808            auto_continue,
809            dry_run,
810        };
811
812        let msg = if dry_run {
813            "Generating handoff document (dry run)\u{2026}"
814        } else if auto_continue {
815            "Generating handoff and starting new session\u{2026}"
816        } else {
817            "Generating handoff document\u{2026}"
818        };
819        ctx.reply(InlineMessageKind::Info, msg);
820
821        // Show a spinner while the LLM call runs (10-30s). The handle is
822        // Clone (cheap Arc) and fire-and-forget — no awaiting the worker.
823        let handle = ctx.handle.clone();
824        handle.set_reasoning_stage(Some("Generating handoff\u{2026}".to_string()));
825        let session = ctx.session.clone();
826
827        tokio::spawn(async move {
828            let result = generate_and_apply_handoff(&session, &opts).await;
829            // Always clear the spinner, even on failure, so the footer
830            // doesn't stay stuck on "Generating handoff".
831            handle.set_reasoning_stage(None);
832            match result {
833                Ok(path) => tracing::info!(%path, "handoff complete"),
834                Err(err) => {
835                    tracing::warn!(%err, "handoff failed");
836                    session.emit_handoff_failed(err.to_string());
837                }
838            }
839        });
840
841        SlashOutcome::Handled
842    }
843}
844
845/// `/model` — inspect, set, or cycle the active model.
846///   `/model`            show the current model
847///   `/model <id>`       switch to `provider/model`
848///   `/model next`       cycle to the next scoped model
849struct ModelCommand;
850
851impl SlashCommand for ModelCommand {
852    fn name(&self) -> &'static str {
853        "model"
854    }
855    fn description(&self) -> &'static str {
856        "Show or switch model (/model [<id>|next])"
857    }
858    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
859        match args.trim() {
860            "" => {
861                let Some(catalog) = ctx.state.catalog.as_ref() else {
862                    // Catalog never loaded — keep the existing read-only
863                    // message so the user still gets *some* answer.
864                    ctx.reply(
865                        InlineMessageKind::Info,
866                        format!("Current model: {}", ctx.session.model_id()),
867                    );
868                    return SlashOutcome::Handled;
869                };
870
871                let auth = crate::store::auth_storage::shared_auth_storage();
872                let current = ctx.session.model_id();
873                let (cur_provider, cur_model_id) = super::commands::split_model_id(&current);
874
875                let (rows, used_fallback) =
876                    model_picker_rows(catalog, &auth, cur_provider, cur_model_id);
877
878                let keyed_provider_count = rows
879                    .iter()
880                    .filter(|e| auth.has(&e.provider))
881                    .map(|e| e.provider.as_str())
882                    .collect::<std::collections::BTreeSet<_>>()
883                    .len();
884                let filter_label = if used_fallback {
885                    "Showing full catalog — no providers with keys configured yet".to_string()
886                } else {
887                    format!(
888                        "Showing models from {keyed_provider_count} keyed provider{}",
889                        if keyed_provider_count == 1 { "" } else { "s" },
890                    )
891                };
892
893                ctx.state.overlay_model_ids = rows
894                    .iter()
895                    .map(|e| format!("{}/{}", e.provider, e.model_id))
896                    .collect();
897
898                let items: Vec<InlineListItem> = rows
899                    .iter()
900                    .enumerate()
901                    .map(|(i, e)| {
902                        let id = format!("{}/{}", e.provider, e.model_id);
903                        let mut sub = format!(
904                            "{} \u{00b7} {} in / {} out",
905                            super::commands::fmt_ctx(e.context_window),
906                            super::commands::fmt_cost(e.cost_input),
907                            super::commands::fmt_cost(e.cost_output),
908                        );
909                        if e.reasoning {
910                            sub.push_str(" \u{00b7} reasoning");
911                        }
912                        if e.supports_vision {
913                            sub.push_str(" \u{00b7} vision");
914                        }
915                        let badge = if id == current {
916                            Some("active".to_string())
917                        } else if used_fallback {
918                            None
919                        } else if !auth.has(&e.provider) {
920                            Some("no-key".to_string())
921                        } else {
922                            None
923                        };
924                        InlineListItem {
925                            title: id.clone(),
926                            subtitle: Some(sub),
927                            badge,
928                            indent: 0,
929                            selection: Some(InlineListSelection::Model(i)),
930                            search_value: Some(
931                                format!("{} {} {}", e.provider, e.model_id, e.name,),
932                            ),
933                        }
934                    })
935                    .collect();
936
937                let total = items.len();
938                let search = InlineListSearchConfig {
939                    label: "Filter models".into(),
940                    placeholder: Some("Type to filter (provider / model / name)\u{2026}".into()),
941                };
942                ctx.handle.show_list_modal(
943                    format!("Models ({total})"),
944                    vec![format!(
945                        "{filter_label} \u{2014} Enter to switch, Esc to close"
946                    )],
947                    items,
948                    None,
949                    Some(search),
950                );
951            }
952            "next" | "cycle" => match ctx.session.cycle_model() {
953                Some(new_id) => {
954                    crate::tui_vt::main_loop::sync_model_chips(ctx.state, ctx.session);
955                    ctx.reply(InlineMessageKind::Info, format!("Switched to {new_id}"))
956                }
957                None => ctx.reply(
958                    InlineMessageKind::Warning,
959                    "No scoped models configured to cycle.",
960                ),
961            },
962            id => match ctx.session.set_model(id) {
963                Ok(()) => {
964                    crate::tui_vt::main_loop::sync_model_chips(ctx.state, ctx.session);
965                    ctx.reply(InlineMessageKind::Info, format!("Switched to {id}"))
966                }
967                Err(err) => ctx.reply(
968                    InlineMessageKind::Error,
969                    format!("Failed to set model {id}: {err}"),
970                ),
971            },
972        }
973        SlashOutcome::Handled
974    }
975}
976
977/// Build the rows for the `/model` picker.
978///
979/// Rules:
980/// 1. Models from every provider where `auth.has(p)` is true are
981///    included (the current provider's models too — the user wants to
982///    see every model they can call, including the other models from
983///    their current provider).
984/// 2. The active model is always present and pinned at index 0,
985///    even if its provider has no key (e.g. key removed mid-session).
986/// 3. If neither (1) nor (2) produces a row, fall back to the full
987///    catalog and set `used_fallback = true` so the caller can drop
988///    "no-key" badges (every row would be "no-key" in that state and
989///    the footer already explains the fallback).
990fn model_picker_rows(
991    catalog: &std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>,
992    auth: &std::sync::Arc<crate::store::auth_storage::AuthStorage>,
993    cur_provider: &str,
994    cur_model_id: &str,
995) -> (Vec<oxicode_sdk::CatalogModelEntry>, bool) {
996    let all = catalog.search_sync("");
997
998    // Models from every provider the user has a key for. The current
999    // provider is NOT excluded — the user wants to see the other
1000    // models from their current provider, not just cross-provider
1001    // alternatives. The active model is deduplicated below.
1002    let mut keyed: Vec<_> = all
1003        .iter()
1004        .filter(|e| auth.has(&e.provider))
1005        .cloned()
1006        .collect();
1007
1008    let current_entry = all
1009        .iter()
1010        .find(|e| e.provider == cur_provider && e.model_id == cur_model_id)
1011        .cloned();
1012
1013    // Pin the active row at index 0. If the active model is already in
1014    // the keyed set (the normal case), drop the duplicate.
1015    let mut rows = Vec::with_capacity(keyed.len() + 1);
1016    if let Some(ce) = current_entry {
1017        keyed.retain(|e| !(e.provider == ce.provider && e.model_id == ce.model_id));
1018        rows.push(ce); // active row pinned to top
1019    }
1020    rows.append(&mut keyed);
1021
1022    if rows.is_empty() {
1023        (all, true)
1024    } else {
1025        (rows, false)
1026    }
1027}
1028/// `/cancel` — abort any in-progress agent run. Alias: `/stop`.
1029struct CancelCommand;
1030
1031impl SlashCommand for CancelCommand {
1032    fn name(&self) -> &'static str {
1033        "cancel"
1034    }
1035    fn aliases(&self) -> &'static [&'static str] {
1036        &["stop"]
1037    }
1038    fn description(&self) -> &'static str {
1039        "Abort the current run (alias: /stop)"
1040    }
1041    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1042        let session = ctx.session.clone();
1043        tokio::spawn(async move {
1044            session.abort().await;
1045        });
1046        SlashOutcome::Handled
1047    }
1048}
1049
1050/// `/status` — show the active model and session message counts.
1051struct StatusCommand;
1052
1053impl SlashCommand for StatusCommand {
1054    fn name(&self) -> &'static str {
1055        "status"
1056    }
1057    fn description(&self) -> &'static str {
1058        "Show model and session stats"
1059    }
1060    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1061        let stats = ctx.session.session_stats();
1062        let model = ctx.session.model_id();
1063        let (provider, model_part) = super::commands::split_model_id(&model);
1064        let auth = crate::store::auth_storage::shared_auth_storage();
1065        let key = if auth.has(provider) { "set" } else { "missing" };
1066        let ctx_win = ctx
1067            .state
1068            .catalog
1069            .as_ref()
1070            .and_then(|c| c.get_model_sync(provider, model_part))
1071            .map(|e| super::commands::fmt_ctx(e.context_window))
1072            .unwrap_or_else(|| "?".to_string());
1073        let compaction = if ctx.session.auto_compaction_enabled() {
1074            "on"
1075        } else {
1076            "off"
1077        };
1078        let advisor = if ctx.session.is_advisor_enabled() {
1079            "on"
1080        } else {
1081            "off"
1082        };
1083        let thinking = ctx.session.thinking_level();
1084        ctx.reply(
1085            InlineMessageKind::Info,
1086            format!(
1087                "Model: {model}  (key: {key}, {ctx_win})\n\
1088                 Provider: {provider}  \u{00b7}  Thinking: {thinking:?}\n\
1089                 Compaction: {compaction}  \u{00b7}  Advisor: {advisor}\n\
1090                 Messages: {} user / {} assistant\n\
1091                 Tool calls: {} (results: {})\n\
1092                 Total: {}",
1093                stats.user_messages,
1094                stats.assistant_messages,
1095                stats.tool_calls,
1096                stats.tool_results,
1097                stats.total_messages,
1098            ),
1099        );
1100        SlashOutcome::Handled
1101    }
1102}
1103
1104/// `/theme` — cycle, set, or pick a color theme.
1105///   `/theme`            cycle to the next theme
1106///   `/theme list`       open the theme picker overlay
1107///   `/theme <name>`     switch to a named theme
1108struct ThemeCommand;
1109
1110impl SlashCommand for ThemeCommand {
1111    fn name(&self) -> &'static str {
1112        "theme"
1113    }
1114    fn aliases(&self) -> &'static [&'static str] {
1115        &["t"]
1116    }
1117    fn description(&self) -> &'static str {
1118        "Cycle or pick a color theme (/theme [name|list])"
1119    }
1120    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1121        use oxicode_vtui::theme::{
1122            active_theme_id, available_themes, set_active_theme, theme_label,
1123        };
1124        match args.trim() {
1125            "" | "next" | "cycle" => {
1126                let themes = available_themes();
1127                if themes.len() <= 1 {
1128                    ctx.reply(InlineMessageKind::Info, "Only one theme available.");
1129                } else {
1130                    let current = active_theme_id();
1131                    let pos = themes.iter().position(|t| *t == current).unwrap_or(0);
1132                    let next_id = &themes[(pos + 1) % themes.len()];
1133                    match set_active_theme(next_id) {
1134                        Ok(()) => {
1135                            let label = theme_label(next_id).unwrap_or(next_id.as_ref());
1136                            ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
1137                        }
1138                        Err(e) => ctx.reply(
1139                            InlineMessageKind::Error,
1140                            format!("Failed to set theme: {e}"),
1141                        ),
1142                    }
1143                }
1144            }
1145            "list" | "picker" => {
1146                let themes = available_themes();
1147                let current = active_theme_id();
1148                let items: Vec<InlineListItem> = themes
1149                    .iter()
1150                    .map(|id| InlineListItem {
1151                        title: theme_label(id).unwrap_or(id.as_ref()).to_string(),
1152                        subtitle: Some(id.to_string()),
1153                        badge: if *id == current {
1154                            Some("active".to_string())
1155                        } else {
1156                            None
1157                        },
1158                        indent: 0,
1159                        selection: Some(InlineListSelection::Theme(id.to_string())),
1160                        search_value: Some(id.to_string()),
1161                    })
1162                    .collect();
1163                ctx.handle.show_list_modal(
1164                    "Themes".to_string(),
1165                    vec!["Select a theme (Esc to close, Enter to apply)".to_string()],
1166                    items,
1167                    None,
1168                    None,
1169                );
1170            }
1171            name => match set_active_theme(name) {
1172                Ok(()) => {
1173                    let label = theme_label(name).unwrap_or(name);
1174                    ctx.reply(InlineMessageKind::Info, format!("Theme: {label}"));
1175                }
1176                Err(e) => ctx.reply(
1177                    InlineMessageKind::Error,
1178                    format!("Unknown theme '{name}': {e}"),
1179                ),
1180            },
1181        }
1182        SlashOutcome::Handled
1183    }
1184}
1185
1186/// `/find` — search within the transcript. Opens an inline search bar.
1187///   `/find <query>`   search for matches (n/N to navigate)
1188///   `/find`           clear search
1189struct FindCommand;
1190
1191impl SlashCommand for FindCommand {
1192    fn name(&self) -> &'static str {
1193        "find"
1194    }
1195    fn aliases(&self) -> &'static [&'static str] {
1196        &["search", "/"]
1197    }
1198    fn description(&self) -> &'static str {
1199        "Search transcript (/find <query>, n/N to navigate)"
1200    }
1201    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1202        let query = args.trim();
1203        if query.is_empty() {
1204            ctx.state.search = None;
1205            ctx.reply(InlineMessageKind::Info, "Search cleared.");
1206        } else {
1207            ctx.state.start_search(query);
1208            let count = ctx
1209                .state
1210                .search
1211                .as_ref()
1212                .map(|s| s.matches.len())
1213                .unwrap_or(0);
1214            if count == 0 {
1215                ctx.reply(
1216                    InlineMessageKind::Warning,
1217                    format!("No matches for '{query}'."),
1218                );
1219            } else {
1220                ctx.reply(
1221                    InlineMessageKind::Info,
1222                    format!(
1223                        "{count} match{} for '{query}'",
1224                        if count == 1 { "" } else { "es" }
1225                    ),
1226                );
1227            }
1228        }
1229        SlashOutcome::Handled
1230    }
1231}
1232
1233/// `/shortcuts` — show the keyboard shortcuts cheatsheet overlay.
1234struct ShortcutsCommand;
1235
1236impl SlashCommand for ShortcutsCommand {
1237    fn name(&self) -> &'static str {
1238        "shortcuts"
1239    }
1240    fn aliases(&self) -> &'static [&'static str] {
1241        &["keys", "cheatsheet"]
1242    }
1243    fn description(&self) -> &'static str {
1244        "Show keyboard shortcuts (alias: ?)"
1245    }
1246    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
1247        ctx.handle
1248            .show_modal("Keyboard Shortcuts".to_string(), shortcuts_lines(), None);
1249        SlashOutcome::Handled
1250    }
1251}
1252
1253/// Lines for the shortcuts cheatsheet overlay.
1254fn shortcuts_lines() -> Vec<String> {
1255    vec![
1256        "".into(),
1257        "  Navigation".into(),
1258        "  j / ↓      Scroll down (line)".into(),
1259        "  k / ↑      Scroll up (line)".into(),
1260        "  Shift+J    Next assistant turn".into(),
1261        "  Shift+K    Previous user turn".into(),
1262        "  PgDn       Scroll down (page)".into(),
1263        "  PgUp       Scroll up (page)".into(),
1264        "  G          Jump to bottom (follow)".into(),
1265        "  g          Jump to top".into(),
1266        "".into(),
1267        "  Blocks".into(),
1268        "  e          Cycle block (collapse/truncate/expand)".into(),
1269        "  Shift+E    Expand all blocks".into(),
1270        "  Ctrl+E     Collapse all blocks".into(),
1271        "".into(),
1272        "  Search".into(),
1273        "  /find <q>  Search transcript".into(),
1274        "  n          Next match".into(),
1275        "  N          Previous match".into(),
1276        "  Esc        Clear search".into(),
1277        "".into(),
1278        "  Input".into(),
1279        "  Ctrl+M     Toggle multiline input".into(),
1280        "  Ctrl+P     Command palette".into(),
1281        "  Ctrl+Enter Send now (abort + submit)".into(),
1282        "  Esc        Cancel run / quit (y to confirm)".into(),
1283        "".into(),
1284        "  Other".into(),
1285        "  ?          Show this cheatsheet".into(),
1286        "  /theme     Cycle color theme".into(),
1287        "  /model     Pick a model".into(),
1288        "  /models    Browse all models".into(),
1289        "  /providers Manage API keys".into(),
1290        "  /tools     List tools".into(),
1291        "  /mcp       MCP status".into(),
1292        "  /info      Diagnostics".into(),
1293        "  /export    Save as HTML".into(),
1294        "  /vim       Toggle vim mode".into(),
1295        "  Ctrl+C     Cancel run (then y to quit)".into(),
1296        "".into(),
1297    ]
1298}
1299// ─────────────────────────────────────────────────────────────────────────
1300// Tests
1301// ─────────────────────────────────────────────────────────────────────────
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::*;
1306    use oxicode_sdk::ports::catalog::{
1307        CatalogEvent, CatalogModelEntry, CatalogProtocol, CatalogSource, ModelCatalog,
1308    };
1309    use oxicode_sdk::{Model, Provider, ProviderError, ProviderEvent};
1310    use oxicode_vtui::tui::core::{InlineCommand, OverlayRequest};
1311    use std::pin::Pin;
1312    use std::task::{Context as TaskContext, Poll};
1313
1314    #[test]
1315    fn builtins_register_expected_commands() {
1316        let reg = SlashRegistry::builtins();
1317        let names: Vec<&str> = reg.builtins.iter().map(|c| c.name()).collect();
1318        assert!(names.contains(&"quit"));
1319        assert!(names.contains(&"clear"));
1320        assert!(names.contains(&"memory"));
1321        assert!(names.contains(&"model"));
1322        assert!(names.contains(&"cancel"));
1323        assert!(names.contains(&"status"));
1324    }
1325
1326    #[test]
1327    fn matches_resolves_aliases_case_insensitively() {
1328        let cmd = QuitCommand;
1329        assert!(cmd.matches("quit"));
1330        assert!(cmd.matches("EXIT"));
1331        assert!(cmd.matches("q"));
1332        assert!(!cmd.matches("quitter"));
1333    }
1334
1335    #[test]
1336    fn builtin_commands_exposes_aliases_for_rpc() {
1337        let catalog = SlashRegistry::builtin_commands();
1338        let quit = catalog
1339            .iter()
1340            .find(|(name, _, _)| *name == "quit")
1341            .expect("quit command present");
1342        assert!(quit.2.contains(&"exit"));
1343        assert!(quit.2.contains(&"q"));
1344        assert!(!quit.1.is_empty(), "quit has a description");
1345    }
1346
1347    #[test]
1348    fn handoff_command_metadata() {
1349        // /handoff must be registered and exposed via /help + RPC. Aliases
1350        // let power users type /hd instead.
1351        let reg = SlashRegistry::builtins();
1352        let names: Vec<&str> = reg.builtins.iter().map(|c| c.name()).collect();
1353        assert!(names.contains(&"handoff"), "handoff command registered");
1354
1355        let catalog = SlashRegistry::builtin_commands();
1356        let handoff = catalog
1357            .iter()
1358            .find(|(name, _, _)| *name == "handoff")
1359            .expect("handoff present in catalog");
1360        assert!(
1361            handoff.2.contains(&"hd"),
1362            "handoff exposes /hd alias: got {:?}",
1363            handoff.2
1364        );
1365        assert!(!handoff.1.is_empty(), "handoff has a description");
1366
1367        let cmd = HandoffCommand;
1368        assert!(cmd.matches("handoff"));
1369        assert!(cmd.matches("HD"));
1370        assert!(cmd.matches("Hd"));
1371    }
1372
1373    /// `model_picker_rows` filters the catalog to providers with stored
1374    /// API keys, pins the active model at index 0, and falls back to the
1375    /// full catalog when nothing is keyed.
1376    #[test]
1377    fn model_picker_filters_by_keyed_providers() {
1378        use crate::store::auth_storage::AuthStorage;
1379        use std::sync::Arc;
1380
1381        // Two providers, three models. The `anthropic` provider has a
1382        // key below; `google` does not.
1383        let entries = vec![
1384            CatalogModelEntry {
1385                provider: "anthropic".into(),
1386                model_id: "claude-sonnet".into(),
1387                name: "Claude Sonnet".into(),
1388                protocol: CatalogProtocol::AnthropicMessages,
1389                source: CatalogSource::Embedded,
1390                base_url: None,
1391                reasoning: false,
1392                supports_vision: true,
1393                cost_input: 3.0,
1394                cost_output: 15.0,
1395                cost_cache_read: 0.0,
1396                cost_cache_write: 0.0,
1397                context_window: 200_000,
1398                max_tokens: 8_192,
1399                input_modalities: vec!["text".into(), "image".into()],
1400                release_date: None,
1401                status: None,
1402            },
1403            CatalogModelEntry {
1404                provider: "anthropic".into(),
1405                model_id: "claude-opus".into(),
1406                name: "Claude Opus".into(),
1407                protocol: CatalogProtocol::AnthropicMessages,
1408                source: CatalogSource::Embedded,
1409                base_url: None,
1410                reasoning: false,
1411                supports_vision: true,
1412                cost_input: 15.0,
1413                cost_output: 75.0,
1414                cost_cache_read: 0.0,
1415                cost_cache_write: 0.0,
1416                context_window: 200_000,
1417                max_tokens: 8_192,
1418                input_modalities: vec!["text".into(), "image".into()],
1419                release_date: None,
1420                status: None,
1421            },
1422            CatalogModelEntry {
1423                provider: "google".into(),
1424                model_id: "gemini-2.5-pro".into(),
1425                name: "Gemini 2.5 Pro".into(),
1426                protocol: CatalogProtocol::OpenAiCompatible,
1427                source: CatalogSource::Embedded,
1428                base_url: None,
1429                reasoning: true,
1430                supports_vision: true,
1431                cost_input: 1.25,
1432                cost_output: 5.0,
1433                cost_cache_read: 0.0,
1434                cost_cache_write: 0.0,
1435                context_window: 1_000_000,
1436                max_tokens: 65_536,
1437                input_modalities: vec!["text".into(), "image".into()],
1438                release_date: None,
1439                status: None,
1440            },
1441        ];
1442        let catalog: Arc<dyn ModelCatalog> = Arc::new(StaticCatalog::new(entries));
1443
1444        // Hermetic in-memory AuthStorage — no file I/O, no risk of
1445        // touching the user's real ~/.oxicode/auth.json. The production
1446        // `AuthStorage::default()` would point at that file and any
1447        // `set_api_key` would persist there. Always use `in_memory()`
1448        // in tests.
1449        let auth: Arc<AuthStorage> = Arc::new(AuthStorage::in_memory());
1450        auth.set_api_key("anthropic", "test-anthropic-key".to_string());
1451
1452        let (rows, used_fallback) =
1453            model_picker_rows(&catalog, &auth, "anthropic", "claude-sonnet");
1454
1455        assert!(
1456            !used_fallback,
1457            "keyed providers exist — no fallback expected"
1458        );
1459        // Active row pinned to index 0, the other keyed provider row
1460        // follows. google's models are excluded (no key).
1461        assert_eq!(rows.len(), 2);
1462        assert_eq!(rows[0].provider, "anthropic");
1463        assert_eq!(rows[0].model_id, "claude-sonnet");
1464        assert_eq!(rows[1].model_id, "claude-opus");
1465    }
1466    /// When no providers are keyed AND there is no active model match
1467    /// in the catalog, the helper returns the full catalog with
1468    /// `used_fallback = true`.
1469    #[test]
1470    fn model_picker_falls_back_when_unkeyed_and_no_active_match() {
1471        use crate::store::auth_storage::AuthStorage;
1472        use std::sync::Arc;
1473
1474        let entries = vec![CatalogModelEntry {
1475            provider: "openai".into(),
1476            model_id: "gpt-4o".into(),
1477            name: "GPT-4o".into(),
1478            protocol: CatalogProtocol::OpenAiCompatible,
1479            source: CatalogSource::Embedded,
1480            base_url: None,
1481            reasoning: false,
1482            supports_vision: true,
1483            cost_input: 2.5,
1484            cost_output: 10.0,
1485            cost_cache_read: 0.0,
1486            cost_cache_write: 0.0,
1487            context_window: 128_000,
1488            max_tokens: 16_384,
1489            input_modalities: vec!["text".into(), "image".into()],
1490            release_date: None,
1491            status: None,
1492        }];
1493        let catalog: Arc<dyn ModelCatalog> = Arc::new(StaticCatalog::new(entries));
1494
1495        // No keys at all.
1496        let auth: Arc<AuthStorage> = Arc::new(AuthStorage::in_memory());
1497        // Active model id is not in the catalog (impossible in practice
1498        // but the helper must handle it).
1499        let (rows, used_fallback) =
1500            model_picker_rows(&catalog, &auth, "anthropic", "claude-not-in-catalog");
1501
1502        assert!(
1503            used_fallback,
1504            "no keys, no active match — fallback expected"
1505        );
1506        // The full catalog is returned.
1507        assert_eq!(rows.len(), 1);
1508        assert_eq!(rows[0].model_id, "gpt-4o");
1509    }
1510
1511    /// In-memory `ModelCatalog` test double holding a fixed list of
1512    /// entries. Only `search_sync` is exercised by `model_picker_rows`;
1513    /// the async methods are stubbed to keep the trait satisfied and
1514    /// are never invoked by the helper.
1515    struct StaticCatalog {
1516        entries: Vec<CatalogModelEntry>,
1517        tx: tokio::sync::broadcast::Sender<CatalogEvent>,
1518    }
1519
1520    impl StaticCatalog {
1521        fn new(entries: Vec<CatalogModelEntry>) -> Self {
1522            let (tx, _) = tokio::sync::broadcast::channel(16);
1523            Self { entries, tx }
1524        }
1525    }
1526
1527    impl std::fmt::Debug for StaticCatalog {
1528        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1529            f.debug_struct("StaticCatalog")
1530                .field("entries", &self.entries.len())
1531                .finish_non_exhaustive()
1532        }
1533    }
1534
1535    impl ModelCatalog for StaticCatalog {
1536        fn list_providers(
1537            &self,
1538        ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<Vec<String>>> + Send + '_>>
1539        {
1540            let mut providers: Vec<String> =
1541                self.entries.iter().map(|e| e.provider.clone()).collect();
1542            providers.sort();
1543            providers.dedup();
1544            Box::pin(async move { Ok(providers) })
1545        }
1546        fn get_provider(
1547            &self,
1548            _id: &str,
1549        ) -> Pin<
1550            Box<
1551                dyn Future<
1552                        Output = oxicode_sdk::SdkResult<Option<oxicode_sdk::CatalogProviderEntry>>,
1553                    > + Send
1554                    + '_,
1555            >,
1556        > {
1557            Box::pin(async { Ok(None) })
1558        }
1559        fn list_models(
1560            &self,
1561            provider_id: &str,
1562        ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<Vec<CatalogModelEntry>>> + Send + '_>>
1563        {
1564            let v: Vec<_> = self
1565                .entries
1566                .iter()
1567                .filter(|e| e.provider == provider_id)
1568                .cloned()
1569                .collect();
1570            Box::pin(async move { Ok(v) })
1571        }
1572        fn get_model(
1573            &self,
1574            provider: &str,
1575            model_id: &str,
1576        ) -> Pin<
1577            Box<dyn Future<Output = oxicode_sdk::SdkResult<Option<CatalogModelEntry>>> + Send + '_>,
1578        > {
1579            let hit = self.entries.iter().find_map(|e| {
1580                if e.provider == provider && e.model_id == model_id {
1581                    Some(e.clone())
1582                } else {
1583                    None
1584                }
1585            });
1586            Box::pin(async move { Ok(hit) })
1587        }
1588        fn search(
1589            &self,
1590            _pattern: &str,
1591        ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<Vec<CatalogModelEntry>>> + Send + '_>>
1592        {
1593            let v = self.entries.clone();
1594            Box::pin(async move { Ok(v) })
1595        }
1596        fn model_count(
1597            &self,
1598        ) -> Pin<Box<dyn Future<Output = oxicode_sdk::SdkResult<usize>> + Send + '_>> {
1599            let n = self.entries.len();
1600            Box::pin(async move { Ok(n) })
1601        }
1602        fn refresh(
1603            &self,
1604        ) -> Pin<
1605            Box<
1606                dyn Future<Output = oxicode_sdk::SdkResult<oxicode_sdk::RefreshOutcome>>
1607                    + Send
1608                    + '_,
1609            >,
1610        > {
1611            Box::pin(async { Ok(oxicode_sdk::RefreshOutcome::Unchanged) })
1612        }
1613        fn subscribe(&self) -> tokio::sync::broadcast::Receiver<CatalogEvent> {
1614            self.tx.subscribe()
1615        }
1616        fn search_sync(&self, _pattern: &str) -> Vec<CatalogModelEntry> {
1617            self.entries.clone()
1618        }
1619    }
1620
1621    // ── /settings ────────────────────────────────────────────────────
1622
1623    /// `/settings` items are built from the `SETTING_DEFS` General tab:
1624    /// a heading row at every group change, then one row per def in
1625    /// table order with the per-widget selection mapping.
1626    #[test]
1627    fn settings_items_mirror_general_tab_defs() {
1628        let settings = Settings::default();
1629        let defs = defs_for_tab(SettingsTab::General, &settings);
1630        assert!(!defs.is_empty(), "General tab must define settings");
1631
1632        let items = settings_overlay_items(SettingsTab::General, &settings).0;
1633        assert!(!items.is_empty());
1634
1635        // Walk the emitted items against the table: heading rows (no
1636        // subtitle/badge) must announce the next def's group and only
1637        // when the group changed; setting rows must match the next def
1638        // exactly. This pins both order and group contiguity.
1639        let mut next_def = 0usize;
1640        let mut last_group: Option<&str> = None;
1641        let mut heading_count = 0usize;
1642        for item in &items {
1643            if item.subtitle.is_none() && item.badge.is_none() {
1644                assert!(next_def < defs.len(), "heading with no def to follow");
1645                let def = defs[next_def];
1646                assert_eq!(item.title, def.group, "heading carries the group name");
1647                assert!(item.selection.is_none(), "headings are never interactive");
1648                assert_ne!(
1649                    last_group,
1650                    Some(def.group),
1651                    "heading emitted without a group change"
1652                );
1653                last_group = Some(def.group);
1654                heading_count += 1;
1655            } else {
1656                let def = defs[next_def];
1657                assert_eq!(item.title, def.label, "rows follow SETTING_DEFS order");
1658                assert_eq!(item.subtitle.as_deref(), Some(def.description));
1659                assert_eq!(
1660                    item.badge.as_deref(),
1661                    Some(get_display_value(def.key, &settings).as_str())
1662                );
1663                let expected = match def.widget {
1664                    SettingWidget::Toggle | SettingWidget::Cycle => {
1665                        Some(InlineListSelection::ConfigAction(format!("{:?}", def.key)))
1666                    }
1667                    SettingWidget::Text => Some(InlineListSelection::SettingTextEdit(format!(
1668                        "{:?}",
1669                        def.key
1670                    ))),
1671                    SettingWidget::SubmenuSelect(_) => Some(
1672                        InlineListSelection::SettingSubmenuOpen(format!("{:?}", def.key)),
1673                    ),
1674                    SettingWidget::Multiselect => Some(InlineListSelection::SettingMultiselect(
1675                        format!("{:?}", def.key),
1676                    )),
1677                    SettingWidget::MapEditor | SettingWidget::Pointer => None,
1678                };
1679                assert_eq!(item.selection.as_ref(), expected.as_ref());
1680                assert!(
1681                    item.search_value
1682                        .as_deref()
1683                        .is_some_and(|v| v.contains(def.label))
1684                );
1685                next_def += 1;
1686            }
1687        }
1688        assert_eq!(next_def, defs.len(), "exactly one row per General-tab def");
1689        assert!(heading_count > 0, "at least one group heading emitted");
1690    }
1691
1692    /// Minimal provider double so a real `AgentSessionHandle` can back
1693    /// the `SlashCtx` — `/settings` itself never calls the model.
1694    struct NullProvider;
1695
1696    struct EmptyStream;
1697
1698    impl futures::Stream for EmptyStream {
1699        type Item = ProviderEvent;
1700        fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
1701            Poll::Ready(None)
1702        }
1703    }
1704
1705    impl Provider for NullProvider {
1706        fn stream<'a>(
1707            &'a self,
1708            _model: &'a Model,
1709            _context: &'a oxicode_sdk::Context,
1710            _options: Option<oxicode_sdk::StreamOptions>,
1711        ) -> Pin<
1712            Box<
1713                dyn Future<
1714                        Output = Result<
1715                            Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
1716                            ProviderError,
1717                        >,
1718                    > + Send
1719                    + 'a,
1720            >,
1721        > {
1722            Box::pin(async move {
1723                Ok::<_, ProviderError>(Box::pin(EmptyStream)
1724                    as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
1725            })
1726        }
1727    }
1728
1729    /// Dispatching `/settings` opens a "Settings" list overlay whose
1730    /// items are exactly the table-built General-tab rows.
1731    #[test]
1732    fn settings_command_opens_table_driven_overlay() {
1733        use crate::app::agent_session::AgentSession;
1734        use crate::store::session::SessionManager;
1735        use oxicode_agent::{Agent, AgentConfig, ToolRegistry};
1736
1737        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
1738        let handle = InlineHandle::new_for_tests(cmd_tx);
1739
1740        let session = AgentSession::new(
1741            std::sync::Arc::new(Agent::new(
1742                std::sync::Arc::new(NullProvider),
1743                AgentConfig::new("anthropic/claude-sonnet-4-20250514"),
1744                std::sync::Arc::new(ToolRegistry::new()),
1745            )),
1746            Settings::default(),
1747            SessionManager::in_memory("/tmp/test"),
1748            "/tmp/test".to_string(),
1749            crate::SessionState::default(),
1750        )
1751        .clone_handle();
1752
1753        let mut state = RenderState::default();
1754        let mut ctx = SlashCtx {
1755            session: &session,
1756            handle: &handle,
1757            state: &mut state,
1758        };
1759        assert!(matches!(
1760            SlashRegistry::builtins().dispatch("/settings", &mut ctx),
1761            SlashOutcome::Handled
1762        ));
1763
1764        // Drain the command channel for the overlay-open command.
1765        let list = loop {
1766            match cmd_rx.try_recv().expect("overlay command emitted") {
1767                InlineCommand::ShowOverlay { request } => match *request {
1768                    OverlayRequest::List(list) => break list,
1769                    _ => panic!("expected a list overlay request"),
1770                },
1771                _ => continue,
1772            }
1773        };
1774        assert_eq!(list.title, "Settings");
1775        assert!(list.search.is_some(), "settings overlay is filterable");
1776
1777        // `execute` loads settings from disk, so badge VALUES may differ
1778        // from `Settings::default()` — but structure is table-determined.
1779        // Assert the row titles mirror the General defs in order, with a
1780        // heading row present.
1781        let defs = defs_for_tab(SettingsTab::General, &Settings::default());
1782        let rows: Vec<&str> = list
1783            .items
1784            .iter()
1785            .filter(|i| i.subtitle.is_some())
1786            .map(|i| i.title.as_str())
1787            .collect();
1788        let labels: Vec<&str> = defs.iter().map(|d| d.label).collect();
1789        assert_eq!(rows, labels, "overlay rows mirror the General tab defs");
1790        assert!(
1791            list.items
1792                .iter()
1793                .any(|i| i.subtitle.is_none() && i.badge.is_none()),
1794            "at least one group heading row present"
1795        );
1796    }
1797}