use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); struct DelayedLoadingHeader { started_at: Instant, frame_requester: FrameRequester, animations_enabled: bool, loading_text: String, note: Option, } impl DelayedLoadingHeader { fn new( frame_requester: FrameRequester, animations_enabled: bool, loading_text: String, note: Option, ) -> Self { Self { started_at: Instant::now(), frame_requester, animations_enabled, loading_text, note, } } } impl Renderable for DelayedLoadingHeader { fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; } let mut lines = Vec::with_capacity(3); lines.push(Line::from("Plugins".bold())); let now = Instant::now(); let elapsed = now.saturating_duration_since(self.started_at); if elapsed < LOADING_ANIMATION_DELAY { self.frame_requester .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); lines.push(Line::from(self.loading_text.as_str().dim())); } else if self.animations_enabled { self.frame_requester .schedule_frame_in(LOADING_ANIMATION_INTERVAL); lines.push(Line::from(shimmer_spans(self.loading_text.as_str()))); } else { lines.push(Line::from(self.loading_text.as_str().dim())); } if let Some(note) = &self.note { lines.push(Line::from(note.as_str().dim())); } Paragraph::new(lines).render_ref(area, buf); } fn desired_height(&self, _width: u16) -> u16 { 2 + u16::from(self.note.is_some()) } } const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; struct PluginDisclosureLine { line: Line<'static>, } impl Renderable for PluginDisclosureLine { fn render(&self, area: Rect, buf: &mut Buffer) { Paragraph::new(self.line.clone()) .wrap(Wrap { trim: false }) .render(area, buf); mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); } fn desired_height(&self, width: u16) -> u16 { Paragraph::new(self.line.clone()) .wrap(Wrap { trim: false }) .line_count(width) .try_into() .unwrap_or(u16::MAX) } } #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] Uninitialized, Loading, Ready(PluginListResponse), Failed(String), } impl ChatWidget { pub(crate) fn add_plugins_output(&mut self) { if !self.config.features.enabled(Feature::Plugins) { self.add_info_message( "Plugins are disabled.".to_string(), Some("Enable the plugins feature to use /plugins.".to_string()), ); return; } self.prefetch_plugins(); match self.plugins_cache_for_current_cwd() { PluginsCacheState::Ready(response) => { self.open_plugins_popup(&response); } PluginsCacheState::Failed(err) => { self.add_to_history(history_cell::new_error_event(err)); } PluginsCacheState::Loading | PluginsCacheState::Uninitialized => { self.open_plugins_loading_popup(); } } self.request_redraw(); } pub(crate) fn on_plugins_loaded( &mut self, cwd: PathBuf, result: Result, ) { if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { self.plugins_fetch_state.in_flight_cwd = None; } if self.config.cwd.as_path() != cwd.as_path() { return; } let auth_flow_active = self.plugin_install_auth_flow.is_some(); match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); self.plugins_cache = PluginsCacheState::Ready(response.clone()); if !auth_flow_active { self.refresh_plugins_popup_if_open(&response); } } Err(err) => { if !auth_flow_active { self.plugins_fetch_state.cache_cwd = None; self.plugins_cache = PluginsCacheState::Failed(err.clone()); let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugins_error_popup_params(&err), ); } } } } fn prefetch_plugins(&mut self) { let cwd = self.config.cwd.to_path_buf(); if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { return; } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } self.app_event_tx.send(AppEvent::FetchPluginsList { cwd }); } fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { PluginsCacheState::Uninitialized } } fn open_plugins_loading_popup(&mut self) { if !self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugins_loading_popup_params(), ) { self.bottom_pane .show_selection_view(self.plugins_loading_popup_params()); } } fn open_plugins_popup(&mut self, response: &PluginListResponse) { self.bottom_pane .show_selection_view(self.plugins_popup_params(response)); } pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { let params = self.plugin_detail_loading_popup_params(plugin_display_name); let _ = self .bottom_pane .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } pub(crate) fn open_plugin_install_loading_popup(&mut self, plugin_display_name: &str) { let params = self.plugin_install_loading_popup_params(plugin_display_name); let _ = self .bottom_pane .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } pub(crate) fn open_plugin_uninstall_loading_popup(&mut self, plugin_display_name: &str) { let params = self.plugin_uninstall_loading_popup_params(plugin_display_name); let _ = self .bottom_pane .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } pub(crate) fn on_plugin_detail_loaded( &mut self, cwd: PathBuf, result: Result, ) { if self.config.cwd.as_path() != cwd.as_path() { return; } let plugins_response = match self.plugins_cache_for_current_cwd() { PluginsCacheState::Ready(response) => Some(response), _ => None, }; match result { Ok(response) => { if let Some(plugins_response) = plugins_response { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugin_detail_popup_params(&plugins_response, &response.plugin), ); } } Err(err) => { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), ); } } } pub(crate) fn on_plugin_install_loaded( &mut self, cwd: PathBuf, _marketplace_path: AbsolutePathBuf, _plugin_name: String, plugin_display_name: String, result: Result, ) -> bool { if self.config.cwd.as_path() != cwd.as_path() { return true; } match result { Ok(response) => { self.plugin_install_apps_needing_auth = response.apps_needing_auth; self.plugin_install_auth_flow = None; if self.plugin_install_apps_needing_auth.is_empty() { self.add_info_message( format!("Installed {plugin_display_name} plugin."), Some("No additional app authentication is required.".to_string()), ); true } else { let app_names = self .plugin_install_apps_needing_auth .iter() .map(|app| app.name.as_str()) .collect::>() .join(", "); self.add_info_message( format!("Installed {plugin_display_name} plugin."), Some(format!( "{} app(s) still need authentication: {app_names}", self.plugin_install_apps_needing_auth.len() )), ); self.plugin_install_auth_flow = Some(super::PluginInstallAuthFlowState { plugin_display_name, next_app_index: 0, }); self.open_plugin_install_auth_popup(); false } } Err(err) => { self.plugin_install_apps_needing_auth.clear(); self.plugin_install_auth_flow = None; let plugins_response = match self.plugins_cache_for_current_cwd() { PluginsCacheState::Ready(response) => Some(response), _ => None, }; let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), ); true } } } pub(crate) fn on_plugin_uninstall_loaded( &mut self, cwd: PathBuf, plugin_display_name: String, result: Result, ) { if self.config.cwd.as_path() != cwd.as_path() { return; } match result { Ok(_response) => { self.plugin_install_apps_needing_auth.clear(); self.plugin_install_auth_flow = None; self.add_info_message( format!("Uninstalled {plugin_display_name} plugin."), Some("Bundled apps remain installed.".to_string()), ); } Err(err) => { let plugins_response = match self.plugins_cache_for_current_cwd() { PluginsCacheState::Ready(response) => Some(response), _ => None, }; let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugin_detail_error_popup_params(&err, plugins_response.as_ref()), ); } } } pub(crate) fn advance_plugin_install_auth_flow(&mut self) { let should_finish = { let Some(flow) = self.plugin_install_auth_flow.as_mut() else { return; }; flow.next_app_index += 1; flow.next_app_index >= self.plugin_install_apps_needing_auth.len() }; if should_finish { self.finish_plugin_install_auth_flow(/*abandoned*/ false); return; } self.open_plugin_install_auth_popup(); } pub(crate) fn abandon_plugin_install_auth_flow(&mut self) { self.finish_plugin_install_auth_flow(/*abandoned*/ true); } fn open_plugin_install_auth_popup(&mut self) { let Some(params) = self.plugin_install_auth_popup_params() else { self.finish_plugin_install_auth_flow(/*abandoned*/ false); return; }; if !self .bottom_pane .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) && let Some(params) = self.plugin_install_auth_popup_params() { self.bottom_pane.show_selection_view(params); } } fn plugin_install_auth_popup_params(&self) -> Option { let flow = self.plugin_install_auth_flow.as_ref()?; let app = self .plugin_install_apps_needing_auth .get(flow.next_app_index)?; let total = self.plugin_install_apps_needing_auth.len(); let current = flow.next_app_index + 1; let is_installed = self.plugin_install_auth_app_is_installed(app.id.as_str()); let status_label = if is_installed { "Already installed in this session." } else { "Not installed yet." }; let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( format!("{} plugin installed.", flow.plugin_display_name).bold(), )); header.push(Line::from( format!("App setup {current}/{total}: {}", app.name).dim(), )); header.push(Line::from(status_label.dim())); let mut items = Vec::new(); if let Some(install_url) = app.install_url.clone() { let install_label = if is_installed { "Manage on ChatGPT" } else { "Install on ChatGPT" }; items.push(SelectionItem { name: install_label.to_string(), description: Some("Open the ChatGPT app management page".to_string()), selected_description: Some("Open the app page in your browser.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenUrlInBrowser { url: install_url.clone(), }); })], ..Default::default() }); } else { items.push(SelectionItem { name: "ChatGPT apps link unavailable".to_string(), description: Some("This app did not provide an install/manage URL.".to_string()), is_disabled: true, ..Default::default() }); } if is_installed { items.push(SelectionItem { name: "Continue".to_string(), description: Some("This app is already installed.".to_string()), selected_description: Some("Advance to the next app.".to_string()), actions: vec![Box::new(|tx| { tx.send(AppEvent::PluginInstallAuthAdvance { refresh_connectors: false, }); })], ..Default::default() }); } else { items.push(SelectionItem { name: "I've installed it".to_string(), description: Some( "Trust your confirmation and continue to the next app.".to_string(), ), selected_description: Some( "Continue without waiting for refresh to complete.".to_string(), ), actions: vec![Box::new(|tx| { tx.send(AppEvent::PluginInstallAuthAdvance { refresh_connectors: true, }); })], ..Default::default() }); } items.push(SelectionItem { name: "Skip remaining app setup".to_string(), description: Some("Stop this follow-up flow for this plugin.".to_string()), selected_description: Some("Abandon remaining required app setup.".to_string()), actions: vec![Box::new(|tx| { tx.send(AppEvent::PluginInstallAuthAbandon); })], ..Default::default() }); Some(SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(plugins_popup_hint_line()), items, col_width_mode: ColumnWidthMode::AutoAllRows, ..Default::default() }) } fn plugin_install_auth_app_is_installed(&self, app_id: &str) -> bool { self.connectors_for_mentions().is_some_and(|connectors| { connectors .iter() .any(|connector| connector.id == app_id && connector.is_accessible) }) } fn finish_plugin_install_auth_flow(&mut self, abandoned: bool) { let Some(flow) = self.plugin_install_auth_flow.take() else { return; }; self.plugin_install_apps_needing_auth.clear(); if abandoned { self.add_info_message( format!( "Skipped remaining app setup for {} plugin.", flow.plugin_display_name ), Some("The plugin may not be usable until required apps are installed.".to_string()), ); } else { self.add_info_message( format!( "Completed app setup flow for {} plugin.", flow.plugin_display_name ), Some("You can now continue managing plugins from /plugins.".to_string()), ); } let plugins_response = match self.plugins_cache_for_current_cwd() { PluginsCacheState::Ready(response) => Some(response), _ => None, }; if let Some(plugins_response) = plugins_response { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugins_popup_params(&plugins_response), ); } } fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) { let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, self.plugins_popup_params(response), ); } fn plugins_loading_popup_params(&self) -> SelectionViewParams { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), self.config.animations, "Loading available plugins...".to_string(), Some("This first pass shows the ChatGPT marketplace only.".to_string()), )), items: vec![SelectionItem { name: "Loading plugins...".to_string(), description: Some("This updates when the marketplace list is ready.".to_string()), is_disabled: true, ..Default::default() }], ..Default::default() } } fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), self.config.animations, format!("Loading details for {plugin_display_name}..."), /*note*/ None, )), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), description: Some("This updates when plugin details load.".to_string()), is_disabled: true, ..Default::default() }], ..Default::default() } } fn plugin_install_loading_popup_params( &self, plugin_display_name: &str, ) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( format!("Installing {plugin_display_name}...").dim(), )); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), items: vec![SelectionItem { name: "Installing plugin...".to_string(), description: Some("This updates when plugin installation completes.".to_string()), is_disabled: true, ..Default::default() }], ..Default::default() } } fn plugin_uninstall_loading_popup_params( &self, plugin_display_name: &str, ) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( format!("Uninstalling {plugin_display_name}...").dim(), )); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), items: vec![SelectionItem { name: "Uninstalling plugin...".to_string(), description: Some("This updates when the plugin removal completes.".to_string()), is_disabled: true, ..Default::default() }], ..Default::default() } } fn plugins_error_popup_params(&self, err: &str) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from("Failed to load plugins.".dim())); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), items: vec![SelectionItem { name: "Plugin marketplace unavailable".to_string(), description: Some(err.to_string()), is_disabled: true, ..Default::default() }], ..Default::default() } } fn plugin_detail_error_popup_params( &self, err: &str, plugins_response: Option<&PluginListResponse>, ) -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from("Failed to load plugin details.".dim())); let mut items = vec![SelectionItem { name: "Plugin detail unavailable".to_string(), description: Some(err.to_string()), is_disabled: true, ..Default::default() }]; if let Some(plugins_response) = plugins_response.cloned() { let cwd = self.config.cwd.to_path_buf(); items.push(SelectionItem { name: "Back to plugins".to_string(), description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::PluginsLoaded { cwd: cwd.clone(), result: Ok(plugins_response.clone()), }); })], ..Default::default() }); } SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(plugins_popup_hint_line()), items, ..Default::default() } } fn plugins_popup_params(&self, response: &PluginListResponse) -> SelectionViewParams { let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); let total: usize = marketplaces .iter() .map(|marketplace| marketplace.plugins.len()) .sum(); let installed = marketplaces .iter() .flat_map(|marketplace| marketplace.plugins.iter()) .filter(|plugin| plugin.installed) .count(); let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( "Browse plugins from available marketplaces.".dim(), )); header.push(Line::from( format!("Installed {installed} of {total} available plugins.").dim(), )); if let Some(remote_sync_error) = response.remote_sync_error.as_deref() { header.push(Line::from( format!("Using cached marketplace data: {remote_sync_error}").dim(), )); } let mut plugin_entries: Vec<(&PluginMarketplaceEntry, &PluginSummary, String)> = marketplaces .iter() .flat_map(|marketplace| { marketplace .plugins .iter() .map(move |plugin| (*marketplace, plugin, plugin_display_name(plugin))) }) .collect(); plugin_entries.sort_by(|left, right| { right .1 .installed .cmp(&left.1.installed) .then_with(|| { left.2 .to_ascii_lowercase() .cmp(&right.2.to_ascii_lowercase()) }) .then_with(|| left.2.cmp(&right.2)) .then_with(|| left.1.name.cmp(&right.1.name)) .then_with(|| left.1.id.cmp(&right.1.id)) }); let status_label_width = plugin_entries .iter() .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) .max() .unwrap_or(0); let mut items: Vec = Vec::new(); for (marketplace, plugin, display_name) in plugin_entries { let marketplace_label = marketplace_display_name(marketplace); let status_label = plugin_status_label(plugin); let description = plugin_brief_description(plugin, &marketplace_label, status_label_width); let selected_status_label = format!("{status_label: SelectionViewParams { let marketplace_label = plugin.marketplace_name.clone(); let display_name = plugin_display_name(&plugin.summary); let detail_status_label = if plugin.summary.installed { if plugin.summary.enabled { "Installed" } else { "Installed · Disabled" } } else { match plugin.summary.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Can be installed", PluginInstallPolicy::InstalledByDefault => "Available by default", } }; let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), )); if !plugin.summary.installed { header.push(PluginDisclosureLine { line: Line::from(vec![ "Data shared with this app is subject to the app's ".into(), "terms of service".bold(), " and ".into(), "privacy policy".bold(), ". ".into(), "Learn more".cyan().underlined(), ".".into(), ]), }); } if let Some(description) = plugin_detail_description(plugin) { header.push(Line::from(description.dim())); } let cwd = self.config.cwd.to_path_buf(); let plugins_response = plugins_response.clone(); let mut items = vec![SelectionItem { name: "Back to plugins".to_string(), description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::PluginsLoaded { cwd: cwd.clone(), result: Ok(plugins_response.clone()), }); })], ..Default::default() }]; if plugin.summary.installed { let uninstall_cwd = self.config.cwd.to_path_buf(); let plugin_id = plugin.summary.id.clone(); let plugin_display_name = display_name; items.push(SelectionItem { name: "Uninstall plugin".to_string(), description: Some("Remove this plugin now.".to_string()), selected_description: Some("Remove this plugin now.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenPluginUninstallLoading { plugin_display_name: plugin_display_name.clone(), }); tx.send(AppEvent::FetchPluginUninstall { cwd: uninstall_cwd.clone(), plugin_id: plugin_id.clone(), plugin_display_name: plugin_display_name.clone(), }); })], ..Default::default() }); } else if plugin.summary.install_policy == PluginInstallPolicy::NotAvailable { items.push(SelectionItem { name: "Install plugin".to_string(), description: Some( "This plugin is not installable from this marketplace.".to_string(), ), is_disabled: true, ..Default::default() }); } else { let install_cwd = self.config.cwd.to_path_buf(); let marketplace_path = plugin.marketplace_path.clone(); let plugin_name = plugin.summary.name.clone(); let plugin_display_name = display_name; items.push(SelectionItem { name: "Install plugin".to_string(), description: Some("Install this plugin now.".to_string()), selected_description: Some("Install this plugin now.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenPluginInstallLoading { plugin_display_name: plugin_display_name.clone(), }); tx.send(AppEvent::FetchPluginInstall { cwd: install_cwd.clone(), marketplace_path: marketplace_path.clone(), plugin_name: plugin_name.clone(), plugin_display_name: plugin_display_name.clone(), }); })], ..Default::default() }); } items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), is_disabled: true, ..Default::default() }); items.push(SelectionItem { name: "Apps".to_string(), description: Some(plugin_app_summary(plugin)), is_disabled: true, ..Default::default() }); items.push(SelectionItem { name: "MCP Servers".to_string(), description: Some(plugin_mcp_summary(plugin)), is_disabled: true, ..Default::default() }); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(plugins_popup_hint_line()), items, col_width_mode: ColumnWidthMode::AutoAllRows, ..Default::default() } } } fn plugins_popup_hint_line() -> Line<'static> { Line::from("Press esc to close.") } fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { marketplace .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(|| marketplace.name.clone()) } fn plugin_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_brief_description( plugin: &PluginSummary, marketplace_label: &str, status_label_width: usize, ) -> String { let status_label = plugin_status_label(plugin); let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), None => format!("{status_label} · {marketplace_label}"), } } fn plugin_status_label(plugin: &PluginSummary) -> &'static str { if plugin.installed { if plugin.enabled { "Installed" } else { "Installed · Disabled" } } else { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", PluginInstallPolicy::InstalledByDefault => "Available by default", } } } fn plugin_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) } fn plugin_detail_description(plugin: &PluginDetail) -> Option { plugin .description .as_deref() .or_else(|| { plugin .summary .interface .as_ref() .and_then(|interface| interface.long_description.as_deref()) }) .or_else(|| { plugin .summary .interface .as_ref() .and_then(|interface| interface.short_description.as_deref()) }) .map(str::trim) .filter(|description| !description.is_empty()) .map(str::to_string) } fn plugin_skill_summary(plugin: &PluginDetail) -> String { if plugin.skills.is_empty() { "No plugin skills.".to_string() } else { plugin .skills .iter() .map(|skill| skill.name.as_str()) .collect::>() .join(", ") } } fn plugin_app_summary(plugin: &PluginDetail) -> String { if plugin.apps.is_empty() { "No plugin apps.".to_string() } else { plugin .apps .iter() .map(|app| app.name.as_str()) .collect::>() .join(", ") } } fn plugin_mcp_summary(plugin: &PluginDetail) -> String { if plugin.mcp_servers.is_empty() { "No plugin MCP servers.".to_string() } else { plugin.mcp_servers.join(", ") } }