/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
This commit is contained in:
canvrno-oai
2026-04-28 14:22:39 -07:00
committed by GitHub
Unverified
parent c6e7d564c3
commit 66b0781502
13 changed files with 606 additions and 24 deletions
+4 -8
View File
@@ -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"
)));
}
@@ -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<F>(
@@ -160,8 +161,7 @@ fn resolve_local_source_path(source: &str) -> Result<PathBuf, MarketplaceAddErro
path.canonicalize().map_err(|err| {
MarketplaceAddError::InvalidRequest(format!(
"failed to resolve local marketplace source {}: {err}",
path.display()
"failed to resolve local marketplace source path: {err}"
))
})
}
@@ -5,6 +5,9 @@
//! the main event loop remains single-threaded.
use super::*;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
impl App {
pub(super) fn fetch_mcp_inventory(
@@ -105,6 +108,28 @@ impl App {
});
}
pub(super) fn fetch_marketplace_add(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
source: 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 source_for_event = source.clone();
let result = fetch_marketplace_add(request_handle, cwd, source)
.await
.map_err(|err| format!("Failed to add marketplace: {err}"));
app_event_tx.send(AppEvent::MarketplaceAddLoaded {
cwd: cwd_for_event,
source: source_for_event,
result,
});
});
}
pub(super) fn fetch_plugin_install(
&mut self,
app_server: &AppServerSession,
@@ -509,6 +534,54 @@ pub(super) async fn fetch_plugin_detail(
.wrap_err("plugin/read failed in TUI")
}
pub(super) async fn fetch_marketplace_add(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
source: String,
) -> Result<MarketplaceAddResponse> {
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 {
+21
View File
@@ -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);
}
+22
View File
@@ -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<PluginListResponse, String>,
},
/// 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<MarketplaceAddResponse, String>,
},
/// Replace the plugins popup with a plugin-detail loading state.
OpenPluginDetailLoading {
plugin_display_name: String,
+2
View File
@@ -910,6 +910,7 @@ pub(crate) struct ChatWidget {
plugin_install_apps_needing_auth: Vec<AppSummary>,
plugin_install_auth_flow: Option<PluginInstallAuthFlowState>,
plugins_active_tab_id: Option<String>,
newly_installed_marketplace_tab_id: Option<String>,
// 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(),
+248 -7
View File
@@ -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<MarketplaceAddResponse, String>,
) {
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<String> {
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<String>) -> Vec<String> {
let mut counts: Vec<(String, usize)> = Vec::new();
for label in &labels {
@@ -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.
@@ -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
@@ -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
+1
View File
@@ -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;
@@ -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(),
@@ -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;