From 66b0781502be5de3b1909525c987643b9e5e407d Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 28 Apr 2026 14:22:39 -0700 Subject: [PATCH] /plugins: add marketplace install flow (#18704) This PR adds a new feature to the `/plugins` menu that gives users the ability to add new plugin marketplaces. It introduces an Add Marketplace tab to the right of installed marketplaces, a source prompt, loading and error states, and the app-server request flow needed to perform the install. After a successful `marketplace/add`, the popup refreshes back into the newly added marketplace tab so the new plugins are immediately visible. - Add an Add Marketplace tab to the `/plugins` menu - Prompt for marketplace source input from git repo, URL, or local path - Show loading and error states during `marketplace/add` - Refresh plugin data after success and switch into the newly added marketplace tab - Add tests and snapshot updates --- codex-rs/core-plugins/src/marketplace_add.rs | 12 +- .../src/marketplace_add/source.rs | 10 +- codex-rs/tui/src/app/background_requests.rs | 98 +++++++ codex-rs/tui/src/app/event_dispatch.rs | 21 ++ codex-rs/tui/src/app_event.rs | 22 ++ codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/chatwidget/plugins.rs | 255 +++++++++++++++++- ...ts__plugins_popup_curated_marketplace.snap | 2 +- ...ins_popup_newly_installed_marketplace.snap | 15 ++ ..._tests__plugins_popup_search_filtered.snap | 7 +- codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 184 +++++++++++++ 13 files changed, 606 insertions(+), 24 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap diff --git a/codex-rs/core-plugins/src/marketplace_add.rs b/codex-rs/core-plugins/src/marketplace_add.rs index 57e587e48..927d337d2 100644 --- a/codex-rs/core-plugins/src/marketplace_add.rs +++ b/codex-rs/core-plugins/src/marketplace_add.rs @@ -123,14 +123,12 @@ where let marketplace_name = validate_marketplace_source_root(path)?; if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from {}", - source.display() + "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source" ))); } if find_marketplace_root_by_name(codex_home, &install_root, &marketplace_name)?.is_some() { return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace '{marketplace_name}' is already added from a different source; remove it before adding {}", - source.display() + "marketplace '{marketplace_name}' is already added from a different source; remove it before adding this source" ))); } record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?; @@ -169,8 +167,7 @@ where let marketplace_name = validate_marketplace_source_root(&staged_root)?; if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from {}", - source.display() + "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source" ))); } @@ -178,8 +175,7 @@ where ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?; if destination.exists() { return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace '{marketplace_name}' is already added from a different source; remove it before adding {}", - source.display() + "marketplace '{marketplace_name}' is already added from a different source; remove it before adding this source" ))); } diff --git a/codex-rs/core-plugins/src/marketplace_add/source.rs b/codex-rs/core-plugins/src/marketplace_add/source.rs index fdcbd4094..3cec30949 100644 --- a/codex-rs/core-plugins/src/marketplace_add/source.rs +++ b/codex-rs/core-plugins/src/marketplace_add/source.rs @@ -58,9 +58,10 @@ pub(crate) fn parse_marketplace_source( }); } - Err(MarketplaceAddError::InvalidRequest(format!( - "invalid marketplace source format: {source}" - ))) + Err(MarketplaceAddError::InvalidRequest( + "invalid marketplace source format; expected owner/repo, a git URL, or a local marketplace path" + .to_string(), + )) } pub(super) fn stage_marketplace_source( @@ -160,8 +161,7 @@ fn resolve_local_source_path(source: &str) -> Result Result { + let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("marketplace/add cwd must be absolute")?; + let source = marketplace_add_source_for_request(cwd.as_path(), source); + let request_id = RequestId::String(format!("marketplace-add-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::MarketplaceAdd { + request_id, + params: MarketplaceAddParams { + source, + ref_name: None, + sparse_paths: None, + }, + }) + .await + .wrap_err("marketplace/add failed in TUI") +} + +fn marketplace_add_source_for_request(cwd: &std::path::Path, source: String) -> String { + let (base_source, suffix) = if let Some((base, ref_name)) = source.rsplit_once('#') { + (base, Some(format!("#{ref_name}"))) + } else if let Some((base, ref_name)) = source.rsplit_once('@') { + (base, Some(format!("@{ref_name}"))) + } else { + (source.as_str(), None) + }; + + if matches!(base_source, "." | "..") + || base_source.starts_with("./") + || base_source.starts_with("../") + || base_source.starts_with(".\\") + || base_source.starts_with("..\\") + { + let mut resolved = AbsolutePathBuf::resolve_path_against_base(base_source, cwd) + .to_string_lossy() + .into_owned(); + if let Some(suffix) = suffix { + resolved.push_str(&suffix); + } + return resolved; + } + + source +} + pub(super) async fn fetch_plugin_install( request_handle: AppServerRequestHandle, marketplace_path: AbsolutePathBuf, @@ -650,6 +723,31 @@ mod tests { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } + #[test] + fn marketplace_add_source_for_request_resolves_relative_local_paths() { + let cwd = if cfg!(windows) { + PathBuf::from(r"C:\workspace\project") + } else { + PathBuf::from("/workspace/project") + }; + + let resolved = marketplace_add_source_for_request(&cwd, "./marketplace".to_string()); + assert!(std::path::Path::new(&resolved).is_absolute()); + assert_eq!(resolved, cwd.join("marketplace").display().to_string()); + assert_eq!( + marketplace_add_source_for_request(&cwd, "./marketplace#main".to_string()), + format!("{}#main", cwd.join("marketplace").display()) + ); + assert_eq!( + marketplace_add_source_for_request(&cwd, "owner/repo".to_string()), + "owner/repo" + ); + assert_eq!( + marketplace_add_source_for_request(&cwd, "~/marketplace".to_string()), + "~/marketplace" + ); + } + #[test] fn hide_cli_only_plugin_marketplaces_removes_openai_bundled() { let mut response = PluginListResponse { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 39f837e96..d4002f245 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -385,6 +385,12 @@ impl App { AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } + AppEvent::OpenMarketplaceAddPrompt => { + self.chat_widget.open_marketplace_add_prompt(); + } + AppEvent::OpenMarketplaceAddLoading { source } => { + self.chat_widget.open_marketplace_add_loading_popup(&source); + } AppEvent::OpenPluginDetailLoading { plugin_display_name, } => { @@ -406,6 +412,21 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::FetchMarketplaceAdd { cwd, source } => { + self.fetch_marketplace_add(app_server, cwd, source); + } + AppEvent::MarketplaceAddLoaded { + cwd, + source, + result, + } => { + let add_succeeded = result.is_ok(); + self.chat_widget + .on_marketplace_add_loaded(cwd.clone(), source, result); + if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { + self.fetch_plugins_list(app_server, cwd); + } + } AppEvent::FetchPluginDetail { cwd, params } => { self.fetch_plugin_detail(app_server, cwd, params); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 5c5e2bc94..ee0de5082 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::PluginInstallResponse; @@ -287,6 +288,27 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the prompt for adding a marketplace source. + OpenMarketplaceAddPrompt, + + /// Replace the plugins popup with a marketplace-add loading state. + OpenMarketplaceAddLoading { + source: String, + }, + + /// Add a marketplace from the provided source. + FetchMarketplaceAdd { + cwd: PathBuf, + source: String, + }, + + /// Result of adding a marketplace. + MarketplaceAddLoaded { + cwd: PathBuf, + source: String, + result: Result, + }, + /// Replace the plugins popup with a plugin-detail loading state. OpenPluginDetailLoading { plugin_display_name: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e8486a08e..5756bf5d1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -910,6 +910,7 @@ pub(crate) struct ChatWidget { plugin_install_apps_needing_auth: Vec, plugin_install_auth_flow: Option, plugins_active_tab_id: Option, + newly_installed_marketplace_tab_id: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -5599,6 +5600,7 @@ impl ChatWidget { plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, plugins_active_tab_id: None, + newly_installed_marketplace_tab_id: 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 ae6b08c07..b6b5242f0 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -11,12 +12,14 @@ use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionTab; use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -41,7 +44,9 @@ use unicode_width::UnicodeWidthStr; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; +const MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; +const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); @@ -183,10 +188,25 @@ impl ChatWidget { match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); + let active_tab_id = self + .plugins_active_tab_id + .as_deref() + .and_then(|tab_id| { + marketplace_tab_id_matching_saved_id(tab_id, &response.marketplaces) + }) + .or_else(|| self.plugins_active_tab_id.clone()); + self.newly_installed_marketplace_tab_id = self + .newly_installed_marketplace_tab_id + .as_deref() + .and_then(|tab_id| { + marketplace_tab_id_matching_saved_id(tab_id, &response.marketplaces) + }); + self.plugins_active_tab_id = active_tab_id; self.plugins_cache = PluginsCacheState::Ready(response.clone()); if !auth_flow_active { self.refresh_plugins_popup_if_open(&response); } + self.newly_installed_marketplace_tab_id = None; } Err(err) => { if !auth_flow_active { @@ -243,6 +263,44 @@ impl ChatWidget { )); } + pub(crate) fn open_marketplace_add_prompt(&mut self) { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let tx = self.app_event_tx.clone(); + let cwd = self.config.cwd.to_path_buf(); + let view = CustomPromptView::new( + "Add marketplace".to_string(), + "owner/repo, git URL, or local marketplace path".to_string(), + String::new(), + Some("Examples: owner/repo, git URL, ./marketplace".to_string()), + Box::new(move |source: String| { + let source = source.trim().to_string(); + if source.is_empty() { + return; + } + tx.send(AppEvent::OpenMarketplaceAddLoading { + source: source.clone(), + }); + tx.send(AppEvent::FetchMarketplaceAdd { + cwd: cwd.clone(), + source, + }); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn open_marketplace_add_loading_popup(&mut self, _source: &str) { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let params = self.marketplace_add_loading_popup_params(); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane + .show_selection_view(self.marketplace_add_loading_popup_params()); + } + } + pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { self.plugins_active_tab_id = self .bottom_pane @@ -361,6 +419,52 @@ impl ChatWidget { } } + pub(crate) fn on_marketplace_add_loaded( + &mut self, + cwd: PathBuf, + _source: String, + result: Result, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + match result { + Ok(response) => { + let marketplace_tab_id = marketplace_tab_id_from_path(&response.installed_root); + self.plugins_active_tab_id = Some(marketplace_tab_id.clone()); + self.newly_installed_marketplace_tab_id = + (!response.already_added).then_some(marketplace_tab_id); + let message = if response.already_added { + format!( + "Marketplace {} is already added.", + response.marketplace_name + ) + } else { + format!("Added marketplace {}.", response.marketplace_name) + }; + self.add_info_message( + message, + Some(format!( + "Marketplace root: {}", + response.installed_root.as_path().display() + )), + ); + } + Err(_) => { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let params = self.marketplace_add_error_popup_params(); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane + .show_selection_view(self.marketplace_add_error_popup_params()); + } + } + } + } + pub(crate) fn on_plugin_enabled_set( &mut self, cwd: PathBuf, @@ -655,6 +759,27 @@ impl ChatWidget { } } + fn marketplace_add_loading_popup_params(&self) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Adding marketplace...".to_string(), + /*note*/ None, + )), + items: vec![SelectionItem { + name: "Adding marketplace...".to_string(), + description: Some( + "This updates when marketplace installation completes.".to_string(), + ), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), @@ -738,6 +863,56 @@ impl ChatWidget { } } + fn marketplace_add_error_popup_params(&self) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to add marketplace.".dim())); + + let mut items = vec![ + SelectionItem { + name: "Marketplace add failed".to_string(), + description: Some( + "Failed to add marketplace from the provided source.".to_string(), + ), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Enter a marketplace source.".to_string()), + selected_description: Some("Enter a marketplace source.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + fn plugin_detail_error_popup_params( &self, err: &str, @@ -802,6 +977,7 @@ impl ChatWidget { .map(|(_, _, display_name)| { PLUGIN_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) }) + .chain([UnicodeWidthStr::width("Add marketplace")]) .max(); let installed_entries = all_entries .iter() @@ -893,15 +1069,25 @@ impl ChatWidget { .iter() .filter(|(_, plugin, _)| plugin.installed) .count(); - tabs.push(SelectionTab { - id: marketplace_tab_id(marketplace), - label: label.clone(), - header: plugins_header( + let tab_id = marketplace_tab_id(marketplace); + let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { + plugins_header( + format!("{label} installed successfully."), + "Select the plugins you want to use and press Enter to install or view details." + .to_string(), + ) + } else { + plugins_header( format!("{label}."), format!( "Installed {marketplace_installed} of {marketplace_total} {label} plugins." ), - ), + ) + }; + tabs.push(SelectionTab { + id: tab_id, + label: label.clone(), + header, items: self.plugin_selection_items( entries, /*include_marketplace_names*/ false, @@ -911,6 +1097,8 @@ impl ChatWidget { }); } + tabs.push(self.marketplace_add_tab()); + SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(()), @@ -927,6 +1115,30 @@ impl ChatWidget { } } + fn marketplace_add_tab(&self) -> SelectionTab { + SelectionTab { + id: ADD_MARKETPLACE_TAB_ID.to_string(), + label: "Add Marketplace".to_string(), + header: plugins_header( + "Add a marketplace from a Git repo or local root.".to_string(), + "Enter a source to make its plugins available in this menu.".to_string(), + ), + items: vec![SelectionItem { + name: "Add marketplace".to_string(), + description: Some( + "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), + ), + selected_description: Some( + "Press Enter to enter a marketplace source.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }], + } + } + fn plugin_detail_popup_params( &self, plugins_response: &PluginListResponse, @@ -1107,7 +1319,7 @@ impl ChatWidget { format!("{selected_status_label} Space to {toggle_action}.") } } else if can_view_details { - format!("{selected_status_label} Press Enter to view plugin details.") + format!("{selected_status_label} Press Enter to install or view plugin details.") } else { format!("{selected_status_label} Remote plugin details are not available yet.") }; @@ -1227,11 +1439,40 @@ fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { match marketplace.path.as_ref() { - Some(path) => format!("marketplace:{}", path.display()), + Some(path) => marketplace_tab_id_from_path(path.as_path()), None => format!("marketplace:{}", marketplace.name), } } +fn marketplace_tab_id_from_path(path: &Path) -> String { + format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) +} + +fn marketplace_tab_id_matching_saved_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { + let tab_id = marketplace_tab_id(marketplace); + (tab_id == saved_tab_id).then_some(tab_id) + }) { + return Some(tab_id); + } + + let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; + if root.is_empty() { + return None; + } + let root = Path::new(root); + marketplaces.iter().find_map(|marketplace| { + marketplace + .path + .as_ref() + .is_some_and(|path| path.as_path().starts_with(root)) + .then(|| marketplace_tab_id(marketplace)) + }) +} + fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { let mut counts: Vec<(String, usize)> = Vec::new(); for label in &labels { 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 index e0f99b443..40d26d3c9 100644 --- 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 @@ -6,7 +6,7 @@ expression: popup Browse plugins from available marketplaces. Installed 1 of 4 available plugins. - [All Plugins] Installed (1) OpenAI Curated Repo Marketplace + [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace Type to search plugins › [ ] Alpha Sync Disabled Space to enable; Enter view details. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap new file mode 100644 index 000000000..8fa472115 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 329 +expression: popup +--- + Plugins + Debug Marketplace installed successfully. + Select the plugins you want to use and press Enter to install or view details. + + All Plugins Installed (0) OpenAI Curated [Debug Marketplace] Add Marketplace + + Type to search plugins +› [-] Debug Plugin Available Press Enter to install or view plugin details. + + space enable/disable · ←/→ select marketplace · enter view details · esc close 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 index da1b0913f..849b90b2c 100644 --- 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 @@ -1,14 +1,15 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 767 expression: popup --- Plugins Browse plugins from available marketplaces. Installed 0 of 3 available plugins. - [All Plugins] Installed (0) OpenAI Curated + [All Plugins] Installed (0) OpenAI Curated Add Marketplace sla -› [-] Slack Available Press Enter to view plugin details. +› [-] Slack Available Press Enter to install or view plugin details. space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 547c42509..1641dc7e3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -65,6 +65,7 @@ pub(super) use codex_app_server_protocol::ItemCompletedNotification; pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; pub(super) use codex_app_server_protocol::ItemStartedNotification; +pub(super) use codex_app_server_protocol::MarketplaceAddResponse; pub(super) use codex_app_server_protocol::MarketplaceInterface; pub(super) use codex_app_server_protocol::McpServerStartupState; pub(super) use codex_app_server_protocol::McpServerStatusDetail; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 464508c7a..d26a503a8 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -244,6 +244,7 @@ pub(super) async fn make_chatwidget_manual( plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, plugins_active_tab_id: None, + newly_installed_marketplace_tab_id: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 728cbf017..11505b3e7 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -163,6 +163,190 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ ); } +#[tokio::test] +async fn plugins_popup_truncates_long_descriptions_in_list_rows() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha"), + Some("Short description."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-verbose", + "verbose", + Some("Verbose Plugin"), + Some("This description keeps going and going until the row would normally wrap."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]); + + let cwd = chat.config.cwd.to_path_buf(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, /*width*/ 70); + let verbose_row = popup + .lines() + .find(|line| line.contains("Verbose Plugin")) + .expect("expected verbose plugin row in popup"); + insta::assert_snapshot!( + verbose_row, + @" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…" + ); + assert!( + !popup + .contains("This description keeps going and going until the row would normally wrap."), + "expected the long plugin description to truncate instead of wrapping, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let cwd = chat.config.cwd.to_path_buf(); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]), + ); + + while rx.try_recv().is_ok() {} + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Add a marketplace from a Git repo or local root."), + "expected Add Marketplace tab, got:\n{popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match rx.try_recv() { + Ok(AppEvent::OpenMarketplaceAddPrompt) => {} + other => panic!("expected OpenMarketplaceAddPrompt event, got {other:?}"), + } + + chat.open_marketplace_add_prompt(); + let prompt = render_bottom_popup(&chat, /*width*/ 100); + assert!( + prompt.contains("owner/repo, git URL, or local marketplace path"), + "expected marketplace source prompt, got:\n{prompt}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenMarketplaceAddLoading { source }) => { + assert_eq!(source, "owner/repo"); + } + other => panic!("expected OpenMarketplaceAddLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchMarketplaceAdd { + cwd: event_cwd, + source, + }) => { + assert_eq!(event_cwd, cwd); + assert_eq!(source, "owner/repo"); + } + other => panic!("expected FetchMarketplaceAdd event, got {other:?}"), + } +} + +#[tokio::test] +async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let cwd = chat.config.cwd.to_path_buf(); + let marketplace_root = plugins_test_absolute_path("marketplaces/debug"); + let marketplace_path = + plugins_test_absolute_path("marketplaces/debug/.agents/plugins/marketplace.json"); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]), + ); + chat.open_marketplace_add_loading_popup("owner/repo"); + let loading_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + !loading_popup.contains("owner/repo"), + "expected marketplace loading popup to avoid echoing the source, got:\n{loading_popup}" + ); + chat.on_marketplace_add_loaded( + cwd.clone(), + "owner/repo".to_string(), + Ok(MarketplaceAddResponse { + marketplace_name: "debug".to_string(), + installed_root: marketplace_root, + already_added: false, + }), + ); + chat.on_plugins_loaded( + cwd, + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + PluginMarketplaceEntry { + name: "debug".to_string(), + path: Some(marketplace_path), + interface: Some(MarketplaceInterface { + display_name: Some("Debug Marketplace".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-debug", + "debug", + Some("Debug Plugin"), + Some("Debug marketplace plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + ])), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert_chatwidget_snapshot!("plugins_popup_newly_installed_marketplace", popup); + assert!( + popup.contains("Debug Marketplace installed successfully.") + && popup.contains("Debug Plugin"), + "expected marketplace add refresh to switch to the new marketplace tab, got:\n{popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + chat.add_plugins_output(); + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + reopened_popup.contains("Installed 0 of 1 Debug Marketplace plugins.") + && !reopened_popup.contains("installed successfully"), + "expected reopening the marketplace tab later to use the normal header, got:\n{reopened_popup}" + ); +} + #[tokio::test] async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;