mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
/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:
committed by
GitHub
Unverified
parent
c6e7d564c3
commit
66b0781502
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+15
@@ -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
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user