mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Toggleable plugin enablement in TUI /plugins menu v2
This commit is contained in:
@@ -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;
|
||||
@@ -2150,6 +2153,31 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
fn set_plugin_enabled(
|
||||
&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();
|
||||
@@ -4664,6 +4692,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,
|
||||
@@ -5217,6 +5252,26 @@ impl App {
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
}
|
||||
AppEvent::PluginEnabledSet {
|
||||
cwd,
|
||||
plugin_id,
|
||||
enabled,
|
||||
result,
|
||||
} => {
|
||||
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::RefreshPluginMentions => {
|
||||
self.refresh_plugin_mentions();
|
||||
}
|
||||
@@ -6462,6 +6517,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>,
|
||||
|
||||
@@ -271,6 +271,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,
|
||||
|
||||
|
||||
@@ -92,6 +92,11 @@ pub(crate) fn side_by_side_layout_widths(
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
pub(crate) struct SelectionToggle {
|
||||
pub is_on: bool,
|
||||
pub action: Box<dyn Fn(bool, &AppEventSender) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Callback invoked whenever the highlighted item changes (arrow keys, search
|
||||
/// filter, number-key jump). Receives the *actual* index into the unfiltered
|
||||
/// `items` list and the event sender. Used by the theme picker for live preview.
|
||||
@@ -112,6 +117,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>,
|
||||
@@ -273,6 +280,7 @@ impl ListSelectionView {
|
||||
} else {
|
||||
Some(active_tab_idx.unwrap_or(0))
|
||||
};
|
||||
let has_initial_selected_idx = params.initial_selected_idx.is_some();
|
||||
let mut s = Self {
|
||||
view_id: params.view_id,
|
||||
footer_note: params.footer_note,
|
||||
@@ -305,7 +313,7 @@ impl ListSelectionView {
|
||||
on_cancel: params.on_cancel,
|
||||
};
|
||||
s.apply_filter();
|
||||
if s.tabs_enabled() {
|
||||
if s.tabs_enabled() && !has_initial_selected_idx {
|
||||
s.select_first_enabled_row();
|
||||
}
|
||||
s
|
||||
@@ -326,6 +334,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))
|
||||
@@ -435,6 +452,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())
|
||||
@@ -555,6 +577,44 @@ 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() && 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);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_search_query(&mut self, query: String) {
|
||||
self.search_query = query;
|
||||
@@ -719,6 +779,18 @@ 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.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, ..
|
||||
} => {
|
||||
|
||||
@@ -89,6 +89,7 @@ mod skills_toggle_view;
|
||||
mod slash_commands;
|
||||
pub(crate) use footer::CollaborationModeIndicator;
|
||||
pub(crate) use list_selection_view::ColumnWidthMode;
|
||||
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;
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::ColumnWidthMode;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
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;
|
||||
@@ -39,7 +40,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_MENU_ROW_PREFIX_WIDTH: usize = 2;
|
||||
const PLUGIN_MENU_ROW_PREFIX_WIDTH: usize = 6;
|
||||
const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1);
|
||||
const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
@@ -232,9 +233,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) {
|
||||
@@ -387,6 +391,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 advance_plugin_install_auth_flow(&mut self) {
|
||||
let should_finish = {
|
||||
let Some(flow) = self.plugin_install_auth_flow.as_mut() else {
|
||||
@@ -562,7 +609,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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -573,10 +624,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -726,6 +780,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();
|
||||
|
||||
@@ -868,6 +923,7 @@ impl ChatWidget {
|
||||
search_placeholder: Some("Type to search plugins".to_string()),
|
||||
col_width_mode: ColumnWidthMode::AutoAllRows,
|
||||
name_column_width,
|
||||
initial_selected_idx,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -883,7 +939,7 @@ impl ChatWidget {
|
||||
if plugin.summary.enabled {
|
||||
"Installed"
|
||||
} else {
|
||||
"Installed · Disabled"
|
||||
"Disabled"
|
||||
}
|
||||
} else {
|
||||
match plugin.summary.install_policy {
|
||||
@@ -1035,8 +1091,12 @@ impl ChatWidget {
|
||||
plugin_brief_description_without_marketplace(plugin, status_label_width)
|
||||
};
|
||||
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" };
|
||||
format!("{selected_status_label} Space to {toggle_action}; Enter view details.")
|
||||
} else {
|
||||
format!("{selected_status_label} Press Enter to view plugin details.")
|
||||
};
|
||||
let search_value = format!(
|
||||
"{display_name} {} {} {}",
|
||||
plugin.id, plugin.name, marketplace_label
|
||||
@@ -1045,9 +1105,23 @@ impl ChatWidget {
|
||||
let plugin_display_name = display_name.clone();
|
||||
let marketplace_path = marketplace.path.clone();
|
||||
let plugin_name = plugin.name.clone();
|
||||
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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
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),
|
||||
@@ -1080,7 +1154,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> {
|
||||
@@ -1228,13 +1302,13 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str {
|
||||
if plugin.enabled {
|
||||
"Installed"
|
||||
} else {
|
||||
"Installed · Disabled"
|
||||
"Disabled"
|
||||
}
|
||||
} else {
|
||||
match plugin.install_policy {
|
||||
PluginInstallPolicy::NotAvailable => "Not installable",
|
||||
PluginInstallPolicy::Available => "Available",
|
||||
PluginInstallPolicy::InstalledByDefault => "Available by default",
|
||||
PluginInstallPolicy::InstalledByDefault => "Available",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -10,9 +10,9 @@ expression: popup
|
||||
[All Plugins] Installed (1) OpenAI Curated Repo Marketplace
|
||||
|
||||
Type to search plugins
|
||||
› Alpha Sync Installed · 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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -388,11 +388,113 @@ 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_search_filters_visible_rows_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user