diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7af981d7d..4455a6b67 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -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 { 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(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7fffdeb98..ad30dedf5 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index a19ede3ab..cb92ea7d3 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -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: &'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 { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 2b69864cc..bf1a4165b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_admin_disabled_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_admin_disabled_installed.snap new file mode 100644 index 000000000..b8f109a9f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_admin_disabled_installed.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 2ec91937f..fbf5104c3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_empty_shared_section_hidden.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_empty_shared_section_hidden.snap new file mode 100644 index 000000000..146d02a5a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_empty_shared_section_hidden.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap index 803ff9fbe..60ebee6f9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index bfb0affb2..3a2e455f6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index a8053dc5a..7d162d41e 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -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; diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 480010391..fed3e5456 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -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 { diff --git a/codex-rs/tui/src/tui/test_support.rs b/codex-rs/tui/src/tui/test_support.rs index 5c1f30eec..c3f9f6bc3 100644 --- a/codex-rs/tui/src/tui/test_support.rs +++ b/codex-rs/tui/src/tui/test_support.rs @@ -11,7 +11,7 @@ use crate::custom_terminal::Terminal; pub(crate) fn make_test_tui() -> io::Result { 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,