diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6221cebee531..cd59784f1542 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -746,13 +746,12 @@ impl MessageProcessor { self.outgoing.send_error(connection_request_id, error).await; return; } - let connection_id = connection_request_id.connection_id; if self.config.features.enabled(Feature::GeneralAnalytics) && let ClientRequest::TurnStart { request_id, .. } | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( - connection_id.0, + connection_request_id.connection_id.0, request_id.clone(), codex_request.clone(), ); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1db3dbc7ed9e..187e3e17e7a5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -75,13 +75,18 @@ 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; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::MarketplaceAddResponse; 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; @@ -1044,6 +1049,9 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + // Serialize plugin enablement writes per plugin so stale completions cannot + // overwrite a newer toggle. + pending_plugin_enabled_writes: HashMap<(PathBuf, String), Option>, } #[derive(Default)] @@ -2083,6 +2091,28 @@ impl App { }); } + fn fetch_marketplace_add( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + source: String, + ) { + 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 source_for_event = source.clone(); + let result = fetch_marketplace_add(request_handle, source) + .await + .map_err(|err| format!("Failed to add marketplace: {err}")); + app_event_tx.send(AppEvent::MarketplaceAddLoaded { + cwd: cwd_for_event, + source: source_for_event, + result, + }); + }); + } + fn fetch_plugin_detail( &mut self, app_server: &AppServerSession, @@ -2150,6 +2180,48 @@ impl App { }); } + fn set_plugin_enabled( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + enabled: bool, + ) { + let key = (cwd.clone(), plugin_id.clone()); + if let Some(queued_enabled) = self.pending_plugin_enabled_writes.get_mut(&key) { + *queued_enabled = Some(enabled); + return; + } + + self.pending_plugin_enabled_writes.insert(key, 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(); @@ -4012,6 +4084,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) @@ -4630,6 +4703,37 @@ impl App { AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } + AppEvent::OpenMarketplaceAddPrompt => { + self.chat_widget.open_marketplace_add_prompt(); + } + AppEvent::OpenMarketplaceAddLoading { source } => { + self.chat_widget.open_marketplace_add_loading_popup(&source); + } + AppEvent::FetchMarketplaceAdd { cwd, source } => { + self.fetch_marketplace_add(app_server, cwd, source); + } + AppEvent::MarketplaceAddLoaded { + cwd, + source, + result, + } => { + let add_succeeded = result.is_ok(); + if add_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after marketplace add" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget + .on_marketplace_add_loaded(cwd.clone(), source, result); + if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { + self.fetch_plugins_list(app_server, cwd); + } + } AppEvent::OpenPluginDetailLoading { plugin_display_name, } => { @@ -4678,6 +4782,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, @@ -5231,6 +5342,47 @@ impl App { self.fetch_plugins_list(app_server, cwd); } } + AppEvent::PluginEnabledSet { + cwd, + plugin_id, + enabled, + result, + } => { + let key = (cwd.clone(), plugin_id.clone()); + let queued_enabled = self + .pending_plugin_enabled_writes + .get_mut(&key) + .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(&key); + 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(); } @@ -6429,6 +6581,24 @@ fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) { .retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str())); } +async fn fetch_marketplace_add( + request_handle: AppServerRequestHandle, + source: String, +) -> Result { + let request_id = RequestId::String(format!("marketplace-add-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::MarketplaceAdd { + request_id, + params: MarketplaceAddParams { + source, + ref_name: None, + sparse_paths: None, + }, + }) + .await + .wrap_err("marketplace/add failed") +} + async fn fetch_plugin_detail( request_handle: AppServerRequestHandle, params: PluginReadParams, @@ -6476,6 +6646,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 { + 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, rollout_path: Option, @@ -9726,6 +9917,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(), } } @@ -9783,6 +9975,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, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 934baac2317e..72d714e63a8d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; @@ -212,6 +213,27 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the prompt for adding a marketplace source. + OpenMarketplaceAddPrompt, + + /// Replace the plugins popup with a marketplace-add loading state. + OpenMarketplaceAddLoading { + source: String, + }, + + /// Add a marketplace from the provided source. + FetchMarketplaceAdd { + cwd: PathBuf, + source: String, + }, + + /// Result of adding a marketplace. + MarketplaceAddLoaded { + cwd: PathBuf, + source: String, + result: Result, + }, + /// Replace the plugins popup with a plugin-detail loading state. OpenPluginDetailLoading { plugin_display_name: String, @@ -271,6 +293,21 @@ pub(crate) enum AppEvent { result: Result, }, + /// 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, diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 3c0ab2c74eb7..207c4386f6ea 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -49,6 +49,11 @@ pub(crate) trait BottomPaneView: Renderable { None } + /// Active tab id for tabbed list-based views. + fn active_tab_id(&self) -> Option<&str> { + None + } + /// Handle Ctrl-C while this view is active. fn on_ctrl_c(&mut self) -> CancellationEvent { CancellationEvent::NotHandled diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index c95565455d80..04e46746356f 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -24,14 +24,15 @@ use super::bottom_pane_view::BottomPaneView; use super::bottom_pane_view::ViewCompletion; use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; +use super::selection_popup_common::ColumnWidthConfig; pub(crate) use super::selection_popup_common::ColumnWidthMode; use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::measure_rows_height; -use super::selection_popup_common::measure_rows_height_stable_col_widths; use super::selection_popup_common::measure_rows_height_with_col_width_mode; -use super::selection_popup_common::render_rows; -use super::selection_popup_common::render_rows_stable_col_widths; +use super::selection_popup_common::render_rows_single_line_with_col_width_mode; use super::selection_popup_common::render_rows_with_col_width_mode; +use super::selection_tabs::SelectionTab; +use super::selection_tabs::render_tab_bar; +use super::selection_tabs::tab_bar_height; use unicode_width::UnicodeWidthStr; /// Minimum list width (in content columns) required before the side-by-side @@ -91,8 +92,21 @@ pub(crate) fn side_by_side_layout_widths( (list_width >= MIN_LIST_WIDTH_FOR_SIDE).then_some((list_width, side_width)) } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum SelectionRowDisplay { + #[default] + Wrapped, + SingleLine, +} + /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +pub(crate) type SelectionToggleAction = Box; + +pub(crate) struct SelectionToggle { + pub is_on: bool, + pub action: SelectionToggleAction, +} /// Callback invoked whenever the highlighted item changes (arrow keys, search /// filter, number-key jump). Receives the *actual* index into the unfiltered @@ -114,6 +128,8 @@ pub(crate) type OnCancelCallback = Option>, + pub toggle: Option, + pub toggle_placeholder: Option<&'static str>, pub display_shortcut: Option, pub description: Option, pub selected_description: Option, @@ -137,6 +153,7 @@ pub(crate) struct SelectionItem { /// `AutoVisible` (default) measures only rows visible in the viewport /// `AutoAllRows` measures all rows to ensure stable column widths as the user scrolls /// `Fixed` used a fixed 30/70 split between columns +/// `row_display` controls whether rows can wrap or stay single-line with ellipsis truncation pub(crate) struct SelectionViewParams { pub view_id: Option<&'static str>, pub title: Option, @@ -144,9 +161,14 @@ pub(crate) struct SelectionViewParams { pub footer_note: Option>, pub footer_hint: Option>, pub items: Vec, + pub tabs: Vec, + pub initial_tab_id: Option, pub is_searchable: bool, pub search_placeholder: Option, pub col_width_mode: ColumnWidthMode, + pub row_display: SelectionRowDisplay, + /// Rendered left-column width to use for auto-sized rows. + pub name_column_width: Option, pub header: Box, pub initial_selected_idx: Option, @@ -186,9 +208,13 @@ impl Default for SelectionViewParams { footer_note: None, footer_hint: None, items: Vec::new(), + tabs: Vec::new(), + initial_tab_id: None, is_searchable: false, search_placeholder: None, col_width_mode: ColumnWidthMode::AutoVisible, + row_display: SelectionRowDisplay::Wrapped, + name_column_width: None, header: Box::new(()), initial_selected_idx: None, side_content: Box::new(()), @@ -212,6 +238,8 @@ pub(crate) struct ListSelectionView { footer_note: Option>, footer_hint: Option>, items: Vec, + tabs: Vec, + active_tab_idx: Option, state: ScrollState, completion: Option, dismiss_after_child_accept: bool, @@ -220,6 +248,8 @@ pub(crate) struct ListSelectionView { search_query: String, search_placeholder: Option, col_width_mode: ColumnWidthMode, + row_display: SelectionRowDisplay, + name_column_width: Option, filtered_indices: Vec, last_selected_actual_idx: Option, header: Box, @@ -256,11 +286,25 @@ impl ListSelectionView { Box::new(subtitle), ])); } + let active_tab_idx = params.initial_tab_id.as_ref().and_then(|initial_tab_id| { + params + .tabs + .iter() + .position(|tab| tab.id.as_str() == initial_tab_id.as_str()) + }); + let active_tab_idx = if params.tabs.is_empty() { + None + } 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, footer_hint: params.footer_hint, items: params.items, + tabs: params.tabs, + active_tab_idx, state: ScrollState::new(), completion: None, dismiss_after_child_accept: false, @@ -273,6 +317,8 @@ impl ListSelectionView { None }, col_width_mode: params.col_width_mode, + row_display: params.row_display, + name_column_width: params.name_column_width, filtered_indices: Vec::new(), last_selected_actual_idx: None, header, @@ -286,6 +332,9 @@ impl ListSelectionView { on_cancel: params.on_cancel, }; s.apply_filter(); + if s.tabs_enabled() && !has_initial_selected_idx { + s.select_first_enabled_row(); + } s } @@ -293,6 +342,39 @@ impl ListSelectionView { self.filtered_indices.len() } + fn tabs_enabled(&self) -> bool { + self.active_tab_idx.is_some() + } + + fn active_items(&self) -> &[SelectionItem] { + self.active_tab_idx + .and_then(|idx| self.tabs.get(idx)) + .map(|tab| tab.items.as_slice()) + .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)) + .map(|tab| tab.header.as_ref()) + .unwrap_or(self.header.as_ref()) + } + + fn active_tab_id(&self) -> Option<&str> { + self.active_tab_idx + .and_then(|idx| self.tabs.get(idx)) + .map(|tab| tab.id.as_str()) + } + fn max_visible_rows(len: usize) -> usize { MAX_POPUP_ROWS.min(len.max(1)) } @@ -308,7 +390,7 @@ impl ListSelectionView { .selected_actual_idx() .or_else(|| { (!self.is_searchable) - .then(|| self.items.iter().position(|item| item.is_current)) + .then(|| self.active_items().iter().position(|item| item.is_current)) .flatten() }) .or_else(|| self.initial_selected_idx.take()); @@ -316,7 +398,7 @@ impl ListSelectionView { if self.is_searchable && !self.search_query.is_empty() { let query_lower = self.search_query.to_lowercase(); self.filtered_indices = self - .items + .active_items() .iter() .positions(|item| { item.search_value @@ -325,7 +407,7 @@ impl ListSelectionView { }) .collect(); } else { - self.filtered_indices = (0..self.items.len()).collect(); + self.filtered_indices = (0..self.active_items().len()).collect(); } let len = self.filtered_indices.len(); @@ -363,7 +445,7 @@ impl ListSelectionView { .iter() .enumerate() .filter_map(|(visible_idx, actual_idx)| { - self.items.get(*actual_idx).map(|item| { + self.active_items().get(*actual_idx).map(|item| { let is_selected = self.state.selected_idx == Some(visible_idx); let prefix = if is_selected { '›' } else { ' ' }; let name = item.name.as_str(); @@ -389,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()) @@ -411,6 +498,42 @@ impl ListSelectionView { .collect() } + fn switch_tab(&mut self, step: isize) { + let Some(active_idx) = self.active_tab_idx else { + return; + }; + let len = self.tabs.len(); + if len == 0 { + return; + } + + let next_idx = if step.is_negative() { + active_idx.checked_sub(1).unwrap_or(len - 1) + } else { + (active_idx + 1) % len + }; + self.active_tab_idx = Some(next_idx); + self.search_query.clear(); + self.state.reset(); + self.apply_filter(); + self.select_first_enabled_row(); + self.fire_selection_changed(); + } + + fn select_first_enabled_row(&mut self) { + let selected_visible_idx = self + .filtered_indices + .iter() + .position(|actual_idx| { + self.active_items() + .get(*actual_idx) + .is_some_and(|item| item.disabled_reason.is_none() && !item.is_disabled) + }) + .or_else(|| (!self.filtered_indices.is_empty()).then_some(0)); + self.state.selected_idx = selected_visible_idx; + self.state.scroll_top = 0; + } + fn move_up(&mut self) { let before = self.selected_actual_idx(); let len = self.visible_len(); @@ -444,20 +567,21 @@ impl ListSelectionView { } fn accept(&mut self) { - let selected_item = self + let selected_actual_idx = self .state .selected_idx - .and_then(|idx| self.filtered_indices.get(idx)) - .and_then(|actual_idx| self.items.get(*actual_idx)); - if let Some(item) = selected_item - && item.disabled_reason.is_none() - && !item.is_disabled - { - if let Some(idx) = self.state.selected_idx - && let Some(actual_idx) = self.filtered_indices.get(idx) - { - self.last_selected_actual_idx = Some(*actual_idx); - } + .and_then(|idx| self.filtered_indices.get(idx).copied()); + let selected_is_enabled = selected_actual_idx + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| item.disabled_reason.is_none() && !item.is_disabled); + if selected_is_enabled { + self.last_selected_actual_idx = selected_actual_idx; + let Some(actual_idx) = selected_actual_idx else { + return; + }; + let Some(item) = self.active_items().get(actual_idx) else { + return; + }; for act in &item.actions { act(&self.app_event_tx); } @@ -466,7 +590,7 @@ impl ListSelectionView { } else if item.dismiss_parent_on_child_accept { self.dismiss_after_child_accept = true; } - } else if selected_item.is_none() { + } else if selected_actual_idx.is_none() { if let Some(cb) = &self.on_cancel { cb(&self.app_event_tx); } @@ -474,6 +598,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; @@ -551,7 +713,7 @@ impl ListSelectionView { if let Some(idx) = self.state.selected_idx && let Some(actual_idx) = self.filtered_indices.get(idx) && self - .items + .active_items() .get(*actual_idx) .is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled) { @@ -568,7 +730,7 @@ impl ListSelectionView { if let Some(idx) = self.state.selected_idx && let Some(actual_idx) = self.filtered_indices.get(idx) && self - .items + .active_items() .get(*actual_idx) .is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled) { @@ -599,6 +761,14 @@ impl BottomPaneView for ListSelectionView { modifiers: KeyModifiers::NONE, .. } /* ^P */ => self.move_up(), + KeyEvent { + code: KeyCode::Left, + .. + } if self.tabs_enabled() => self.switch_tab(/*step*/ -1), + KeyEvent { + code: KeyCode::Right, + .. + } if self.tabs_enabled() => self.switch_tab(/*step*/ 1), KeyEvent { code: KeyCode::Char('k'), modifiers: KeyModifiers::NONE, @@ -630,6 +800,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, .. } => { @@ -658,9 +844,9 @@ impl BottomPaneView for ListSelectionView { .to_digit(10) .map(|d| d as usize) .and_then(|d| d.checked_sub(1)) - && idx < self.items.len() + && idx < self.active_items().len() && self - .items + .active_items() .get(idx) .is_some_and(|item| item.disabled_reason.is_none() && !item.is_disabled) { @@ -701,6 +887,10 @@ impl BottomPaneView for ListSelectionView { self.selected_actual_idx() } + fn active_tab_id(&self) -> Option<&str> { + ListSelectionView::active_tab_id(self) + } + fn on_ctrl_c(&mut self) -> CancellationEvent { if let Some(cb) = &self.on_cancel { cb(&self.app_event_tx); @@ -725,29 +915,22 @@ impl Renderable for ListSelectionView { // Measure wrapped height for up to MAX_POPUP_ROWS items. let rows = self.build_rows(); - let rows_height = match self.col_width_mode { - ColumnWidthMode::AutoVisible => measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - ), - ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - ), - ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode( + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); + let rows_height = match self.row_display { + SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode( &rows, &self.state, MAX_POPUP_ROWS, effective_rows_width.saturating_add(1), - ColumnWidthMode::Fixed, + column_width, ), + SelectionRowDisplay::SingleLine => rows.len().clamp(1, MAX_POPUP_ROWS) as u16, }; - let mut height = self.header.desired_height(inner_width); + let header = self.active_header(); + let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); + let mut height = header.desired_height(inner_width); + height = height.saturating_add(tab_height + u16::from(tab_height > 0)); height = height.saturating_add(rows_height + 3); if self.is_searchable { height = height.saturating_add(1); @@ -806,28 +989,20 @@ impl Renderable for ListSelectionView { full_rows_width }; - let header_height = self.header.desired_height(inner_width); + let header = self.active_header(); + let header_height = header.desired_height(inner_width); + let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); let rows = self.build_rows(); - let rows_height = match self.col_width_mode { - ColumnWidthMode::AutoVisible => measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - ), - ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - ), - ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode( + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); + let rows_height = match self.row_display { + SelectionRowDisplay::Wrapped => measure_rows_height_with_col_width_mode( &rows, &self.state, MAX_POPUP_ROWS, effective_rows_width.saturating_add(1), - ColumnWidthMode::Fixed, + column_width, ), + SelectionRowDisplay::SingleLine => rows.len().clamp(1, MAX_POPUP_ROWS) as u16, }; // Stacked (fallback) side content height — only used when not side-by-side. @@ -838,9 +1013,20 @@ impl Renderable for ListSelectionView { }; let stacked_gap = if stacked_side_h > 0 { 1 } else { 0 }; - let [header_area, _, search_area, list_area, _, stacked_side_area] = Layout::vertical([ + let [ + header_area, + _, + tabs_area, + _, + search_area, + list_area, + _, + stacked_side_area, + ] = Layout::vertical([ Constraint::Max(header_height), Constraint::Max(1), + Constraint::Length(tab_height), + Constraint::Length(u16::from(tab_height > 0)), Constraint::Length(if self.is_searchable { 1 } else { 0 }), Constraint::Length(rows_height), Constraint::Length(stacked_gap), @@ -852,13 +1038,18 @@ impl Renderable for ListSelectionView { if header_area.height < header_height { let [header_area, elision_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); - self.header.render(header_area, buf); + header.render(header_area, buf); Paragraph::new(vec![ Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(), ]) .render(elision_area, buf); } else { - self.header.render(header_area, buf); + header.render(header_area, buf); + } + + // -- Tabs -- + if tab_height > 0 { + render_tab_bar(&self.tabs, self.active_tab_idx.unwrap_or(0), tabs_area, buf); } // -- Search bar -- @@ -883,31 +1074,24 @@ impl Renderable for ListSelectionView { width: effective_rows_width.max(1), height: list_area.height, }; - match self.col_width_mode { - ColumnWidthMode::AutoVisible => render_rows( - render_area, - buf, - &rows, - &self.state, - render_area.height as usize, - "no matches", - ), - ColumnWidthMode::AutoAllRows => render_rows_stable_col_widths( + match self.row_display { + SelectionRowDisplay::Wrapped => render_rows_with_col_width_mode( render_area, buf, &rows, &self.state, render_area.height as usize, "no matches", + column_width, ), - ColumnWidthMode::Fixed => render_rows_with_col_width_mode( + SelectionRowDisplay::SingleLine => render_rows_single_line_with_col_width_mode( render_area, buf, &rows, &self.state, render_area.height as usize, "no matches", - ColumnWidthMode::Fixed, + column_width, ), }; } @@ -1320,6 +1504,45 @@ mod tests { ); } + #[test] + fn space_appends_to_active_search_instead_of_toggling_selected_item() { + let (tx_raw, mut rx) = unbounded_channel::(); + 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 enter_with_no_matches_triggers_cancel_callback() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2600a90cd9c2..6411f0182ee9 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -90,6 +90,8 @@ 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::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; @@ -115,9 +117,11 @@ mod pending_thread_approvals; pub(crate) mod popup_consts; mod scroll_state; mod selection_popup_common; +mod selection_tabs; mod textarea; mod unified_exec_footer; pub(crate) use feedback_view::FeedbackNoteView; +pub(crate) use selection_tabs::SelectionTab; /// How long the "press again to quit" hint stays visible. /// @@ -852,6 +856,13 @@ impl BottomPane { .and_then(|view| view.selected_index()) } + pub(crate) fn active_tab_id_for_active_view(&self, view_id: &'static str) -> Option<&str> { + self.view_stack + .last() + .filter(|view| view.view_id() == Some(view_id)) + .and_then(|view| view.active_tab_id()) + } + /// Update the pending-input preview shown above the composer. pub(crate) fn set_pending_input_preview( &mut self, diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index cff3a34f727d..3507cb31eca5 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -55,6 +55,22 @@ pub(crate) enum ColumnWidthMode { Fixed, } +/// Column-width behavior plus an optional shared left-column width override. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ColumnWidthConfig { + pub mode: ColumnWidthMode, + pub name_column_width: Option, +} + +impl ColumnWidthConfig { + pub(crate) const fn new(mode: ColumnWidthMode, name_column_width: Option) -> Self { + Self { + mode, + name_column_width, + } + } +} + // Fixed split used by explicitly fixed column mode: 30% label, 70% // description. const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3; @@ -126,7 +142,7 @@ fn compute_desc_col( start_idx: usize, visible_items: usize, content_width: u16, - col_width_mode: ColumnWidthMode, + column_width: ColumnWidthConfig, ) -> usize { if content_width <= 1 { return 0; @@ -141,12 +157,12 @@ fn compute_desc_col( / FIXED_LEFT_COLUMN_DENOMINATOR) .max(1), ); - match col_width_mode { + match column_width.mode { ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR) / FIXED_LEFT_COLUMN_DENOMINATOR) .clamp(1, max_desc_col), ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => { - let max_name_width = match col_width_mode { + let max_name_width = match column_width.mode { ColumnWidthMode::AutoVisible => rows_all .iter() .enumerate() @@ -177,7 +193,12 @@ fn compute_desc_col( ColumnWidthMode::Fixed => 0, }; - max_name_width.saturating_add(2).min(max_auto_desc_col) + column_width + .name_column_width + .map(|width| width.max(max_name_width)) + .unwrap_or(max_name_width) + .saturating_add(2) + .min(max_auto_desc_col) } } } @@ -373,7 +394,7 @@ fn adjust_start_for_wrapped_selection_visibility( desc_measure_items: usize, width: u16, viewport_height: u16, - col_width_mode: ColumnWidthMode, + column_width: ColumnWidthConfig, ) -> usize { let mut start_idx = compute_item_window_start(rows_all, state, max_items); let Some(sel) = state.selected_idx else { @@ -386,13 +407,8 @@ fn adjust_start_for_wrapped_selection_visibility( // If wrapped row heights push the selected item out of view, advance the // item window until the selected row is visible. while start_idx < sel { - let desc_col = compute_desc_col( - rows_all, - start_idx, - desc_measure_items, - width, - col_width_mode, - ); + let desc_col = + compute_desc_col(rows_all, start_idx, desc_measure_items, width, column_width); if is_selected_visible_in_wrapped_viewport( rows_all, start_idx, @@ -506,7 +522,7 @@ fn render_rows_inner( state: &ScrollState, max_results: usize, empty_message: &str, - col_width_mode: ColumnWidthMode, + column_width: ColumnWidthConfig, ) -> u16 { if rows_all.is_empty() { if area.height > 0 { @@ -531,7 +547,7 @@ fn render_rows_inner( desc_measure_items, area.width, area.height, - col_width_mode, + column_width, ); let desc_col = compute_desc_col( @@ -539,7 +555,7 @@ fn render_rows_inner( start_idx, desc_measure_items, area.width, - col_width_mode, + column_width, ); // Render items, wrapping descriptions and aligning wrapped lines under the @@ -603,26 +619,24 @@ pub(crate) fn render_rows( state, max_results, empty_message, - ColumnWidthMode::AutoVisible, + ColumnWidthConfig::default(), ) } -/// Render a list of rows using the provided ScrollState, with shared styling -/// and behavior for selection popups. -/// This mode keeps column placement stable while scrolling by sizing the -/// description column against the full dataset. +/// Render a list of rows using the provided ScrollState and explicit +/// [`ColumnWidthMode`] behavior. /// -/// This function should be paired with -/// [`measure_rows_height_stable_col_widths`] so reserved and rendered heights -/// stay in sync. +/// This is the low-level entry point for callers that need to thread a mode +/// through higher-level configuration. /// Returns the number of terminal lines actually rendered. -pub(crate) fn render_rows_stable_col_widths( +pub(crate) fn render_rows_with_col_width_mode( area: Rect, buf: &mut Buffer, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str, + column_width: ColumnWidthConfig, ) -> u16 { render_rows_inner( area, @@ -631,48 +645,44 @@ pub(crate) fn render_rows_stable_col_widths( state, max_results, empty_message, - ColumnWidthMode::AutoAllRows, + column_width, ) } -/// Render a list of rows using the provided ScrollState and explicit -/// [`ColumnWidthMode`] behavior. +/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis. /// -/// This is the low-level entry point for callers that need to thread a mode -/// through higher-level configuration. +/// This path always uses viewport-local width alignment and is best for dense +/// list UIs where multi-line descriptions would add too much vertical churn. /// Returns the number of terminal lines actually rendered. -pub(crate) fn render_rows_with_col_width_mode( +pub(crate) fn render_rows_single_line( area: Rect, buf: &mut Buffer, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str, - col_width_mode: ColumnWidthMode, ) -> u16 { - render_rows_inner( + render_rows_single_line_with_col_width_mode( area, buf, rows_all, state, max_results, empty_message, - col_width_mode, + ColumnWidthConfig::default(), ) } -/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis. -/// -/// This path always uses viewport-local width alignment and is best for dense -/// list UIs where multi-line descriptions would add too much vertical churn. -/// Returns the number of terminal lines actually rendered. -pub(crate) fn render_rows_single_line( +/// Render a list of rows as a single line each (no wrapping), truncating overflow with an +/// ellipsis while honoring the configured column width behavior. +pub(crate) fn render_rows_single_line_with_col_width_mode( area: Rect, buf: &mut Buffer, rows_all: &[GenericDisplayRow], state: &ScrollState, max_results: usize, empty_message: &str, + column_width: ColumnWidthConfig, ) -> u16 { if rows_all.is_empty() { if area.height > 0 { @@ -698,13 +708,7 @@ pub(crate) fn render_rows_single_line( } } - let desc_col = compute_desc_col( - rows_all, - start_idx, - visible_items, - area.width, - ColumnWidthMode::AutoVisible, - ); + let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width, column_width); let mut cur_y = area.y; let mut rendered_lines: u16 = 0; @@ -766,25 +770,7 @@ pub(crate) fn measure_rows_height( state, max_results, width, - ColumnWidthMode::AutoVisible, - ) -} - -/// Measures selection-row height while using full-dataset column alignment. -/// This should be paired with [`render_rows_stable_col_widths`] so layout -/// reservation matches rendering behavior. -pub(crate) fn measure_rows_height_stable_col_widths( - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - width: u16, -) -> u16 { - measure_rows_height_inner( - rows_all, - state, - max_results, - width, - ColumnWidthMode::AutoAllRows, + ColumnWidthConfig::default(), ) } @@ -796,9 +782,9 @@ pub(crate) fn measure_rows_height_with_col_width_mode( state: &ScrollState, max_results: usize, width: u16, - col_width_mode: ColumnWidthMode, + column_width: ColumnWidthConfig, ) -> u16 { - measure_rows_height_inner(rows_all, state, max_results, width, col_width_mode) + measure_rows_height_inner(rows_all, state, max_results, width, column_width) } fn measure_rows_height_inner( @@ -806,7 +792,7 @@ fn measure_rows_height_inner( state: &ScrollState, max_results: usize, width: u16, - col_width_mode: ColumnWidthMode, + column_width: ColumnWidthConfig, ) -> u16 { if rows_all.is_empty() { return 1; // placeholder "no matches" line @@ -832,7 +818,7 @@ fn measure_rows_height_inner( start_idx, visible_items, content_width, - col_width_mode, + column_width, ); let mut total: u16 = 0; diff --git a/codex-rs/tui/src/bottom_pane/selection_tabs.rs b/codex-rs/tui/src/bottom_pane/selection_tabs.rs new file mode 100644 index 000000000000..8d5612b5aba2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/selection_tabs.rs @@ -0,0 +1,103 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Widget; + +use crate::render::renderable::Renderable; + +use super::SelectionItem; + +const TAB_GAP_WIDTH: usize = 2; + +pub(crate) struct SelectionTab { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) header: Box, + pub(crate) items: Vec, +} + +pub(crate) fn tab_bar_height(tabs: &[SelectionTab], active_idx: usize, width: u16) -> u16 { + if tabs.is_empty() { + return 0; + } + tab_bar_lines(tabs, active_idx, width) + .len() + .try_into() + .unwrap_or(u16::MAX) +} + +pub(crate) fn render_tab_bar( + tabs: &[SelectionTab], + active_idx: usize, + area: Rect, + buf: &mut Buffer, +) { + for (offset, line) in tab_bar_lines(tabs, active_idx, area.width) + .into_iter() + .take(area.height as usize) + .enumerate() + { + line.render( + Rect { + x: area.x, + y: area.y.saturating_add(offset as u16), + width: area.width, + height: 1, + }, + buf, + ); + } +} + +fn tab_bar_lines(tabs: &[SelectionTab], active_idx: usize, width: u16) -> Vec> { + if tabs.is_empty() { + return Vec::new(); + } + + let max_width = width.max(1) as usize; + let mut lines = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut current_width = 0usize; + + for (idx, tab) in tabs.iter().enumerate() { + let unit = tab_unit(tab.label.as_str(), idx == active_idx); + let unit_width = Line::from(unit.clone()).width(); + let gap_width = if current_spans.is_empty() { + 0 + } else { + TAB_GAP_WIDTH + }; + + if !current_spans.is_empty() && current_width + gap_width + unit_width > max_width { + lines.push(Line::from(current_spans)); + current_spans = Vec::new(); + current_width = 0; + } + + if !current_spans.is_empty() { + current_spans.push(" ".into()); + current_width += TAB_GAP_WIDTH; + } + current_width += unit_width; + current_spans.extend(unit); + } + + if !current_spans.is_empty() { + lines.push(Line::from(current_spans)); + } + lines +} + +fn tab_unit(label: &str, active: bool) -> Vec> { + if active { + vec![ + "[".cyan().bold(), + label.to_string().cyan().bold(), + "]".cyan().bold(), + ] + } else { + vec![label.to_string().dim()] + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f2697d9072d7..2908e33f8ea1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -841,6 +841,7 @@ pub(crate) struct ChatWidget { plugins_fetch_state: PluginListFetchState, plugin_install_apps_needing_auth: Vec, plugin_install_auth_flow: Option, + plugins_active_tab_id: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -4909,6 +4910,7 @@ impl ChatWidget { plugins_fetch_state: PluginListFetchState::default(), plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, + plugins_active_tab_id: None, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 9b3f91b202ce..a34d02abd875 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -6,13 +7,19 @@ use super::ChatWidget; use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; 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::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; +use crate::legacy_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -31,8 +38,15 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthStr; 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 MARKETPLACE_TAB_ID_PREFIX: &str = "marketplace:"; +const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; +const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; +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); @@ -138,6 +152,7 @@ impl ChatWidget { return; } + self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string()); self.prefetch_plugins(); match self.plugins_cache_for_current_cwd() { @@ -172,6 +187,14 @@ impl ChatWidget { match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); + let active_tab_id = self + .plugins_active_tab_id + .as_deref() + .and_then(|tab_id| { + marketplace_tab_id_matching_saved_id(tab_id, &response.marketplaces) + }) + .or_else(|| self.plugins_active_tab_id.clone()); + self.plugins_active_tab_id = active_tab_id; self.plugins_cache = PluginsCacheState::Ready(response.clone()); if !auth_flow_active { self.refresh_plugins_popup_if_open(&response); @@ -223,11 +246,59 @@ 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)); + .show_selection_view(self.plugins_popup_params( + response, + self.plugins_active_tab_id.clone(), + /*initial_selected_idx*/ None, + )); + } + + pub(crate) fn open_marketplace_add_prompt(&mut self) { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let tx = self.app_event_tx.clone(); + let cwd = self.config.cwd.to_path_buf(); + let view = CustomPromptView::new( + "Add marketplace".to_string(), + "owner/repo, git URL, or local marketplace path".to_string(), + String::new(), + Some("Examples: owner/repo, git URL, ./marketplace".to_string()), + Box::new(move |source: String| { + let source = source.trim().to_string(); + if source.is_empty() { + return; + } + tx.send(AppEvent::OpenMarketplaceAddLoading { + source: source.clone(), + }); + tx.send(AppEvent::FetchMarketplaceAdd { + cwd: cwd.clone(), + source, + }); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn open_marketplace_add_loading_popup(&mut self, source: &str) { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let params = self.marketplace_add_loading_popup_params(source); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane + .show_selection_view(self.marketplace_add_loading_popup_params(source)); + } } pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { + self.plugins_active_tab_id = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .map(str::to_string) + .or_else(|| self.plugins_active_tab_id.clone()); let params = self.plugin_detail_loading_popup_params(plugin_display_name); let _ = self .bottom_pane @@ -248,6 +319,51 @@ impl ChatWidget { .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params); } + pub(crate) fn on_marketplace_add_loaded( + &mut self, + cwd: PathBuf, + source: String, + result: Result, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + match result { + Ok(response) => { + self.plugins_active_tab_id = + Some(marketplace_tab_id_from_path(&response.installed_root)); + let message = if response.already_added { + format!( + "Marketplace {} is already added.", + response.marketplace_name + ) + } else { + format!("Added marketplace {}.", response.marketplace_name) + }; + self.add_info_message( + message, + Some(format!( + "Marketplace root: {}", + response.installed_root.as_path().display() + )), + ); + } + Err(err) => { + self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); + let params = self.marketplace_add_error_popup_params(&source, &err); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.bottom_pane.show_selection_view( + self.marketplace_add_error_popup_params(&source, &err), + ); + } + } + } + } + pub(crate) fn on_plugin_detail_loaded( &mut self, cwd: PathBuf, @@ -372,6 +488,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 { @@ -501,7 +660,7 @@ impl ChatWidget { Some(SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), - footer_hint: Some(plugins_popup_hint_line()), + footer_hint: Some(plugin_detail_hint_line()), items, col_width_mode: ColumnWidthMode::AutoAllRows, ..Default::default() @@ -544,17 +703,31 @@ impl ChatWidget { _ => None, }; if let Some(plugins_response) = plugins_response { + 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), + self.plugins_popup_params( + &plugins_response, + tab_id, + /*initial_selected_idx*/ None, + ), ); } } fn refresh_plugins_popup_if_open(&mut self, response: &PluginListResponse) { + let active_tab_id = self + .bottom_pane + .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), + self.plugins_popup_params(response, active_tab_id, selected_idx), ); } @@ -565,7 +738,7 @@ impl ChatWidget { self.frame_requester.clone(), self.config.animations, "Loading available plugins...".to_string(), - Some("This first pass shows the ChatGPT marketplace only.".to_string()), + Some("This updates when the marketplace list is ready.".to_string()), )), items: vec![SelectionItem { name: "Loading plugins...".to_string(), @@ -577,6 +750,27 @@ impl ChatWidget { } } + fn marketplace_add_loading_popup_params(&self, source: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Adding marketplace...".to_string(), + Some(source.to_string()), + )), + items: vec![SelectionItem { + name: "Adding marketplace...".to_string(), + description: Some( + "This updates when marketplace installation completes.".to_string(), + ), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), @@ -660,6 +854,54 @@ impl ChatWidget { } } + fn marketplace_add_error_popup_params(&self, source: &str, err: &str) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from("Failed to add marketplace.".dim())); + + let mut items = vec![ + SelectionItem { + name: "Marketplace add failed".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Try again".to_string(), + description: Some(format!("Enter a marketplace source. Last source: {source}")), + selected_description: Some("Enter a marketplace source.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }, + ]; + + if let PluginsCacheState::Ready(plugins_response) = self.plugins_cache_for_current_cwd() { + let cwd = self.config.cwd.to_path_buf(); + items.push(SelectionItem { + name: "Back to plugins".to_string(), + description: Some("Return to the plugin list.".to_string()), + selected_description: Some("Return to the plugin list.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result: Ok(plugins_response.clone()), + }); + })], + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(PLUGINS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(plugin_detail_hint_line()), + items, + ..Default::default() + } + } + fn plugin_detail_error_popup_params( &self, err: &str, @@ -694,13 +936,18 @@ impl ChatWidget { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), - footer_hint: Some(plugins_popup_hint_line()), + footer_hint: Some(plugin_detail_hint_line()), items, ..Default::default() } } - fn plugins_popup_params(&self, response: &PluginListResponse) -> SelectionViewParams { + fn plugins_popup_params( + &self, + response: &PluginListResponse, + active_tab_id: Option, + initial_selected_idx: Option, + ) -> SelectionViewParams { let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); let total: usize = marketplaces @@ -713,112 +960,169 @@ impl ChatWidget { .filter(|plugin| plugin.installed) .count(); - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - "Browse plugins from available marketplaces.".dim(), - )); - header.push(Line::from( - format!("Installed {installed} of {total} available plugins.").dim(), - )); - if let Some(remote_sync_error) = response.remote_sync_error.as_deref() { - header.push(Line::from( - format!("Using cached marketplace data: {remote_sync_error}").dim(), - )); - } + let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); + let name_column_width = all_entries + .iter() + .map(|(_, _, display_name)| { + PLUGIN_MENU_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(display_name.as_str()) + }) + .chain([UnicodeWidthStr::width("Add marketplace")]) + .max(); + let installed_entries = all_entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .cloned() + .collect(); + + let mut tabs = Vec::new(); + tabs.push(SelectionTab { + id: ALL_PLUGINS_TAB_ID.to_string(), + label: "All Plugins".to_string(), + header: plugins_header( + "Browse plugins from available marketplaces.".to_string(), + format!("Installed {installed} of {total} available plugins."), + response.remote_sync_error.as_deref(), + ), + items: self.plugin_selection_items( + all_entries, + /*include_marketplace_names*/ true, + "No marketplace plugins available", + "No plugins are available in the discovered marketplaces.", + ), + }); - let mut plugin_entries: Vec<(&PluginMarketplaceEntry, &PluginSummary, String)> = - marketplaces - .iter() - .flat_map(|marketplace| { - marketplace - .plugins - .iter() - .map(move |plugin| (*marketplace, plugin, plugin_display_name(plugin))) - }) - .collect(); - plugin_entries.sort_by(|left, right| { - right - .1 - .installed - .cmp(&left.1.installed) - .then_with(|| { - left.2 - .to_ascii_lowercase() - .cmp(&right.2.to_ascii_lowercase()) - }) - .then_with(|| left.2.cmp(&right.2)) - .then_with(|| left.1.name.cmp(&right.1.name)) - .then_with(|| left.1.id.cmp(&right.1.id)) + tabs.push(SelectionTab { + id: INSTALLED_PLUGINS_TAB_ID.to_string(), + label: format!("Installed ({installed})"), + header: plugins_header( + "Installed plugins.".to_string(), + format!("Showing {installed} installed plugins."), + response.remote_sync_error.as_deref(), + ), + items: self.plugin_selection_items( + installed_entries, + /*include_marketplace_names*/ true, + "No installed plugins", + "No installed plugins.", + ), }); - let status_label_width = plugin_entries - .iter() - .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) - .max() - .unwrap_or(0); - let mut items: Vec = Vec::new(); - for (marketplace, plugin, display_name) in plugin_entries { - let marketplace_label = marketplace_display_name(marketplace); - let status_label = plugin_status_label(plugin); - let description = - plugin_brief_description(plugin, &marketplace_label, status_label_width); - let selected_status_label = format!("{status_label: = marketplaces + .iter() + .copied() + .filter(|marketplace| marketplace.name != OPENAI_CURATED_MARKETPLACE_NAME) + .collect(); + additional_marketplaces.sort_by(|left, right| { + marketplace_display_name(left) + .to_ascii_lowercase() + .cmp(&marketplace_display_name(right).to_ascii_lowercase()) + .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) + .then_with(|| left.name.cmp(&right.name)) + }); - if items.is_empty() { - items.push(SelectionItem { - name: "No marketplace plugins available".to_string(), - description: Some( - "No plugins are available in the discovered marketplaces.".to_string(), + let labels = disambiguate_duplicate_tab_labels( + additional_marketplaces + .iter() + .map(|marketplace| marketplace_display_name(marketplace)) + .collect(), + ); + for (marketplace, label) in additional_marketplaces.into_iter().zip(labels) { + let entries = plugin_entries_for_marketplaces([marketplace]); + let marketplace_total = entries.len(); + let marketplace_installed = entries + .iter() + .filter(|(_, plugin, _)| plugin.installed) + .count(); + tabs.push(SelectionTab { + id: marketplace_tab_id(marketplace), + label: label.clone(), + header: plugins_header( + format!("{label}."), + format!( + "Installed {marketplace_installed} of {marketplace_total} {label} plugins." + ), + response.remote_sync_error.as_deref(), + ), + items: self.plugin_selection_items( + entries, + /*include_marketplace_names*/ false, + "No plugins available in this marketplace", + "No plugins available in this marketplace.", ), - is_disabled: true, - ..Default::default() }); } + tabs.push(self.marketplace_add_tab()); + SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(()), footer_hint: Some(plugins_popup_hint_line()), - items, + tabs, + initial_tab_id: active_tab_id, is_searchable: true, search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, + row_display: SelectionRowDisplay::SingleLine, + name_column_width, + initial_selected_idx, ..Default::default() } } + fn marketplace_add_tab(&self) -> SelectionTab { + SelectionTab { + id: ADD_MARKETPLACE_TAB_ID.to_string(), + label: "Add Marketplace".to_string(), + header: plugins_header( + "Add a marketplace from a Git repo or local root.".to_string(), + "Enter a source to make its plugins available in this menu.".to_string(), + /*remote_sync_error*/ None, + ), + items: vec![SelectionItem { + name: "Add marketplace".to_string(), + description: Some( + "Enter owner/repo, a Git URL, or a local marketplace path.".to_string(), + ), + selected_description: Some( + "Press Enter to enter a marketplace source.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenMarketplaceAddPrompt); + })], + ..Default::default() + }], + } + } + fn plugin_detail_popup_params( &self, plugins_response: &PluginListResponse, @@ -830,7 +1134,7 @@ impl ChatWidget { if plugin.summary.enabled { "Installed" } else { - "Installed · Disabled" + "Disabled" } } else { match plugin.summary.install_policy { @@ -951,18 +1255,225 @@ impl ChatWidget { SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(header), - footer_hint: Some(plugins_popup_hint_line()), + footer_hint: Some(plugin_detail_hint_line()), items, col_width_mode: ColumnWidthMode::AutoAllRows, ..Default::default() } } + + fn plugin_selection_items<'a>( + &self, + mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, + include_marketplace_names: bool, + empty_name: &str, + empty_description: &str, + ) -> Vec { + sort_plugin_entries(&mut plugin_entries); + let status_label_width = plugin_entries + .iter() + .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) + .max() + .unwrap_or(0); + + let mut items: Vec = Vec::new(); + for (marketplace, plugin, display_name) in plugin_entries { + let marketplace_label = marketplace_display_name(marketplace); + let status_label = plugin_status_label(plugin); + let description = if include_marketplace_names { + plugin_brief_description(plugin, &marketplace_label, status_label_width) + } else { + plugin_brief_description_without_marketplace(plugin, status_label_width) + }; + let selected_status_label = format!("{status_label: Line<'static> { + Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close") +} + +fn plugin_detail_hint_line() -> Line<'static> { Line::from("Press esc to close.") } +fn plugins_header( + subtitle: String, + count_line: String, + remote_sync_error: Option<&str>, +) -> Box { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Plugins".bold())); + header.push(Line::from(subtitle.dim())); + header.push(Line::from(count_line.dim())); + if let Some(remote_sync_error) = remote_sync_error { + header.push(Line::from( + format!("Using cached marketplace data: {remote_sync_error}").dim(), + )); + } + Box::new(header) +} + +fn plugin_entries_for_marketplaces<'a>( + marketplaces: impl IntoIterator, +) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { + marketplaces + .into_iter() + .flat_map(|marketplace| { + marketplace + .plugins + .iter() + .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) + }) + .collect() +} + +fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { + entries.sort_by(|left, right| { + right + .1 + .installed + .cmp(&left.1.installed) + .then_with(|| { + left.2 + .to_ascii_lowercase() + .cmp(&right.2.to_ascii_lowercase()) + }) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.1.name.cmp(&right.1.name)) + .then_with(|| left.1.id.cmp(&right.1.id)) + }); +} + +fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { + marketplace_tab_id_from_path(&marketplace.path) +} + +fn marketplace_tab_id_from_path(path: &AbsolutePathBuf) -> String { + format!("{MARKETPLACE_TAB_ID_PREFIX}{}", path.display()) +} + +fn marketplace_tab_id_matching_saved_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { + let tab_id = marketplace_tab_id(marketplace); + (tab_id == saved_tab_id).then_some(tab_id) + }) { + return Some(tab_id); + } + + let root = saved_tab_id.strip_prefix(MARKETPLACE_TAB_ID_PREFIX)?; + if root.is_empty() { + return None; + } + let root = Path::new(root); + marketplaces.iter().find_map(|marketplace| { + marketplace + .path + .as_path() + .starts_with(root) + .then(|| marketplace_tab_id(marketplace)) + }) +} + +fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { + let mut counts: Vec<(String, usize)> = Vec::new(); + for label in &labels { + if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { + *count += 1; + } else { + counts.push((label.clone(), 1)); + } + } + + let mut seen: Vec<(String, usize)> = Vec::new(); + labels + .into_iter() + .map(|label| { + let total = counts + .iter() + .find(|(existing, _)| existing == &label) + .map(|(_, count)| *count) + .unwrap_or(1); + if total == 1 { + return label; + } + + let current = if let Some((_, seen_count)) = + seen.iter_mut().find(|(existing, _)| existing == &label) + { + *seen_count += 1; + *seen_count + } else { + seen.push((label.clone(), 1)); + 1 + }; + format!("{label} ({current}/{total})") + }) + .collect() +} + fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { marketplace .interface @@ -998,18 +1509,30 @@ fn plugin_brief_description( } } +fn plugin_brief_description_without_marketplace( + plugin: &PluginSummary, + status_label_width: usize, +) -> String { + let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {description}"), + None => status_label, + } +} + fn plugin_status_label(plugin: &PluginSummary) -> &'static str { if plugin.installed { 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", } } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 3c46da769c2a..4174e8fbbb31 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -7,10 +7,12 @@ expression: popup Installed 1 of 4 available plugins. Using cached marketplace data: remote sync timed out + [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add 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. - Press esc to close. + space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap index 2b0f6837eb64..27fbf8f5f79c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - This first pass shows the ChatGPT marketplace only. + This updates when the marketplace list is ready. › Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index d65308fb5ba4..e0637b6b7ab0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -6,7 +6,9 @@ expression: popup Browse plugins from available marketplaces. Installed 0 of 3 available plugins. + [All Plugins] Installed (0) OpenAI Curated Add Marketplace + sla -› Slack Available Press Enter to view plugin details. +› [-] Slack Available Press Enter to view plugin details. - Press esc to close. + space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e92a809e8021..67be4384776d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -68,6 +68,7 @@ pub(super) use codex_app_server_protocol::ItemCompletedNotification; pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; pub(super) use codex_app_server_protocol::ItemStartedNotification; +pub(super) use codex_app_server_protocol::MarketplaceAddResponse; pub(super) use codex_app_server_protocol::MarketplaceInterface; pub(super) use codex_app_server_protocol::McpServerStartupState; pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index c0f881e2b82a..377a13be683e 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -232,6 +232,7 @@ pub(super) async fn make_chatwidget_manual( connectors_partial_snapshot: None, plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, + plugins_active_tab_id: None, connectors_prefetch_in_flight: false, connectors_force_refetch_pending: false, plugins_cache: PluginsCacheState::default(), diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 10e3451d033c..609966996931 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -164,6 +164,170 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ ); } +#[tokio::test] +async fn plugins_popup_truncates_long_descriptions_in_list_rows() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-alpha", + "alpha", + Some("Alpha"), + Some("Short description."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-verbose", + "verbose", + Some("Verbose Plugin"), + Some("This description keeps going and going until the row would normally wrap."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]); + + let cwd = chat.config.cwd.to_path_buf(); + chat.on_plugins_loaded(cwd, Ok(response)); + chat.add_plugins_output(); + + let popup = render_bottom_popup(&chat, /*width*/ 70); + let verbose_row = popup + .lines() + .find(|line| line.contains("Verbose Plugin")) + .expect("expected verbose plugin row in popup"); + insta::assert_snapshot!( + verbose_row, + @" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…" + ); + assert!( + !popup + .contains("This description keeps going and going until the row would normally wrap."), + "expected the long plugin description to truncate instead of wrapping, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { + 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::new())]), + ); + + while rx.try_recv().is_ok() {} + for _ in 0..3 { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Add a marketplace from a Git repo or local root."), + "expected Add Marketplace tab, got:\n{popup}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + match rx.try_recv() { + Ok(AppEvent::OpenMarketplaceAddPrompt) => {} + other => panic!("expected OpenMarketplaceAddPrompt event, got {other:?}"), + } + + chat.open_marketplace_add_prompt(); + let prompt = render_bottom_popup(&chat, /*width*/ 100); + assert!( + prompt.contains("owner/repo, git URL, or local marketplace path"), + "expected marketplace source prompt, got:\n{prompt}" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenMarketplaceAddLoading { source }) => { + assert_eq!(source, "owner/repo"); + } + other => panic!("expected OpenMarketplaceAddLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchMarketplaceAdd { + cwd: event_cwd, + source, + }) => { + assert_eq!(event_cwd, cwd); + assert_eq!(source, "owner/repo"); + } + other => panic!("expected FetchMarketplaceAdd event, got {other:?}"), + } +} + +#[tokio::test] +async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { + let (mut chat, _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(); + let marketplace_root = plugins_test_absolute_path("marketplaces/debug"); + let marketplace_path = + plugins_test_absolute_path("marketplaces/debug/.agents/plugins/marketplace.json"); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]), + ); + chat.open_marketplace_add_loading_popup("owner/repo"); + chat.on_marketplace_add_loaded( + cwd.clone(), + "owner/repo".to_string(), + Ok(MarketplaceAddResponse { + marketplace_name: "debug".to_string(), + installed_root: marketplace_root, + already_added: false, + }), + ); + chat.on_plugins_loaded( + cwd, + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + PluginMarketplaceEntry { + name: "debug".to_string(), + path: marketplace_path, + interface: Some(MarketplaceInterface { + display_name: Some("Debug Marketplace".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-debug", + "debug", + Some("Debug Plugin"), + Some("Debug marketplace plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + ])), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Debug Marketplace.") && popup.contains("Debug Plugin"), + "expected marketplace add refresh to switch to the new marketplace tab, got:\n{popup}" + ); +} + #[tokio::test] async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -249,7 +413,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); @@ -278,7 +442,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}" ); @@ -316,8 +480,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"), @@ -389,11 +557,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; @@ -442,6 +752,160 @@ async fn plugins_popup_search_filters_visible_rows_snapshot() { ); } +#[tokio::test] +async fn plugins_popup_installed_tab_filters_rows_and_clears_search() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + 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-slack", + "slack", + Some("Slack"), + Some("Team chat."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + type_plugins_search_query(&mut chat, "sla"); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Installed plugins.") && popup.contains("Showing 1 installed plugins."), + "expected Installed tab header, got:\n{popup}" + ); + assert!( + popup.contains("Calendar") && !popup.contains("Slack"), + "expected Installed tab to show only installed plugins, got:\n{popup}" + ); + assert!( + !popup.contains("sla"), + "expected tab switch to clear search query, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_openai_curated_tab_omits_marketplace_in_rows() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )]), + plugins_test_repo_marketplace(vec![plugins_test_summary( + "plugin-repo", + "repo", + Some("Repo Plugin"), + Some("Repo-only plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )]), + ]), + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("OpenAI Curated marketplace."), + "expected OpenAI Curated tab header, got:\n{popup}" + ); + assert!( + popup.contains("Calendar") && !popup.contains("Repo Plugin"), + "expected OpenAI Curated tab to show only official marketplace plugins, got:\n{popup}" + ); + assert!( + !popup.contains("ChatGPT Marketplace ·"), + "expected marketplace-specific rows to omit marketplace labels, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let response = plugins_test_response(vec![ + PluginMarketplaceEntry { + name: "duplicate".to_string(), + path: plugins_test_absolute_path("marketplaces/home/marketplace.json"), + interface: Some(MarketplaceInterface { + display_name: Some("Duplicate Marketplace".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-home", + "home", + Some("Home Plugin"), + Some("Home marketplace plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + PluginMarketplaceEntry { + name: "duplicate".to_string(), + path: plugins_test_absolute_path("marketplaces/repo/marketplace.json"), + interface: Some(MarketplaceInterface { + display_name: Some("Duplicate Marketplace".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-repo", + "repo", + Some("Repo Plugin"), + Some("Repo marketplace plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + ]); + let cwd = chat.config.cwd.to_path_buf(); + chat.on_plugins_loaded(cwd.clone(), Ok(response.clone())); + chat.add_plugins_output(); + + for _ in 0..4 { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + chat.on_plugins_loaded(cwd, Ok(response)); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Duplicate Marketplace (2/2)."), + "expected refresh to preserve the second duplicate marketplace tab, got:\n{popup}" + ); + assert!( + popup.contains("Repo Plugin") && !popup.contains("Home Plugin"), + "expected second duplicate marketplace rows after refresh, got:\n{popup}" + ); +} + #[tokio::test] async fn plugins_popup_search_no_matches_and_backspace_restores_results() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;