diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 62b29f5b3e..bab42be2f1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -127,7 +127,6 @@ use codex_app_server_protocol::TurnStatus; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; -use codex_core_plugins::PluginsManager; use codex_exec_server::EnvironmentManager; use codex_features::Feature; use codex_model_provider::create_model_provider; @@ -196,6 +195,7 @@ mod loaded_threads; mod pending_interactive_replay; mod pets; mod platform_actions; +mod plugin_mentions; mod replay_filter; mod resize_reflow; mod session_lifecycle; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 57c82cb7f7..c323a2d5bf 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -4,6 +4,7 @@ //! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so //! the main event loop remains single-threaded. +use super::plugin_mentions::fetch_plugin_mentions; use super::*; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; @@ -358,8 +359,9 @@ impl App { }); } - pub(super) fn refresh_plugin_mentions(&mut self) { + pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) { let config = self.config.clone(); + let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); if !config.features.enabled(Feature::Plugins) { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None }); @@ -367,15 +369,16 @@ impl App { } tokio::spawn(async move { - let plugins_input = config.plugins_config_input(); - let plugins = PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(&plugins_input) - .await - .capability_summaries() - .to_vec(); - app_event_tx.send(AppEvent::PluginMentionsLoaded { - plugins: Some(plugins), - }); + match fetch_plugin_mentions(request_handle, config).await { + Ok(plugins) => { + app_event_tx.send(AppEvent::PluginMentionsLoaded { + plugins: Some(plugins), + }); + } + Err(err) => { + tracing::warn!(error = %err, "plugin/list failed while refreshing plugin mention candidates"); + } + } }); } @@ -635,18 +638,9 @@ pub(super) async fn fetch_plugins_list( request_handle: AppServerRequestHandle, cwd: PathBuf, ) -> Result { - let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?; - let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4())); - let mut response = request_handle - .request_typed(ClientRequest::PluginList { - request_id, - params: PluginListParams { - cwds: Some(vec![cwd]), - marketplace_kinds: None, - }, - }) + let mut response = request_plugin_list(request_handle, cwd) .await - .wrap_err("plugin/list failed in TUI")?; + .wrap_err("plugin/list failed while loading the plugins menu")?; hide_cli_only_plugin_marketplaces(&mut response); Ok(response) } @@ -659,6 +653,24 @@ pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListRespons .retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str())); } +pub(super) async fn request_plugin_list( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> Result { + let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?; + let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginList { + request_id, + params: PluginListParams { + cwds: Some(vec![cwd]), + marketplace_kinds: None, + }, + }) + .await + .wrap_err("plugin/list failed in TUI") +} + pub(super) async fn fetch_plugin_detail( request_handle: AppServerRequestHandle, params: PluginReadParams, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1d2df3db16..16d7daee72 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1253,7 +1253,7 @@ impl App { } } AppEvent::RefreshPluginMentions => { - self.refresh_plugin_mentions(); + self.refresh_plugin_mentions(app_server); } AppEvent::PluginMentionsLoaded { mut plugins } => { if !self.config.features.enabled(Feature::Plugins) { diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs new file mode 100644 index 0000000000..ef300896bc --- /dev/null +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -0,0 +1,147 @@ +//! Plugin mention capability enrichment for the TUI. +//! +//! Mention inventory comes from app-server `plugin/list`, while mention eligibility still reuses +//! the older local bulk capability summaries. That keeps the feature app-server-shaped without +//! paying for a `plugin/read` per plugin. + +use super::background_requests::request_plugin_list; +use super::*; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSummary; +use codex_core_plugins::PluginsManager; +use codex_plugin::PluginCapabilitySummary; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +struct PluginMentionEntry { + config_name: String, + display_name: String, + description: Option, +} + +impl PluginMentionEntry { + fn capability_summary( + self, + capabilities_by_config_name: &HashMap, + ) -> Option { + let capabilities = capabilities_by_config_name.get(&self.config_name)?; + Some(PluginCapabilitySummary { + config_name: self.config_name, + display_name: self.display_name, + description: self.description, + has_skills: capabilities.has_skills, + mcp_server_names: capabilities.mcp_server_names.clone(), + app_connector_ids: capabilities.app_connector_ids.clone(), + }) + } +} + +pub(super) async fn fetch_plugin_mentions( + request_handle: AppServerRequestHandle, + config: crate::legacy_core::config::Config, +) -> Result> { + let response = request_plugin_list(request_handle, config.cwd.to_path_buf()).await?; + let mention_entries = plugin_mention_entries_from_list_response(response); + let capabilities_by_config_name = load_plugin_mention_capabilities(&config).await; + + Ok(mention_entries + .into_iter() + .filter_map(|entry| entry.capability_summary(&capabilities_by_config_name)) + .collect()) +} + +async fn load_plugin_mention_capabilities( + config: &crate::legacy_core::config::Config, +) -> HashMap { + let plugins_input = config.plugins_config_input(); + PluginsManager::new(config.codex_home.to_path_buf()) + .plugins_for_config(&plugins_input) + .await + .capability_summaries() + .iter() + .cloned() + .map(|summary| (summary.config_name.clone(), summary)) + .collect() +} + +fn plugin_mention_entries_from_list_response( + response: PluginListResponse, +) -> Vec { + response + .marketplaces + .into_iter() + .flat_map(plugin_mention_entries_from_marketplace) + .collect() +} + +fn plugin_mention_entries_from_marketplace( + marketplace: PluginMarketplaceEntry, +) -> Vec { + let marketplace_name = marketplace.name; + marketplace + .plugins + .into_iter() + .filter_map(|plugin| plugin_mention_entry(&marketplace_name, plugin)) + .collect() +} + +fn plugin_mention_entry( + marketplace_name: &str, + plugin: PluginSummary, +) -> Option { + if !plugin_is_eligible_for_mentions(&plugin) { + return None; + } + + let config_name = plugin_mention_config_name(marketplace_name, &plugin)?; + Some(PluginMentionEntry { + config_name, + display_name: plugin_mention_display_name(&plugin), + description: plugin_mention_description(&plugin), + }) +} + +fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool { + plugin.installed && plugin.enabled +} + +fn plugin_mention_config_name(marketplace_name: &str, plugin: &PluginSummary) -> Option { + codex_plugin::PluginId::new(plugin.name.clone(), marketplace_name.to_string()) + .map(|plugin_id| plugin_id.as_key()) + .map_err(|err| { + tracing::warn!( + plugin_name = plugin.name, + marketplace_name, + error = %err, + "skipping plugin mention with invalid identity" + ); + }) + .ok() +} + +fn plugin_mention_display_name(plugin: &PluginSummary) -> String { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.name.clone()) +} + +fn plugin_mention_description(plugin: &PluginSummary) -> Option { + plugin + .interface + .as_ref() + .and_then(|interface| { + interface + .short_description + .as_deref() + .or(interface.long_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +}