/plugins: remove marketplace (#19843)

This PR adds marketplace removal to the /plugins menu, giving users a
way to remove user-configured plugin marketplaces. It adds a `Ctrl+R`
shortcut to remove selected marketplace tabs, a confirmation prompt,
loading and error states, and the app-server request flow needed to
perform marketplace/remove. After a successful removal, the TUI
refreshes config, plugin mentions, user config, and plugin data so the
removed marketplace disappears from the menu and other surfaces in the
TUI.

- Add `Ctrl+R` removal option for user-configured marketplace tabs
- Show marketplace removal confirmation, loading, and error states
- Route `marketplace/remove` through the TUI background request flow
- Refresh config, plugin mentions, and plugin data after successful
removal
- Adds reusable per-tab footer hints so removal guidance only appears on
applicable tabs
- Add test coverage for `Ctrl+R` behavior while plugin search is active

Steps to test:
- Add a marketplace using the TUI /plugins menu
- Use Ctrl+R to remove the marketplace
- Accept the confirmation prompt
- Confirm the marketplace is removed when the process completes.
This commit is contained in:
canvrno-oai
2026-04-30 10:25:07 -07:00
committed by GitHub
parent c02814c106
commit a85d265097
9 changed files with 593 additions and 9 deletions
@@ -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::<TomlValue>(
"[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::<TomlValue>(
"[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;