Toggleable plugin enablement in TUI /plugins menu v2

This commit is contained in:
canvrno-oai
2026-04-16 09:50:40 -07:00
parent 0ec99ef9e2
commit f49b187150
8 changed files with 361 additions and 21 deletions
+76
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;
@@ -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>,
+15
View File
@@ -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, ..
} => {
+1
View File
@@ -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;
+86 -12
View File
@@ -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",
}
}
}
@@ -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
@@ -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;