feat(tui): standardize picker navigation keys (#22347)

## Why

Picker-style UI in the TUI has accumulated a mix of hardcoded navigation
keys. Some lists supported page movement, some did not; some accepted
Vim-like keys, while others only accepted arrows; and tabbed or
horizontally adjustable pickers had no shared keymap action for
left/right movement.

This PR makes picker/list navigation consistent and configurable so
users can rely on the same defaults across the TUI.

## What Changed

- Adds shared list keymap actions for:
  - vertical movement: `move_up`, `move_down`
  - horizontal movement: `move_left`, `move_right`
  - paging and jumps: `page_up`, `page_down`, `jump_top`, `jump_bottom`
- Adds defaults:
- Up/down: arrows, `Ctrl+P/N`, `Ctrl+K/J`, and plain `k/j` where text
input is not active
  - Page up/down: `PageUp/PageDown` and `Ctrl+B/F`
  - First/last: `Home/End`
  - Left/right: `Left/Right` and `Ctrl+H/L`
- Wires the shared list keymap through picker and list surfaces
including session resume, multi-select, tabbed selection lists,
settings-style lists, app-link selection, MCP elicitation,
request-user-input, and the OSS selection wizard.
- Keeps search behavior intact by reserving printable characters for
query text in searchable pickers.
- Updates keymap setup actions, config schema, snapshots, and focused
coverage for the new list actions.

## How to Test

1. Start Codex from this branch and open the session picker, for example
with an existing session history.
2. In the session list, verify that `Ctrl+J/K` moves the selection
down/up.
3. Verify that `Ctrl+F/B` pages down/up and `Home/End` jumps to the
first/last visible session.
4. Type printable search text such as `j` or `k` and confirm it updates
the query instead of navigating.
5. Focus a picker control that changes values horizontally, such as a
session picker toolbar control, and verify `Ctrl+H/L` changes the
focused value like left/right arrows.

Targeted tests run:

- `cargo test -p codex-tui keymap::tests::`
- `cargo test -p codex-tui keymap_setup::tests::`
- `cargo test -p codex-tui horizontal_list_keys`
- `cargo test -p codex-tui page_and_jump_navigation_use_list_keymap`
- `cargo test -p codex-tui ctrl_h_l_move_provider_selection`
- `cargo test -p codex-tui scroll_state::tests`
- `cargo test -p codex-tui
switching_tabs_changes_visible_items_and_clears_search`
- `cargo test -p codex-tui toggle_sort_key_reloads_with_new_sort`

Also ran `just write-config-schema`, `just fmt`, `just fix -p
codex-tui`, `just argument-comment-lint`, and `git diff --check`.

Note: `cargo test -p codex-tui` was attempted and still aborts in the
pre-existing
`tests::fork_last_filters_latest_session_by_cwd_unless_show_all` stack
overflow, which is unrelated to this branch.
This commit is contained in:
Felipe Coury authored and GitHub committed 2026-05-13 15:33:27 +00:00
1 parent 441c2f818f
commit 3d517fbd00
37 files changed
+1635 -426

No files matched your search

@@ -61,6 +61,11 @@ use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt;
@@ -182,6 +187,9 @@ pub(crate) struct MultiSelectPicker {
/// Whether left/right arrow reordering is enabled.
ordering_enabled: bool,
/// Shared list keybindings for navigation and completion.
keymap: ListKeymap,
/// Optional callback to generate a preview line from current item states.
preview_builder: Option<PreviewCallback>,
@@ -352,6 +360,30 @@ impl MultiSelectPicker {
self.state.ensure_visible(len, visible);
}
fn page_up(&mut self) {
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.page_up_clamped(len, visible);
}
fn page_down(&mut self) {
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.page_down_clamped(len, visible);
}
fn jump_top(&mut self) {
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.jump_top(len, visible);
}
fn jump_bottom(&mut self) {
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.jump_bottom(len, visible);
}
/// Toggles the enabled state of the currently selected item.
///
/// Updates the preview line and invokes the `on_change` callback if set.
@@ -493,50 +525,41 @@ impl BottomPaneView for MultiSelectPicker {
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
// Printable characters always feed search. Movement aliases such as
// plain j/k only apply through non-text events or modified bindings.
let allow_plain_char_navigation = !is_plain_text_key_event(key_event);
match key_event {
KeyEvent { code: KeyCode::Left, .. } if self.ordering_enabled => {
_ if allow_plain_char_navigation
&& self.ordering_enabled
&& self.keymap.move_left.is_pressed(key_event) =>
{
self.move_selected_item(Direction::Up);
}
KeyEvent { code: KeyCode::Right, .. } if self.ordering_enabled => {
_ if allow_plain_char_navigation
&& self.ordering_enabled
&& self.keymap.move_right.is_pressed(key_event) =>
{
self.move_selected_item(Direction::Down);
}
KeyEvent {
code: KeyCode::Up, ..
_ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => {
self.move_up()
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
_ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => {
self.move_down()
}
| KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::CONTROL,
..
_ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => {
self.page_up()
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
_ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => {
self.page_down()
}
| KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::CONTROL,
..
_ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => {
self.jump_top()
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
_ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => {
self.jump_bottom()
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
@@ -549,15 +572,8 @@ impl BottomPaneView for MultiSelectPicker {
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Enter,
..
} => self.confirm_selection(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.close();
}
_ if self.keymap.accept.is_pressed(key_event) => self.confirm_selection(),
_ if self.keymap.cancel.is_pressed(key_event) => self.close(),
KeyEvent {
code: KeyCode::Char(c),
modifiers,
@@ -703,6 +719,7 @@ pub(crate) struct MultiSelectPickerBuilder {
items: Vec<MultiSelectItem>,
ordering_enabled: bool,
app_event_tx: AppEventSender,
keymap: ListKeymap,
preview_builder: Option<PreviewCallback>,
on_change: Option<ChangeCallBack>,
on_confirm: Option<ConfirmCallback>,
@@ -719,6 +736,7 @@ impl MultiSelectPickerBuilder {
items: Vec::new(),
ordering_enabled: false,
app_event_tx,
keymap: RuntimeKeymap::defaults().list,
preview_builder: None,
on_change: None,
on_confirm: None,
@@ -732,15 +750,6 @@ impl MultiSelectPickerBuilder {
self
}
/// Sets custom instruction spans for the footer hint line.
///
/// If not set, default instructions are shown (Space to toggle, Enter to
/// confirm, Escape to close).
pub fn instructions(mut self, instructions: Vec<Span<'static>>) -> Self {
self.instructions = instructions;
self
}
/// Enables left/right arrow keys for reordering items.
///
/// Reordering is only active when the search query is empty.
@@ -749,6 +758,12 @@ impl MultiSelectPickerBuilder {
self
}
/// Sets the shared list keymap used for navigation and completion.
pub fn list_keymap(mut self, keymap: ListKeymap) -> Self {
self.keymap = keymap;
self
}
/// Sets a callback to generate a preview line from the current item states.
///
/// The callback receives all items and should return a [`Line`] to display,
@@ -806,15 +821,34 @@ impl MultiSelectPickerBuilder {
}
let instructions = if self.instructions.is_empty() {
vec![
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm and close; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
]
" to toggle".into(),
];
if self.ordering_enabled
&& let (Some(move_left), Some(move_right)) = (
primary_binding(&self.keymap.move_left),
primary_binding(&self.keymap.move_right),
)
{
spans.push("; ".into());
spans.push(move_left.into());
spans.push("/".into());
spans.push(move_right.into());
spans.push(" to move".into());
}
if let Some(accept) = primary_binding(&self.keymap.accept) {
spans.push("; ".into());
spans.push(accept.into());
spans.push(" to confirm and close".into());
}
if let Some(cancel) = primary_binding(&self.keymap.cancel) {
spans.push("; ".into());
spans.push(cancel.into());
spans.push(" to close".into());
}
spans
} else {
self.instructions
};
@@ -827,6 +861,7 @@ impl MultiSelectPickerBuilder {
header: Box::new(header),
footer_hint: Line::from(instructions),
ordering_enabled: self.ordering_enabled,
keymap: self.keymap,
search_query: String::new(),
filtered_indices: Vec::new(),
preview_builder: self.preview_builder,
@@ -940,6 +975,38 @@ mod tests {
);
}
#[test]
fn horizontal_list_keys_reorder_orderable_items() {
let mut picker = test_picker(vec![
item(
"model", /*orderable*/ true, /*section_break_after*/ false,
),
item(
"branch", /*orderable*/ true, /*section_break_after*/ false,
),
]);
picker.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert_eq!(
picker
.items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
vec!["branch", "model"]
);
picker.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(
picker
.items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
vec!["model", "branch"]
);
}
#[test]
fn section_break_after_item_renders_separator_row() {
let picker = test_picker(vec![
@@ -964,4 +1031,65 @@ mod tests {
);
assert_eq!(rows.state.selected_idx, Some(0));
}
#[test]
fn searchable_plain_j_updates_query_instead_of_navigating() {
let mut picker = test_picker(vec![
item(
"alpha", /*orderable*/ true, /*section_break_after*/ false,
),
item(
"jupiter", /*orderable*/ true, /*section_break_after*/ false,
),
]);
picker.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(picker.search_query, "j");
assert_eq!(picker.filtered_indices, vec![1]);
assert_eq!(picker.state.selected_idx, Some(0));
}
#[test]
fn page_and_jump_navigation_use_list_keymap() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let mut keymap = RuntimeKeymap::defaults().list;
keymap.page_down = vec![key_hint::ctrl(KeyCode::Char('d'))];
keymap.page_up = vec![key_hint::ctrl(KeyCode::Char('u'))];
keymap.jump_bottom = vec![key_hint::ctrl(KeyCode::Char('e'))];
keymap.jump_top = vec![key_hint::ctrl(KeyCode::Char('a'))];
let mut picker = MultiSelectPicker::builder(
"Test".to_string(),
/*subtitle*/ None,
AppEventSender::new(tx),
)
.items(
(0..12)
.map(|idx| {
item(
&format!("item-{idx}"),
/*orderable*/ true,
/*section_break_after*/ false,
)
})
.collect(),
)
.list_keymap(keymap)
.build();
picker.handle_key_event(KeyEvent::from(KeyCode::PageDown));
assert_eq!(picker.state.selected_idx, Some(0));
picker.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_eq!(picker.state.selected_idx, Some(8));
picker.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert_eq!(picker.state.selected_idx, Some(0));
picker.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert_eq!(picker.state.selected_idx, Some(11));
picker.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(picker.state.selected_idx, Some(0));
}
}