/plugins: Add inline enablement toggles (#18395)

This PR adds inline enable/disable controls to the new /plugins browse
menu. Installed plugins can now be toggled directly from the list with
keyboard interaction, and the associated config-write plumbing is
included so the UI and persisted plugin state stay in sync. This also
includes the queued-write handling needed to avoid stale toggle
completions overwriting newer intent.

- Add toggleable plugin rows for installed plugins in /plugins
- Support Space to enable or disable without leaving the list
- Persist plugin enablement through the existing app/config write path
- Preserve the current selection while the list refreshes after a toggle
- Add tests and snapshot updates for toggling behavior

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
canvrno-oai
2026-04-17 17:33:11 -07:00
committed by GitHub
Unverified
parent 26d9894a27
commit 06f8ec54db
8 changed files with 503 additions and 23 deletions
+120
View File
@@ -75,6 +75,8 @@ use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
@@ -82,6 +84,7 @@ use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListParams;
@@ -1045,6 +1048,10 @@ pub(crate) struct App {
primary_session_configured: Option<ThreadSessionState>,
pending_primary_events: VecDeque<ThreadBufferedEvent>,
pending_app_server_requests: PendingAppServerRequests,
// Serialize plugin enablement writes per plugin so stale completions cannot
// overwrite a newer toggle, even if the plugin is toggled from different
// cwd contexts.
pending_plugin_enabled_writes: HashMap<String, Option<bool>>,
}
#[derive(Default)]
@@ -2170,6 +2177,48 @@ impl App {
});
}
fn set_plugin_enabled(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&plugin_id) {
*queued_enabled = Some(enabled);
return;
}
self.pending_plugin_enabled_writes
.insert(plugin_id.clone(), None);
self.spawn_plugin_enabled_write(app_server, cwd, plugin_id, enabled);
}
fn spawn_plugin_enabled_write(
&mut self,
app_server: &AppServerSession,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
) {
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 plugin_id_for_event = plugin_id.clone();
let result = write_plugin_enabled(request_handle, plugin_id, enabled)
.await
.map(|_| ())
.map_err(|err| format!("Failed to update plugin config: {err}"));
app_event_tx.send(AppEvent::PluginEnabledSet {
cwd: cwd_for_event,
plugin_id: plugin_id_for_event,
enabled,
result,
});
});
}
fn refresh_plugin_mentions(&mut self) {
let config = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
@@ -4051,6 +4100,7 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
};
if let Some(started) = initial_started_thread {
app.enqueue_primary_thread_session(started.session, started.turns)
@@ -4742,6 +4792,13 @@ impl App {
} => {
self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name);
}
AppEvent::SetPluginEnabled {
cwd,
plugin_id,
enabled,
} => {
self.set_plugin_enabled(app_server, cwd, plugin_id, enabled);
}
AppEvent::PluginInstallLoaded {
cwd,
marketplace_path,
@@ -4780,6 +4837,46 @@ impl App {
}
}
}
AppEvent::PluginEnabledSet {
cwd,
plugin_id,
enabled,
result,
} => {
let queued_enabled = self
.pending_plugin_enabled_writes
.get_mut(&plugin_id)
.and_then(Option::take);
let should_apply_result = if let Some(queued_enabled) = queued_enabled
&& (result.is_err() || queued_enabled != enabled)
{
self.spawn_plugin_enabled_write(
app_server,
cwd.clone(),
plugin_id.clone(),
queued_enabled,
);
false
} else {
true
};
if should_apply_result {
self.pending_plugin_enabled_writes.remove(&plugin_id);
let update_succeeded = result.is_ok();
if update_succeeded {
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after plugin toggle"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
}
self.chat_widget
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
}
}
AppEvent::FetchMcpInventory => {
self.fetch_mcp_inventory(app_server);
}
@@ -6563,6 +6660,27 @@ async fn fetch_plugin_uninstall(
.wrap_err("plugin/uninstall failed in TUI")
}
async fn write_plugin_enabled(
request_handle: AppServerRequestHandle,
plugin_id: String,
enabled: bool,
) -> Result<ConfigWriteResponse> {
let request_id = RequestId::String(format!("plugin-enable-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::ConfigValueWrite {
request_id,
params: ConfigValueWriteParams {
key_path: format!("plugins.{plugin_id}"),
value: serde_json::json!({ "enabled": enabled }),
merge_strategy: MergeStrategy::Upsert,
file_path: None,
expected_version: None,
},
})
.await
.wrap_err("config/value/write failed while updating plugin enablement in TUI")
}
fn build_feedback_upload_params(
origin_thread_id: Option<ThreadId>,
rollout_path: Option<PathBuf>,
@@ -9813,6 +9931,7 @@ guardian_approval = true
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
}
}
@@ -9870,6 +9989,7 @@ guardian_approval = true
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
},
rx,
op_rx,
+15
View File
@@ -283,6 +283,21 @@ pub(crate) enum AppEvent {
result: Result<PluginUninstallResponse, String>,
},
/// Enable or disable an installed plugin.
SetPluginEnabled {
cwd: PathBuf,
plugin_id: String,
enabled: bool,
},
/// Result of enabling or disabling a plugin.
PluginEnabledSet {
cwd: PathBuf,
plugin_id: String,
enabled: bool,
result: Result<(), String>,
},
/// Refresh plugin mention bindings from the current config.
RefreshPluginMentions,
@@ -101,6 +101,12 @@ pub(crate) enum SelectionRowDisplay {
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
pub(crate) type SelectionToggleAction = dyn Fn(bool, &AppEventSender) + Send + Sync;
pub(crate) struct SelectionToggle {
pub is_on: bool,
pub action: Box<SelectionToggleAction>,
}
/// Callback invoked whenever the highlighted item changes (arrow keys, search
/// filter, number-key jump). Receives the *actual* index into the unfiltered
@@ -122,6 +128,8 @@ pub(crate) type OnCancelCallback = Option<Box<dyn Fn(&AppEventSender) + Send + S
pub(crate) struct SelectionItem {
pub name: String,
pub name_prefix_spans: Vec<Span<'static>>,
pub toggle: Option<SelectionToggle>,
pub toggle_placeholder: Option<&'static str>,
pub display_shortcut: Option<KeyBinding>,
pub description: Option<String>,
pub selected_description: Option<String>,
@@ -345,6 +353,15 @@ impl ListSelectionView {
.unwrap_or(self.items.as_slice())
}
fn active_items_mut(&mut self) -> &mut [SelectionItem] {
if let Some(idx) = self.active_tab_idx
&& let Some(tab) = self.tabs.get_mut(idx)
{
return tab.items.as_mut_slice();
}
self.items.as_mut_slice()
}
fn active_header(&self) -> &dyn Renderable {
self.active_tab_idx
.and_then(|idx| self.tabs.get(idx))
@@ -454,6 +471,11 @@ 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());
}
name_prefix_spans.extend(item.name_prefix_spans.clone());
let description = is_selected
.then(|| item.selected_description.clone())
@@ -514,6 +536,44 @@ impl ListSelectionView {
self.state.scroll_top = 0;
}
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() && item.disabled_reason.is_none() && !item.is_disabled
})
}
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()
&& item.disabled_reason.is_none()
&& !item.is_disabled
})
}
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 item.is_disabled || item.disabled_reason.is_some() {
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();
@@ -742,6 +802,22 @@ impl BottomPaneView for ListSelectionView {
self.search_query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} if self.selected_item_has_toggle()
&& (!self.is_searchable || self.search_query.is_empty()) =>
{
self.toggle_selected()
}
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} if self.is_searchable
&& self.search_query.is_empty()
&& self.selected_item_has_toggle_placeholder() => {}
KeyEvent {
code: KeyCode::Esc, ..
} => {
@@ -1536,6 +1612,45 @@ mod tests {
assert_eq!(view.selected_actual_idx(), Some(1));
}
#[test]
fn space_appends_to_active_search_instead_of_toggling_selected_item() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = ListSelectionView::new(
SelectionViewParams {
items: vec![SelectionItem {
name: "Plugin".to_string(),
toggle: Some(SelectionToggle {
is_on: false,
action: Box::new(|_enabled, tx: &_| {
tx.send(AppEvent::OpenApprovalsPopup);
}),
}),
..Default::default()
}],
is_searchable: true,
..Default::default()
},
tx,
);
view.set_search_query("plugin".to_string());
view.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(view.search_query, "plugin ");
assert!(
!view.active_items()[0]
.toggle
.as_ref()
.is_some_and(|toggle| toggle.is_on),
"expected Space to leave the toggle state unchanged while search is active"
);
assert!(
rx.try_recv().is_err(),
"expected Space with an active search query to avoid firing the toggle action"
);
}
#[test]
fn single_line_row_display_truncates_instead_of_wrapping() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
+1
View File
@@ -91,6 +91,7 @@ mod slash_commands;
pub(crate) use footer::CollaborationModeIndicator;
pub(crate) use list_selection_view::ColumnWidthMode;
pub(crate) use list_selection_view::SelectionRowDisplay;
pub(crate) use list_selection_view::SelectionToggle;
pub(crate) use list_selection_view::SelectionViewParams;
pub(crate) use list_selection_view::SideContentWidth;
pub(crate) use list_selection_view::popup_content_width;
+94 -11
View File
@@ -9,6 +9,7 @@ use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionRowDisplay;
use crate::bottom_pane::SelectionTab;
use crate::bottom_pane::SelectionToggle;
use crate::bottom_pane::SelectionViewParams;
use crate::history_cell;
use crate::legacy_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
@@ -41,7 +42,7 @@ 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 OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated";
const PLUGIN_ROW_PREFIX_WIDTH: usize = 2;
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);
@@ -234,9 +235,12 @@ impl ChatWidget {
fn open_plugins_popup(&mut self, response: &PluginListResponse) {
self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string());
self.bottom_pane.show_selection_view(
self.plugins_popup_params(response, self.plugins_active_tab_id.clone()),
);
self.bottom_pane
.show_selection_view(self.plugins_popup_params(
response,
self.plugins_active_tab_id.clone(),
/*initial_selected_idx*/ None,
));
}
pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) {
@@ -357,6 +361,49 @@ impl ChatWidget {
}
}
pub(crate) fn on_plugin_enabled_set(
&mut self,
cwd: PathBuf,
plugin_id: String,
enabled: bool,
result: Result<(), String>,
) {
if self.config.cwd.as_path() != cwd.as_path() {
return;
}
if let Err(err) = result {
self.add_error_message(format!(
"Failed to update plugin config for {plugin_id}: {err}"
));
if let PluginsCacheState::Ready(response) = self.plugins_cache_for_current_cwd() {
self.refresh_plugins_popup_if_open(&response);
}
return;
}
let refreshed_response = match &mut self.plugins_cache {
PluginsCacheState::Ready(response)
if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) =>
{
for plugin in response
.marketplaces
.iter_mut()
.flat_map(|marketplace| marketplace.plugins.iter_mut())
.filter(|plugin| plugin.id == plugin_id)
{
plugin.enabled = enabled;
}
Some(response.clone())
}
_ => None,
};
if let Some(response) = refreshed_response {
self.refresh_plugins_popup_if_open(&response);
}
}
pub(crate) fn on_plugin_uninstall_loaded(
&mut self,
cwd: PathBuf,
@@ -564,7 +611,11 @@ impl ChatWidget {
let tab_id = self.plugins_active_tab_id.clone();
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_popup_params(&plugins_response, tab_id),
self.plugins_popup_params(
&plugins_response,
tab_id,
/*initial_selected_idx*/ None,
),
);
}
}
@@ -575,10 +626,13 @@ impl ChatWidget {
.active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID)
.map(str::to_string)
.or_else(|| self.plugins_active_tab_id.clone());
let selected_idx = self
.bottom_pane
.selected_index_for_active_view(PLUGINS_SELECTION_VIEW_ID);
self.plugins_active_tab_id = active_tab_id.clone();
let _ = self.bottom_pane.replace_selection_view_if_active(
PLUGINS_SELECTION_VIEW_ID,
self.plugins_popup_params(response, active_tab_id),
self.plugins_popup_params(response, active_tab_id, selected_idx),
);
}
@@ -728,6 +782,7 @@ impl ChatWidget {
&self,
response: &PluginListResponse,
active_tab_id: Option<String>,
initial_selected_idx: Option<usize>,
) -> SelectionViewParams {
let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect();
@@ -867,6 +922,7 @@ impl ChatWidget {
col_width_mode: ColumnWidthMode::AutoAllRows,
row_display: SelectionRowDisplay::SingleLine,
name_column_width,
initial_selected_idx,
..Default::default()
}
}
@@ -1033,9 +1089,22 @@ impl ChatWidget {
} else {
plugin_brief_description_without_marketplace(plugin, status_label_width)
};
let can_view_details = marketplace.path.is_some();
let selected_status_label = format!("{status_label:<status_label_width$}");
let selected_description =
format!("{selected_status_label} Press Enter to view plugin details.");
let selected_description = if plugin.installed {
let toggle_action = if plugin.enabled { "disable" } else { "enable" };
if can_view_details {
format!(
"{selected_status_label} Space to {toggle_action}; Enter view details."
)
} else {
format!("{selected_status_label} Space to {toggle_action}.")
}
} else if can_view_details {
format!("{selected_status_label} Press Enter to 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
@@ -1044,7 +1113,18 @@ impl ChatWidget {
let plugin_display_name = display_name.clone();
let marketplace_path = marketplace.path.clone();
let plugin_name = plugin.name.clone();
let is_disabled = marketplace_path.is_none();
let toggle_cwd = cwd.clone();
let toggle_plugin_id = plugin.id.clone();
let toggle = plugin.installed.then(|| SelectionToggle {
is_on: plugin.enabled,
action: Box::new(move |enabled, tx| {
tx.send(AppEvent::SetPluginEnabled {
cwd: toggle_cwd.clone(),
plugin_id: toggle_plugin_id.clone(),
enabled,
});
}),
});
let actions: Vec<SelectionAction> = if let Some(marketplace_path) = marketplace_path {
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenPluginDetailLoading {
@@ -1062,11 +1142,14 @@ impl ChatWidget {
} else {
Vec::new()
};
let is_disabled = !can_view_details && !plugin.installed;
let disabled_reason =
is_disabled.then(|| "remote plugin details are not available yet".to_string());
items.push(SelectionItem {
name: display_name,
toggle,
toggle_placeholder: (!plugin.installed).then_some("[-] "),
description: Some(description),
selected_description: Some(selected_description),
search_value: Some(search_value),
@@ -1090,7 +1173,7 @@ impl ChatWidget {
}
fn plugins_popup_hint_line() -> Line<'static> {
Line::from("←/→ select marketplace · enter view details · esc close")
Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close")
}
fn plugin_detail_hint_line() -> Line<'static> {
@@ -1238,7 +1321,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 => "Available",
}
}
}
@@ -9,9 +9,9 @@ expression: popup
[All Plugins] Installed (1) OpenAI Curated Repo Marketplace
Type to search plugins
Alpha Sync Disabled Press Enter to view plugin details.
Bravo Search Available · ChatGPT Marketplace · Search docs and tickets.
Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
Starter Available by default · ChatGPT Marketplace · Included by default.
[ ] 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.
←/→ select marketplace · enter view details · esc close
space enable/disable · ←/→ select marketplace · enter view details · esc close
@@ -9,6 +9,6 @@ expression: popup
[All Plugins] Installed (0) OpenAI Curated
sla
Slack Available Press Enter to view plugin details.
[-] Slack Available Press Enter to view plugin details.
←/→ select marketplace · enter view details · esc close
space enable/disable · ←/→ select marketplace · enter view details · esc close
@@ -247,7 +247,7 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
}
#[tokio::test]
async fn plugins_popup_refresh_replaces_selection_with_first_row() {
async fn plugins_popup_refresh_preserves_selected_row_position() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
@@ -276,7 +276,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() {
let before = render_bottom_popup(&chat, /*width*/ 100);
assert!(
before.contains(" Slack"),
before.contains(" [-] Slack"),
"expected Slack to be selected before refresh, got:\n{before}"
);
@@ -314,8 +314,12 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() {
let after = render_bottom_popup(&chat, /*width*/ 100);
assert!(
after.contains(" Airtable"),
"expected refresh to rebuild the popup from the new first row, got:\n{after}"
after.contains(" [-] Notion"),
"expected refresh to preserve the selected row position, got:\n{after}"
);
assert!(
after.contains("Airtable"),
"expected refreshed popup to include the updated plugin list, got:\n{after}"
);
assert!(
after.contains("Slack"),
@@ -387,11 +391,153 @@ async fn plugins_popup_refreshes_installed_counts_after_install() {
"expected /plugins to refresh installed counts after install, got:\n{after}"
);
assert!(
after.contains("Installed Press Enter to view plugin details."),
after.contains("Installed Space to disable; Enter view details."),
"expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}"
);
}
#[tokio::test]
async fn plugins_popup_space_toggles_installed_plugin_from_list() {
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![
plugins_test_summary(
"plugin-calendar",
"calendar",
Some("Calendar"),
Some("Schedule management."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SetPluginEnabled {
cwd: event_cwd,
plugin_id,
enabled,
}) => {
assert_eq!(event_cwd, cwd);
assert_eq!(plugin_id, "plugin-drive");
assert!(!enabled);
}
other => panic!("expected SetPluginEnabled event, got {other:?}"),
}
chat.on_plugin_enabled_set(
cwd,
"plugin-drive".to_string(),
/*enabled*/ false,
Ok(()),
);
let popup = render_bottom_popup(&chat, /*width*/ 100);
assert!(
popup.contains(" [ ] Drive"),
"expected selected plugin row to stay selected after refresh, got:\n{popup}"
);
}
#[tokio::test]
async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() {
let (mut chat, mut 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,
),
])]),
);
while rx.try_recv().is_ok() {}
let before = render_bottom_popup(&chat, /*width*/ 100);
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let after = render_bottom_popup(&chat, /*width*/ 100);
assert!(
rx.try_recv().is_err(),
"did not expect Space on an uninstalled plugin to emit an event"
);
assert_eq!(after, before);
}
#[tokio::test]
async fn plugins_popup_space_with_active_search_does_not_toggle_installed_plugin() {
let (mut chat, mut 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*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
plugins_test_summary(
"plugin-drive",
"drive",
Some("Drive"),
Some("Document access."),
/*installed*/ true,
/*enabled*/ true,
PluginInstallPolicy::Available,
),
])]),
);
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
type_plugins_search_query(&mut chat, "dr");
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(
rx.try_recv().is_err(),
"did not expect Space with an active plugin search to emit a toggle event"
);
}
#[tokio::test]
async fn plugins_popup_search_filters_visible_rows_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;