Skip to main content

oxicode/tui_vt/slash/
commands.rs

1//! Extended slash commands for the VT TUI harness.
2//!
3//! These live in a sibling module to [`super::registry`] to keep the catalog-
4//! dependent and introspection commands isolated from the core command
5//! plumbing. They are registered alongside the built-ins by
6//! `register_extra` (called from `register_all` in the sibling `registry`
7//!
8//! Every command here is self-contained: it owns its definition + execution
9//! and receives a `SlashCtx` (`session`, `handle`, `state`). The model
10//! catalog is reached via `ctx.state.catalog` (captured once at TUI startup);
11//! credentials via the process-global `shared_auth_storage()`.
12
13use oxicode_vtui::tui::core::{
14    InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
15};
16
17use super::registry::{SlashCommand, SlashCtx, SlashOutcome, SlashRegistry};
18
19/// Register every extended command. Called from `register_all`.
20pub(crate) fn register_extra(registry: &mut SlashRegistry) {
21    registry.register(Box::new(ModelsCommand));
22    registry.register(Box::new(ProvidersCommand));
23    registry.register(Box::new(ToolsCommand));
24    registry.register(Box::new(McpCommand));
25    registry.register(Box::new(HooksCommand));
26    registry.register(Box::new(InfoCommand));
27    registry.register(Box::new(ExportCommand));
28    registry.register(Box::new(GitCommand));
29    registry.register(Box::new(IssueCommand));
30}
31
32// ─────────────────────────────────────────────────────────────────────────
33// Formatting helpers (pure, unit-tested)
34// ─────────────────────────────────────────────────────────────────────────
35
36/// Format a token count as a compact context-window label.
37pub(super) fn fmt_ctx(tokens: u32) -> String {
38    if tokens == 0 {
39        // 0 = unknown (LOCAL discovery placeholder), not a real window.
40        "? ctx".to_string()
41    } else if tokens >= 1_000_000 {
42        format!("{:.1}M ctx", tokens as f64 / 1_000_000.0)
43    } else if tokens >= 1000 {
44        format!("{}K ctx", tokens / 1000)
45    } else {
46        format!("{tokens} ctx")
47    }
48}
49
50/// Format a USD-per-million-token price. `0.0` (free / undisclosed) → "free".
51pub(super) fn fmt_cost(price: f64) -> String {
52    if price <= 0.0 {
53        "free".to_string()
54    } else if price < 0.01 {
55        "<$0.01/M".to_string()
56    } else {
57        format!("${price:.2}/M")
58    }
59}
60
61/// Split a `provider/model` id into `(provider, model_id)` on the first `/`.
62pub(super) fn split_model_id(model_id: &str) -> (&str, &str) {
63    match model_id.find('/') {
64        Some(i) => (&model_id[..i], &model_id[i + 1..]),
65        None => (model_id, ""),
66    }
67}
68
69// ─────────────────────────────────────────────────────────────────────────
70// /models — browse the FULL catalog
71// ─────────────────────────────────────────────────────────────────────────
72
73/// `/models [query]` — open a searchable list of every catalog model
74/// (provider/model · context window · price). Selecting a row switches the
75/// active model. Unlike `/model` (scoped models only), this browses the entire
76/// models.dev catalog plus local/dynamic discovery.
77struct ModelsCommand;
78
79impl SlashCommand for ModelsCommand {
80    fn name(&self) -> &'static str {
81        "models"
82    }
83    fn description(&self) -> &'static str {
84        "Browse the full model catalog (/models [query])"
85    }
86    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
87        let query = args.trim();
88        let Some(catalog) = ctx.state.catalog.as_ref() else {
89            ctx.reply(
90                InlineMessageKind::Warning,
91                "Model catalog is unavailable in this session.",
92            );
93            return SlashOutcome::Handled;
94        };
95
96        // `search_sync("")` returns the full snapshot; we filter client-side
97        // so the optional `query` narrows provider/model/name in one pass.
98        let mut entries = catalog.search_sync("");
99        entries.sort_by(|a, b| {
100            a.provider
101                .cmp(&b.provider)
102                .then_with(|| a.model_id.cmp(&b.model_id))
103        });
104
105        let q = query.to_ascii_lowercase();
106        let filtered: Vec<_> = if q.is_empty() {
107            entries.iter().collect()
108        } else {
109            entries
110                .iter()
111                .filter(|e| {
112                    e.provider.to_ascii_lowercase().contains(&q)
113                        || e.model_id.to_ascii_lowercase().contains(&q)
114                        || e.name.to_ascii_lowercase().contains(&q)
115                })
116                .collect()
117        };
118
119        if filtered.is_empty() {
120            if entries.is_empty() {
121                ctx.reply(
122                    InlineMessageKind::Warning,
123                    "Model catalog is empty (catalog may have failed to load).",
124                );
125            } else {
126                ctx.reply(
127                    InlineMessageKind::Warning,
128                    format!("No models match '{query}'."),
129                );
130            }
131            return SlashOutcome::Handled;
132        }
133
134        let total = filtered.len();
135        // Record the (provider, model_id) pairs backing each row so the
136        // overlay-submission handler can resolve a selection back to a model.
137        ctx.state.overlay_catalog_models = filtered
138            .iter()
139            .map(|e| (e.provider.clone(), e.model_id.clone()))
140            .collect();
141
142        let current = ctx.session.model_id();
143        let items: Vec<InlineListItem> = filtered
144            .iter()
145            .enumerate()
146            .map(|(i, e)| {
147                let id = format!("{}/{}", e.provider, e.model_id);
148                let mut sub = format!(
149                    "{} · {} in / {} out",
150                    fmt_ctx(e.context_window),
151                    fmt_cost(e.cost_input),
152                    fmt_cost(e.cost_output)
153                );
154                if e.reasoning {
155                    sub.push_str(" · reasoning");
156                }
157                if e.supports_vision {
158                    sub.push_str(" · vision");
159                }
160                InlineListItem {
161                    title: id.clone(),
162                    subtitle: Some(sub),
163                    badge: if id == current {
164                        Some("active".to_string())
165                    } else {
166                        None
167                    },
168                    indent: 0,
169                    selection: Some(InlineListSelection::CatalogModel(i)),
170                    search_value: Some(format!("{} {} {}", e.provider, e.model_id, e.name)),
171                }
172            })
173            .collect();
174
175        let search = InlineListSearchConfig {
176            label: "Filter models".into(),
177            placeholder: Some("Type to filter (provider / model / name)\u{2026}".into()),
178        };
179        ctx.handle.show_list_modal(
180            format!("Models ({total})"),
181            vec![format!(
182                "{} model{} \u{2014} Enter to switch, Esc to close",
183                total,
184                if total == 1 { "" } else { "s" }
185            )],
186            items,
187            None,
188            Some(search),
189        );
190        SlashOutcome::Handled
191    }
192}
193
194// ─────────────────────────────────────────────────────────────────────────
195// /providers — credential status + key removal
196// ─────────────────────────────────────────────────────────────────────────
197
198/// `/providers` — list every known provider with its credential status.
199/// `/providers remove <name>` — remove a stored API key (asks for
200/// confirmation; `--yes` skips it).
201/// `/providers add <name> <base_url> [api_key_env] [api]` — register a
202/// custom OpenAI-compatible provider into `~/.oxicode/settings.toml`.
203/// `/providers run-oauth <name>` — kick off the OAuth flow non-interactively
204/// (mostly a power-user shortcut; the in-OAuth UI is the default path).
205struct ProvidersCommand;
206
207impl SlashCommand for ProvidersCommand {
208    fn name(&self) -> &'static str {
209        "providers"
210    }
211    fn aliases(&self) -> &'static [&'static str] {
212        &["keys"]
213    }
214    fn description(&self) -> &'static str {
215        "Manage providers: status, add custom, remove a key, run OAuth"
216    }
217    fn execute(&self, args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
218        let tokens: Vec<&str> = args.split_whitespace().collect();
219
220        // `/providers remove <name> [--yes]`
221        if tokens
222            .first()
223            .map(|t| *t == "remove" || *t == "rm")
224            .unwrap_or(false)
225        {
226            return remove_provider_key(ctx, tokens.get(1).copied(), tokens.contains(&"--yes"));
227        }
228
229        // `/providers add <name> <base_url> [api_key_env] [api]`
230        if tokens.first().map(|t| *t == "add").unwrap_or(false) {
231            return add_custom_provider(ctx, &tokens[1..]);
232        }
233
234        // `/providers run-oauth <name>`
235        if tokens
236            .first()
237            .map(|t| *t == "run-oauth" || *t == "oauth")
238            .unwrap_or(false)
239        {
240            return run_provider_oauth(ctx, tokens.get(1).copied());
241        }
242
243        // `/providers` — status overlay.
244        let auth = crate::store::auth_storage::shared_auth_storage();
245        let mut names: Vec<String> = ctx
246            .state
247            .catalog
248            .as_ref()
249            .map(|c| c.list_providers_sync())
250            .unwrap_or_default();
251        // Merge custom providers from settings that the catalog doesn't list.
252        if let Ok(settings) = crate::store::settings::Settings::load() {
253            for cp in &settings.custom_providers {
254                if !names.iter().any(|n| n == &cp.name) {
255                    names.push(cp.name.clone());
256                }
257            }
258        }
259        names.sort();
260
261        if names.is_empty() {
262            ctx.reply(
263                InlineMessageKind::Info,
264                "No providers configured. Run `oxicode setup` to add one.",
265            );
266            return SlashOutcome::Handled;
267        }
268
269        ctx.state.overlay_providers = names.clone();
270        let catalog = ctx.state.catalog.as_ref();
271        let items: Vec<InlineListItem> = names
272            .iter()
273            .enumerate()
274            .map(|(i, name)| {
275                let has_key = auth.has(name);
276                let entry = catalog.and_then(|c| c.get_provider_sync(name));
277                let base = entry.as_ref().and_then(|p| p.base_url.clone());
278                let env_key = entry.as_ref().and_then(|p| p.env_key.clone());
279                // OAuth-capable? `product-meta.toml` ships exactly two
280                // OAuth blocks today (openai, anthropic); every other
281                // provider is key-only. Surface this so the user does
282                // not look for an OAuth menu where there is none.
283                let oauth_capable = crate::provider_oauth::spec_for(name).is_some();
284                let subtitle = match (env_key.as_deref(), base.as_deref(), oauth_capable) {
285                    (Some(env), Some(url), true) if !url.is_empty() => {
286                        format!("{env} · {url} · oauth")
287                    }
288                    (Some(env), Some(url), false) if !url.is_empty() => {
289                        format!("{env} · {url}")
290                    }
291                    (Some(env), _, true) => format!("{env} · oauth"),
292                    (Some(env), _, false) => env.to_string(),
293                    (_, Some(url), true) => format!("{url} · oauth"),
294                    (_, Some(url), false) => url.to_string(),
295                    _ => "Enter to manage".to_string(),
296                };
297                InlineListItem {
298                    title: name.clone(),
299                    subtitle: Some(subtitle),
300                    badge: Some(if has_key {
301                        "key".to_string()
302                    } else {
303                        "\u{2014}".to_string()
304                    }),
305                    indent: 0,
306                    selection: Some(InlineListSelection::ProviderRow(i)),
307                    search_value: Some(name.clone()),
308                }
309            })
310            .collect();
311
312        let keyed = names.iter().filter(|n| auth.has(n)).count();
313        let search = InlineListSearchConfig {
314            label: "Filter providers".into(),
315            placeholder: Some("Type to filter\u{2026}".into()),
316        };
317        ctx.handle.show_list_modal(
318            "Providers".into(),
319            vec![format!(
320                "{keyed}/{} with keys \u{2014} Enter to manage, Esc to close",
321                names.len()
322            )],
323            items,
324            None,
325            Some(search),
326        );
327        SlashOutcome::Handled
328    }
329}
330
331/// Handler for `/providers remove <name> [--yes]`.
332fn remove_provider_key(ctx: &mut SlashCtx<'_>, name: Option<&str>, yes: bool) -> SlashOutcome {
333    let Some(name) = name else {
334        ctx.reply(InlineMessageKind::Error, "Usage: /providers remove <name>");
335        return SlashOutcome::Handled;
336    };
337    let auth = crate::store::auth_storage::shared_auth_storage();
338    if !auth.has(name) {
339        ctx.reply(
340            InlineMessageKind::Warning,
341            format!("No stored key for '{name}'."),
342        );
343        return SlashOutcome::Handled;
344    }
345    if !yes {
346        ctx.state.confirmation = Some(crate::tui_vt::main_loop::ModalConfirmation {
347            title: format!("Remove key for {name}?"),
348            message: "  y \u{2014} remove key     n / x \u{2014} cancel".into(),
349            action: crate::tui_vt::main_loop::ConfirmationAction::RemoveProviderKey(
350                name.to_string(),
351            ),
352        });
353        return SlashOutcome::Handled;
354    }
355    auth.remove(name);
356    ctx.reply(
357        InlineMessageKind::Info,
358        format!("Removed key for '{name}'."),
359    );
360    SlashOutcome::Handled
361}
362
363/// Handler for `/providers add <name> <base_url> [api_key_env] [api]`.
364///
365/// Persists a new `CustomProvider` entry into `~/.oxicode/settings.toml`.
366/// `api_key_env` defaults to `<NAME>_API_KEY` (uppercased, hyphens → `_`)
367/// to match the convention in `setup_wizard.rs`. `api` defaults to
368/// `"openai-completions"` via `CustomProvider::default_api`.
369///
370/// Run from the TUI composer so the user never has to leave the session
371/// for the structured equivalent of `oxicode setup`. The new provider
372/// appears immediately in `/providers` because the status overlay reads
373/// `settings.custom_providers` on every open.
374fn add_custom_provider(ctx: &mut SlashCtx<'_>, tokens: &[&str]) -> SlashOutcome {
375    // Need at least name + base_url. api_key_env and api are optional.
376    let (name, base_url, api_key_env, api) = match tokens {
377        [name, base_url] => (
378            (*name).to_string(),
379            (*base_url).to_string(),
380            default_api_key_env(name),
381            None,
382        ),
383        [name, base_url, env] => (
384            (*name).to_string(),
385            (*base_url).to_string(),
386            (*env).to_string(),
387            None,
388        ),
389        [name, base_url, env, api] => (
390            (*name).to_string(),
391            (*base_url).to_string(),
392            (*env).to_string(),
393            Some((*api).to_string()),
394        ),
395        _ => {
396            ctx.reply(
397                InlineMessageKind::Error,
398                "Usage: /providers add <name> <base_url> [api_key_env] [api]".to_string(),
399            );
400            return SlashOutcome::Handled;
401        }
402    };
403
404    if name.is_empty() || base_url.is_empty() {
405        ctx.reply(
406            InlineMessageKind::Error,
407            "Provider name and base URL must be non-empty.".to_string(),
408        );
409        return SlashOutcome::Handled;
410    }
411
412    let mut settings = match crate::store::settings::Settings::load() {
413        Ok(s) => s,
414        Err(e) => {
415            ctx.reply(
416                InlineMessageKind::Error,
417                format!("Failed to load settings: {e}"),
418            );
419            return SlashOutcome::Handled;
420        }
421    };
422    if settings.custom_providers.iter().any(|cp| cp.name == name) {
423        ctx.reply(
424            InlineMessageKind::Warning,
425            format!("Custom provider '{name}' already exists."),
426        );
427        return SlashOutcome::Handled;
428    }
429    let cp = crate::store::settings::CustomProvider {
430        name: name.clone(),
431        base_url,
432        api_key_env,
433        api: api.unwrap_or_else(crate::store::settings::default_custom_provider_api),
434    };
435    settings.custom_providers.push(cp);
436
437    if let Err(e) = settings.save() {
438        ctx.reply(
439            InlineMessageKind::Error,
440            format!("Failed to persist settings: {e}"),
441        );
442        return SlashOutcome::Handled;
443    }
444
445    // Chain into the secure prompt so the user can finish the setup
446    // without a second navigation step. The SecureInput consumer in
447    // `main_loop.rs` will emit a contextual follow-up based on the
448    // `NewlyAdded` origin (vs. the generic `SetKey` message used when
449    // the user rekeys an existing provider).
450    crate::tui_vt::main_loop::open_secure_prompt(
451        ctx.state,
452        ctx.handle,
453        crate::tui_vt::main_loop::SecureInputOrigin::NewlyAdded {
454            provider: name.clone(),
455        },
456    );
457    SlashOutcome::Handled
458}
459
460/// Default api_key_env for a custom provider name: uppercased, hyphens
461/// replaced with underscores, suffixed with `_API_KEY`. Matches the
462/// convention used in `setup_wizard.rs` (`api_key_env` formatter).
463fn default_api_key_env(name: &str) -> String {
464    format!("{}_API_KEY", name.to_uppercase().replace('-', "_"))
465}
466
467/// Handler for `/providers run-oauth <name>`.
468///
469/// Power-user shortcut that drives the OAuth flow without going through
470/// the per-row action menu. Same PKCE + loopback + token exchange path as
471/// the in-overlay OAuth action; the `InlineHandle` is used to emit
472/// progress lines (the task posts to it directly).
473fn run_provider_oauth(ctx: &mut SlashCtx<'_>, name: Option<&str>) -> SlashOutcome {
474    let Some(name) = name else {
475        ctx.reply(
476            InlineMessageKind::Error,
477            "Usage: /providers run-oauth <name>".to_string(),
478        );
479        return SlashOutcome::Handled;
480    };
481    let Some(spec) = crate::provider_oauth::spec_for(name) else {
482        ctx.reply(
483            InlineMessageKind::Error,
484            format!("No OAuth spec for '{name}'. Not an OAuth-capable provider."),
485        );
486        return SlashOutcome::Handled;
487    };
488    let provider = name.to_string();
489    let provider_for_log = provider.clone();
490    let tx = ctx.handle.clone();
491    let auth = crate::store::auth_storage::shared_auth_storage();
492    let auth_clone = std::sync::Arc::clone(&auth);
493    tokio::spawn(async move {
494        crate::tui_vt::main_loop::run_oauth_flow(provider, spec, tx, auth_clone).await;
495    });
496    ctx.reply(
497        InlineMessageKind::Info,
498        format!("Starting OAuth flow for '{provider_for_log}'…"),
499    );
500    SlashOutcome::Handled
501}
502
503// ─────────────────────────────────────────────────────────────────────────
504// /tools — registered tool inventory
505// ─────────────────────────────────────────────────────────────────────────
506
507/// `/tools` — list every registered agent tool (built-in + extension) with its
508/// description and whether it is essential (cannot be disabled). Read-only.
509struct ToolsCommand;
510
511impl SlashCommand for ToolsCommand {
512    fn name(&self) -> &'static str {
513        "tools"
514    }
515    fn description(&self) -> &'static str {
516        "List registered agent tools"
517    }
518    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
519        let tools = ctx.session.agent_ref().tools();
520        let mut tools = tools.get_tools();
521        tools.sort_by(|a, b| a.name().cmp(b.name()));
522
523        let items: Vec<InlineListItem> = tools
524            .iter()
525            .map(|t| InlineListItem {
526                title: t.name().to_string(),
527                subtitle: Some(t.description().to_string()),
528                badge: if t.essential() {
529                    Some("essential".to_string())
530                } else {
531                    None
532                },
533                indent: 0,
534                selection: None,
535                search_value: Some(format!("{} {}", t.name(), t.label())),
536            })
537            .collect();
538
539        let count = items.len();
540        let essential = items.iter().filter(|i| i.badge.is_some()).count();
541        let search = InlineListSearchConfig {
542            label: "Filter tools".into(),
543            placeholder: Some("Type to filter\u{2026}".into()),
544        };
545        ctx.handle.show_list_modal(
546            format!("Tools ({count})"),
547            vec![format!("{essential} essential \u{2014} Esc to close")],
548            items,
549            None,
550            Some(search),
551        );
552        SlashOutcome::Handled
553    }
554}
555
556// ─────────────────────────────────────────────────────────────────────────
557// /mcp — MCP server dashboard
558// ─────────────────────────────────────────────────────────────────────────
559
560/// `/mcp` — show the MCP server dashboard: connection state, tool counts, and
561/// settings summary, sourced from the synchronous `dashboard_data()` snapshot.
562struct McpCommand;
563
564impl SlashCommand for McpCommand {
565    fn name(&self) -> &'static str {
566        "mcp"
567    }
568    fn description(&self) -> &'static str {
569        "Show MCP server status"
570    }
571    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
572        let Some(mcp) = ctx.session.agent_ref().tools().mcp_manager() else {
573            ctx.reply(InlineMessageKind::Info, "No MCP manager configured.");
574            return SlashOutcome::Handled;
575        };
576        let dash = mcp.dashboard_data();
577        let s = &dash.settings;
578
579        if dash.servers.is_empty() {
580            ctx.reply(
581                InlineMessageKind::Info,
582                format!(
583                    "No MCP servers configured ({} servers, {} tools).",
584                    s.total_servers, s.total_tools
585                ),
586            );
587            return SlashOutcome::Handled;
588        }
589
590        let items: Vec<InlineListItem> = dash
591            .servers
592            .iter()
593            .map(|srv| {
594                use oxicode_agent::mcp::types::McpConnectionStatus;
595                let status = match &srv.status {
596                    McpConnectionStatus::Connected => "connected",
597                    McpConnectionStatus::Disconnected => "disconnected",
598                    McpConnectionStatus::Connecting => "connecting",
599                    McpConnectionStatus::Error(_) => "error",
600                };
601                InlineListItem {
602                    title: srv.name.clone(),
603                    subtitle: Some(format!(
604                        "{status} · {} tool{} · {}",
605                        srv.tool_count,
606                        if srv.tool_count == 1 { "" } else { "s" },
607                        srv.lifecycle
608                    )),
609                    badge: Some(status.to_string()),
610                    indent: 0,
611                    selection: None,
612                    search_value: Some(srv.name.clone()),
613                }
614            })
615            .collect();
616
617        ctx.handle.show_list_modal(
618            "MCP Servers".into(),
619            vec![format!(
620                "{}/{} connected · {} tools · prefix: {}",
621                s.connected_servers, s.total_servers, s.total_tools, s.tool_prefix
622            )],
623            items,
624            None,
625            None,
626        );
627        SlashOutcome::Handled
628    }
629}
630
631// ──────────────────────────────────────────────────────────────────────
632// /hooks — hooks dashboard
633// ──────────────────────────────────────────────────────────────────────
634
635/// Format the `/hooks` reply body from a slice of `HookSpec`s.
636///
637/// One line per hook: `[<event>] <command>`. The matcher (if any) is
638/// appended in parentheses for clarity. Pure helper so the dashboard can be
639/// unit-tested without touching the user's `~/.oxicode/settings.toml`.
640pub(super) fn fmt_hooks_dashboard(hooks: &[oxicode_sdk::ports::HookSpec]) -> String {
641    let mut out = String::new();
642    for h in hooks {
643        match h.matcher.as_deref() {
644            Some(matcher) => {
645                out.push_str(&format!(
646                    "- [{:?}] {} (matcher: {})\n",
647                    h.event, h.command, matcher
648                ));
649            }
650            None => {
651                out.push_str(&format!("- [{:?}] {}\n", h.event, h.command));
652            }
653        }
654    }
655    out
656}
657
658/// `/hooks` — show the configured event→command hooks (`[[hooks]]` in
659/// `~/.oxicode/settings.toml`). Read-only; mirrors the `/mcp` dashboard
660/// pattern: pull a snapshot synchronously, render a one-screen reply, and
661/// tell the user where to edit when the list is empty. Project-level
662/// approval state is not surfaced here — that flag is per-repo + per
663/// settings-hash and is intentionally not reachable from a generic slash
664/// context; run `/settings` (or open `.oxicode/settings.toml`) to inspect
665/// or revoke approvals.
666struct HooksCommand;
667
668impl SlashCommand for HooksCommand {
669    fn name(&self) -> &'static str {
670        "hooks"
671    }
672    fn description(&self) -> &'static str {
673        "List configured event hooks (read-only)"
674    }
675    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
676        let settings = crate::store::settings::Settings::load().unwrap_or_default();
677        if settings.hooks.is_empty() {
678            ctx.reply(
679                InlineMessageKind::Info,
680                "No hooks configured. Edit [[hooks]] in ~/.oxicode/settings.toml.".to_string(),
681            );
682        } else {
683            let out = fmt_hooks_dashboard(&settings.hooks);
684            ctx.reply(InlineMessageKind::Info, out);
685        }
686        SlashOutcome::Handled
687    }
688}
689// ──────────────────────────────────────────────────────────────────────
690// /info — diagnostics
691// ─────────────────────────────────────────────────────────────────────────
692
693/// `/info` — a diagnostics snapshot: version, paths, model, provider, key
694/// status, and catalog size. Useful for bug reports and "why isn't X working".
695struct InfoCommand;
696
697impl SlashCommand for InfoCommand {
698    fn name(&self) -> &'static str {
699        "info"
700    }
701    fn aliases(&self) -> &'static [&'static str] {
702        &["diagnostics", "debug"]
703    }
704    fn description(&self) -> &'static str {
705        "Show diagnostics: version, paths, model, catalog (alias: /diagnostics)"
706    }
707    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
708        let home = dirs::home_dir().unwrap_or_default();
709        let model = ctx.session.model_id();
710        let (provider, _) = split_model_id(&model);
711        let auth = crate::store::auth_storage::shared_auth_storage();
712        let key_status = if auth.has(provider) { "set" } else { "missing" };
713        let catalog_count = ctx
714            .state
715            .catalog
716            .as_ref()
717            .map(|c| c.model_count_sync())
718            .unwrap_or(0);
719        let session_file = ctx
720            .session
721            .session_file()
722            .unwrap_or_else(|| "(none)".into());
723
724        let lines = vec![
725            "".into(),
726            format!("  oxicode   v{}", env!("CARGO_PKG_VERSION")),
727            format!("  cwd       {}", ctx.state.cwd.display()),
728            format!("  session   {}", ctx.session.session_id()),
729            format!("  file      {session_file}"),
730            "".into(),
731            format!("  model     {model}"),
732            format!("  provider  {provider}  (key: {key_status})"),
733            format!("  catalog   {catalog_count} models"),
734            "".into(),
735            "  Paths".into(),
736            format!(
737                "  config    {}",
738                home.join(".oxicode/settings.toml").display()
739            ),
740            format!("  auth      {}", home.join(".oxicode/auth.json").display()),
741            format!("  sessions  {}", home.join(".oxicode/sessions").display()),
742            format!("  logs      {}", home.join(".oxicode/logs").display()),
743            "".into(),
744        ];
745        ctx.handle.show_modal("Diagnostics".into(), lines, None);
746        SlashOutcome::Handled
747    }
748}
749
750// ─────────────────────────────────────────────────────────────────────────
751// /export — conversation → HTML
752// ─────────────────────────────────────────────────────────────────────────
753
754/// `/export` — render the current conversation as a self-contained HTML file
755/// in the working directory and reply with the path.
756struct ExportCommand;
757
758impl SlashCommand for ExportCommand {
759    fn name(&self) -> &'static str {
760        "export"
761    }
762    fn description(&self) -> &'static str {
763        "Export the conversation to HTML"
764    }
765    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
766        match ctx.session.export_html() {
767            Ok(html) => {
768                let id = ctx.session.session_id();
769                let stem: String = id.chars().take(12).collect();
770                let path = ctx.state.cwd.join(format!("oxicode-export-{stem}.html"));
771                match std::fs::write(&path, html) {
772                    Ok(()) => ctx.reply(
773                        InlineMessageKind::Info,
774                        format!("Exported to {}", path.display()),
775                    ),
776                    Err(e) => ctx.reply(
777                        InlineMessageKind::Error,
778                        format!("Failed to write export: {e}"),
779                    ),
780                }
781            }
782            Err(e) => ctx.reply(
783                InlineMessageKind::Error,
784                format!("Failed to export conversation: {e}"),
785            ),
786        }
787        SlashOutcome::Handled
788    }
789}
790// ───────────────────────────────────────────────────────────────────────
791// /git — open the interactive git TUI overlay
792// ───────────────────────────────────────────────────────────────────────
793
794/// `/git` opens the interactive git overlay (`status`, `diff`, staging,
795/// commit). Load errors are surfaced as inline reply lines so the user
796/// can see why a load failed (missing git binary, non-repo cwd, etc.)
797/// without the TUI silently no-op'ing.
798struct GitCommand;
799
800impl SlashCommand for GitCommand {
801    fn name(&self) -> &'static str {
802        "git"
803    }
804    fn description(&self) -> &'static str {
805        "Open the interactive git TUI (status, diff, stage, commit)"
806    }
807    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
808        let cwd = ctx.state.cwd.clone();
809        match crate::tui_vt::git_tui::GitTuiState::load(&cwd) {
810            Ok(state) => {
811                ctx.state.git_tui = Some(state);
812            }
813            Err(err) => {
814                ctx.reply(
815                    oxicode_vtui::tui::core::InlineMessageKind::Error,
816                    format!("/git: failed to load git state: {err}"),
817                );
818            }
819        }
820        SlashOutcome::Handled
821    }
822}
823
824// ───────────────────────────────────────────────────────────────────────
825// /issue — local issues panel
826// ─────────────────────────────────────────────────────────────────────────
827
828/// `/issue` — open the local issues panel (list, filter, create, edit,
829/// close/reopen). The store handle is opened lazily on first use and cached
830/// on `RenderState` so every later panel action reuses it.
831pub(crate) struct IssueCommand;
832
833impl SlashCommand for IssueCommand {
834    fn name(&self) -> &'static str {
835        "issue"
836    }
837    fn description(&self) -> &'static str {
838        "Open the issues panel (list, create, edit, close/reopen local issues)"
839    }
840    fn execute(&self, _args: &str, ctx: &mut SlashCtx<'_>) -> SlashOutcome {
841        let store = match crate::tui_vt::issues_panel::get_or_open_store(ctx.state) {
842            Ok(s) => s,
843            Err(e) => {
844                ctx.reply(
845                    InlineMessageKind::Error,
846                    format!("Could not open issue store: {e}"),
847                );
848                return SlashOutcome::Handled;
849            }
850        };
851        let mut panel = crate::tui_vt::issues_panel::IssuesPanelState::default();
852        panel.refresh(&store, &store.issues_dir());
853        ctx.state.issues_panel = Some(panel);
854        SlashOutcome::Handled
855    }
856}
857
858// ─────────────────────────────────────────────────────────────────────────
859// Tests
860// ───────────────────────────────────────────────────────────────────────
861#[cfg(test)]
862mod tests {
863    use super::*;
864    #[test]
865    fn fmt_ctx_compact() {
866        assert_eq!(fmt_ctx(0), "? ctx");
867        assert_eq!(fmt_ctx(500), "500 ctx");
868        assert_eq!(fmt_ctx(8192), "8K ctx");
869        assert_eq!(fmt_ctx(128_000), "128K ctx");
870        assert_eq!(fmt_ctx(1_000_000), "1.0M ctx");
871        assert_eq!(fmt_ctx(2_000_000), "2.0M ctx");
872    }
873
874    #[test]
875    fn fmt_cost_edges() {
876        assert_eq!(fmt_cost(0.0), "free");
877        assert_eq!(fmt_cost(0.001), "<$0.01/M");
878        assert_eq!(fmt_cost(3.0), "$3.00/M");
879        assert_eq!(fmt_cost(15.0), "$15.00/M");
880    }
881
882    #[test]
883    fn split_model_id_basic() {
884        assert_eq!(
885            split_model_id("anthropic/claude-3"),
886            ("anthropic", "claude-3")
887        );
888        assert_eq!(split_model_id("bare"), ("bare", ""));
889        // Only the first slash splits.
890        assert_eq!(split_model_id("oai/gpt-4/vision"), ("oai", "gpt-4/vision"));
891    }
892
893    /// OAuth-capable providers must surface in the catalog overlay so the
894    /// user can discover the OAuth action instead of going through the
895    /// `Remove key` only branch. `product-meta.toml` ships exactly two
896    /// OAuth blocks (openai, anthropic); every other built-in is key-only.
897    #[test]
898    fn provider_oauth_capability_matches_meta() {
899        // OAuth-capable: openai, anthropic.
900        assert!(
901            crate::provider_oauth::spec_for("openai").is_some(),
902            "openai must be oauth-capable per product-meta.toml"
903        );
904        assert!(
905            crate::provider_oauth::spec_for("anthropic").is_some(),
906            "anthropic must be oauth-capable per product-meta.toml"
907        );
908        // Key-only: ollama, google, vertex, etc.
909        assert!(
910            crate::provider_oauth::spec_for("ollama").is_none(),
911            "ollama has no OAuth spec"
912        );
913        assert!(
914            crate::provider_oauth::spec_for("google").is_none(),
915            "google has no OAuth spec"
916        );
917    }
918
919    /// Default api_key_env format used by `/providers add <name> <url>`:
920    /// uppercased + hyphens → underscores + `_API_KEY`. Mirrors the
921    /// convention in `setup_wizard.rs`.
922    #[test]
923    fn default_api_key_env_for_custom_provider() {
924        assert_eq!(default_api_key_env("minimax"), "MINIMAX_API_KEY");
925        assert_eq!(default_api_key_env("zai-org"), "ZAI_ORG_API_KEY");
926        assert_eq!(default_api_key_env("Foo-Bar"), "FOO_BAR_API_KEY");
927    }
928
929    /// `/hooks` dashboard lists every configured hook: `[<event>] <command>`,
930    /// one per line. Empty list → the help message shown by the command.
931    /// Pure formatter, decoupled from `Settings::load()` so the test doesn't
932    /// touch the user's real `~/.oxicode/settings.toml`.
933    #[test]
934    fn fmt_hooks_dashboard_lists_events_and_commands() {
935        use oxicode_sdk::ports::{HookEvent, HookSpec};
936        let hooks = vec![
937            HookSpec {
938                event: HookEvent::PreToolUse,
939                matcher: None,
940                command: "echo pre".into(),
941                timeout_secs: None,
942            },
943            HookSpec {
944                event: HookEvent::Stop,
945                matcher: Some("bash".into()),
946                command: "logger post-stop".into(),
947                timeout_secs: Some(10),
948            },
949        ];
950        let out = fmt_hooks_dashboard(&hooks);
951        assert!(out.contains("[PreToolUse]"), "missing first event: {out}");
952        assert!(out.contains("echo pre"), "missing first command: {out}");
953        assert!(out.contains("[Stop]"), "missing second event: {out}");
954        assert!(
955            out.contains("logger post-stop"),
956            "missing second command: {out}"
957        );
958    }
959
960    // `add_custom_provider` is exercised end-to-end by the in-TUI flow. We
961    // can't call it without a real `SlashCtx` (which requires an
962    // `AgentSessionHandle` + `InlineHandle`), and the function writes to
963    // `~/.oxicode/settings.toml` so it can't run unmodified in a unit test.
964    // The handler-level contract is pinned by the regex above and the
965    // `custom_provider_default_api` integration test in
966    // `oxicode-cli/src/store/settings.rs`; the persistence path is a
967    // straight `Settings::save()` call.
968
969    /// `/git` must register via `register_extra` so the registry's full
970    /// built-in list includes a `git` command. We construct a fresh
971    /// `SlashRegistry` and run `register_extra` against it — same code
972    /// path `register_all` uses to populate the runtime registry.
973    #[test]
974    fn git_slash_command_registers() {
975        // `builtins()` is the same code path `register_all` uses, so a
976        // `git` command must be present in the resulting registry.
977        let mut names: Vec<&str> = super::super::registry::SlashRegistry::builtin_commands()
978            .into_iter()
979            .map(|(n, _, _)| n)
980            .collect();
981        names.sort();
982        assert!(
983            names.contains(&"git"),
984            "git command must register via register_extra (got {names:?})"
985        );
986    }
987}