TUI Plugin Sharing 3 - render remote plugin catalog sections (#26703)

## Summary

[#26701](https://github.com/openai/codex/pull/26701) added remote plugin
identity support, [#26702](https://github.com/openai/codex/pull/26702)
added remote-section fetching and state, and
[#28768](https://github.com/openai/codex/pull/28768) extracted the
catalog rendering module. This PR builds the product-facing `/plugins`
catalog on that foundation so remote records appear as OpenAI Curated,
Workspace, and Shared with me sections rather than backend marketplace
implementation details.

Plugin details remain read-only for sharing metadata. This PR does not
add share-authoring actions or change the app-server protocol.

## Changes

- Renders OpenAI Curated, Workspace, and Shared with me sections with
loading, empty, and error states.
- Preserves section selection and stable tab ordering as remote sections
transition between fallback and populated states.
- Shows OpenAI Curated loading only when the explicit vertical fallback
request was issued.
- Centralizes remote marketplace identity matching around the existing
marketplace constants.
- Uses product labels for remote marketplaces and identifies the
personal marketplace as Local by its path.
- Shows read-only source, authentication, version, and sharing metadata
in plugin detail views.
- Applies narrow display deduplication for local and remote records
sharing a remote plugin ID:
  - installed records take precedence;
- local mapped sources are preferred for details only when their
installed state matches the selected record.
- Returns from detail and confirmation views through the current plugin
cache so newly loaded remote sections are not overwritten by an older
captured response.
- Keeps admin-disabled plugins view-only and labels default-installed
plugins as Available by default.

## Tests

New tests:

- `plugins_popup_admin_disabled_available_plugin_has_view_only_hint`
- `plugins_popup_remote_section_fallback_states_snapshot`
-
`plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled`

Updated existing plugin catalog tests and snapshots for product labels,
detail metadata, personal-marketplace labeling, and stable tab ordering.

Verification:

- `cargo clippy -p codex-tui --all-targets -- -D warnings`

## Follow-ups

- Local/remote duplicate normalization should eventually move into
app-server. This PR intentionally keeps the compatibility behavior
narrow and display-only.
- PR5 will sanitize sensitive components before displaying Git source
URLs.
This commit is contained in:
canvrno-oai
2026-06-22 10:30:16 -07:00
committed by GitHub
Unverified
parent 44dbae90eb
commit f6fa259312
9 changed files with 1003 additions and 150 deletions
+3
View File
@@ -507,6 +507,9 @@ impl App {
AppEvent::PluginsLoaded { cwd, result } => {
self.chat_widget.on_plugins_loaded(cwd, result);
}
AppEvent::OpenPluginsList { cwd, response } => {
self.chat_widget.open_plugins_list(cwd, response);
}
AppEvent::PluginRemoteSectionsLoaded {
cwd,
marketplaces,
+6
View File
@@ -450,6 +450,12 @@ pub(crate) enum AppEvent {
result: Result<PluginListResponse, String>,
},
/// Open the plugin list from an already cached response.
OpenPluginsList {
cwd: PathBuf,
response: PluginListResponse,
},
/// Result of explicitly fetching remote-backed plugin sections.
PluginRemoteSectionsLoaded {
cwd: PathBuf,
File diff suppressed because it is too large Load Diff
+35
View File
@@ -42,6 +42,7 @@ pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace";
pub(super) struct PluginListFetchState {
pub(super) cache_cwd: Option<PathBuf>,
pub(super) in_flight_cwd: Option<PathBuf>,
pub(super) vertical_section_requested: bool,
}
#[derive(Debug, Clone)]
@@ -147,6 +148,7 @@ impl ChatWidget {
Err(err) => {
self.plugin_remote_sections_loading = false;
self.plugin_remote_sections_loaded = false;
self.plugins_fetch_state.vertical_section_requested = false;
if should_refresh_plugins_popup {
self.plugins_fetch_state.cache_cwd = None;
self.plugins_cache = PluginsCacheState::Failed(err.clone());
@@ -175,6 +177,7 @@ impl ChatWidget {
.is_some();
self.plugin_remote_sections_loading = false;
self.plugin_remote_sections_loaded = true;
self.plugins_fetch_state.vertical_section_requested = false;
let refreshed_response = match &mut self.plugins_cache {
PluginsCacheState::Ready(response)
if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) =>
@@ -212,6 +215,8 @@ impl ChatWidget {
}
self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone());
self.plugins_fetch_state.vertical_section_requested =
!self.config.features.enabled(Feature::RemotePlugin);
if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) {
self.plugins_cache = PluginsCacheState::Loading;
}
@@ -245,6 +250,36 @@ impl ChatWidget {
));
}
pub(crate) fn open_plugins_list(&mut self, cwd: PathBuf, response: PluginListResponse) {
if self.config.cwd.as_path() != cwd.as_path() {
return;
}
let response = match self.plugins_cache_for_current_cwd() {
PluginsCacheState::Ready(current_response) => current_response,
PluginsCacheState::Uninitialized
| PluginsCacheState::Loading
| PluginsCacheState::Failed(_) => response,
};
self.plugins_fetch_state.cache_cwd = Some(cwd);
self.plugins_cache = PluginsCacheState::Ready(response.clone());
let active_tab_id = self
.bottom_pane
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
.map(str::to_string)
.or_else(|| self.plugins_active_tab_id.clone())
.or_else(|| Some(ALL_PLUGINS_TAB_ID.to_string()));
self.plugins_active_tab_id = active_tab_id.clone();
let params =
self.plugins_popup_params(&response, active_tab_id, /*initial_selected_idx*/ None);
if !self
.bottom_pane
.replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params)
{
self.open_plugins_popup(&response);
}
}
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();
@@ -3,13 +3,15 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: strip_osc8_for_snapshot(&popup)
---
Plugins
Figma · Can be installed · ChatGPT Marketplace
Figma · Can be installed · Local
Data shared with this app is subject to the app's terms of service and privacy policy. Learn
more.
Turn Figma files into implementation context.
1. Back to plugins Return to the plugin list.
2. Install plugin Install this plugin now.
Source Local
Auth Auth on install
Skills design-review, extract-copy
Hooks PreToolUse (1), Stop (2)
Apps Figma, Slack
@@ -8,6 +8,8 @@ expression: strip_osc8_for_snapshot(&popup)
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
@@ -9,9 +9,9 @@ expression: popup
[All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace
Type to search plugins
[ ] Alpha Sync Disabled Space to enable; Enter view details.
[-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets.
[-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
[-] Starter Available · ChatGPT Marketplace · 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 /plugi
[-] Starter Available by default · OpenAI Curated · Included by default.
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: 339
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
All Plugins Installed (0) OpenAI Curated Workspace Shared with me [Debug Marketplace]
Add Marketplace
Type to search plugins
[-] Debug Plugin Available Press Enter to install or view plugin details.
@@ -7,6 +7,7 @@ use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginShareContext;
use codex_features::Stage;
use pretty_assertions::assert_eq;
@@ -250,7 +251,7 @@ async fn plugins_popup_truncates_long_descriptions_in_list_rows() {
.expect("expected verbose plugin row in popup");
insta::assert_snapshot!(
verbose_row,
@" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…"
@" [-] Verbose Plugin Available · OpenAI Curated · This description"
);
assert!(
!popup
@@ -459,11 +460,20 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
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);
let reopened_popup = (0..8)
.find_map(|_| {
let popup = render_bottom_popup(&chat, /*width*/ 100);
if popup.contains("[Debug Marketplace]") {
Some(popup)
} else {
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
None
}
})
.unwrap_or_else(|| {
let popup = render_bottom_popup(&chat, /*width*/ 100);
panic!("expected Debug Marketplace tab after reopening, got:\n{popup}");
});
assert!(
reopened_popup.contains("Installed 0 of 1 Debug Marketplace plugins.")
&& !reopened_popup.contains("installed successfully"),
@@ -584,10 +594,17 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() {
}
#[tokio::test]
async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() {
async fn plugin_detail_popup_snapshot_labels_personal_marketplace_as_local() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let marketplace_name = "personal-marketplace";
let personal_marketplace_path = AbsolutePathBuf::try_from(
dirs::home_dir()
.expect("home directory")
.join(".agents/plugins/marketplace.json"),
)
.expect("absolute personal marketplace path");
let summary = plugins_test_summary(
"plugin-figma",
"figma",
@@ -597,28 +614,29 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa
/*enabled*/ true,
PluginInstallPolicy::Available,
);
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
summary.clone(),
])]);
let response = plugins_test_response(vec![PluginMarketplaceEntry {
name: marketplace_name.to_string(),
path: Some(personal_marketplace_path.clone()),
interface: None,
plugins: vec![summary.clone()],
}]);
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
chat.add_plugins_output();
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"],
),
}),
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"],
);
plugin.marketplace_name = marketplace_name.to_string();
plugin.marketplace_path = Some(personal_marketplace_path);
chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin }));
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert_chatwidget_snapshot!(
@@ -1022,6 +1040,222 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() {
assert_eq!(after, before);
}
#[tokio::test]
async fn plugins_popup_admin_disabled_available_plugin_has_view_only_hint() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let summary = PluginSummary {
availability: PluginAvailability::DisabledByAdmin,
..plugins_test_summary(
"plugin-admin-blocked",
"admin-blocked",
Some("Admin Blocked"),
Some("Blocked by policy."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)
};
render_loaded_plugins_popup(
&mut chat,
plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
let admin_blocked_row = popup
.lines()
.find(|line| line.contains("Admin Blocked"))
.expect("expected admin-disabled plugin row");
assert!(
admin_blocked_row.contains("Press Enter to view plugin details.")
&& !admin_blocked_row.contains("install or view"),
"expected admin-disabled available plugin to stay view-only, got:\n{admin_blocked_row}"
);
}
#[tokio::test]
async fn plugins_popup_remote_section_fallback_states_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
let select_tab_containing = |chat: &mut ChatWidget, visible_text: &str| -> String {
for _ in 0..8 {
let popup = render_bottom_popup(chat, /*width*/ 100);
if popup.contains(visible_text) {
return popup;
}
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
}
let popup = render_bottom_popup(chat, /*width*/ 100);
panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}");
};
let remote_section_state = |popup: &str| -> String {
let header = popup
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.nth(1)
.expect("expected remote section header");
let item = popup
.lines()
.find_map(|line| line.trim_start().strip_prefix(''))
.expect("expected selected remote section item")
.trim();
format!("{header}\n{item}")
};
chat.add_plugins_output();
let cwd = chat.config.cwd.clone();
chat.on_plugins_loaded(
cwd.to_path_buf(),
Ok(plugins_test_response(vec![
plugins_test_curated_marketplace(Vec::new()),
])),
);
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.");
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.");
chat.on_plugin_remote_sections_loaded(
cwd.to_path_buf(),
Vec::new(),
vec![crate::app_event::PluginRemoteSectionError {
section_id: "workspace".to_string(),
label: "Workspace".to_string(),
message: "Sign in to ChatGPT to load workspace plugins.".to_string(),
}],
);
let workspace_error_popup = select_tab_containing(&mut chat, "Workspace unavailable.");
let (mut remote_chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
remote_chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
remote_chat.set_feature_enabled(Feature::RemotePlugin, /*enabled*/ true);
remote_chat.add_plugins_output();
let remote_cwd = remote_chat.config.cwd.clone();
remote_chat.on_plugins_loaded(
remote_cwd.to_path_buf(),
Ok(plugins_test_response(vec![
plugins_test_curated_marketplace(Vec::new()),
])),
);
let remote_curated_empty_popup =
select_tab_containing(&mut remote_chat, "No OpenAI Curated plugins available");
insta::assert_snapshot!(
[
remote_section_state(&curated_loading_popup),
remote_section_state(&workspace_loading_popup),
remote_section_state(&shared_empty_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 Workspace plugins.
Loading Workspace plugins... This section updates when app-server returns it.
Shared with me.
No shared plugins available No plugins have been shared with you.
Workspace unavailable.
Workspace unavailable Sign in to ChatGPT to load workspace plugins.
OpenAI Curated marketplace.
No OpenAI Curated plugins available No OpenAI Curated plugins available.
"###
);
}
#[tokio::test]
async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled() {
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 {
share_context: Some(PluginShareContext {
remote_plugin_id: remote_plugin_id.to_string(),
remote_version: None,
discoverability: None,
share_url: None,
creator_account_user_id: None,
creator_name: None,
share_principals: None,
}),
..plugins_test_summary(
"plugin-docs",
"docs",
Some("Docs"),
Some("Local editable docs plugin."),
/*installed*/ false,
/*enabled*/ true,
PluginInstallPolicy::Available,
)
};
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![plugins_test_remote_summary(
remote_plugin_id,
"docs",
Some("Docs"),
Some("Shared docs plugin."),
/*installed*/ true,
)],
},
]),
);
let all_plugins_row = popup
.lines()
.find(|line| line.contains("Docs"))
.expect("expected all-plugins row");
assert!(
popup.contains("Installed 1 of 1 available plugins.")
&& all_plugins_row.contains("Installed")
&& !all_plugins_row.contains("Available"),
"expected installed remote duplicate to win over local mapped share, got:\n{popup}"
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::OpenPluginDetailLoading {
plugin_display_name,
}) => {
assert_eq!(plugin_display_name, "Docs");
}
other => panic!("expected OpenPluginDetailLoading event, got {other:?}"),
}
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]
async fn plugin_detail_error_popup_skips_disabled_row_numbering() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;