TUI Plugin Sharing 5 - polish remote plugin catalog rows (#26705)

This is the final plugin sharing PR in the 5-PR stack. It applies the
remaining TUI polish for remote plugin catalog rows and tabs:
admin-disabled plugins now read as blocked/view-only instead of looking
toggleable, admin-installed/default-installed plugins count and sort
like installed plugins, plugin search matches richer metadata, and an
empty successful `Shared with me` section stays hidden.

- Admin-disabled rows use a blocked marker, show `Disabled`, and keep
Enter-only detail behavior without a toggle hint.
- Admin-installed/default-installed plugins show as installed in counts,
ordering, tabs, and detail copy.
- Plugin search now matches descriptions and keywords in addition to
existing row metadata.
- Successful-empty `Shared with me` tabs are hidden, while loading,
error, workspace-empty, and real shared-plugin states remain visible.
- Updates coverage in
`plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name`,
`plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint`,
`plugins_popup_search_matches_plugin_descriptions`, and
`plugins_popup_remote_section_fallback_states_snapshot`.
- Updates snapshots `plugins_popup_curated_marketplace` and
`plugins_popup_empty_shared_section_hidden`.


<img width="2034" height="106" alt="image"
src="https://github.com/user-attachments/assets/3f9a57e1-edd8-4e6c-b0b0-9f632a3c9529"
/>
<img width="2038" height="380" alt="image"
src="https://github.com/user-attachments/assets/45a47491-3381-4846-a13d-496bc0051d42"
/>
This commit is contained in:
canvrno-oai
2026-06-24 18:48:11 -07:00
committed by GitHub
Unverified
parent f15df624a6
commit a0d5fd772e
12 changed files with 362 additions and 112 deletions
@@ -281,6 +281,63 @@ pub(crate) struct ListSelectionView {
keymap: ListKeymap,
}
const SELECTION_TOGGLE_ON_PREFIX: &str = "[*] ";
const SELECTION_TOGGLE_OFF_PREFIX: &str = "[ ] ";
pub(crate) const SELECTION_TOGGLE_UNAVAILABLE_PREFIX: &str = "[-] ";
pub(crate) const SELECTION_TOGGLE_BLOCKED_PREFIX: &str = "[!] ";
fn selection_toggle_prefix(toggle: &SelectionToggle) -> &'static str {
if toggle.is_on {
SELECTION_TOGGLE_ON_PREFIX
} else {
SELECTION_TOGGLE_OFF_PREFIX
}
}
fn selection_item_toggle_prefix(item: &SelectionItem) -> Option<&'static str> {
item.toggle
.as_ref()
.map(selection_toggle_prefix)
.or(item.toggle_placeholder)
}
impl ListSelectionView {
fn selected_item_has_toggle(&self) -> bool {
self.selected_actual_idx()
.and_then(|actual_idx| self.active_items().get(actual_idx))
.is_some_and(|item| item.toggle.is_some() && Self::item_is_enabled(item))
}
fn selected_item_has_toggle_placeholder(&self) -> bool {
self.selected_actual_idx()
.and_then(|actual_idx| self.active_items().get(actual_idx))
.is_some_and(|item| {
item.toggle.is_none()
&& item.toggle_placeholder.is_some()
&& Self::item_is_enabled(item)
})
}
fn toggle_selected(&mut self) {
let Some(actual_idx) = self.selected_actual_idx() else {
return;
};
let app_event_tx = self.app_event_tx.clone();
let Some(item) = self.active_items_mut().get_mut(actual_idx) else {
return;
};
if !Self::item_is_enabled(item) {
return;
}
let Some(toggle) = item.toggle.as_mut() else {
return;
};
toggle.is_on = !toggle.is_on;
(toggle.action)(toggle.is_on, &app_event_tx);
}
}
impl ListSelectionView {
/// Create a selection popup view with filtering, scrolling, and callbacks wired.
///
@@ -533,10 +590,8 @@ impl ListSelectionView {
let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str());
let mut name_prefix_spans = Vec::new();
name_prefix_spans.push(wrap_prefix.into());
if let Some(toggle) = &item.toggle {
name_prefix_spans.push(if toggle.is_on { "[*] " } else { "[ ] " }.into());
} else if let Some(placeholder) = item.toggle_placeholder {
name_prefix_spans.push(placeholder.into());
if let Some(toggle_prefix) = selection_item_toggle_prefix(item) {
name_prefix_spans.push(toggle_prefix.into());
}
name_prefix_spans.extend(item.name_prefix_spans.clone());
let description = is_selected
@@ -611,22 +666,6 @@ impl ListSelectionView {
item.disabled_reason.is_none() && !item.is_disabled
}
fn selected_item_has_toggle(&self) -> bool {
self.selected_actual_idx()
.and_then(|actual_idx| self.active_items().get(actual_idx))
.is_some_and(|item| item.toggle.is_some() && Self::item_is_enabled(item))
}
fn selected_item_has_toggle_placeholder(&self) -> bool {
self.selected_actual_idx()
.and_then(|actual_idx| self.active_items().get(actual_idx))
.is_some_and(|item| {
item.toggle.is_none()
&& item.toggle_placeholder.is_some()
&& Self::item_is_enabled(item)
})
}
fn actual_idx_for_enabled_number(&self, number: usize) -> Option<usize> {
if number == 0 {
return None;
@@ -640,25 +679,6 @@ impl ListSelectionView {
.map(|(idx, _)| idx)
}
fn toggle_selected(&mut self) {
let Some(actual_idx) = self.selected_actual_idx() else {
return;
};
let app_event_tx = self.app_event_tx.clone();
let Some(item) = self.active_items_mut().get_mut(actual_idx) else {
return;
};
if !Self::item_is_enabled(item) {
return;
}
let Some(toggle) = item.toggle.as_mut() else {
return;
};
toggle.is_on = !toggle.is_on;
(toggle.action)(toggle.is_on, &app_event_tx);
}
fn move_up(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
+2
View File
@@ -190,6 +190,8 @@ use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SELECTION_TOGGLE_BLOCKED_PREFIX;
pub(crate) use list_selection_view::SELECTION_TOGGLE_UNAVAILABLE_PREFIX;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
+80 -17
View File
@@ -12,6 +12,8 @@ use crate::app_event::AppEvent;
use crate::app_event::PluginLocation;
use crate::app_event::PluginRemoteSectionError;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SELECTION_TOGGLE_BLOCKED_PREFIX;
use crate::bottom_pane::SELECTION_TOGGLE_UNAVAILABLE_PREFIX;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionRowDisplay;
@@ -67,6 +69,8 @@ const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.js
const REMOTE_LOADING_TAB_ID_PREFIX: &str = "remote-loading:";
const REMOTE_EMPTY_TAB_ID_PREFIX: &str = "remote-empty:";
const REMOTE_ERROR_TAB_ID_PREFIX: &str = "remote-error:";
const OPENAI_CURATED_LOADING_DESCRIPTION: &str =
"This updates when OpenAI Curated plugins finish loading.";
const WORKSPACE_SECTION_TAB_ORDER: u8 = 0;
const SHARED_WITH_ME_SECTION_TAB_ORDER: u8 = 1;
const SHARED_WITH_ME_LINK_SECTION_TAB_ORDER: u8 = 2;
@@ -78,6 +82,7 @@ struct PreferredLocalPluginSource {
marketplace_path: AbsolutePathBuf,
plugin_name: String,
installed: bool,
install_policy: PluginInstallPolicy,
}
#[derive(Debug, Clone, Copy)]
@@ -153,7 +158,9 @@ struct RemoteMarketplaceSection {
id: &'static str,
label: &'static str,
loading_tab_id: &'static str,
loading_item_description: &'static str,
marketplace_names: &'static [&'static str],
show_empty_tab: bool,
empty_item_name: &'static str,
empty_item_description: &'static str,
tab_order: u8,
@@ -164,7 +171,9 @@ const REMOTE_MARKETPLACE_SECTIONS: [RemoteMarketplaceSection; 2] = [
id: "workspace",
label: "Workspace",
loading_tab_id: "workspace-loading",
loading_item_description: "This updates when workspace plugins finish loading.",
marketplace_names: &[REMOTE_WORKSPACE_MARKETPLACE_NAME],
show_empty_tab: true,
empty_item_name: "No workspace plugins available",
empty_item_description: "No workspace directory plugins are available.",
tab_order: WORKSPACE_SECTION_TAB_ORDER,
@@ -173,11 +182,13 @@ const REMOTE_MARKETPLACE_SECTIONS: [RemoteMarketplaceSection; 2] = [
id: "shared-with-me",
label: "Shared with me",
loading_tab_id: "shared-with-me-loading",
loading_item_description: "This updates when shared plugins finish loading.",
marketplace_names: &[
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME,
],
show_empty_tab: false,
empty_item_name: "No shared plugins available",
empty_item_description: "No plugins have been shared with you.",
tab_order: SHARED_WITH_ME_SECTION_TAB_ORDER,
@@ -200,10 +211,16 @@ impl RemoteMarketplaceSection {
}
let tab = if remote_sections_loading {
remote_section_loading_tab(self.loading_tab_id, self.label)
remote_section_loading_tab(
self.loading_tab_id,
self.label,
self.loading_item_description,
)
} else if remote_sections_loaded {
if let Some(section_error) = plugin_remote_section_error(section_errors, self.id) {
remote_section_error_tab(section_error)
} else if !self.show_empty_tab {
return None;
} else {
remote_section_empty_tab(
self.id,
@@ -799,7 +816,7 @@ impl ChatWidget {
if curated_loading && !curated_has_entries {
(
"Loading OpenAI Curated plugins...",
"This section updates when app-server returns it.",
OPENAI_CURATED_LOADING_DESCRIPTION,
)
} else if let Some(section_error) = by_openai_section_error
&& !curated_has_entries
@@ -819,7 +836,10 @@ impl ChatWidget {
curated_empty_description,
);
if curated_loading && curated_has_entries {
curated_items.push(remote_section_loading_item("OpenAI Curated"));
curated_items.push(remote_section_loading_item(
"OpenAI Curated",
OPENAI_CURATED_LOADING_DESCRIPTION,
));
}
if let Some(section_error) = by_openai_section_error
&& curated_has_entries
@@ -1024,7 +1044,16 @@ impl ChatWidget {
}];
if plugin.summary.installed {
if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) {
if plugin.summary.install_policy == PluginInstallPolicy::InstalledByDefault {
items.push(SelectionItem {
name: "Installed by admin".to_string(),
description: Some(
"This plugin is installed by your workspace admin.".to_string(),
),
is_disabled: true,
..Default::default()
});
} else if let Some(plugin_id) = plugin_uninstall_id(&plugin.summary) {
let uninstall_cwd = self.config.cwd.to_path_buf();
let plugin_display_name = display_name;
items.push(SelectionItem {
@@ -1164,7 +1193,9 @@ impl ChatWidget {
plugin_detail_request_for_entry(marketplace, plugin, preferred_local_sources);
let can_view_details = plugin_detail_request.is_some();
let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin;
let can_toggle_plugin = plugin.installed && !disabled_by_admin;
let can_toggle_plugin = plugin.installed
&& plugin.install_policy != PluginInstallPolicy::InstalledByDefault
&& !disabled_by_admin;
let selected_status_label = format!("{status_label:<status_label_width$}");
let selected_description = if can_toggle_plugin {
let toggle_action = if plugin.enabled { "disable" } else { "enable" };
@@ -1175,20 +1206,26 @@ impl ChatWidget {
} else {
format!("{selected_status_label} Space to {toggle_action}.")
}
} else if disabled_by_admin && can_view_details {
format!("{selected_status_label} Press Enter to view plugin details.")
} else if disabled_by_admin {
format!("{selected_status_label} Plugin details are unavailable.")
} else if plugin.installed && can_view_details {
format!("{selected_status_label} Press Enter to view plugin details.")
} else if plugin.installed {
format!("{selected_status_label} Plugin details are unavailable.")
} else if disabled_by_admin && can_view_details {
format!("{selected_status_label} Press Enter to view plugin details.")
} else if can_view_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.")
};
let search_value = format!(
"{display_name} {} {} {}",
plugin.id, plugin.name, marketplace_label
"{display_name} {} {} {} {} {}",
plugin.id,
plugin.name,
marketplace_label,
plugin_description(plugin).unwrap_or_default(),
plugin.keywords.join(" ")
);
let cwd = self.config.cwd.to_path_buf();
let plugin_display_name = display_name.clone();
@@ -1230,7 +1267,13 @@ impl ChatWidget {
items.push(SelectionItem {
name: display_name,
toggle,
toggle_placeholder: (!can_toggle_plugin).then_some("[-] "),
toggle_placeholder: if plugin.availability == PluginAvailability::DisabledByAdmin {
Some(SELECTION_TOGGLE_BLOCKED_PREFIX)
} else if can_toggle_plugin {
None
} else {
Some(SELECTION_TOGGLE_UNAVAILABLE_PREFIX)
},
description: Some(description),
selected_description: Some(selected_description),
search_value: Some(search_value),
@@ -1317,6 +1360,14 @@ fn plugin_entry_preferred(
return candidate.1.installed;
}
let candidate_is_admin_managed =
candidate.1.install_policy == PluginInstallPolicy::InstalledByDefault;
let existing_is_admin_managed =
existing.1.install_policy == PluginInstallPolicy::InstalledByDefault;
if candidate_is_admin_managed != existing_is_admin_managed {
return candidate_is_admin_managed;
}
let candidate_is_local_share =
candidate.1.share_context.is_some() && !matches!(&candidate.1.source, PluginSource::Remote);
let existing_is_local_share =
@@ -1350,6 +1401,7 @@ fn preferred_local_plugin_sources(
marketplace_path: marketplace_path.clone(),
plugin_name: plugin.name.clone(),
installed: plugin.installed,
install_policy: plugin.install_policy,
});
}
}
@@ -1360,6 +1412,13 @@ fn plugin_detail_status_label(plugin: &PluginSummary) -> &'static str {
if plugin.availability == PluginAvailability::DisabledByAdmin {
return "Disabled by admin";
}
if plugin.install_policy == PluginInstallPolicy::InstalledByDefault {
return if plugin.installed {
"Installed by admin"
} else {
"Enabled by Admin"
};
}
if plugin.installed {
if plugin.enabled {
"Installed"
@@ -1370,7 +1429,7 @@ fn plugin_detail_status_label(plugin: &PluginSummary) -> &'static str {
match plugin.install_policy {
PluginInstallPolicy::NotAvailable => "Not installable",
PluginInstallPolicy::Available => "Can be installed",
PluginInstallPolicy::InstalledByDefault => "Available by default",
PluginInstallPolicy::InstalledByDefault => "Installed by admin",
}
}
}
@@ -1643,10 +1702,10 @@ fn is_personal_marketplace_path(marketplace_path: &AbsolutePathBuf) -> bool {
.is_some_and(|personal_path| personal_path.as_path() == marketplace_path.as_path())
}
fn remote_section_loading_item(label: &str) -> SelectionItem {
fn remote_section_loading_item(label: &str, description: &str) -> SelectionItem {
SelectionItem {
name: format!("Loading {label} plugins..."),
description: Some("This section updates when app-server returns it.".to_string()),
description: Some(description.to_string()),
is_disabled: true,
..Default::default()
}
@@ -1670,7 +1729,7 @@ fn plugin_remote_section_error<'a>(
.find(|section_error| section_error.section_id == section_id)
}
fn remote_section_loading_tab(id: &str, label: &str) -> SelectionTab {
fn remote_section_loading_tab(id: &str, label: &str, item_description: &str) -> SelectionTab {
SelectionTab {
id: format!("{REMOTE_LOADING_TAB_ID_PREFIX}{id}"),
label: label.to_string(),
@@ -1678,7 +1737,7 @@ fn remote_section_loading_tab(id: &str, label: &str) -> SelectionTab {
format!("Loading {label} plugins."),
"Local plugin functionality is already available.".to_string(),
),
items: vec![remote_section_loading_item(label)],
items: vec![remote_section_loading_item(label, item_description)],
}
}
@@ -1816,7 +1875,10 @@ fn plugin_brief_description_without_marketplace(
fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
if plugin.availability == PluginAvailability::DisabledByAdmin {
return "Disabled by admin";
return "Disabled";
}
if !plugin.installed && plugin.install_policy == PluginInstallPolicy::InstalledByDefault {
return "Admin assigned";
}
if plugin.installed {
if plugin.enabled {
@@ -1828,7 +1890,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
match plugin.install_policy {
PluginInstallPolicy::NotAvailable => "Not installable",
PluginInstallPolicy::Available => "Available",
PluginInstallPolicy::InstalledByDefault => "Available by default",
PluginInstallPolicy::InstalledByDefault => "Installed",
}
}
}
@@ -1863,6 +1925,7 @@ fn plugin_detail_request_for_entry(
&& let Some(remote_plugin_id) = plugin_remote_identity(plugin)
&& let Some(preferred_source) = preferred_local_sources.get(remote_plugin_id)
&& preferred_source.installed == plugin.installed
&& preferred_source.install_policy == plugin.install_policy
{
return Some((
PluginLocation::Local {
@@ -3,16 +3,16 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: strip_osc8_for_snapshot(&popup)
---
Plugins
Figma · Installed · ChatGPT Marketplace
Figma · Installed by admin · ChatGPT Marketplace
Turn Figma files into implementation context.
1. Back to plugins Return to the plugin list.
2. Uninstall plugin Remove this plugin now.
Source Local
Auth Auth on install
Skills design-review, extract-copy
Hooks PreToolUse (1), Stop (2)
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
1. Back to plugins Return to the plugin list.
Installed by admin This plugin is installed by your workspace admin.
Source Local
Auth Auth on install
Skills design-review, extract-copy
Hooks PreToolUse (1), Stop (2)
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
Press esc to close.
@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Plugins
Browse plugins from available marketplaces.
Installed 1 of 1 available plugins.
[All Plugins] Installed (1) OpenAI Curated Workspace Add Marketplace
Type to search plugins
[!] Admin Blocked Disabled Press Enter to view plugin details.
space enable/disable · ←/→ select marketplace · enter view details · esc close
@@ -6,12 +6,12 @@ expression: popup
Browse plugins from available marketplaces.
Installed 1 of 4 available plugins.
[All Plugins] Installed (1) OpenAI Curated Workspace Shared with me Repo Marketplace Add Marketplace
[All Plugins] Installed (1) OpenAI Curated Workspace Repo Marketplace Add Marketplace
Type to search plugins
[ ] Alpha Sync Disabled Space to enable; Enter view details.
[-] Bravo Search Available · OpenAI Curated · Search docs and tickets.
[-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
[-] Starter Available by default · OpenAI Curated · Included by default.
[ ] Alpha Sync Disabled Space to enable; Enter view details.
[-] Bravo Search Available · OpenAI Curated · Search docs and tickets.
[-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
[-] Starter Admin assigned · OpenAI Curated · Included by default.
space enable/disable · ←/→ select marketplace · enter view details · esc close
@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: loaded_popup
---
Plugins
Workspace.
This section loaded successfully.
All Plugins Installed (0) OpenAI Curated [Workspace] Add Marketplace
Type to search plugins
No workspace plugins available No workspace directory plugins are available.
space enable/disable · ←/→ select marketplace · enter view details · esc close
@@ -1,13 +1,13 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 473
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 Workspace Shared with me [Debug Marketplace]
Add Marketplace
All Plugins Installed (0) OpenAI Curated Workspace [Debug Marketplace] Add Marketplace
Type to search plugins
[-] Debug Plugin Available Press Enter to install or view plugin details.
@@ -1,13 +1,13 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 767
assertion_line: 1684
expression: popup
---
Plugins
Browse plugins from available marketplaces.
Installed 0 of 3 available plugins.
[All Plugins] Installed (0) OpenAI Curated Workspace Shared with me Add Marketplace
[All Plugins] Installed (0) OpenAI Curated Workspace Add Marketplace
sla
[-] Slack Available Press Enter to install or view plugin details.
@@ -186,7 +186,7 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_
Some("Starter"),
Some("Included by default."),
/*installed*/ false,
/*enabled*/ true,
/*enabled*/ false,
PluginInstallPolicy::InstalledByDefault,
),
]),
@@ -665,10 +665,9 @@ async fn plugin_detail_popup_snapshot_labels_personal_marketplace_as_local() {
}
#[tokio::test]
async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
async fn plugin_detail_popup_distinguishes_admin_installed_from_enabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = plugins_test_summary(
"plugin-figma",
"figma",
@@ -676,7 +675,7 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
Some("Design handoff."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
PluginInstallPolicy::InstalledByDefault,
);
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
summary.clone(),
@@ -684,32 +683,54 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
chat.add_plugins_output();
let mut plugin = plugins_test_detail(
summary,
Some("Turn Figma files into implementation context."),
&["design-review", "extract-copy"],
&[
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
(codex_app_server_protocol::HookEventName::Stop, 2),
],
&["Figma", "Slack"],
&["figma-mcp", "docs-mcp"],
);
chat.on_plugin_detail_loaded(
cwd.to_path_buf(),
Ok(PluginReadResponse {
plugin: plugins_test_detail(
summary,
Some("Turn Figma files into implementation context."),
&["design-review", "extract-copy"],
&[
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
(codex_app_server_protocol::HookEventName::Stop, 2),
],
&["Figma", "Slack"],
&["figma-mcp", "docs-mcp"],
),
plugin: plugin.clone(),
}),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
!popup.contains("Data shared with this app is subject to the app's"),
"expected installed plugin details to hide the disclosure line, got:\n{popup}"
popup.contains("Installed by admin")
&& !popup.contains("Uninstall plugin")
&& !popup.contains("Data shared with this app is subject to the app's"),
"expected admin-installed plugin details to block uninstall and hide the disclosure line, got:\n{popup}"
);
assert_chatwidget_snapshot!(
"plugin_detail_popup_installed",
strip_osc8_for_snapshot(&popup)
);
plugin.summary.installed = false;
plugin.summary.enabled = false;
chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin }));
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Enabled by Admin")
&& popup.contains("Install plugin")
&& !popup.contains("Installed by admin"),
"expected an unmaterialized default plugin to show its admin assignment and remain installable, got:\n{popup}"
);
insta::assert_snapshot!(
popup
.lines()
.find(|line| line.contains("Figma ·"))
.expect("expected plugin detail header")
.trim(),
@"Figma · Enabled by Admin · ChatGPT Marketplace"
);
}
#[tokio::test]
@@ -769,17 +790,20 @@ async fn plugins_popup_remote_row_opens_remote_detail() {
}
#[tokio::test]
async fn plugin_detail_remote_install_uses_remote_location() {
async fn plugin_detail_unmaterialized_default_uses_remote_install_path() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = plugins_test_remote_summary(
"plugins~Plugin_linear",
"linear",
Some("Linear"),
Some("Issue tracking."),
/*installed*/ false,
);
let summary = PluginSummary {
install_policy: PluginInstallPolicy::InstalledByDefault,
..plugins_test_remote_summary(
"plugins~Plugin_linear",
"linear",
Some("Linear"),
Some("Issue tracking."),
/*installed*/ false,
)
};
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(
cwd.to_path_buf(),
@@ -1075,9 +1099,12 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() {
);
let popup = render_bottom_popup(&chat, /*width*/ 120);
assert_chatwidget_snapshot!("plugins_popup_admin_disabled_installed", popup);
assert!(
popup.contains("Disabled by admin")
popup.contains("[!] Admin Blocked")
&& popup.contains("Disabled")
&& popup.contains("Press Enter to view plugin details.")
&& !popup.contains("Disabled by admin")
&& !popup.contains("Space to disable"),
"expected admin-disabled installed row to omit toggle hint, got:\n{popup}"
);
@@ -1170,9 +1197,16 @@ async fn plugins_popup_remote_section_fallback_states_snapshot() {
let curated_loading_popup =
select_tab_containing(&mut chat, "Loading OpenAI Curated plugins...");
let workspace_loading_popup = select_tab_containing(&mut chat, "Loading Workspace plugins.");
let shared_loading_popup = select_tab_containing(&mut chat, "Loading Shared with me plugins.");
let _ = select_tab_containing(&mut chat, "Loading Workspace plugins.");
chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), Vec::new(), Vec::new());
let shared_empty_popup = select_tab_containing(&mut chat, "Shared with me.");
let loaded_popup = render_bottom_popup(&chat, /*width*/ 100);
assert_chatwidget_snapshot!("plugins_popup_empty_shared_section_hidden", loaded_popup);
assert!(
!loaded_popup.contains("Shared with me"),
"expected empty shared section to stay hidden, got:\n{loaded_popup}"
);
chat.on_plugin_remote_sections_loaded(
cwd.to_path_buf(),
@@ -1203,20 +1237,20 @@ async fn plugins_popup_remote_section_fallback_states_snapshot() {
[
remote_section_state(&curated_loading_popup),
remote_section_state(&workspace_loading_popup),
remote_section_state(&shared_empty_popup),
remote_section_state(&shared_loading_popup),
remote_section_state(&workspace_error_popup),
remote_section_state(&remote_curated_empty_popup),
]
.join("\n\n"),
@r###"
OpenAI Curated marketplace.
Loading OpenAI Curated plugins... This section updates when app-server returns it.
Loading OpenAI Curated plugins... This updates when OpenAI Curated plugins finish loading.
Loading Workspace plugins.
Loading Workspace plugins... This section updates when app-server returns it.
Loading Workspace plugins... This updates when workspace plugins finish loading.
Shared with me.
No shared plugins available No plugins have been shared with you.
Loading Shared with me plugins.
Loading Shared with me plugins... This updates when shared plugins finish loading.
Workspace unavailable.
Workspace unavailable Sign in to ChatGPT to load workspace plugins.
@@ -1228,13 +1262,13 @@ async fn plugins_popup_remote_section_fallback_states_snapshot() {
}
#[tokio::test]
async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled() {
async fn plugins_popup_remote_detail_tracks_physical_and_policy_install_state() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let remote_plugin_id = "plugins~Plugin_docs";
let remote_marketplace_name = "workspace-shared-with-me-private";
let local_summary = PluginSummary {
let mut local_summary = PluginSummary {
share_context: Some(PluginShareContext {
remote_plugin_id: remote_plugin_id.to_string(),
remote_version: None,
@@ -1251,13 +1285,13 @@ async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share
Some("Local editable docs plugin."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
PluginInstallPolicy::InstalledByDefault,
)
};
let popup = render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![
plugins_test_curated_marketplace(vec![local_summary]),
plugins_test_curated_marketplace(vec![local_summary.clone()]),
PluginMarketplaceEntry {
name: remote_marketplace_name.to_string(),
path: None,
@@ -1315,6 +1349,62 @@ async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share
}
other => panic!("expected FetchPluginDetail event, got {other:?}"),
}
local_summary.install_policy = PluginInstallPolicy::Available;
let mut remote_summary = plugins_test_remote_summary(
remote_plugin_id,
"docs",
Some("Docs"),
Some("Shared docs plugin."),
/*installed*/ false,
);
remote_summary.install_policy = PluginInstallPolicy::InstalledByDefault;
let popup = render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![
plugins_test_curated_marketplace(vec![local_summary]),
PluginMarketplaceEntry {
name: remote_marketplace_name.to_string(),
path: None,
interface: Some(MarketplaceInterface {
display_name: Some("Shared with me".to_string()),
}),
plugins: vec![remote_summary],
},
]),
);
assert!(
popup.contains("Installed 0 of 1 available plugins.") && popup.contains("Admin assigned"),
"expected the unmaterialized admin-assigned remote duplicate to win without counting as installed, got:\n{popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
let installed_popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
installed_popup.contains("Showing 0 installed plugins.")
&& installed_popup.contains("No installed plugins")
&& !installed_popup.contains("Docs"),
"expected the unmaterialized admin-assigned plugin to stay out of the Installed tab, got:\n{installed_popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Left));
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::OpenPluginDetailLoading { .. })
));
match rx.try_recv() {
Ok(AppEvent::FetchPluginDetail { params, .. }) => {
assert_eq!(params.marketplace_path, None);
assert_eq!(
params.remote_marketplace_name,
Some(remote_marketplace_name.to_string())
);
assert_eq!(params.plugin_name, remote_plugin_id);
}
other => panic!("expected FetchPluginDetail event, got {other:?}"),
}
}
#[tokio::test]
@@ -1685,6 +1775,44 @@ async fn plugins_popup_search_filters_visible_rows_snapshot() {
);
}
#[tokio::test]
async fn plugins_popup_search_matches_plugin_descriptions() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![plugins_test_curated_marketplace(vec![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
type_plugins_search_query(&mut chat, "document");
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains("Drive") && !popup.contains("Calendar"),
"expected plugin search to match descriptions, got:\n{popup}"
);
}
#[tokio::test]
async fn plugins_popup_installed_tab_filters_rows_and_clears_search() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+10 -1
View File
@@ -223,7 +223,7 @@ where
))
}
pub(crate) fn with_screen_size_and_cursor_position(
fn with_screen_size_and_cursor_position(
backend: B,
screen_size: Size,
cursor_pos: Position,
@@ -245,6 +245,15 @@ where
}
}
#[cfg(test)]
pub(crate) fn with_screen_size_and_cursor_position_for_test(
backend: B,
screen_size: Size,
cursor_pos: Position,
) -> Self {
Self::with_screen_size_and_cursor_position(backend, screen_size, cursor_pos)
}
/// Get a Frame object which provides a consistent view into the terminal state for rendering.
pub fn get_frame(&mut self) -> Frame<'_> {
Frame {
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::custom_terminal::Terminal;
pub(crate) fn make_test_tui() -> io::Result<Tui> {
let backend = CrosstermBackend::new(stdout());
let terminal = Terminal::with_screen_size_and_cursor_position(
let terminal = Terminal::with_screen_size_and_cursor_position_for_test(
backend,
Size {
width: 80,