From 3872873f9f0d5db8d19080897b2c9ea01df6e315 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 13:23:14 -0700 Subject: [PATCH 01/18] TUI /plugins menu v2 - Selectable marketplaces --- .../tui/src/bottom_pane/bottom_pane_view.rs | 5 + .../src/bottom_pane/list_selection_view.rs | 171 ++++++-- codex-rs/tui/src/bottom_pane/mod.rs | 9 + .../tui/src/bottom_pane/selection_tabs.rs | 103 +++++ codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/chatwidget/plugins.rs | 408 +++++++++++++----- ...ts__plugins_popup_curated_marketplace.snap | 4 +- ...t__tests__plugins_popup_loading_state.snap | 2 +- ..._tests__plugins_popup_search_filtered.snap | 4 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 94 ++++ 11 files changed, 678 insertions(+), 125 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/selection_tabs.rs 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 6fa946a5af2b..b01c26ad364a 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -29,6 +29,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 e3eecf2ed001..93d4e0953d8a 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -31,6 +31,9 @@ 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_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 @@ -142,6 +145,8 @@ 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, @@ -184,6 +189,8 @@ 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, @@ -210,6 +217,8 @@ pub(crate) struct ListSelectionView { footer_note: Option>, footer_hint: Option>, items: Vec, + tabs: Vec, + active_tab_idx: Option, state: ScrollState, complete: bool, app_event_tx: AppEventSender, @@ -253,11 +262,24 @@ 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 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(), complete: false, app_event_tx, @@ -282,6 +304,9 @@ impl ListSelectionView { on_cancel: params.on_cancel, }; s.apply_filter(); + if s.tabs_enabled() { + s.select_first_enabled_row(); + } s } @@ -289,6 +314,30 @@ 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_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)) } @@ -304,7 +353,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()); @@ -312,7 +361,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 @@ -321,7 +370,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(); @@ -359,7 +408,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(); @@ -407,6 +456,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(); @@ -440,27 +525,28 @@ 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); } if item.dismiss_on_select { self.complete = 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); } @@ -545,7 +631,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) { @@ -562,7 +648,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) { @@ -593,6 +679,14 @@ impl BottomPaneView for ListSelectionView { modifiers: KeyModifiers::NONE, .. } /* ^P */ => self.move_up(), + KeyEvent { + code: KeyCode::Left, + .. + } if self.tabs_enabled() => self.switch_tab(-1), + KeyEvent { + code: KeyCode::Right, + .. + } if self.tabs_enabled() => self.switch_tab(1), KeyEvent { code: KeyCode::Char('k'), modifiers: KeyModifiers::NONE, @@ -652,9 +746,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) { @@ -683,6 +777,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); @@ -729,7 +827,10 @@ impl Renderable for ListSelectionView { ), }; - 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); @@ -788,7 +889,9 @@ 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( @@ -820,9 +923,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), @@ -834,13 +948,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 -- diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 91c8bd48eba6..74aa4ba9714e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -114,9 +114,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. /// @@ -828,6 +830,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_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 de71efb9d80e..0587feb7e98f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -840,6 +840,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 @@ -4908,6 +4909,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..800280ed4cfe 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -6,8 +6,10 @@ use super::ChatWidget; use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionTab; use crate::bottom_pane::SelectionViewParams; 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; @@ -33,6 +35,9 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; 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 LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); @@ -138,6 +143,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() { @@ -223,11 +229,18 @@ impl ChatWidget { } fn open_plugins_popup(&mut self, response: &PluginListResponse) { - self.bottom_pane - .show_selection_view(self.plugins_popup_params(response)); + 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()), + ); } 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 @@ -501,7 +514,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 +557,24 @@ 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), ); } } 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()); + 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), ); } @@ -565,7 +585,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(), @@ -694,13 +714,17 @@ 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, + ) -> SelectionViewParams { let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); let total: usize = marketplaces @@ -713,105 +737,125 @@ 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 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() }); } 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, @@ -951,18 +995,178 @@ 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("←/→ switch tabs · 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 { + format!("marketplace:{}", marketplace.name) +} + +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,6 +1202,18 @@ 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 { 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..c59e767c0ccc 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 + 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. - Press esc to close. + ←/→ switch tabs · 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..50b428eec43e 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 + sla › Slack Available Press Enter to view plugin details. - Press esc to close. + ←/→ switch tabs · enter view details · esc close 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 375c37314b3b..6396b5844a9b 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -441,6 +441,100 @@ 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_search_no_matches_and_backspace_restores_results() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 445547f80a2cd679bcf3b64f859dd90a72e2089a Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 15:15:55 -0700 Subject: [PATCH 02/18] Unique marketplace name change --- codex-rs/tui/src/chatwidget/plugins.rs | 2 +- .../chatwidget/tests/popups_and_settings.rs | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 800280ed4cfe..7f0d98721eec 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1127,7 +1127,7 @@ fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, } fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - format!("marketplace:{}", marketplace.name) + format!("marketplace:{}", marketplace.path.display()) } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { 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 6396b5844a9b..218b7fb6370d 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -535,6 +535,66 @@ async fn plugins_popup_openai_curated_tab_omits_marketplace_in_rows() { ); } +#[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; From a65e80c9676928df2720db44cf6695d3b294ba1b Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 15:44:42 -0700 Subject: [PATCH 03/18] fix --- codex-rs/app-server/src/message_processor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6221cebee531..5a443c6820fb 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -752,7 +752,7 @@ impl MessageProcessor { | 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(), ); From 03803272927dbd0b473e53ee2fe9948d698374a9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 15:49:24 -0700 Subject: [PATCH 04/18] Stable name colkumn widths --- .../src/bottom_pane/list_selection_view.rs | 104 ++++++------------ .../src/bottom_pane/selection_popup_common.rs | 30 ++++- codex-rs/tui/src/chatwidget/plugins.rs | 9 ++ 3 files changed, 68 insertions(+), 75 deletions(-) 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 93d4e0953d8a..a933e078f31a 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -25,11 +25,7 @@ use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; 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_with_col_width_mode; use super::selection_tabs::SelectionTab; use super::selection_tabs::render_tab_bar; @@ -150,6 +146,8 @@ pub(crate) struct SelectionViewParams { pub is_searchable: bool, pub search_placeholder: Option, pub col_width_mode: ColumnWidthMode, + /// Rendered left-column width to use for auto-sized rows. + pub name_column_width: Option, pub header: Box, pub initial_selected_idx: Option, @@ -194,6 +192,7 @@ impl Default for SelectionViewParams { is_searchable: false, search_placeholder: None, col_width_mode: ColumnWidthMode::AutoVisible, + name_column_width: None, header: Box::new(()), initial_selected_idx: None, side_content: Box::new(()), @@ -226,6 +225,7 @@ pub(crate) struct ListSelectionView { search_query: String, search_placeholder: Option, col_width_mode: ColumnWidthMode, + name_column_width: Option, filtered_indices: Vec, last_selected_actual_idx: Option, header: Box, @@ -291,6 +291,7 @@ impl ListSelectionView { None }, col_width_mode: params.col_width_mode, + name_column_width: params.name_column_width, filtered_indices: Vec::new(), last_selected_actual_idx: None, header, @@ -805,27 +806,14 @@ 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( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - ColumnWidthMode::Fixed, - ), - }; + let rows_height = measure_rows_height_with_col_width_mode( + &rows, + &self.state, + MAX_POPUP_ROWS, + effective_rows_width.saturating_add(1), + self.col_width_mode, + self.name_column_width, + ); let header = self.active_header(); let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); @@ -893,27 +881,14 @@ impl Renderable for ListSelectionView { 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( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - ColumnWidthMode::Fixed, - ), - }; + let rows_height = measure_rows_height_with_col_width_mode( + &rows, + &self.state, + MAX_POPUP_ROWS, + effective_rows_width.saturating_add(1), + self.col_width_mode, + self.name_column_width, + ); // Stacked (fallback) side content height — only used when not side-by-side. let stacked_side_h = if side_w.is_none() { @@ -984,33 +959,16 @@ 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( - render_area, - buf, - &rows, - &self.state, - render_area.height as usize, - "no matches", - ), - ColumnWidthMode::Fixed => render_rows_with_col_width_mode( - render_area, - buf, - &rows, - &self.state, - render_area.height as usize, - "no matches", - ColumnWidthMode::Fixed, - ), - }; + render_rows_with_col_width_mode( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + self.col_width_mode, + self.name_column_width, + ); } // -- Side content (preview panel) -- 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..0c612e3a9098 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -127,6 +127,7 @@ fn compute_desc_col( visible_items: usize, content_width: u16, col_width_mode: ColumnWidthMode, + name_column_width: Option, ) -> usize { if content_width <= 1 { return 0; @@ -177,7 +178,10 @@ fn compute_desc_col( ColumnWidthMode::Fixed => 0, }; - max_name_width.saturating_add(2).min(max_auto_desc_col) + name_column_width + .unwrap_or(max_name_width) + .saturating_add(2) + .min(max_auto_desc_col) } } } @@ -374,6 +378,7 @@ fn adjust_start_for_wrapped_selection_visibility( width: u16, viewport_height: u16, col_width_mode: ColumnWidthMode, + name_column_width: Option, ) -> usize { let mut start_idx = compute_item_window_start(rows_all, state, max_items); let Some(sel) = state.selected_idx else { @@ -392,6 +397,7 @@ fn adjust_start_for_wrapped_selection_visibility( desc_measure_items, width, col_width_mode, + name_column_width, ); if is_selected_visible_in_wrapped_viewport( rows_all, @@ -507,6 +513,7 @@ fn render_rows_inner( max_results: usize, empty_message: &str, col_width_mode: ColumnWidthMode, + name_column_width: Option, ) -> u16 { if rows_all.is_empty() { if area.height > 0 { @@ -532,6 +539,7 @@ fn render_rows_inner( area.width, area.height, col_width_mode, + name_column_width, ); let desc_col = compute_desc_col( @@ -540,6 +548,7 @@ fn render_rows_inner( desc_measure_items, area.width, col_width_mode, + name_column_width, ); // Render items, wrapping descriptions and aligning wrapped lines under the @@ -604,6 +613,7 @@ pub(crate) fn render_rows( max_results, empty_message, ColumnWidthMode::AutoVisible, + None, ) } @@ -632,6 +642,7 @@ pub(crate) fn render_rows_stable_col_widths( max_results, empty_message, ColumnWidthMode::AutoAllRows, + None, ) } @@ -649,6 +660,7 @@ pub(crate) fn render_rows_with_col_width_mode( max_results: usize, empty_message: &str, col_width_mode: ColumnWidthMode, + name_column_width: Option, ) -> u16 { render_rows_inner( area, @@ -658,6 +670,7 @@ pub(crate) fn render_rows_with_col_width_mode( max_results, empty_message, col_width_mode, + name_column_width, ) } @@ -704,6 +717,7 @@ pub(crate) fn render_rows_single_line( visible_items, area.width, ColumnWidthMode::AutoVisible, + None, ); let mut cur_y = area.y; @@ -767,6 +781,7 @@ pub(crate) fn measure_rows_height( max_results, width, ColumnWidthMode::AutoVisible, + None, ) } @@ -785,6 +800,7 @@ pub(crate) fn measure_rows_height_stable_col_widths( max_results, width, ColumnWidthMode::AutoAllRows, + None, ) } @@ -797,8 +813,16 @@ pub(crate) fn measure_rows_height_with_col_width_mode( max_results: usize, width: u16, col_width_mode: ColumnWidthMode, + name_column_width: Option, ) -> u16 { - measure_rows_height_inner(rows_all, state, max_results, width, col_width_mode) + measure_rows_height_inner( + rows_all, + state, + max_results, + width, + col_width_mode, + name_column_width, + ) } fn measure_rows_height_inner( @@ -807,6 +831,7 @@ fn measure_rows_height_inner( max_results: usize, width: u16, col_width_mode: ColumnWidthMode, + name_column_width: Option, ) -> u16 { if rows_all.is_empty() { return 1; // placeholder "no matches" line @@ -833,6 +858,7 @@ fn measure_rows_height_inner( visible_items, content_width, col_width_mode, + name_column_width, ); let mut total: u16 = 0; diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 7f0d98721eec..c8f41e11a9f3 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -33,11 +33,13 @@ 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 OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; +const PLUGIN_MENU_ROW_PREFIX_WIDTH: usize = 2; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); @@ -738,6 +740,12 @@ impl ChatWidget { .count(); 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()) + }) + .max(); let installed_entries = all_entries .iter() .filter(|(_, plugin, _)| plugin.installed) @@ -859,6 +867,7 @@ impl ChatWidget { is_searchable: true, search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, + name_column_width, ..Default::default() } } From 0366d2f8cc09d4af8f992474397077e61db107c4 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 15:53:21 -0700 Subject: [PATCH 05/18] Remove dead code --- .../src/bottom_pane/selection_popup_common.rs | 48 ------------------- 1 file changed, 48 deletions(-) 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 0c612e3a9098..cae75104814d 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -617,35 +617,6 @@ pub(crate) fn render_rows( ) } -/// 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. -/// -/// This function should be paired with -/// [`measure_rows_height_stable_col_widths`] so reserved and rendered heights -/// stay in sync. -/// Returns the number of terminal lines actually rendered. -pub(crate) fn render_rows_stable_col_widths( - area: Rect, - buf: &mut Buffer, - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - empty_message: &str, -) -> u16 { - render_rows_inner( - area, - buf, - rows_all, - state, - max_results, - empty_message, - ColumnWidthMode::AutoAllRows, - None, - ) -} - /// Render a list of rows using the provided ScrollState and explicit /// [`ColumnWidthMode`] behavior. /// @@ -785,25 +756,6 @@ pub(crate) fn measure_rows_height( ) } -/// 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, - None, - ) -} - /// Measure selection-row height using explicit [`ColumnWidthMode`] behavior. /// /// This is the low-level companion to [`render_rows_with_col_width_mode`]. From 76ad9a03adaabd39db7116b170d98b14cd5f1fef Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 16:13:56 -0700 Subject: [PATCH 06/18] Cleaner column width calc --- codex-rs/tui/src/bottom_pane/selection_popup_common.rs | 1 + 1 file changed, 1 insertion(+) 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 cae75104814d..810bfb528bc4 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -179,6 +179,7 @@ fn compute_desc_col( }; name_column_width + .map(|width| width.max(max_name_width)) .unwrap_or(max_name_width) .saturating_add(2) .min(max_auto_desc_col) From 0ec99ef9e21a0073937594fac94a8ba8405833e9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 16:23:57 -0700 Subject: [PATCH 07/18] label change --- codex-rs/tui/src/chatwidget/plugins.rs | 2 +- ...i__chatwidget__tests__plugins_popup_curated_marketplace.snap | 2 +- ...x_tui__chatwidget__tests__plugins_popup_search_filtered.snap | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index c8f41e11a9f3..d2a4823251b2 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1080,7 +1080,7 @@ impl ChatWidget { } fn plugins_popup_hint_line() -> Line<'static> { - Line::from("←/→ switch tabs · enter view details · esc close") + Line::from("←/→ select marketplace · enter view details · esc close") } fn plugin_detail_hint_line() -> Line<'static> { 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 c59e767c0ccc..7eafc19672eb 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 @@ -15,4 +15,4 @@ expression: popup Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. Starter Available by default · ChatGPT Marketplace · Included by default. - ←/→ switch tabs · enter view details · esc close + ←/→ select marketplace · enter view details · esc close 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 50b428eec43e..d803aa42abc4 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 @@ -11,4 +11,4 @@ expression: popup sla › Slack Available Press Enter to view plugin details. - ←/→ switch tabs · enter view details · esc close + ←/→ select marketplace · enter view details · esc close From f49b187150fc4044a22c3cf6b7cf1b9965d73b3e Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 17:41:36 -0700 Subject: [PATCH 08/18] Toggleable plugin enablement in TUI /plugins menu v2 --- codex-rs/tui/src/app.rs | 76 +++++++++++++ codex-rs/tui/src/app_event.rs | 15 +++ .../src/bottom_pane/list_selection_view.rs | 74 ++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget/plugins.rs | 98 +++++++++++++++-- ...ts__plugins_popup_curated_marketplace.snap | 10 +- ..._tests__plugins_popup_search_filtered.snap | 4 +- .../chatwidget/tests/popups_and_settings.rs | 104 +++++++++++++++++- 8 files changed, 361 insertions(+), 21 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3fbcd6490999..800be4606934 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -75,6 +75,8 @@ use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; @@ -82,6 +84,7 @@ use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListParams; @@ -2150,6 +2153,31 @@ impl App { }); } + fn set_plugin_enabled( + &mut self, + app_server: &AppServerSession, + cwd: PathBuf, + plugin_id: String, + enabled: bool, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let cwd_for_event = cwd.clone(); + let plugin_id_for_event = plugin_id.clone(); + let result = write_plugin_enabled(request_handle, plugin_id, enabled) + .await + .map(|_| ()) + .map_err(|err| format!("Failed to update plugin config: {err}")); + app_event_tx.send(AppEvent::PluginEnabledSet { + cwd: cwd_for_event, + plugin_id: plugin_id_for_event, + enabled, + result, + }); + }); + } + fn refresh_plugin_mentions(&mut self) { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); @@ -4664,6 +4692,13 @@ impl App { } => { self.fetch_plugin_uninstall(app_server, cwd, plugin_id, plugin_display_name); } + AppEvent::SetPluginEnabled { + cwd, + plugin_id, + enabled, + } => { + self.set_plugin_enabled(app_server, cwd, plugin_id, enabled); + } AppEvent::PluginInstallLoaded { cwd, marketplace_path, @@ -5217,6 +5252,26 @@ impl App { self.fetch_plugins_list(app_server, cwd); } } + AppEvent::PluginEnabledSet { + cwd, + plugin_id, + enabled, + result, + } => { + let update_succeeded = result.is_ok(); + if update_succeeded { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after plugin toggle" + ); + } + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + self.chat_widget + .on_plugin_enabled_set(cwd, plugin_id, enabled, result); + } AppEvent::RefreshPluginMentions => { self.refresh_plugin_mentions(); } @@ -6462,6 +6517,27 @@ async fn fetch_plugin_uninstall( .wrap_err("plugin/uninstall failed in TUI") } +async fn write_plugin_enabled( + request_handle: AppServerRequestHandle, + plugin_id: String, + enabled: bool, +) -> Result { + 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, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a3b31aa988f4..8e56eafcc0e4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -271,6 +271,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/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index a933e078f31a..8629c5720811 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -92,6 +92,11 @@ pub(crate) fn side_by_side_layout_widths( /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +pub(crate) struct SelectionToggle { + pub is_on: bool, + pub action: Box, +} + /// Callback invoked whenever the highlighted item changes (arrow keys, search /// filter, number-key jump). Receives the *actual* index into the unfiltered /// `items` list and the event sender. Used by the theme picker for live preview. @@ -112,6 +117,8 @@ pub(crate) type OnCancelCallback = Option>, + pub toggle: Option, + pub toggle_placeholder: Option<&'static str>, pub display_shortcut: Option, pub description: Option, pub selected_description: Option, @@ -273,6 +280,7 @@ impl ListSelectionView { } else { Some(active_tab_idx.unwrap_or(0)) }; + let has_initial_selected_idx = params.initial_selected_idx.is_some(); let mut s = Self { view_id: params.view_id, footer_note: params.footer_note, @@ -305,7 +313,7 @@ impl ListSelectionView { on_cancel: params.on_cancel, }; s.apply_filter(); - if s.tabs_enabled() { + if s.tabs_enabled() && !has_initial_selected_idx { s.select_first_enabled_row(); } s @@ -326,6 +334,15 @@ impl ListSelectionView { .unwrap_or(self.items.as_slice()) } + fn active_items_mut(&mut self) -> &mut [SelectionItem] { + if let Some(idx) = self.active_tab_idx + && let Some(tab) = self.tabs.get_mut(idx) + { + return tab.items.as_mut_slice(); + } + self.items.as_mut_slice() + } + fn active_header(&self) -> &dyn Renderable { self.active_tab_idx .and_then(|idx| self.tabs.get(idx)) @@ -435,6 +452,11 @@ impl ListSelectionView { let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); let mut name_prefix_spans = Vec::new(); name_prefix_spans.push(wrap_prefix.into()); + if let Some(toggle) = &item.toggle { + name_prefix_spans.push(if toggle.is_on { "[*] " } else { "[ ] " }.into()); + } else if let Some(placeholder) = item.toggle_placeholder { + name_prefix_spans.push(placeholder.into()); + } name_prefix_spans.extend(item.name_prefix_spans.clone()); let description = is_selected .then(|| item.selected_description.clone()) @@ -555,6 +577,44 @@ impl ListSelectionView { } } + fn selected_item_has_toggle(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| { + item.toggle.is_some() && item.disabled_reason.is_none() && !item.is_disabled + }) + } + + fn selected_item_has_toggle_placeholder(&self) -> bool { + self.selected_actual_idx() + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(|item| { + item.toggle.is_none() + && item.toggle_placeholder.is_some() + && item.disabled_reason.is_none() + && !item.is_disabled + }) + } + + fn toggle_selected(&mut self) { + let Some(actual_idx) = self.selected_actual_idx() else { + return; + }; + let app_event_tx = self.app_event_tx.clone(); + let Some(item) = self.active_items_mut().get_mut(actual_idx) else { + return; + }; + if item.is_disabled || item.disabled_reason.is_some() { + return; + } + let Some(toggle) = item.toggle.as_mut() else { + return; + }; + + toggle.is_on = !toggle.is_on; + (toggle.action)(toggle.is_on, &app_event_tx); + } + #[cfg(test)] pub(crate) fn set_search_query(&mut self, query: String) { self.search_query = query; @@ -719,6 +779,18 @@ impl BottomPaneView for ListSelectionView { self.search_query.pop(); self.apply_filter(); } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.selected_item_has_toggle() => self.toggle_selected(), + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } if self.is_searchable + && self.search_query.is_empty() + && self.selected_item_has_toggle_placeholder() => {} KeyEvent { code: KeyCode::Esc, .. } => { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 74aa4ba9714e..23acfd1f7c5b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -89,6 +89,7 @@ mod skills_toggle_view; mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::ColumnWidthMode; +pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; pub(crate) use list_selection_view::SideContentWidth; pub(crate) use list_selection_view::popup_content_width; diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index d2a4823251b2..b3ef0902e6da 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -7,6 +7,7 @@ use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionToggle; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::legacy_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; @@ -39,7 +40,7 @@ const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const ALL_PLUGINS_TAB_ID: &str = "all-plugins"; const INSTALLED_PLUGINS_TAB_ID: &str = "installed-plugins"; const OPENAI_CURATED_TAB_ID: &str = "marketplace:openai-curated"; -const PLUGIN_MENU_ROW_PREFIX_WIDTH: usize = 2; +const PLUGIN_MENU_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); @@ -232,9 +233,12 @@ impl ChatWidget { fn open_plugins_popup(&mut self, response: &PluginListResponse) { self.plugins_active_tab_id = Some(ALL_PLUGINS_TAB_ID.to_string()); - self.bottom_pane.show_selection_view( - self.plugins_popup_params(response, self.plugins_active_tab_id.clone()), - ); + self.bottom_pane + .show_selection_view(self.plugins_popup_params( + response, + self.plugins_active_tab_id.clone(), + /*initial_selected_idx*/ None, + )); } pub(crate) fn open_plugin_detail_loading_popup(&mut self, plugin_display_name: &str) { @@ -387,6 +391,49 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_enabled_set( + &mut self, + cwd: PathBuf, + plugin_id: String, + enabled: bool, + result: Result<(), String>, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + if let Err(err) = result { + self.add_error_message(format!( + "Failed to update plugin config for {plugin_id}: {err}" + )); + if let PluginsCacheState::Ready(response) = self.plugins_cache_for_current_cwd() { + self.refresh_plugins_popup_if_open(&response); + } + return; + } + + let refreshed_response = match &mut self.plugins_cache { + PluginsCacheState::Ready(response) + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => + { + for plugin in response + .marketplaces + .iter_mut() + .flat_map(|marketplace| marketplace.plugins.iter_mut()) + .filter(|plugin| plugin.id == plugin_id) + { + plugin.enabled = enabled; + } + Some(response.clone()) + } + _ => None, + }; + + if let Some(response) = refreshed_response { + self.refresh_plugins_popup_if_open(&response); + } + } + pub(crate) fn advance_plugin_install_auth_flow(&mut self) { let should_finish = { let Some(flow) = self.plugin_install_auth_flow.as_mut() else { @@ -562,7 +609,11 @@ impl ChatWidget { let tab_id = self.plugins_active_tab_id.clone(); let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, - self.plugins_popup_params(&plugins_response, tab_id), + self.plugins_popup_params( + &plugins_response, + tab_id, + /*initial_selected_idx*/ None, + ), ); } } @@ -573,10 +624,13 @@ impl ChatWidget { .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) .map(str::to_string) .or_else(|| self.plugins_active_tab_id.clone()); + let selected_idx = self + .bottom_pane + .selected_index_for_active_view(PLUGINS_SELECTION_VIEW_ID); self.plugins_active_tab_id = active_tab_id.clone(); let _ = self.bottom_pane.replace_selection_view_if_active( PLUGINS_SELECTION_VIEW_ID, - self.plugins_popup_params(response, active_tab_id), + self.plugins_popup_params(response, active_tab_id, selected_idx), ); } @@ -726,6 +780,7 @@ impl ChatWidget { &self, response: &PluginListResponse, active_tab_id: Option, + initial_selected_idx: Option, ) -> SelectionViewParams { let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); @@ -868,6 +923,7 @@ impl ChatWidget { search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, name_column_width, + initial_selected_idx, ..Default::default() } } @@ -883,7 +939,7 @@ impl ChatWidget { if plugin.summary.enabled { "Installed" } else { - "Installed · Disabled" + "Disabled" } } else { match plugin.summary.install_policy { @@ -1035,8 +1091,12 @@ impl ChatWidget { plugin_brief_description_without_marketplace(plugin, status_label_width) }; let selected_status_label = format!("{status_label: Line<'static> { - Line::from("←/→ select marketplace · enter view details · esc close") + Line::from("space enable/disable · ←/→ select marketplace · enter view details · esc close") } fn plugin_detail_hint_line() -> Line<'static> { @@ -1228,13 +1302,13 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { if plugin.enabled { "Installed" } else { - "Installed · Disabled" + "Disabled" } } else { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available by default", + PluginInstallPolicy::InstalledByDefault => "Available", } } } 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 7eafc19672eb..563b8d0bd256 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 @@ -10,9 +10,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Type to search plugins -› Alpha Sync Installed · Disabled Press Enter to view plugin details. - Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. - Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. - Starter Available by default · ChatGPT Marketplace · Included by default. +› [ ] Alpha Sync Disabled Space to enable; Enter view details. + [-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. + [-] Starter Available · ChatGPT Marketplace · Included by default. - ←/→ select marketplace · enter view details · esc close + space enable/disable · ←/→ select marketplace · enter view details · esc close 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 d803aa42abc4..da1b0913fe72 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 @@ -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 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 218b7fb6370d..108509042a13 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -388,11 +388,113 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { "expected /plugins to refresh installed counts after install, got:\n{after}" ); assert!( - after.contains("Installed Press Enter to view plugin details."), + after.contains("Installed Space to disable; Enter view details."), "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" ); } +#[tokio::test] +async fn plugins_popup_space_toggles_installed_plugin_from_list() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let cwd = chat.config.cwd.to_path_buf(); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + /*installed*/ true, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetPluginEnabled { + cwd: event_cwd, + plugin_id, + enabled, + }) => { + assert_eq!(event_cwd, cwd); + assert_eq!(plugin_id, "plugin-drive"); + assert!(!enabled); + } + other => panic!("expected SetPluginEnabled event, got {other:?}"), + } + + chat.on_plugin_enabled_set( + cwd, + "plugin-drive".to_string(), + /*enabled*/ false, + Ok(()), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("› [ ] Drive"), + "expected selected plugin row to stay selected after refresh, got:\n{popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + plugins_test_summary( + "plugin-calendar", + "calendar", + Some("Calendar"), + Some("Schedule management."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + plugins_test_summary( + "plugin-drive", + "drive", + Some("Drive"), + Some("Document access."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ), + ])]), + ); + + while rx.try_recv().is_ok() {} + let before = render_bottom_popup(&chat, /*width*/ 100); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let after = render_bottom_popup(&chat, /*width*/ 100); + + assert!( + rx.try_recv().is_err(), + "did not expect Space on an uninstalled plugin to emit an event" + ); + assert_eq!(after, before); +} + #[tokio::test] async fn plugins_popup_search_filters_visible_rows_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From f039a131f84432b4410ba4125b69b6f4aea47f71 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 19:11:30 -0700 Subject: [PATCH 09/18] marketplace/add support in plugins menu v2 --- codex-rs/tui/src/app.rs | 73 +++++++ codex-rs/tui/src/app_event.rs | 22 ++ codex-rs/tui/src/chatwidget/plugins.rs | 188 +++++++++++++++++- ...ts__plugins_popup_curated_marketplace.snap | 2 +- ..._tests__plugins_popup_search_filtered.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 116 +++++++++++ 7 files changed, 401 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 800be4606934..565f11923ee1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -82,6 +82,8 @@ 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; @@ -2086,6 +2088,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, @@ -4644,6 +4668,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, } => { @@ -6470,6 +6525,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, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8e56eafcc0e4..827b7e34aaea 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +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, diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index b3ef0902e6da..8118ad94a6fd 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -9,6 +9,7 @@ use crate::bottom_pane::SelectionItem; 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; @@ -16,6 +17,7 @@ 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; @@ -40,6 +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 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); @@ -241,6 +244,43 @@ impl ChatWidget { )); } + 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(), + 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 @@ -267,6 +307,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, @@ -653,6 +738,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), @@ -736,6 +842,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, @@ -800,6 +954,7 @@ impl ChatWidget { .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() @@ -913,6 +1068,8 @@ impl ChatWidget { }); } + tabs.push(self.marketplace_add_tab()); + SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(()), @@ -928,6 +1085,31 @@ impl ChatWidget { } } + 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, @@ -1210,7 +1392,11 @@ fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, } fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - format!("marketplace:{}", marketplace.path.display()) + marketplace_tab_id_from_path(&marketplace.path) +} + +fn marketplace_tab_id_from_path(path: &AbsolutePathBuf) -> String { + format!("marketplace:{}", path.display()) } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { 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 563b8d0bd256..f9fbeaa4c90f 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,7 +7,7 @@ expression: popup Installed 1 of 4 available plugins. Using cached marketplace data: remote sync timed out - [All Plugins] Installed (1) OpenAI Curated Repo Marketplace + [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace Type to search plugins › [ ] Alpha Sync Disabled Space to enable; Enter view details. 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 da1b0913fe72..297a842ea77a 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,7 @@ expression: popup Browse plugins from available marketplaces. Installed 0 of 3 available plugins. - [All Plugins] Installed (0) OpenAI Curated + [All Plugins] Installed (0) OpenAI Curated Add Marketplace sla › [-] Slack Available Press Enter to view plugin details. 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/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 108509042a13..0ed77e0756f1 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -163,6 +163,122 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ ); } +#[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_path = plugins_test_absolute_path("marketplaces/debug"); + 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_path.clone(), + 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; From 80b873d5151b9208c89e2cd4e7d0a193a5baea16 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 19:33:03 -0700 Subject: [PATCH 10/18] Fix for excessive arguments --- .../src/bottom_pane/list_selection_view.rs | 15 ++-- .../src/bottom_pane/selection_popup_common.rs | 81 +++++++++---------- 2 files changed, 44 insertions(+), 52 deletions(-) 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 8629c5720811..c15b7123bc44 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -23,6 +23,7 @@ use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; 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_with_col_width_mode; @@ -91,10 +92,11 @@ pub(crate) fn side_by_side_layout_widths( /// 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: Box, + pub action: SelectionToggleAction, } /// Callback invoked whenever the highlighted item changes (arrow keys, search @@ -878,13 +880,13 @@ impl Renderable for ListSelectionView { // Measure wrapped height for up to MAX_POPUP_ROWS items. let rows = self.build_rows(); + let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); let rows_height = measure_rows_height_with_col_width_mode( &rows, &self.state, MAX_POPUP_ROWS, effective_rows_width.saturating_add(1), - self.col_width_mode, - self.name_column_width, + column_width, ); let header = self.active_header(); @@ -953,13 +955,13 @@ impl Renderable for ListSelectionView { 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 column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); let rows_height = measure_rows_height_with_col_width_mode( &rows, &self.state, MAX_POPUP_ROWS, effective_rows_width.saturating_add(1), - self.col_width_mode, - self.name_column_width, + column_width, ); // Stacked (fallback) side content height — only used when not side-by-side. @@ -1038,8 +1040,7 @@ impl Renderable for ListSelectionView { &self.state, render_area.height as usize, "no matches", - self.col_width_mode, - self.name_column_width, + column_width, ); } 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 810bfb528bc4..078bf9d61931 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,8 +142,7 @@ fn compute_desc_col( start_idx: usize, visible_items: usize, content_width: u16, - col_width_mode: ColumnWidthMode, - name_column_width: Option, + column_width: ColumnWidthConfig, ) -> usize { if content_width <= 1 { return 0; @@ -142,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() @@ -178,7 +193,8 @@ fn compute_desc_col( ColumnWidthMode::Fixed => 0, }; - name_column_width + column_width + .name_column_width .map(|width| width.max(max_name_width)) .unwrap_or(max_name_width) .saturating_add(2) @@ -378,8 +394,7 @@ fn adjust_start_for_wrapped_selection_visibility( desc_measure_items: usize, width: u16, viewport_height: u16, - col_width_mode: ColumnWidthMode, - name_column_width: Option, + column_width: ColumnWidthConfig, ) -> usize { let mut start_idx = compute_item_window_start(rows_all, state, max_items); let Some(sel) = state.selected_idx else { @@ -392,14 +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, - name_column_width, - ); + 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, @@ -513,8 +522,7 @@ fn render_rows_inner( state: &ScrollState, max_results: usize, empty_message: &str, - col_width_mode: ColumnWidthMode, - name_column_width: Option, + column_width: ColumnWidthConfig, ) -> u16 { if rows_all.is_empty() { if area.height > 0 { @@ -539,8 +547,7 @@ fn render_rows_inner( desc_measure_items, area.width, area.height, - col_width_mode, - name_column_width, + column_width, ); let desc_col = compute_desc_col( @@ -548,8 +555,7 @@ fn render_rows_inner( start_idx, desc_measure_items, area.width, - col_width_mode, - name_column_width, + column_width, ); // Render items, wrapping descriptions and aligning wrapped lines under the @@ -613,8 +619,7 @@ pub(crate) fn render_rows( state, max_results, empty_message, - ColumnWidthMode::AutoVisible, - None, + ColumnWidthConfig::default(), ) } @@ -631,8 +636,7 @@ pub(crate) fn render_rows_with_col_width_mode( state: &ScrollState, max_results: usize, empty_message: &str, - col_width_mode: ColumnWidthMode, - name_column_width: Option, + column_width: ColumnWidthConfig, ) -> u16 { render_rows_inner( area, @@ -641,8 +645,7 @@ pub(crate) fn render_rows_with_col_width_mode( state, max_results, empty_message, - col_width_mode, - name_column_width, + column_width, ) } @@ -688,8 +691,7 @@ pub(crate) fn render_rows_single_line( start_idx, visible_items, area.width, - ColumnWidthMode::AutoVisible, - None, + ColumnWidthConfig::default(), ); let mut cur_y = area.y; @@ -752,8 +754,7 @@ pub(crate) fn measure_rows_height( state, max_results, width, - ColumnWidthMode::AutoVisible, - None, + ColumnWidthConfig::default(), ) } @@ -765,17 +766,9 @@ pub(crate) fn measure_rows_height_with_col_width_mode( state: &ScrollState, max_results: usize, width: u16, - col_width_mode: ColumnWidthMode, - name_column_width: Option, + column_width: ColumnWidthConfig, ) -> u16 { - measure_rows_height_inner( - rows_all, - state, - max_results, - width, - col_width_mode, - name_column_width, - ) + measure_rows_height_inner(rows_all, state, max_results, width, column_width) } fn measure_rows_height_inner( @@ -783,8 +776,7 @@ fn measure_rows_height_inner( state: &ScrollState, max_results: usize, width: u16, - col_width_mode: ColumnWidthMode, - name_column_width: Option, + column_width: ColumnWidthConfig, ) -> u16 { if rows_all.is_empty() { return 1; // placeholder "no matches" line @@ -810,8 +802,7 @@ fn measure_rows_height_inner( start_idx, visible_items, content_width, - col_width_mode, - name_column_width, + column_width, ); let mut total: u16 = 0; From b7ac6a0867f46e0cff8896240421e43eabf42938 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 19:45:14 -0700 Subject: [PATCH 11/18] snapshots --- ...twidget__tests__plugins_popup_curated_marketplace.snap | 8 ++++---- ..._chatwidget__tests__plugins_popup_search_filtered.snap | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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 f9fbeaa4c90f..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 @@ -10,9 +10,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace Type to search plugins -› [ ] 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. +› [ ] 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. 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_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index 297a842ea77a..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 @@ -9,6 +9,6 @@ expression: popup [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. space enable/disable · ←/→ select marketplace · enter view details · esc close From 75d345ff70a6fc6ee5e7b6945ce4eb86281f2a0b Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 21:17:19 -0700 Subject: [PATCH 12/18] snapshot --- .../tui/src/chatwidget/tests/popups_and_settings.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 0ed77e0756f1..899fd7f9ac32 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -364,7 +364,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); @@ -393,7 +393,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}" ); @@ -431,8 +431,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"), From ba9500914058f3112bc5768dbd682a6b7dbd0f0f Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 14 Apr 2026 21:24:37 -0700 Subject: [PATCH 13/18] lint --- codex-rs/tui/src/bottom_pane/list_selection_view.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c15b7123bc44..b5c4392a10e2 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -745,11 +745,11 @@ impl BottomPaneView for ListSelectionView { KeyEvent { code: KeyCode::Left, .. - } if self.tabs_enabled() => self.switch_tab(-1), + } if self.tabs_enabled() => self.switch_tab(/*step*/ -1), KeyEvent { code: KeyCode::Right, .. - } if self.tabs_enabled() => self.switch_tab(1), + } if self.tabs_enabled() => self.switch_tab(/*step*/ 1), KeyEvent { code: KeyCode::Char('k'), modifiers: KeyModifiers::NONE, From db3820c9061d9257862ca08a2a7b336973ba326d Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 15 Apr 2026 15:10:40 -0700 Subject: [PATCH 14/18] truncate plugin desc in list view --- .../src/bottom_pane/list_selection_view.rs | 76 +++++++++++++------ codex-rs/tui/src/bottom_pane/mod.rs | 1 + .../src/bottom_pane/selection_popup_common.rs | 30 ++++++-- codex-rs/tui/src/chatwidget/plugins.rs | 2 + .../chatwidget/tests/popups_and_settings.rs | 46 +++++++++++ 5 files changed, 125 insertions(+), 30 deletions(-) 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 b5c4392a10e2..cbdd87804f75 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -27,6 +27,7 @@ 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_with_col_width_mode; +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; @@ -90,6 +91,13 @@ 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; @@ -143,6 +151,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, @@ -155,6 +164,7 @@ pub(crate) struct SelectionViewParams { 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, @@ -201,6 +211,7 @@ impl Default for SelectionViewParams { 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, @@ -234,6 +245,7 @@ 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, @@ -301,6 +313,7 @@ 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, @@ -881,13 +894,16 @@ impl Renderable for ListSelectionView { // Measure wrapped height for up to MAX_POPUP_ROWS items. let rows = self.build_rows(); let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); - let rows_height = measure_rows_height_with_col_width_mode( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - 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), + column_width, + ), + SelectionRowDisplay::SingleLine => rows.len().clamp(1, MAX_POPUP_ROWS) as u16, + }; let header = self.active_header(); let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); @@ -956,13 +972,16 @@ impl Renderable for ListSelectionView { let tab_height = tab_bar_height(&self.tabs, self.active_tab_idx.unwrap_or(0), inner_width); let rows = self.build_rows(); let column_width = ColumnWidthConfig::new(self.col_width_mode, self.name_column_width); - let rows_height = measure_rows_height_with_col_width_mode( - &rows, - &self.state, - MAX_POPUP_ROWS, - effective_rows_width.saturating_add(1), - 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), + 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. let stacked_side_h = if side_w.is_none() { @@ -1033,15 +1052,26 @@ impl Renderable for ListSelectionView { width: effective_rows_width.max(1), height: list_area.height, }; - render_rows_with_col_width_mode( - render_area, - buf, - &rows, - &self.state, - render_area.height as usize, - "no matches", - column_width, - ); + 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, + ), + SelectionRowDisplay::SingleLine => render_rows_single_line_with_col_width_mode( + render_area, + buf, + &rows, + &self.state, + render_area.height as usize, + "no matches", + column_width, + ), + }; } // -- Side content (preview panel) -- diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 23acfd1f7c5b..98681227eb5b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -89,6 +89,7 @@ mod skills_toggle_view; mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::ColumnWidthMode; +pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; pub(crate) use list_selection_view::SideContentWidth; 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 078bf9d61931..3507cb31eca5 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -661,6 +661,28 @@ pub(crate) fn render_rows_single_line( state: &ScrollState, max_results: usize, empty_message: &str, +) -> u16 { + render_rows_single_line_with_col_width_mode( + area, + buf, + rows_all, + state, + max_results, + empty_message, + ColumnWidthConfig::default(), + ) +} + +/// 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 { @@ -686,13 +708,7 @@ pub(crate) fn render_rows_single_line( } } - let desc_col = compute_desc_col( - rows_all, - start_idx, - visible_items, - area.width, - ColumnWidthConfig::default(), - ); + 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; diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 8118ad94a6fd..55deb5017542 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -6,6 +6,7 @@ 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; @@ -1079,6 +1080,7 @@ impl ChatWidget { 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() 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 899fd7f9ac32..2d5a9d42ccfa 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -163,6 +163,52 @@ 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; From 1baeaa95e96e117108ec92ff76bfed74fb9322ef Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 15 Apr 2026 17:50:43 -0700 Subject: [PATCH 15/18] Fix marketplace add tab selection after refresh --- codex-rs/tui/src/chatwidget/plugins.rs | 37 ++++++++++++++++++- .../chatwidget/tests/popups_and_settings.rs | 6 ++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 55deb5017542..148b5e2639d6 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; @@ -42,6 +43,7 @@ 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; @@ -185,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); @@ -1398,7 +1408,32 @@ fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { } fn marketplace_tab_id_from_path(path: &AbsolutePathBuf) -> String { - format!("marketplace:{}", path.display()) + 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 { 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 2d5a9d42ccfa..67ce7edac499 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -280,7 +280,9 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let cwd = chat.config.cwd.to_path_buf(); - let marketplace_path = plugins_test_absolute_path("marketplaces/debug"); + 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())]), @@ -291,7 +293,7 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { "owner/repo".to_string(), Ok(MarketplaceAddResponse { marketplace_name: "debug".to_string(), - installed_root: marketplace_path.clone(), + installed_root: marketplace_root, already_added: false, }), ); From 34a8025994da718357d5f13050babc8d30294818 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Thu, 16 Apr 2026 10:26:29 -0700 Subject: [PATCH 16/18] Queue plugin toggle writes and let Space extend active search --- codex-rs/tui/src/app.rs | 59 +++++++++++++++---- .../src/bottom_pane/list_selection_view.rs | 45 +++++++++++++- .../chatwidget/tests/popups_and_settings.rs | 40 +++++++++++++ 3 files changed, 132 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 565f11923ee1..988ba55d05a5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1049,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)] @@ -2183,6 +2186,23 @@ impl App { 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(); @@ -4049,6 +4069,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) @@ -5313,19 +5334,35 @@ impl App { enabled, result, } => { - let update_succeeded = result.is_ok(); - if update_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin toggle" - ); + 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, plugin_id, 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.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.chat_widget + .on_plugin_enabled_set(cwd, plugin_id, enabled, result); } - self.chat_widget - .on_plugin_enabled_set(cwd, plugin_id, enabled, result); } AppEvent::RefreshPluginMentions => { self.refresh_plugin_mentions(); 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 cbdd87804f75..258ac3fcabb4 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -798,7 +798,11 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. - } if self.selected_item_has_toggle() => self.toggle_selected(), + } if self.selected_item_has_toggle() + && (!self.is_searchable || self.search_query.is_empty()) => + { + self.toggle_selected() + } KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, @@ -1482,6 +1486,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/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 67ce7edac499..3f8e9616d2f5 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -663,6 +663,46 @@ async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() { 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; From b19cf08d86ed67ea74c17bfb312e37a8070e7a1c Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Thu, 16 Apr 2026 10:56:04 -0700 Subject: [PATCH 17/18] fixes --- codex-rs/app-server/src/message_processor.rs | 1 - codex-rs/tui/src/app.rs | 9 ++++++++- codex-rs/tui/src/chatwidget/plugins.rs | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 5a443c6820fb..cd59784f1542 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -746,7 +746,6 @@ 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 diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 988ba55d05a5..dc5f0502c3b7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5342,7 +5342,12 @@ impl App { 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, plugin_id, queued_enabled); + self.spawn_plugin_enabled_write( + app_server, + cwd.clone(), + plugin_id.clone(), + queued_enabled, + ); false } else { true @@ -9897,6 +9902,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(), } } @@ -9954,6 +9960,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/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 148b5e2639d6..a34d02abd875 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -262,6 +262,7 @@ impl ChatWidget { 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(); From d9ae56acc9c72b348a52eb63e161416a2d514db6 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Thu, 16 Apr 2026 11:30:08 -0700 Subject: [PATCH 18/18] move --- codex-rs/tui/src/app_event.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 39d7e8db0990..72d714e63a8d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,8 +10,8 @@ use std::path::PathBuf; -use codex_app_server_protocol::MarketplaceAddResponse; 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;