mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
/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:
committed by
GitHub
Unverified
parent
26d9894a27
commit
06f8ec54db
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user