From 4277abdca30a48ed6853157d8f7ca943042ee5a1 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 23 Jul 2026 17:42:43 +0300 Subject: [PATCH 1/5] feat: Slash /config: left/right arrows navigate its multi-level menus (Right should drill in, not close) --- .../src/bottom_pane/list_selection_view.rs | 29 ++++++++ .../tui/src/chatwidget/settings_popups.rs | 20 ++++-- ...ests__config_account_automation_popup.snap | 2 +- ...tests__config_agent_max_threads_popup.snap | 16 +++++ ...idget__tests__config_ai_context_popup.snap | 2 +- ...tests__config_interface_privacy_popup.snap | 2 +- ..._tui__chatwidget__tests__config_popup.snap | 2 +- ...age_limit_reset_picker_default_cancel.snap | 2 +- .../chatwidget/tests/popups_and_settings.rs | 56 +++++++++++++++ .../tests/usage_limit_reset_tests/manual.rs | 72 +++++++++++++++++++ .../chatwidget/usage_limit_reset/manual.rs | 3 +- 11 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_agent_max_threads_popup.snap 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 3e54946d35..02b672983e 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -176,6 +176,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 +232,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 +269,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 +342,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 +999,31 @@ 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(|item| { + !item.is_disabled + && item.disabled_reason.is_none() + && !item.actions.is_empty() + && item.dismiss_parent_on_child_accept + }); + if selected_opens_child { + self.accept(); + } + } KeyEvent { code: KeyCode::Backspace, .. diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index fcab2600c7..8e72a14a42 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -143,8 +143,9 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), - footer_hint: Some(Line::from("Press enter to open; esc to close")), + footer_hint: Some(Line::from("Right or Enter opens; Left or Esc closes")), items, + tree_navigation_enabled: true, ..Default::default() }); } @@ -181,7 +182,8 @@ impl ChatWidget { is_disabled: multi_agent_v2, disabled_reason: multi_agent_v2.then(|| "Managed by multi_agent_v2.".to_string()), actions, - dismiss_on_select: true, + dismiss_on_select: false, + dismiss_parent_on_child_accept: true, ..Default::default() } } @@ -241,8 +243,9 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), + footer_hint: Some(Line::from("Enter selects; Left or Esc goes back")), items, + tree_navigation_enabled: true, ..Default::default() }); } @@ -337,9 +340,12 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), - footer_hint: Some(Line::from("Press space to toggle; esc to close")), + footer_hint: Some(Line::from( + "Right or Enter opens; Space toggles; Left or Esc goes back", + )), items, is_searchable: true, + tree_navigation_enabled: true, search_placeholder: Some(format!("Search {} settings", section.label())), ..Default::default() }); @@ -649,7 +655,8 @@ fn config_section_item( section.description() )), actions, - dismiss_on_select: true, + dismiss_on_select: false, + dismiss_parent_on_child_accept: true, search_value: Some(format!( "{} {} {}", section.id(), @@ -681,7 +688,8 @@ fn rate_limit_reset_config_item() -> SelectionItem { name: "Use a usage limit reset".to_string(), description: Some("Refresh and choose an exact banked reset.".to_string()), actions, - dismiss_on_select: true, + dismiss_on_select: false, + dismiss_parent_on_child_accept: true, search_value: Some("usage limit reset banked credit manual".to_string()), ..Default::default() } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_account_automation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_account_automation_popup.snap index 935a12e255..45be389bf9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_account_automation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_account_automation_popup.snap @@ -23,4 +23,4 @@ expression: account_popup [ ] Automated goal plans Advance goal plans when exactly one next goal is ready. - Press space to toggle; esc to close + Right or Enter opens; Space toggles; Left or Esc goes back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_agent_max_threads_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_agent_max_threads_popup.snap new file mode 100644 index 0000000000..578cdecc98 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_agent_max_threads_popup.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Agent subagent threads + Cap concurrent subagent threads per agent run; restart to apply. + +› 1. 1 (current) Allow up to 1 concurrent subagent threads per agent run. + 2. 2 Allow up to 2 concurrent subagent threads per agent run. + 3. 3 Allow up to 3 concurrent subagent threads per agent run. + 4. 4 Allow up to 4 concurrent subagent threads per agent run. + 5. 6 (default) Allow up to 6 concurrent subagent threads per agent run. + 6. 8 Allow up to 8 concurrent subagent threads per agent run. + 7. 12 Allow up to 12 concurrent subagent threads per agent run. + + Enter selects; Left or Esc goes back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_ai_context_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_ai_context_popup.snap index f7810b7365..75dc229481 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_ai_context_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_ai_context_popup.snap @@ -18,4 +18,4 @@ expression: ai_context_popup context. Back to sections Return to the config section menu. - Press space to toggle; esc to close + Right or Enter opens; Space toggles; Left or Esc goes back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_interface_privacy_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_interface_privacy_popup.snap index f415efcce9..a9a9c7e42e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_interface_privacy_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_interface_privacy_popup.snap @@ -17,4 +17,4 @@ expression: interface_popup [*] Feedback Allow feedback collection from the TUI. [*] Unstable feature warnings Show warnings for enabled under-development features. - Press space to toggle; esc to close + Right or Enter opens; Space toggles; Left or Esc goes back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_popup.snap index 9e5eb16b06..960d6d7817 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__config_popup.snap @@ -16,4 +16,4 @@ expression: popup the built-in default). 6. Goal objective limit Max characters echoed per goal objective (now 4000). - Press enter to open; esc to close + Right or Enter opens; Left or Esc closes diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_picker_default_cancel.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_picker_default_cancel.snap index 2e36a634e8..27829f1b3e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_picker_default_cancel.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_picker_default_cancel.snap @@ -8,4 +8,4 @@ expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 90))" 1. Banked reset Earned reset credit. Expires in 3d 4h. › 2. Cancel (default) (n) - Press enter to confirm or esc to go back + Enter selects; Left or Esc goes back 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 b18a8b67a2..df52466d95 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3587,6 +3587,61 @@ async fn config_popup_snapshot_and_toggle() { ); } +#[tokio::test] +async fn config_popup_arrow_keys_navigate_menu_tree() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; + chat.thread_id = Some(ThreadId::new()); + while rx.try_recv().is_ok() {} + + chat.open_config_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + let section = match rx.try_recv() { + Ok(AppEvent::OpenConfigSection { section }) => { + assert_eq!( + section, + crate::common_config_options::CommonConfigSection::AiContext + ); + section + } + event => panic!("expected Right to open the selected config section, got {event:?}"), + }; + + // Simulate the app dispatching the event while the root remains on the view stack. + chat.open_config_section_popup(section); + assert!(render_bottom_popup(&chat, /*width*/ 90).contains("Config: AI context")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Left)); + let root = render_bottom_popup(&chat, /*width*/ 90); + assert!(root.contains("Choose a focused config.toml settings section.")); + assert!(root.contains("› 2. AI context"), "{root}"); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenAgentMaxThreadsMenu)); + + chat.open_agent_max_threads_popup(); + assert!(render_bottom_popup(&chat, /*width*/ 90).contains("Cap concurrent subagent threads")); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); + assert!(render_bottom_popup(&chat, /*width*/ 90).contains("Cap concurrent subagent threads")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Left)); + let root = render_bottom_popup(&chat, /*width*/ 90); + assert!(root.contains("› 4. Agent subagent threads"), "{root}"); + + chat.handle_key_event(KeyEvent::from(KeyCode::Left)); + assert!(chat.bottom_pane.no_modal_or_popup_active()); + + chat.open_config_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn config_agent_max_threads_popup_selects_value() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; @@ -3597,6 +3652,7 @@ async fn config_agent_max_threads_popup_selects_value() { chat.open_agent_max_threads_popup(); let popup = render_bottom_popup(&chat, /*width*/ 90); + assert_chatwidget_snapshot!("config_agent_max_threads_popup", popup); assert!(popup.contains("Agent subagent threads"), "{popup}"); assert!(popup.contains("(default)"), "{popup}"); diff --git a/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/manual.rs b/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/manual.rs index 66fcbe26f1..961ca86fd1 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/manual.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/manual.rs @@ -48,6 +48,78 @@ async fn reset_picker_defaults_to_cancel() { ); } +#[tokio::test] +async fn config_usage_limit_reset_left_and_escape_return_one_level() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_canonical_reset_provider(&mut chat); + chat.on_rate_limit_reset_credits(Some(exact_reset_summary())); + while rx.try_recv().is_ok() {} + + chat.open_config_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::OpenConfigSection { + section: crate::common_config_options::CommonConfigSection::AccountAutomation, + }) + ); + chat.open_config_section_popup( + crate::common_config_options::CommonConfigSection::AccountAutomation, + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetConfirm)); + let generation = chat.rate_limit_reset_generation; + chat.open_rate_limit_reset_confirm(); + + chat.handle_key_event(KeyEvent::from(KeyCode::Left)); + let account_section = render_bottom_popup(&chat, /*width*/ 90); + assert!( + account_section.contains("Config: Account & automation"), + "{account_section}" + ); + assert!(!account_section.contains("Usage limit resets")); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::CancelRateLimitResetCreditSelection { + generation: cancelled_generation, + }) if cancelled_generation == generation + ); + assert_matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetConfirm)); + chat.open_rate_limit_reset_confirm(); + + chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); + let account_section = render_bottom_popup(&chat, /*width*/ 90); + assert!( + account_section.contains("Config: Account & automation"), + "{account_section}" + ); + assert!(!account_section.contains("Usage limit resets")); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::CancelRateLimitResetCreditSelection { + generation: cancelled_generation, + }) if cancelled_generation == generation + ); + assert_matches!( + rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Left)); + let root = render_bottom_popup(&chat, /*width*/ 90); + assert!( + root.contains("Choose a focused config.toml settings section."), + "{root}" + ); +} + #[tokio::test] async fn idle_snapshots_never_consume_a_reset() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/usage_limit_reset/manual.rs b/codex-rs/tui/src/chatwidget/usage_limit_reset/manual.rs index a24be4ccc2..87e378886a 100644 --- a/codex-rs/tui/src/chatwidget/usage_limit_reset/manual.rs +++ b/codex-rs/tui/src/chatwidget/usage_limit_reset/manual.rs @@ -157,9 +157,10 @@ impl ChatWidget { selected, reset_label(i64::try_from(selected).unwrap_or(i64::MAX)) )), - footer_hint: Some(standard_popup_hint_line()), + footer_hint: Some(Line::from("Enter selects; Left or Esc goes back")), items, initial_selected_idx: Some(selected), + tree_navigation_enabled: true, on_cancel: Some(Box::new(move |tx| { tx.send(AppEvent::CancelRateLimitResetCreditSelection { generation }); })), From 945b59abfeb86b35f7c4ab84028923b22d5512cb Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Fri, 24 Jul 2026 21:25:59 +0300 Subject: [PATCH 2/5] fix(tui): extend /config tree navigation to the goal objective picker The root /config menu's "Goal objective limit" entry still dismissed the parent on select, so Right did not drill in and Left/Esc in the picker closed the whole menu instead of going back one level. Mark it as a child-opening entry and enable tree navigation on its picker so every root entry behaves consistently. --- codex-rs/tui/src/chatwidget/settings_popups.rs | 6 ++++-- .../tui/src/chatwidget/tests/popups_and_settings.rs | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index 8e72a14a42..ec96be52f9 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -262,7 +262,8 @@ impl ChatWidget { actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenGoalPlanNodeObjectiveMenu); })], - dismiss_on_select: true, + dismiss_on_select: false, + dismiss_parent_on_child_accept: true, ..Default::default() } } @@ -314,8 +315,9 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), + footer_hint: Some(Line::from("Enter selects; Left or Esc goes back")), items, + tree_navigation_enabled: true, ..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 df52466d95..0405f370be 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3634,6 +3634,17 @@ async fn config_popup_arrow_keys_navigate_menu_tree() { let root = render_bottom_popup(&chat, /*width*/ 90); assert!(root.contains("› 4. Agent subagent threads"), "{root}"); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenGoalPlanNodeObjectiveMenu)); + + chat.open_goal_plan_node_objective_popup(); + assert!(render_bottom_popup(&chat, /*width*/ 90).contains("Goal plan node objective limit")); + + chat.handle_key_event(KeyEvent::from(KeyCode::Left)); + let root = render_bottom_popup(&chat, /*width*/ 90); + assert!(root.contains("› 5. Goal objective limit"), "{root}"); + chat.handle_key_event(KeyEvent::from(KeyCode::Left)); assert!(chat.bottom_pane.no_modal_or_popup_active()); From 2bb8a62d27c1fecaa9f5c217853c3400b55134d6 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Fri, 24 Jul 2026 21:54:00 +0300 Subject: [PATCH 3/5] refactor(tui): derive /config tree-navigation footer hints from the list keymap Footer hints for the tree-navigating /config menus were hardcoded English key names, so a rebound move_left/move_right/accept/cancel would advertise the wrong keys. Resolve the labels from the live ListKeymap the same way standard_popup_hint_line_for_keymap already does. --- codex-rs/tui/src/bottom_pane/popup_consts.rs | 68 +++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 2 + .../tui/src/chatwidget/settings_popups.rs | 40 +++++++++-- ...ests__config_account_automation_popup.snap | 2 +- ...tests__config_agent_max_threads_popup.snap | 2 +- ...idget__tests__config_ai_context_popup.snap | 2 +- ...tests__config_interface_privacy_popup.snap | 2 +- ..._tui__chatwidget__tests__config_popup.snap | 2 +- ...age_limit_reset_picker_default_cancel.snap | 4 +- .../chatwidget/usage_limit_reset/manual.rs | 10 ++- 10 files changed, 121 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index d85b9c6385..e538b5cd72 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,73 @@ 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 { + /// Verb for the drill-in keys, e.g. "opens" for a menu whose rows open a sub-menu or + /// "selects" for a leaf picker. + pub accept_label: &'static str, + /// Whether the drill-in hint should advertise `move_right`. Leaf pickers have no children to + /// drill into, so they only advertise `accept`. + pub include_move_right: bool, + /// 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, +} + +pub(crate) fn tree_navigation_hint_line( + list_keymap: &ListKeymap, + hint: TreeNavigationHint, +) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + + let accept_keys: Vec = hint + .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, hint.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 ]