From b5d0a5518ded010f9c78227ec723e63f072dbd83 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 23 Mar 2026 12:38:39 -0700 Subject: [PATCH] Plugins TUI install/uninstall (#15342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add install/uninstall actions to the TUI plugins menu - Wire plugin install/uninstall through both TUI and `tui_app_server` - Refresh config/plugin state after changes so the UI updates immediately - Add a post-install app setup flow for plugins that require additional app auth Screenshot 2026-03-20 at 4 08 44 PM Screenshot 2026-03-20 at 4 08 54 PM Screenshot 2026-03-20 at 4 09 07 PM Screenshot 2026-03-20 at 4 09 24 PM Note/known issue: The /plugin install flow fails in `tui_app_server` because after a successful install it tries to trigger a ReloadUserConfig operation, but `tui_app_server` has not yet implemented transport for that operation, so it falls through to the generic “Not available in app-server TUI yet” stub. --- codex-rs/tui/src/app.rs | 244 ++++++++ codex-rs/tui/src/app_event.rs | 53 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 53 +- codex-rs/tui/src/chatwidget.rs | 15 + codex-rs/tui/src/chatwidget/plugins.rs | 421 +++++++++++++- ...ests__plugin_detail_popup_installable.snap | 16 + ...ts__plugins_popup_curated_marketplace.snap | 17 + ...t__tests__plugins_popup_loading_state.snap | 9 + ..._tests__plugins_popup_search_filtered.snap | 12 + codex-rs/tui/src/chatwidget/tests.rs | 521 ++++++++++++++++++ codex-rs/tui_app_server/src/app.rs | 193 +++++++ codex-rs/tui_app_server/src/app_event.rs | 53 ++ .../src/bottom_pane/chat_composer.rs | 53 +- codex-rs/tui_app_server/src/chatwidget.rs | 11 + .../tui_app_server/src/chatwidget/plugins.rs | 421 +++++++++++++- ...ests__plugin_detail_popup_installable.snap | 16 + ...ts__plugins_popup_curated_marketplace.snap | 17 + ...t__tests__plugins_popup_loading_state.snap | 9 + ..._tests__plugins_popup_search_filtered.snap | 12 + .../tui_app_server/src/chatwidget/tests.rs | 521 ++++++++++++++++++ 20 files changed, 2611 insertions(+), 56 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4086efd98..4860787e8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -45,10 +45,14 @@ use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; use codex_core::AuthManager; @@ -355,6 +359,72 @@ async fn request_plugin_detail( response } +async fn request_plugin_install( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + params: PluginInstallParams, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginInstall { request_id, params }) + .await + .wrap_err("plugin/install failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/install"); + } + response +} + +async fn request_plugin_uninstall( + arg0_paths: Arg0DispatchPaths, + config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, + feedback: codex_feedback::CodexFeedback, + plugin_id: String, +) -> Result { + let client = start_plugin_request_client( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + ) + .await?; + let request_handle = client.request_handle(); + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + let response = request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in legacy TUI"); + if let Err(err) = client.shutdown().await { + tracing::warn!(%err, "failed to shut down embedded app server after plugin/uninstall"); + } + response +} + fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) { let mut disabled_folders = Vec::new(); @@ -1365,6 +1435,85 @@ impl App { }); } + fn fetch_plugin_install( + &mut self, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = request_plugin_install( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + ) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let config = self.config.clone(); + let arg0_paths = self.arg0_paths.clone(); + let cli_kv_overrides = self.cli_kv_overrides.clone(); + let loader_overrides = self.loader_overrides.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let feedback = self.feedback.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = request_plugin_uninstall( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + cloud_requirements, + feedback, + plugin_id, + ) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + fn clear_ui_header_lines_with_version( &self, width: u16, @@ -2953,6 +3102,15 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(cwd); } @@ -2962,6 +3120,18 @@ impl App { self.chat_widget .open_plugin_detail_loading_popup(&plugin_display_name); } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -2983,6 +3153,55 @@ impl App { AppEvent::PluginDetailLoaded { cwd, result } => { self.chat_widget.on_plugin_detail_loaded(cwd, result); } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install(cwd, marketplace_path, plugin_name, plugin_display_name); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); self.refresh_status_surfaces(); @@ -3400,6 +3619,31 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 71fc7be27..b5e81edd9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,15 +10,18 @@ use std::path::PathBuf; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallResponse; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -193,6 +196,56 @@ pub(crate) enum AppEvent { result: Result, }, + /// Replace the plugins popup with an install loading state. + OpenPluginInstallLoading { + plugin_display_name: String, + }, + + /// Replace the plugins popup with an uninstall loading state. + OpenPluginUninstallLoading { + plugin_display_name: String, + }, + + /// Install a specific plugin from a marketplace. + FetchPluginInstall { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + }, + + /// Result of installing a plugin. + PluginInstallLoaded { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + result: Result, + }, + + /// Uninstall a specific plugin by canonical plugin id. + FetchPluginUninstall { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + }, + + /// Result of uninstalling a plugin. + PluginUninstallLoaded { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + result: Result, + }, + + /// Advance the post-install plugin app-auth flow. + PluginInstallAuthAdvance { + refresh_connectors: bool, + }, + + /// Abandon the post-install plugin app-auth flow. + PluginInstallAuthAbandon, + InsertHistoryCell(Box), /// Apply rollback semantics to local transcript cells. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 90106f31f..bc3ff3859 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3584,6 +3584,24 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_namespaces: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .filter_map(|plugin| { + let (plugin_name, _) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let plugin_name = plugin_name.trim(); + if plugin_name.is_empty() { + None + } else { + Some(plugin_name.to_ascii_lowercase()) + } + }) + .collect() + }); let plugin_display_names: HashSet = self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { plugins @@ -3594,6 +3612,13 @@ impl ChatComposer { if let Some(skills) = self.skills.as_ref() { for skill in skills { + let is_plugin_namespaced_skill = + skill.name.split_once(':').is_some_and(|(namespace, _)| { + plugin_namespaces.contains(&namespace.to_ascii_lowercase()) + }); + if is_plugin_namespaced_skill { + continue; + } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -5403,7 +5428,7 @@ mod tests { } #[test] - fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -5465,27 +5490,13 @@ mod tests { }], })); - let mut mention_summaries: Vec<_> = composer - .mention_items() - .into_iter() - .map(|mention| (mention.display_name, mention.category_tag, mention.path)) - .collect(); - mention_summaries.sort(); - + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); assert_eq!( - mention_summaries, - vec![ - ( - "Google Calendar".to_string(), - Some("[Plugin]".to_string()), - Some("plugin://google-calendar@debug".to_string()), - ), - ( - "Google Calendar".to_string(), - Some("[Skill]".to_string()), - Some("/tmp/repo/google-calendar/SKILL.md".to_string()), - ), - ] + mentions[0].path, + Some("plugin://google-calendar@debug".to_string()) ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7a42b5c26..3f548fc23 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -57,6 +57,7 @@ use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::ConfigLayerSource; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; @@ -537,6 +538,12 @@ struct PluginListFetchState { in_flight_cwd: Option, } +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -732,6 +739,8 @@ pub(crate) struct ChatWidget { pending_mcp_output_requests: usize, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -3747,6 +3756,8 @@ impl ChatWidget { pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -3949,6 +3960,8 @@ impl ChatWidget { pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -4143,6 +4156,8 @@ impl ChatWidget { pending_mcp_output_requests: 0, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 5e4eaecd5..a9250672c 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -9,12 +9,15 @@ use crate::history_cell; use crate::render::renderable::ColumnRenderable; 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_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize; use ratatui::text::Line; @@ -69,19 +72,25 @@ impl ChatWidget { 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()); - self.refresh_plugins_popup_if_open(&response); + if !auth_flow_active { + self.refresh_plugins_popup_if_open(&response); + } } Err(err) => { - 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), - ); + 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), + ); + } } } } @@ -130,6 +139,20 @@ impl ChatWidget { .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, @@ -162,6 +185,291 @@ impl ChatWidget { } } + 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 != cwd { + 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 != cwd { + 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 description = app + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + 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![SelectionItem { + name: app.name.clone(), + description, + is_disabled: true, + ..Default::default() + }]; + + 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 same ChatGPT app management link used by /apps.".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 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, @@ -212,6 +520,52 @@ impl ChatWidget { } } + 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 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())); @@ -397,6 +751,59 @@ impl ChatWidget { ..Default::default() }]; + if plugin.summary.installed { + let uninstall_cwd = self.config.cwd.clone(); + 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.clone(); + 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)), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap new file mode 100644 index 000000000..d2461ec72 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · ChatGPT Marketplace + Can be installed + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + 3. Skills design-review, extract-copy + 4. Apps Figma, Slack + 5. MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap new file mode 100644 index 000000000..54132d3bf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 1 of 3 available plugins. + Using cached marketplace data: remote sync timed out + + Type to search plugins +› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already + installed but disabled. + Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by + default. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap new file mode 100644 index 000000000..eddb86916 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Loading available plugins... + This first pass shows the ChatGPT marketplace only. + +› 1. Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap new file mode 100644 index 000000000..b46cdb825 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 0 of 3 available plugins. + + sla +› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 65daefc91..8e9ccb040 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -18,6 +18,18 @@ use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::SkillSummary; use codex_core::CodexAuth; use codex_core::config::ApprovalsReviewer; use codex_core::config::Config; @@ -35,6 +47,7 @@ use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::RequirementSource; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::ModelsManager; +use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; @@ -2021,6 +2034,8 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, pending_mcp_output_requests: 0, @@ -7067,6 +7082,512 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from( + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path), + ) + .expect("expected absolute test path") +} + +fn plugins_test_interface( + display_name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, +) -> PluginInterface { + PluginInterface { + display_name: display_name.map(str::to_string), + short_description: short_description.map(str::to_string), + long_description: long_description.map(str::to_string), + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + } +} + +fn plugins_test_summary( + id: &str, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + installed: bool, + enabled: bool, + install_policy: PluginInstallPolicy, +) -> PluginSummary { + PluginSummary { + id: id.to_string(), + name: name.to_string(), + source: PluginSource::Local { + path: plugins_test_absolute_path(&format!("plugins/{name}")), + }, + installed, + enabled, + install_policy, + auth_policy: PluginAuthPolicy::OnInstall, + interface: Some(plugins_test_interface(display_name, description, None)), + } +} + +fn plugins_test_curated_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + path: plugins_test_absolute_path("marketplaces/chatgpt"), + interface: Some(MarketplaceInterface { + display_name: Some("ChatGPT Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: "repo".to_string(), + path: plugins_test_absolute_path("marketplaces/repo"), + interface: Some(MarketplaceInterface { + display_name: Some("Repo Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { + PluginListResponse { + marketplaces, + remote_sync_error: None, + featured_plugin_ids: Vec::new(), + } +} + +fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + render_bottom_popup(chat, 100) +} + +fn plugins_test_detail( + summary: PluginSummary, + description: Option<&str>, + skills: &[&str], + apps: &[(&str, bool)], + mcp_servers: &[&str], +) -> PluginDetail { + PluginDetail { + marketplace_name: "ChatGPT Marketplace".to_string(), + marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"), + summary, + description: description.map(str::to_string), + skills: skills + .iter() + .map(|name| SkillSummary { + name: (*name).to_string(), + description: format!("{name} description"), + short_description: None, + interface: None, + path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + }) + .collect(), + apps: apps + .iter() + .map(|(name, needs_auth)| AppSummary { + id: format!("{name}-id"), + name: (*name).to_string(), + description: Some(format!("{name} app")), + install_url: Some(format!("https://example.test/{name}")), + needs_auth: *needs_auth, + }) + .collect(), + mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), + } +} + +fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { + popup + .find(needle) + .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) +} + +fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { + for ch in query.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } +} + +#[tokio::test] +async fn plugins_popup_loading_state_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, 100); + assert!( + popup.contains("Loading available plugins..."), + "expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}" + ); + assert_snapshot!("plugins_popup_loading_state", popup); +} + +#[tokio::test] +async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let mut response = plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-bravo", + "bravo", + Some("Bravo Search"), + Some("Search docs and tickets."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha Sync"), + Some("Already installed but disabled."), + true, + false, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-starter", + "starter", + Some("Starter"), + Some("Included by default."), + false, + true, + PluginInstallPolicy::InstalledByDefault, + ), + ]), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-hidden", + "hidden", + Some("Hidden Repo Plugin"), + Some("Should not be shown in /plugins."), + false, + true, + PluginInstallPolicy::Available, + )]), + ]); + response.remote_sync_error = Some("remote sync timed out".to_string()); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert_snapshot!("plugins_popup_curated_marketplace", popup); + assert!( + !popup.contains("Hidden Repo Plugin"), + "expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}" + ); + assert!( + plugins_test_popup_row_position(&popup, "Bravo Search") + < plugins_test_popup_row_position(&popup, "Alpha Sync") + && plugins_test_popup_row_position(&popup, "Alpha Sync") + < plugins_test_popup_row_position(&popup, "Starter"), + "expected /plugins rows to keep response order, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + false, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugin_detail_popup_installable", popup); +} + +#[tokio::test] +async fn plugins_popup_refresh_replaces_selection_with_first_row() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + render_loaded_plugins_popup(&mut chat, initial); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let before = render_bottom_popup(&chat, 100); + assert!( + before.contains("› Slack"), + "expected Slack to be selected before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-airtable", + "airtable", + Some("Airtable"), + Some("Structured records."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("› Airtable"), + "expected refresh to rebuild the popup from the new first row, got:\n{after}" + ); + assert!( + after.contains("Slack · ChatGPT Marketplace"), + "expected refreshed popup to include the updated plugin list, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_refreshes_installed_counts_after_install() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let before = render_loaded_plugins_popup(&mut chat, initial); + assert!( + before.contains("Installed 1 of 2 available plugins."), + "expected initial installed count before refresh, got:\n{before}" + ); + assert!( + before.contains("Can be installed"), + "expected pre-install popup copy before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + true, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("Installed 2 of 2 available plugins."), + "expected /plugins to refresh installed counts after install, got:\n{after}" + ); + assert!( + after.contains("Installed. Press Enter to view plugin details."), + "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_filters_visible_rows_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "sla"); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugins_popup_search_filtered", popup); + assert!( + !popup.contains("Calendar · ChatGPT Marketplace") + && !popup.contains("Drive · ChatGPT Marketplace"), + "expected search to leave only matching rows visible, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_no_matches_and_backspace_restores_results() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "zzz"); + + let no_matches = render_bottom_popup(&chat, 100); + assert!( + no_matches.contains("zzz"), + "expected popup to show the typed search query, got:\n{no_matches}" + ); + assert!( + no_matches.contains("no matches"), + "expected popup to render the no-matches UX, got:\n{no_matches}" + ); + + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + } + + let restored = render_bottom_popup(&chat, 100); + assert!( + restored.contains("Calendar · ChatGPT Marketplace") + && restored.contains("Slack · ChatGPT Marketplace"), + "expected clearing the query to restore the plugin rows, got:\n{restored}" + ); + assert!( + !restored.contains("no matches"), + "did not expect the no-matches state after clearing the query, got:\n{restored}" + ); +} + fn selected_permissions_popup_line(popup: &str) -> &str { popup .lines() diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index e00a9604f..97c1e549f 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -55,10 +55,14 @@ use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -1831,6 +1835,57 @@ impl App { }); } + fn fetch_plugin_install( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let marketplace_path_for_event = marketplace_path.clone(); + let plugin_name_for_event = plugin_name.clone(); + let result = fetch_plugin_install(request_handle, marketplace_path, plugin_name) + .await + .map_err(|err| format!("Failed to install plugin: {err}")); + app_event_tx.send(AppEvent::PluginInstallLoaded { + cwd: cwd_for_event, + marketplace_path: marketplace_path_for_event, + plugin_name: plugin_name_for_event, + plugin_display_name, + result, + }); + }); + } + + fn fetch_plugin_uninstall( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = fetch_plugin_uninstall(request_handle, plugin_id) + .await + .map_err(|err| format!("Failed to uninstall plugin: {err}")); + app_event_tx.send(AppEvent::PluginUninstallLoaded { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + plugin_display_name, + result, + }); + }); + } + /// Process the completed MCP inventory fetch: clear the loading spinner, then /// render either the full tool/resource listing or an error into chat history. /// @@ -3555,6 +3610,15 @@ impl App { AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } + AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { + if refresh_connectors { + self.chat_widget.refresh_connectors(/*force_refetch*/ true); + } + self.chat_widget.advance_plugin_install_auth_flow(); + } + AppEvent::PluginInstallAuthAbandon => { + self.chat_widget.abandon_plugin_install_auth_flow(); + } AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } @@ -3564,6 +3628,18 @@ impl App { self.chat_widget .open_plugin_detail_loading_popup(&plugin_display_name); } + AppEvent::OpenPluginInstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_install_loading_popup(&plugin_display_name); + } + AppEvent::OpenPluginUninstallLoading { + plugin_display_name, + } => { + self.chat_widget + .open_plugin_uninstall_loading_popup(&plugin_display_name); + } AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } @@ -3573,6 +3649,62 @@ impl App { AppEvent::PluginDetailLoaded { cwd, result } => { self.chat_widget.on_plugin_detail_loaded(cwd, result); } + AppEvent::FetchPluginInstall { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + } => { + self.fetch_plugin_install( + app_server, + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + ); + } + AppEvent::FetchPluginUninstall { + cwd, + plugin_id, + plugin_display_name, + } => { + self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); + } + AppEvent::PluginInstallLoaded { + cwd, + marketplace_path, + plugin_name, + plugin_display_name, + result, + } => { + let install_succeeded = result.is_ok(); + if install_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after plugin install"); + } + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( + cwd.clone(), + marketplace_path.clone(), + plugin_name.clone(), + plugin_display_name, + result, + ); + if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd.clone()); + if should_refresh_plugin_detail { + self.fetch_plugin_detail( + app_server, + cwd, + PluginReadParams { + marketplace_path, + plugin_name, + }, + ); + } + } + } AppEvent::FetchMcpInventory => { self.fetch_mcp_inventory(app_server); } @@ -4012,6 +4144,31 @@ impl App { } } } + AppEvent::PluginUninstallLoaded { + cwd, + plugin_id: _plugin_id, + plugin_display_name, + result, + } => { + let uninstall_succeeded = result.is_ok(); + if uninstall_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin uninstall" + ); + } + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget.on_plugin_uninstall_loaded( + cwd.clone(), + plugin_display_name, + result, + ); + if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + self.fetch_plugins_list(app_server, cwd); + } + } AppEvent::PersistPersonalitySelection { personality } => { let profile = self.active_profile.as_deref(); match ConfigEditsBuilder::new(&self.config.codex_home) @@ -5133,6 +5290,42 @@ async fn fetch_plugin_detail( .wrap_err("plugin/read failed in app-server TUI") } +async fn fetch_plugin_install( + request_handle: AppServerRequestHandle, + marketplace_path: AbsolutePathBuf, + plugin_name: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-install-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginInstall { + request_id, + params: PluginInstallParams { + marketplace_path, + plugin_name, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/install failed in app-server TUI") +} + +async fn fetch_plugin_uninstall( + request_handle: AppServerRequestHandle, + plugin_id: String, +) -> Result { + let request_id = RequestId::String(format!("plugin-uninstall-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginUninstall { + request_id, + params: PluginUninstallParams { + plugin_id, + force_remote_sync: false, + }, + }) + .await + .wrap_err("plugin/uninstall failed in app-server TUI") +} + /// Convert flat `McpServerStatus` responses into the per-server maps used by the /// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus /// per-server resource/template/auth maps). Test-only because the app-server TUI diff --git a/codex-rs/tui_app_server/src/app_event.rs b/codex-rs/tui_app_server/src/app_event.rs index a763410dd..f5284fd61 100644 --- a/codex-rs/tui_app_server/src/app_event.rs +++ b/codex-rs/tui_app_server/src/app_event.rs @@ -11,9 +11,11 @@ use std::path::PathBuf; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginUninstallResponse; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -21,6 +23,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::GetHistoryEntryResponseEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -195,6 +198,56 @@ pub(crate) enum AppEvent { result: Result, }, + /// Replace the plugins popup with an install loading state. + OpenPluginInstallLoading { + plugin_display_name: String, + }, + + /// Replace the plugins popup with an uninstall loading state. + OpenPluginUninstallLoading { + plugin_display_name: String, + }, + + /// Install a specific plugin from a marketplace. + FetchPluginInstall { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + }, + + /// Result of installing a plugin. + PluginInstallLoaded { + cwd: PathBuf, + marketplace_path: AbsolutePathBuf, + plugin_name: String, + plugin_display_name: String, + result: Result, + }, + + /// Uninstall a specific plugin by canonical plugin id. + FetchPluginUninstall { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + }, + + /// Result of uninstalling a plugin. + PluginUninstallLoaded { + cwd: PathBuf, + plugin_id: String, + plugin_display_name: String, + result: Result, + }, + + /// Advance the post-install plugin app-auth flow. + PluginInstallAuthAdvance { + refresh_connectors: bool, + }, + + /// Abandon the post-install plugin app-auth flow. + PluginInstallAuthAbandon, + /// Fetch MCP inventory via app-server RPCs and render it into history. FetchMcpInventory, diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index 06d8396f8..98452410b 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3599,6 +3599,24 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_namespaces: HashSet = + self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { + plugins + .iter() + .filter_map(|plugin| { + let (plugin_name, _) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let plugin_name = plugin_name.trim(); + if plugin_name.is_empty() { + None + } else { + Some(plugin_name.to_ascii_lowercase()) + } + }) + .collect() + }); let plugin_display_names: HashSet = self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { plugins @@ -3609,6 +3627,13 @@ impl ChatComposer { if let Some(skills) = self.skills.as_ref() { for skill in skills { + let is_plugin_namespaced_skill = + skill.name.split_once(':').is_some_and(|(namespace, _)| { + plugin_namespaces.contains(&namespace.to_ascii_lowercase()) + }); + if is_plugin_namespaced_skill { + continue; + } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -5418,7 +5443,7 @@ mod tests { } #[test] - fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() { + fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -5480,27 +5505,13 @@ mod tests { }], })); - let mut mention_summaries: Vec<_> = composer - .mention_items() - .into_iter() - .map(|mention| (mention.display_name, mention.category_tag, mention.path)) - .collect(); - mention_summaries.sort(); - + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); assert_eq!( - mention_summaries, - vec![ - ( - "Google Calendar".to_string(), - Some("[Plugin]".to_string()), - Some("plugin://google-calendar@debug".to_string()), - ), - ( - "Google Calendar".to_string(), - Some("[Skill]".to_string()), - Some("/tmp/repo/google-calendar/SKILL.md".to_string()), - ), - ] + mentions[0].path, + Some("plugin://google-calendar@debug".to_string()) ); } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index ad8b9e709..3242046bf 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -59,6 +59,7 @@ use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; @@ -566,6 +567,12 @@ struct PluginListFetchState { in_flight_cwd: Option, } +#[derive(Debug, Clone)] +struct PluginInstallAuthFlowState { + plugin_display_name: String, + next_app_index: usize, +} + #[derive(Debug)] enum RateLimitErrorKind { ServerOverloaded, @@ -772,6 +779,8 @@ pub(crate) struct ChatWidget { connectors_force_refetch_pending: bool, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, + plugin_install_apps_needing_auth: Vec, + plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -4309,6 +4318,8 @@ impl ChatWidget { connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index 5e4eaecd5..a9250672c 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -9,12 +9,15 @@ use crate::history_cell; use crate::render::renderable::ColumnRenderable; 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_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_features::Feature; +use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::style::Stylize; use ratatui::text::Line; @@ -69,19 +72,25 @@ impl ChatWidget { 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()); - self.refresh_plugins_popup_if_open(&response); + if !auth_flow_active { + self.refresh_plugins_popup_if_open(&response); + } } Err(err) => { - 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), - ); + 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), + ); + } } } } @@ -130,6 +139,20 @@ impl ChatWidget { .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, @@ -162,6 +185,291 @@ impl ChatWidget { } } + 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 != cwd { + 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 != cwd { + 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 description = app + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + 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![SelectionItem { + name: app.name.clone(), + description, + is_disabled: true, + ..Default::default() + }]; + + 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 same ChatGPT app management link used by /apps.".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 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, @@ -212,6 +520,52 @@ impl ChatWidget { } } + 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 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())); @@ -397,6 +751,59 @@ impl ChatWidget { ..Default::default() }]; + if plugin.summary.installed { + let uninstall_cwd = self.config.cwd.clone(); + 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.clone(); + 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)), diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap new file mode 100644 index 000000000..fe88135b8 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap @@ -0,0 +1,16 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · ChatGPT Marketplace + Can be installed + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + 3. Skills design-review, extract-copy + 4. Apps Figma, Slack + 5. MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap new file mode 100644 index 000000000..a5f073bc2 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -0,0 +1,17 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 1 of 3 available plugins. + Using cached marketplace data: remote sync timed out + + Type to search plugins +› Bravo Search · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + Alpha Sync · ChatGPT Marketplace Installed · Disabled · ChatGPT Marketplace · Already + installed but disabled. + Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by + default. + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap new file mode 100644 index 000000000..741c813fb --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap @@ -0,0 +1,9 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Loading available plugins... + This first pass shows the ChatGPT marketplace only. + +› 1. Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap new file mode 100644 index 000000000..b12a33e63 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap @@ -0,0 +1,12 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Browse plugins from the ChatGPT marketplace. + Installed 0 of 3 available plugins. + + sla +› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index e8173a43f..2da8ff77a 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -19,6 +19,7 @@ use crate::model_catalog::ModelCatalog; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool; @@ -32,9 +33,20 @@ use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::MarketplaceInterface; use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem as AppServerThreadItem; use codex_app_server_protocol::Turn as AppServerTurn; @@ -58,6 +70,7 @@ use codex_core::config_loader::ConfigRequirements; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::RequirementSource; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; @@ -2042,6 +2055,8 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), @@ -7664,6 +7679,512 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from( + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path), + ) + .expect("expected absolute test path") +} + +fn plugins_test_interface( + display_name: Option<&str>, + short_description: Option<&str>, + long_description: Option<&str>, +) -> PluginInterface { + PluginInterface { + display_name: display_name.map(str::to_string), + short_description: short_description.map(str::to_string), + long_description: long_description.map(str::to_string), + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + } +} + +fn plugins_test_summary( + id: &str, + name: &str, + display_name: Option<&str>, + description: Option<&str>, + installed: bool, + enabled: bool, + install_policy: PluginInstallPolicy, +) -> PluginSummary { + PluginSummary { + id: id.to_string(), + name: name.to_string(), + source: PluginSource::Local { + path: plugins_test_absolute_path(&format!("plugins/{name}")), + }, + installed, + enabled, + install_policy, + auth_policy: PluginAuthPolicy::OnInstall, + interface: Some(plugins_test_interface(display_name, description, None)), + } +} + +fn plugins_test_curated_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + path: plugins_test_absolute_path("marketplaces/chatgpt"), + interface: Some(MarketplaceInterface { + display_name: Some("ChatGPT Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: "repo".to_string(), + path: plugins_test_absolute_path("marketplaces/repo"), + interface: Some(MarketplaceInterface { + display_name: Some("Repo Marketplace".to_string()), + }), + plugins, + } +} + +fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { + PluginListResponse { + marketplaces, + remote_sync_error: None, + featured_plugin_ids: Vec::new(), + } +} + +fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + render_bottom_popup(chat, 100) +} + +fn plugins_test_detail( + summary: PluginSummary, + description: Option<&str>, + skills: &[&str], + apps: &[(&str, bool)], + mcp_servers: &[&str], +) -> PluginDetail { + PluginDetail { + marketplace_name: "ChatGPT Marketplace".to_string(), + marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"), + summary, + description: description.map(str::to_string), + skills: skills + .iter() + .map(|name| SkillSummary { + name: (*name).to_string(), + description: format!("{name} description"), + short_description: None, + interface: None, + path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + }) + .collect(), + apps: apps + .iter() + .map(|(name, needs_auth)| AppSummary { + id: format!("{name}-id"), + name: (*name).to_string(), + description: Some(format!("{name} app")), + install_url: Some(format!("https://example.test/{name}")), + needs_auth: *needs_auth, + }) + .collect(), + mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), + } +} + +fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { + popup + .find(needle) + .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) +} + +fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { + for ch in query.chars() { + chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } +} + +#[tokio::test] +async fn plugins_popup_loading_state_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, 100); + assert!( + popup.contains("Loading available plugins..."), + "expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}" + ); + assert_snapshot!("plugins_popup_loading_state", popup); +} + +#[tokio::test] +async fn plugins_popup_snapshot_filters_to_curated_marketplace_and_preserves_response_order() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let mut response = plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-bravo", + "bravo", + Some("Bravo Search"), + Some("Search docs and tickets."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha Sync"), + Some("Already installed but disabled."), + true, + false, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-starter", + "starter", + Some("Starter"), + Some("Included by default."), + false, + true, + PluginInstallPolicy::InstalledByDefault, + ), + ]), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-hidden", + "hidden", + Some("Hidden Repo Plugin"), + Some("Should not be shown in /plugins."), + false, + true, + PluginInstallPolicy::Available, + )]), + ]); + response.remote_sync_error = Some("remote sync timed out".to_string()); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert_snapshot!("plugins_popup_curated_marketplace", popup); + assert!( + !popup.contains("Hidden Repo Plugin"), + "expected /plugins to hide non-ChatGPT marketplaces, got:\n{popup}" + ); + assert!( + plugins_test_popup_row_position(&popup, "Bravo Search") + < plugins_test_popup_row_position(&popup, "Alpha Sync") + && plugins_test_popup_row_position(&popup, "Alpha Sync") + < plugins_test_popup_row_position(&popup, "Starter"), + "expected /plugins rows to keep response order, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + false, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugin_detail_popup_installable", popup); +} + +#[tokio::test] +async fn plugins_popup_refresh_replaces_selection_with_first_row() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + render_loaded_plugins_popup(&mut chat, initial); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let before = render_bottom_popup(&chat, 100); + assert!( + before.contains("› Slack"), + "expected Slack to be selected before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-airtable", + "airtable", + Some("Airtable"), + Some("Structured records."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-notion", + "notion", + Some("Notion"), + Some("Workspace docs."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("› Airtable"), + "expected refresh to rebuild the popup from the new first row, got:\n{after}" + ); + assert!( + after.contains("Slack · ChatGPT Marketplace"), + "expected refreshed popup to include the updated plugin list, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_refreshes_installed_counts_after_install() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let before = render_loaded_plugins_popup(&mut chat, initial); + assert!( + before.contains("Installed 1 of 2 available plugins."), + "expected initial installed count before refresh, got:\n{before}" + ); + assert!( + before.contains("Can be installed"), + "expected pre-install popup copy before refresh, got:\n{before}" + ); + + let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + true, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + true, + true, + PluginInstallPolicy::Available, + ), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd, Ok(refreshed)); + + let after = render_bottom_popup(&chat, 100); + assert!( + after.contains("Installed 2 of 2 available plugins."), + "expected /plugins to refresh installed counts after install, got:\n{after}" + ); + assert!( + after.contains("Installed. Press Enter to view plugin details."), + "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_filters_visible_rows_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "sla"); + + let popup = render_bottom_popup(&chat, 100); + assert_snapshot!("plugins_popup_search_filtered", popup); + assert!( + !popup.contains("Calendar · ChatGPT Marketplace") + && !popup.contains("Drive · ChatGPT Marketplace"), + "expected search to leave only matching rows visible, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_search_no_matches_and_backspace_restores_results() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + false, + true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-slack", + "slack", + Some("Slack"), + Some("Team chat."), + false, + true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "zzz"); + + let no_matches = render_bottom_popup(&chat, 100); + assert!( + no_matches.contains("zzz"), + "expected popup to show the typed search query, got:\n{no_matches}" + ); + assert!( + no_matches.contains("no matches"), + "expected popup to render the no-matches UX, got:\n{no_matches}" + ); + + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + } + + let restored = render_bottom_popup(&chat, 100); + assert!( + restored.contains("Calendar · ChatGPT Marketplace") + && restored.contains("Slack · ChatGPT Marketplace"), + "expected clearing the query to restore the plugin rows, got:\n{restored}" + ); + assert!( + !restored.contains("no matches"), + "did not expect the no-matches state after clearing the query, got:\n{restored}" + ); +} + #[tokio::test] async fn apps_popup_stays_loading_until_final_snapshot_updates() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;