1use oxicode_vtui::tui::core::{
14 InlineListItem, InlineListSearchConfig, InlineListSelection, InlineMessageKind,
15};
16
17use super::registry::{SlashCommand, SlashCtx, SlashOutcome, SlashRegistry};
18
19pub(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
32pub(super) fn fmt_ctx(tokens: u32) -> String {
38 if tokens == 0 {
39 "? 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
50pub(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
61pub(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
69struct 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 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 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
194struct 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 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 if tokens.first().map(|t| *t == "add").unwrap_or(false) {
231 return add_custom_provider(ctx, &tokens[1..]);
232 }
233
234 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 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 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 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
331fn 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
363fn add_custom_provider(ctx: &mut SlashCtx<'_>, tokens: &[&str]) -> SlashOutcome {
375 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 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
460fn default_api_key_env(name: &str) -> String {
464 format!("{}_API_KEY", name.to_uppercase().replace('-', "_"))
465}
466
467fn 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
503struct 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
556struct 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
631pub(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
658struct 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}
689struct 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
750struct 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}
790struct 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
824pub(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#[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 assert_eq!(split_model_id("oai/gpt-4/vision"), ("oai", "gpt-4/vision"));
891 }
892
893 #[test]
898 fn provider_oauth_capability_matches_meta() {
899 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 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 #[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 #[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 #[test]
974 fn git_slash_command_registers() {
975 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}