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 3e54946d3..536065b66 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -154,6 +154,20 @@ pub(crate) struct SelectionItem { pub disabled_reason: Option, } +impl SelectionItem { + /// Whether accepting this row drills one level deeper into the menu tree. + /// + /// This is the single source of truth for tree navigation: `move_right` only fires on rows + /// that answer `true`, and footer hints only advertise a drill-in key when a menu contains + /// such a row. Toggle rows (no actions) and rows that merely close the view do not qualify. + pub(crate) fn opens_child_view(&self) -> bool { + !self.is_disabled + && self.disabled_reason.is_none() + && !self.actions.is_empty() + && self.dismiss_parent_on_child_accept + } +} + /// Construction-time configuration for [`ListSelectionView`]. /// /// This config is consumed once by [`ListSelectionView::new`]. After @@ -176,6 +190,7 @@ pub(crate) struct SelectionViewParams { pub tabs: Vec, pub initial_tab_id: Option, pub is_searchable: bool, + pub tree_navigation_enabled: bool, pub search_placeholder: Option, pub col_width_mode: ColumnWidthMode, pub row_display: SelectionRowDisplay, @@ -231,6 +246,7 @@ impl Default for SelectionViewParams { tabs: Vec::new(), initial_tab_id: None, is_searchable: false, + tree_navigation_enabled: false, search_placeholder: None, col_width_mode: ColumnWidthMode::AutoVisible, row_display: SelectionRowDisplay::Wrapped, @@ -267,6 +283,7 @@ pub(crate) struct ListSelectionView { dismiss_after_child_accept: bool, app_event_tx: AppEventSender, is_searchable: bool, + tree_navigation_enabled: bool, search_query: String, search_placeholder: Option, col_width_mode: ColumnWidthMode, @@ -339,6 +356,7 @@ impl ListSelectionView { dismiss_after_child_accept: false, app_event_tx, is_searchable: params.is_searchable, + tree_navigation_enabled: params.tree_navigation_enabled, search_query: String::new(), search_placeholder: if params.is_searchable { params.search_placeholder @@ -995,6 +1013,26 @@ impl BottomPaneView for ListSelectionView { { self.switch_tab(/*step*/ 1) } + _ if allow_plain_char_navigation + && self.tree_navigation_enabled + && self.keymap.move_left.is_pressed(key_event) => + { + self.on_ctrl_c(); + } + _ if allow_plain_char_navigation + && self.tree_navigation_enabled + && self.keymap.move_right.is_pressed(key_event) => + { + let selected_opens_child = self + .state + .selected_idx + .and_then(|idx| self.filtered_indices.get(idx).copied()) + .and_then(|actual_idx| self.active_items().get(actual_idx)) + .is_some_and(SelectionItem::opens_child_view); + if selected_opens_child { + self.accept(); + } + } KeyEvent { code: KeyCode::Backspace, .. diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index d85b9c638..45ea5ab6a 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -1,6 +1,7 @@ //! Shared popup-related constants for bottom pane widgets. use ratatui::text::Line; +use ratatui::text::Span; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -32,6 +33,89 @@ pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> L ) } +/// Footer hint for a menu that participates in tree navigation (see +/// `SelectionViewParams::tree_navigation_enabled`). +/// +/// Key labels are resolved from the live list keymap so rebound keys stay accurate; only the +/// verbs describing what each key does at this level of the tree are supplied by the caller. +pub(crate) struct TreeNavigationHint { + /// What pressing the accept keys does on this menu's rows. + pub accept: TreeNavigationAccept, + /// Whether the menu has toggle rows driven by space. + pub include_space_toggle: bool, + /// Verb for the go-back keys, e.g. "closes" at the root or "goes back" one level down. + pub cancel_label: &'static str, +} + +/// What the accept keys do on the rows of a tree-navigation menu. +/// +/// Only advertise what the rows actually support: `move_right` drills in exclusively for rows +/// that open a child view, so a menu whose rows are toggles (or that has no rows with actions) +/// must not claim otherwise. +pub(crate) enum TreeNavigationAccept { + /// No row opens a child and accepting does nothing meaningful; no clause is rendered. + None, + /// Rows resolve in place (leaf picker): only `accept` is advertised, with this verb. + InPlace(&'static str), + /// Rows open a sub-menu: both `move_right` and `accept` are advertised, with this verb. + DrillIn(&'static str), +} + +pub(crate) fn tree_navigation_hint_line( + list_keymap: &ListKeymap, + hint: TreeNavigationHint, +) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + + let (include_move_right, accept_label) = match hint.accept { + TreeNavigationAccept::None => (false, None), + TreeNavigationAccept::InPlace(label) => (false, Some(label)), + TreeNavigationAccept::DrillIn(label) => (true, Some(label)), + }; + if let Some(accept_label) = accept_label { + let accept_keys: Vec = include_move_right + .then(|| primary_binding(&list_keymap.move_right)) + .flatten() + .into_iter() + .chain(primary_binding(&list_keymap.accept)) + .collect(); + push_key_clause(&mut spans, &accept_keys, accept_label); + } + + if hint.include_space_toggle { + push_key_clause( + &mut spans, + &[key_hint::plain(KeyCode::Char(' '))], + "toggles", + ); + } + + let cancel_keys: Vec = primary_binding(&list_keymap.move_left) + .into_iter() + .chain(primary_binding(&list_keymap.cancel)) + .collect(); + push_key_clause(&mut spans, &cancel_keys, hint.cancel_label); + + Line::from(spans) +} + +/// Append `[ or ]