From 17661e3d023fcf2cce16463e2d5c09c902ad435d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 23 Jul 2026 17:42:43 +0300 Subject: [PATCH] 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 f30edb7a21..014b2780e6 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, @@ -224,6 +225,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, @@ -259,6 +261,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, @@ -330,6 +333,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 @@ -977,6 +981,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 8bc025169b..5ac41655d8 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -142,8 +142,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() }); } @@ -180,7 +181,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() } } @@ -240,8 +242,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() }); } @@ -266,9 +269,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() }); @@ -545,7 +551,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(), @@ -577,7 +584,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 3c7432ac88..d07dca7c91 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 @@ -13,4 +13,4 @@ expression: popup 4. Agent subagent threads Max concurrent subagent threads per agent run (currently the built-in default). - 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 ce179e66ca..ca4075ca9c 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. › 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 ba689f917d..6558ae88e4 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3438,6 +3438,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; @@ -3448,6 +3503,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 28c3b0fa77..d1742b20f3 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 @@ -44,6 +44,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 f6b9b3f43f..dcc638603f 100644 --- a/codex-rs/tui/src/chatwidget/usage_limit_reset/manual.rs +++ b/codex-rs/tui/src/chatwidget/usage_limit_reset/manual.rs @@ -160,9 +160,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 }); })),