diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 559bdfd99..45d7247e9 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -7,6 +7,8 @@ use super::*; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; +use codex_app_server_protocol::MarketplaceRemoveParams; +use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_utils_absolute_path::AbsolutePathBuf; impl App { @@ -130,6 +132,30 @@ impl App { }); } + pub(super) fn fetch_marketplace_remove( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + marketplace_name: String, + marketplace_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_name_for_event = marketplace_name.clone(); + let result = fetch_marketplace_remove(request_handle, marketplace_name) + .await + .map_err(|err| format!("Failed to remove marketplace: {err}")); + app_event_tx.send(AppEvent::MarketplaceRemoveLoaded { + cwd: cwd_for_event, + marketplace_name: marketplace_name_for_event, + marketplace_display_name, + result, + }); + }); + } + pub(super) fn fetch_plugin_install( &mut self, app_server: &AppServerSession, @@ -582,6 +608,19 @@ fn marketplace_add_source_for_request(cwd: &std::path::Path, source: String) -> source } +pub(super) async fn fetch_marketplace_remove( + request_handle: AppServerRequestHandle, + marketplace_name: String, +) -> Result { + let request_id = RequestId::String(format!("marketplace-remove-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::MarketplaceRemove { + request_id, + params: MarketplaceRemoveParams { marketplace_name }, + }) + .await + .wrap_err("marketplace/remove failed in TUI") +} pub(super) async fn fetch_plugin_install( request_handle: AppServerRequestHandle, marketplace_path: AbsolutePathBuf, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 62cb72715..ca17daed7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -390,6 +390,21 @@ impl App { AppEvent::OpenMarketplaceAddLoading { source } => { self.chat_widget.open_marketplace_add_loading_popup(&source); } + AppEvent::OpenMarketplaceRemoveConfirm { + marketplace_name, + marketplace_display_name, + } => { + self.chat_widget.open_marketplace_remove_confirmation( + marketplace_name, + marketplace_display_name, + ); + } + AppEvent::OpenMarketplaceRemoveLoading { + marketplace_display_name, + } => { + self.chat_widget + .open_marketplace_remove_loading_popup(&marketplace_display_name); + } AppEvent::OpenPluginDetailLoading { plugin_display_name, } => { @@ -423,6 +438,44 @@ impl App { 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() { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after marketplace add"); + } + self.fetch_plugins_list(app_server, cwd); + } + } + AppEvent::FetchMarketplaceRemove { + cwd, + marketplace_name, + marketplace_display_name, + } => { + self.fetch_marketplace_remove( + app_server, + cwd, + marketplace_name, + marketplace_display_name, + ); + } + AppEvent::MarketplaceRemoveLoaded { + cwd, + marketplace_name, + marketplace_display_name, + result, + } => { + let remove_succeeded = result.is_ok(); + self.chat_widget.on_marketplace_remove_loaded( + cwd.clone(), + marketplace_name, + marketplace_display_name, + result, + ); + if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after marketplace remove"); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); self.fetch_plugins_list(app_server, cwd); } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4c351266f..87d7484b9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -14,6 +14,7 @@ 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::MarketplaceRemoveResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::PluginInstallResponse; @@ -309,6 +310,32 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the confirmation prompt for removing a marketplace. + OpenMarketplaceRemoveConfirm { + marketplace_name: String, + marketplace_display_name: String, + }, + + /// Replace the plugins popup with a marketplace-remove loading state. + OpenMarketplaceRemoveLoading { + marketplace_display_name: String, + }, + + /// Remove a marketplace by name. + FetchMarketplaceRemove { + cwd: PathBuf, + marketplace_name: String, + marketplace_display_name: String, + }, + + /// Result of removing a marketplace. + MarketplaceRemoveLoaded { + cwd: PathBuf, + marketplace_name: String, + marketplace_display_name: 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/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 9a9ffc6ab..a1e3bba5a 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -162,6 +162,7 @@ pub(crate) struct SelectionViewParams { pub subtitle: Option, pub footer_note: Option>, pub footer_hint: Option>, + pub tab_footer_hints: Vec<(String, Line<'static>)>, pub items: Vec, pub tabs: Vec, pub initial_tab_id: Option, @@ -209,6 +210,7 @@ impl Default for SelectionViewParams { subtitle: None, footer_note: None, footer_hint: None, + tab_footer_hints: Vec::new(), items: Vec::new(), tabs: Vec::new(), initial_tab_id: None, @@ -239,6 +241,7 @@ pub(crate) struct ListSelectionView { view_id: Option<&'static str>, footer_note: Option>, footer_hint: Option>, + tab_footer_hints: Vec<(String, Line<'static>)>, items: Vec, tabs: Vec, active_tab_idx: Option, @@ -309,6 +312,7 @@ impl ListSelectionView { view_id: params.view_id, footer_note: params.footer_note, footer_hint: params.footer_hint, + tab_footer_hints: params.tab_footer_hints, items: params.items, tabs: params.tabs, active_tab_idx, @@ -377,6 +381,16 @@ impl ListSelectionView { .unwrap_or(self.header.as_ref()) } + fn active_footer_hint(&self) -> Option<&Line<'static>> { + self.active_tab_id() + .and_then(|active_tab_id| { + self.tab_footer_hints + .iter() + .find_map(|(tab_id, hint)| (tab_id.as_str() == active_tab_id).then_some(hint)) + }) + .or(self.footer_hint.as_ref()) + } + fn active_tab_id(&self) -> Option<&str> { self.active_tab_idx .and_then(|idx| self.tabs.get(idx)) @@ -1001,7 +1015,7 @@ impl Renderable for ListSelectionView { let note_lines = wrap_styled_line(note, note_width); height = height.saturating_add(note_lines.len() as u16); } - if self.footer_hint.is_some() { + if self.active_footer_hint().is_some() { height = height.saturating_add(1); } height @@ -1018,7 +1032,7 @@ impl Renderable for ListSelectionView { .as_ref() .map(|note| wrap_styled_line(note, note_width)); let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); - let footer_rows = note_height + u16::from(self.footer_hint.is_some()); + let footer_rows = note_height + u16::from(self.active_footer_hint().is_some()); let [content_area, footer_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); @@ -1196,7 +1210,11 @@ impl Renderable for ListSelectionView { if footer_area.height > 0 { let [note_area, hint_area] = Layout::vertical([ Constraint::Length(note_height), - Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), + Constraint::Length(if self.active_footer_hint().is_some() { + 1 + } else { + 0 + }), ]) .areas(footer_area); @@ -1221,7 +1239,7 @@ impl Renderable for ListSelectionView { } } - if let Some(hint) = &self.footer_hint { + if let Some(hint) = self.active_footer_hint() { let hint_area = Rect { x: hint_area.x + 2, y: hint_area.y, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 280ed3517..bebdbf124 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5486,6 +5486,7 @@ impl ChatWidget { .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') ) + && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) { self.bottom_pane.handle_key_event(key_event); if self.bottom_pane.no_modal_or_popup_active() { @@ -5603,6 +5604,10 @@ impl ChatWidget { return; } + if self.handle_plugins_popup_key_event(key_event) { + return; + } + match key_event { KeyEvent { code: KeyCode::BackTab, diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index b6b5242f0..70d4407a2 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -14,12 +14,15 @@ use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; +use crate::key_hint; +use crate::legacy_core::config::Config; 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::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -31,11 +34,14 @@ 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 crossterm::event::KeyCode; +use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; @@ -301,6 +307,53 @@ impl ChatWidget { } } + pub(crate) fn open_marketplace_remove_confirmation( + &mut self, + marketplace_name: String, + marketplace_display_name: String, + ) { + self.plugins_active_tab_id = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .map(str::to_string) + .or_else(|| self.plugins_active_tab_id.clone()); + + let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() + else { + return; + }; + + let params = self.marketplace_remove_confirmation_popup_params( + &plugins_response, + marketplace_name.clone(), + marketplace_display_name.clone(), + ); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane.show_selection_view( + self.marketplace_remove_confirmation_popup_params( + &plugins_response, + marketplace_name, + marketplace_display_name, + ), + ); + } + } + + pub(crate) fn open_marketplace_remove_loading_popup(&mut self, marketplace_display_name: &str) { + let params = self.marketplace_remove_loading_popup_params(marketplace_display_name); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane.show_selection_view( + self.marketplace_remove_loading_popup_params(marketplace_display_name), + ); + } + } + pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { self.plugins_active_tab_id = self .bottom_pane @@ -465,6 +518,82 @@ impl ChatWidget { } } + pub(crate) fn on_marketplace_remove_loaded( + &mut self, + cwd: PathBuf, + marketplace_name: String, + marketplace_display_name: String, + result: Result, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + match result { + Ok(response) => { + self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string()); + self.add_info_message( + format!("Removed marketplace {marketplace_display_name}."), + Some(match response.installed_root { + Some(installed_root) => { + format!("Marketplace root: {}", installed_root.as_path().display()) + } + None => format!( + "Removed marketplace config for {}.", + response.marketplace_name + ), + }), + ); + } + Err(_) => { + let params = self.marketplace_remove_error_popup_params( + &marketplace_name, + &marketplace_display_name, + ); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane.show_selection_view( + self.marketplace_remove_error_popup_params( + &marketplace_name, + &marketplace_display_name, + ), + ); + } + } + } + } + + pub(crate) fn handle_plugins_popup_key_event(&mut self, key_event: KeyEvent) -> bool { + if !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) { + return false; + } + + let Some(active_tab_id) = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + else { + return false; + }; + let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() + else { + return false; + }; + let Some(marketplace) = plugins_response.marketplaces.iter().find(|marketplace| { + marketplace_tab_id(marketplace) == active_tab_id + && marketplace_is_user_configured(&self.config, &marketplace.name) + }) else { + return false; + }; + + self.open_marketplace_remove_confirmation( + marketplace.name.clone(), + marketplace_display_name(marketplace), + ); + true + } + pub(crate) fn on_plugin_enabled_set( &mut self, cwd: PathBuf, @@ -780,6 +909,103 @@ impl ChatWidget { } } + fn marketplace_remove_confirmation_popup_params( + &self, + plugins_response: &PluginListResponse, + marketplace_name: String, + marketplace_display_name: String, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Remove {marketplace_display_name} marketplace?").dim(), + )); + header.push(Line::from( + "This removes the configured marketplace from Codex.".dim(), + )); + + let cwd_for_remove = self.config.cwd.to_path_buf(); + let cwd_for_cancel = self.config.cwd.to_path_buf(); + let cwd_for_on_cancel = self.config.cwd.to_path_buf(); + let plugins_response_for_cancel = plugins_response.clone(); + let plugins_response_for_on_cancel = plugins_response.clone(); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(Line::from(vec![ + Span::from(key_hint::plain(KeyCode::Enter)), + " select".dim(), + " · ".into(), + "esc close".dim(), + ])), + items: vec![ + SelectionItem { + name: "Remove marketplace".to_string(), + description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + selected_description: Some( + "Remove this marketplace from the available plugin list.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveLoading { + marketplace_display_name: marketplace_display_name.clone(), + }); + tx.send(AppEvent::FetchMarketplaceRemove { + cwd: cwd_for_remove.clone(), + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..Default::default() + }, + SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Keep this marketplace installed.".to_string()), + selected_description: Some("Keep this marketplace installed.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd_for_cancel.clone(), + result: Ok(plugins_response_for_cancel.clone()), + }); + })], + ..Default::default() + }, + ], + on_cancel: Some(Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd_for_on_cancel.clone(), + result: Ok(plugins_response_for_on_cancel.clone()), + }); + })), + ..Default::default() + } + } + + fn marketplace_remove_loading_popup_params( + &self, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from( + format!("Removing {marketplace_display_name}...").dim(), + )); + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Removing marketplace...".to_string(), + description: Some("This updates when marketplace removal 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), @@ -913,6 +1139,63 @@ impl ChatWidget { } } + fn marketplace_remove_error_popup_params( + &self, + marketplace_name: &str, + marketplace_display_name: &str, + ) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to remove marketplace.".dim())); + + let marketplace_name = marketplace_name.to_string(); + let marketplace_display_name = marketplace_display_name.to_string(); + let mut items = vec![ + SelectionItem { + name: "Marketplace removal failed".to_string(), + description: Some("Failed to remove the selected marketplace.".to_string()), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some("Review the confirmation prompt again.".to_string()), + selected_description: Some("Review the confirmation prompt again.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenMarketplaceRemoveConfirm { + marketplace_name: marketplace_name.clone(), + marketplace_display_name: marketplace_display_name.clone(), + }); + })], + ..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, @@ -986,6 +1269,7 @@ impl ChatWidget { .collect(); let mut tabs = Vec::new(); + let mut tab_footer_hints = Vec::new(); tabs.push(SelectionTab { id: ALL_PLUGINS_TAB_ID.to_string(), label: "All Plugins".to_string(), @@ -1070,6 +1354,12 @@ impl ChatWidget { .filter(|(_, plugin, _)| plugin.installed) .count(); let tab_id = marketplace_tab_id(marketplace); + if marketplace_is_user_configured(&self.config, &marketplace.name) { + tab_footer_hints.push(( + tab_id.clone(), + plugins_popup_hint_line(/*can_remove_marketplace*/ true), + )); + } let header = if self.newly_installed_marketplace_tab_id.as_deref() == Some(&tab_id) { plugins_header( format!("{label} installed successfully."), @@ -1102,7 +1392,10 @@ impl ChatWidget { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(()), - footer_hint: Some(plugins_popup_hint_line()), + footer_hint: Some(plugins_popup_hint_line( + /*can_remove_marketplace*/ false, + )), + tab_footer_hints, tabs, initial_tab_id: active_tab_id, is_searchable: true, @@ -1390,8 +1683,14 @@ impl ChatWidget { } } -fn plugins_popup_hint_line() -> Line<'static> { - Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close") +fn plugins_popup_hint_line(can_remove_marketplace: bool) -> Line<'static> { + if can_remove_marketplace { + Line::from( + "space enable/disable · ←/→ select marketplace · enter view details · ctrl + r remove marketplace · esc close", + ) + } else { + Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close") + } } fn plugin_detail_hint_line() -> Line<'static> { @@ -1521,6 +1820,15 @@ fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { .unwrap_or_else(|| marketplace.name.clone()) } +fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool { + config + .config_layer_stack + .get_user_layer() + .and_then(|user_layer| user_layer.config.get("marketplaces")) + .and_then(toml::Value::as_table) + .is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name)) +} + fn plugin_display_name(plugin: &PluginSummary) -> String { plugin .interface diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_marketplace_remove_confirmation.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_marketplace_remove_confirmation.snap new file mode 100644 index 000000000..f46bbec9e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_marketplace_remove_confirmation.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 411 +expression: confirmation +--- + Plugins + Remove Repo Marketplace marketplace? + This removes the configured marketplace from Codex. + +› 1. Remove marketplace Remove this marketplace from the available plugin list. + 2. Back to plugins Keep this marketplace installed. + + enter select · esc close 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 index 8fa472115..6957ef2da 100644 --- 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 @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -assertion_line: 329 +assertion_line: 339 expression: popup --- Plugins @@ -12,4 +12,4 @@ expression: popup 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 + space enable/disable · ←/→ select marketplace · enter view details · ctrl + r remove marketplace · 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 135d8dbaa..dd318db13 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,5 +1,6 @@ use super::*; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -283,6 +284,15 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { let marketplace_root = plugins_test_absolute_path("marketplaces/debug"); let marketplace_path = plugins_test_absolute_path("marketplaces/debug/.agents/plugins/marketplace.json"); + let temp = tempdir().expect("tempdir"); + let config_toml_path = temp.path().join("config.toml").abs(); + chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config( + &config_toml_path, + toml::from_str::( + "[marketplaces.debug]\nsource_type = \"git\"\nsource = \"https://github.com/owner/debug.git\"\n", + ) + .expect("marketplace config"), + ); render_loaded_plugins_popup( &mut chat, plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]), @@ -347,6 +357,117 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { ); } +#[tokio::test] +async fn plugins_popup_removes_user_configured_marketplace_flow() { + 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(); + let temp = tempdir().expect("tempdir"); + let config_toml_path = temp.path().join("config.toml").abs(); + chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config( + &config_toml_path, + toml::from_str::( + "[marketplaces.repo]\nsource_type = \"git\"\nsource = \"https://github.com/owner/repo.git\"\n", + ) + .expect("marketplace config"), + ); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-debug", + "debug", + Some("Debug Plugin"), + Some("Debug marketplace plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )]), + ]), + ); + while rx.try_recv().is_ok() {} + + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + let repo_tab = render_bottom_popup(&chat, /*width*/ 100); + assert!( + repo_tab.contains("Repo Marketplace.") + && repo_tab.contains("ctrl + r remove marketplace") + && repo_tab.contains("Debug Plugin"), + "expected removable user-configured marketplace tab, got:\n{repo_tab}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let confirmation = render_bottom_popup(&chat, /*width*/ 100); + assert!( + confirmation.contains("Remove Repo Marketplace marketplace?") + && confirmation.contains("Remove marketplace") + && confirmation.contains("Back to plugins"), + "expected marketplace removal confirmation, got:\n{confirmation}" + ); + assert_chatwidget_snapshot!( + "plugins_popup_marketplace_remove_confirmation", + confirmation + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let marketplace_display_name = match rx.try_recv() { + Ok(AppEvent::OpenMarketplaceRemoveLoading { + marketplace_display_name, + }) => marketplace_display_name, + other => panic!("expected OpenMarketplaceRemoveLoading event, got {other:?}"), + }; + assert_eq!(marketplace_display_name, "Repo Marketplace"); + match rx.try_recv() { + Ok(AppEvent::FetchMarketplaceRemove { + cwd: event_cwd, + marketplace_name, + marketplace_display_name, + }) => { + assert_eq!(event_cwd, cwd); + assert_eq!(marketplace_name, "repo"); + assert_eq!(marketplace_display_name, "Repo Marketplace"); + } + other => panic!("expected FetchMarketplaceRemove event, got {other:?}"), + } + + chat.open_marketplace_remove_loading_popup(&marketplace_display_name); + let loading = render_bottom_popup(&chat, /*width*/ 100); + assert!( + loading.contains("Removing Repo Marketplace...") + && loading.contains("Removing marketplace..."), + "expected marketplace removal loading state, got:\n{loading}" + ); + + chat.on_marketplace_remove_loaded( + cwd.clone(), + "repo".to_string(), + marketplace_display_name, + Ok(MarketplaceRemoveResponse { + marketplace_name: "repo".to_string(), + installed_root: Some(plugins_test_absolute_path("marketplaces/repo")), + }), + ); + chat.on_plugins_loaded( + cwd, + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + + let refreshed = render_bottom_popup(&chat, /*width*/ 100); + assert!( + refreshed.contains("Browse plugins from available marketplaces.") + && !refreshed.contains("Repo Marketplace") + && !refreshed.contains("Debug Plugin") + && !refreshed.contains("ctrl + r remove marketplace"), + "expected refreshed plugin list without removed marketplace, got:\n{refreshed}" + ); +} + #[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;