mirror of
https://github.com/openai/codex.git
synced 2026-11-12 06:40:35 +00:00
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:
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user