Files
codex/codex-rs/tui/src/key_hint.rs
T
Felipe Coury 3d517fbd00 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.
2026-05-13 15:33:27 +00:00

382 lines
13 KiB
Rust

//! Key binding primitives and input matching for the TUI.
//!
//! This module provides `KeyBinding`, the runtime representation of a single
//! keybinding (key code + modifier set), along with matching logic that handles
//! cross-terminal inconsistencies in how shifted letters and raw C0 control
//! characters are reported.
//!
//! List and picker code should match navigation through these helpers instead
//! of comparing `KeyEvent` values directly. The matcher owns compatibility for
//! terminals that report control chords as C0 characters, while
//! `is_plain_text_key_event` gives searchable pickers a shared boundary between
//! text input and navigation commands.
//!
//! It also supplies rendering helpers that convert bindings into styled
//! `ratatui::text::Span` values for UI hint display.
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Span;
#[cfg(test)]
const ALT_PREFIX: &str = "⌥ + ";
#[cfg(all(not(test), target_os = "macos"))]
const ALT_PREFIX: &str = "⌥ + ";
#[cfg(all(not(test), not(target_os = "macos")))]
const ALT_PREFIX: &str = "alt + ";
const CTRL_PREFIX: &str = "ctrl + ";
const SHIFT_PREFIX: &str = "shift + ";
/// One concrete key event that can trigger a TUI action.
///
/// Matching via `is_press` handles exact equality plus compatibility fallbacks
/// for terminals that report uppercase letters without SHIFT and Ctrl keys as
/// raw C0 control characters. This means a binding defined as `shift-a` will
/// match either `Shift+a` or plain `A`, and `ctrl-j` will match raw LF.
///
/// This does not model multi-key chords or partial matches; callers that need
/// sequences must keep that state outside this type.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct KeyBinding {
key: KeyCode,
modifiers: KeyModifiers,
}
impl KeyBinding {
pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
Self { key, modifiers }
}
pub(crate) fn from_event(event: KeyEvent) -> Self {
let (key, modifiers) = normalize_key_parts(event.code, event.modifiers);
Self { key, modifiers }
}
pub fn is_press(&self, event: KeyEvent) -> bool {
normalize_key_parts(self.key, self.modifiers)
== normalize_key_parts(event.code, event.modifiers)
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
}
pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) {
(self.key, self.modifiers)
}
pub(crate) fn display_label(&self) -> String {
let modifiers = modifiers_to_string(self.modifiers);
let key = match self.key {
KeyCode::Enter => "enter".to_string(),
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Up => "".to_string(),
KeyCode::Down => "".to_string(),
KeyCode::Left => "".to_string(),
KeyCode::Right => "".to_string(),
KeyCode::PageUp => "pgup".to_string(),
KeyCode::PageDown => "pgdn".to_string(),
_ => self.key.to_string().to_ascii_lowercase(),
};
format!("{modifiers}{key}")
}
}
pub(crate) fn normalize_key_parts(
key: KeyCode,
mut modifiers: KeyModifiers,
) -> (KeyCode, KeyModifiers) {
let KeyCode::Char(ch) = key else {
return (key, modifiers);
};
if modifiers.is_empty()
&& let Some(ctrl_char) = c0_control_char_to_ctrl_char(ch)
{
return (KeyCode::Char(ctrl_char), KeyModifiers::CONTROL | modifiers);
}
if ch.is_ascii_uppercase() {
modifiers.insert(KeyModifiers::SHIFT);
return (KeyCode::Char(ch.to_ascii_lowercase()), modifiers);
}
(key, modifiers)
}
fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
let code = u32::from(ch);
match code {
0x00 => Some(' '),
0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')),
0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')),
_ => None,
}
}
/// Matching helpers for one action's keybinding set.
///
/// Implementations are expected to treat the slice as alternatives for one
/// action. They should not interpret order as priority for dispatch; order is
/// reserved for UI hint selection via `primary_binding`.
pub(crate) trait KeyBindingListExt {
/// True when any binding in this set matches `event`.
fn is_pressed(&self, event: KeyEvent) -> bool;
}
impl KeyBindingListExt for [KeyBinding] {
fn is_pressed(&self, event: KeyEvent) -> bool {
self.iter().any(|binding| binding.is_press(event))
}
}
/// Returns whether an event should be treated as literal text input.
///
/// Searchable pickers use this to avoid stealing plain printable characters for
/// navigation when the same character might be a valid query. For example, a
/// list may bind `j` and `k` for movement, but a searchable list must let
/// plain `j` update the query while still allowing `Ctrl+J` to move. Calling
/// this after normalizing keybindings would blur that distinction and cause
/// printable search input to disappear.
pub(crate) fn is_plain_text_key_event(event: KeyEvent) -> bool {
matches!(
event,
KeyEvent {
code: KeyCode::Char(ch),
modifiers,
..
} if !ch.is_ascii_control()
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT)
)
}
pub(crate) const fn plain(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::NONE)
}
pub(crate) const fn alt(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::ALT)
}
pub(crate) const fn shift(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::SHIFT)
}
pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::CONTROL)
}
pub(crate) const fn ctrl_alt(key: KeyCode) -> KeyBinding {
KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))
}
fn modifiers_to_string(modifiers: KeyModifiers) -> String {
let mut result = String::new();
if modifiers.contains(KeyModifiers::CONTROL) {
result.push_str(CTRL_PREFIX);
}
if modifiers.contains(KeyModifiers::SHIFT) {
result.push_str(SHIFT_PREFIX);
}
if modifiers.contains(KeyModifiers::ALT) {
result.push_str(ALT_PREFIX);
}
result
}
impl From<KeyBinding> for Span<'static> {
fn from(binding: KeyBinding) -> Self {
(&binding).into()
}
}
impl From<&KeyBinding> for Span<'static> {
fn from(binding: &KeyBinding) -> Self {
Span::styled(binding.display_label(), key_hint_style())
}
}
fn key_hint_style() -> Style {
Style::default().dim()
}
pub(crate) fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {
(mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)
}
#[cfg(windows)]
#[inline]
pub(crate) fn is_altgr(mods: KeyModifiers) -> bool {
mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)
}
#[cfg(not(windows))]
#[inline]
pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_press_accepts_press_and_repeat_but_rejects_release() {
let binding = ctrl(KeyCode::Char('k'));
let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
let repeat = KeyEvent {
kind: KeyEventKind::Repeat,
..press
};
let release = KeyEvent {
kind: KeyEventKind::Release,
..press
};
let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
assert!(binding.is_press(press));
assert!(binding.is_press(repeat));
assert!(!binding.is_press(release));
assert!(!binding.is_press(wrong_modifiers));
}
#[test]
fn keybinding_list_ext_matches_any_binding() {
let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))];
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)));
assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
}
#[test]
fn shifted_letter_binding_matches_uppercase_char_events() {
let binding = shift(KeyCode::Char('a'));
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT)));
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)));
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)));
}
#[test]
fn shift_letter_binding_preserves_other_modifiers_with_uppercase_compat() {
let binding = KeyBinding::new(
KeyCode::Char('i'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::CONTROL)));
}
#[test]
fn shift_letter_binding_does_not_match_plain_lowercase_or_other_uppercase() {
let binding = shift(KeyCode::Char('o'));
assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)));
assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::NONE)));
}
#[test]
fn ctrl_letter_binding_matches_c0_control_char_events() {
let binding = ctrl(KeyCode::Char('p'));
assert!(binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)));
assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT)));
}
#[test]
fn ctrl_bindings_match_all_supported_c0_control_char_events() {
let cases = [
(' ', '\u{0000}'),
('a', '\u{0001}'),
('b', '\u{0002}'),
('c', '\u{0003}'),
('d', '\u{0004}'),
('e', '\u{0005}'),
('f', '\u{0006}'),
('g', '\u{0007}'),
('h', '\u{0008}'),
('i', '\u{0009}'),
('j', '\u{000a}'),
('k', '\u{000b}'),
('l', '\u{000c}'),
('m', '\u{000d}'),
('n', '\u{000e}'),
('o', '\u{000f}'),
('p', '\u{0010}'),
('q', '\u{0011}'),
('r', '\u{0012}'),
('s', '\u{0013}'),
('t', '\u{0014}'),
('u', '\u{0015}'),
('v', '\u{0016}'),
('w', '\u{0017}'),
('x', '\u{0018}'),
('y', '\u{0019}'),
('z', '\u{001a}'),
('4', '\u{001c}'),
('5', '\u{001d}'),
('6', '\u{001e}'),
('7', '\u{001f}'),
];
for (ctrl_char, c0_char) in cases {
assert!(
ctrl(KeyCode::Char(ctrl_char))
.is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::NONE)),
"expected raw C0 {c0_char:?} to match ctrl-{ctrl_char}"
);
assert!(
!ctrl(KeyCode::Char(ctrl_char))
.is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::ALT)),
"expected modified raw C0 {c0_char:?} not to match ctrl-{ctrl_char}"
);
}
}
#[test]
fn ctrl_binding_does_not_match_ambiguous_c0_escape_or_delete() {
assert!(
!ctrl(KeyCode::Char('['))
.is_press(KeyEvent::new(KeyCode::Char('\u{001b}'), KeyModifiers::NONE,))
);
assert!(
!ctrl(KeyCode::Char('?'))
.is_press(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE,))
);
}
#[test]
fn history_search_ctrl_bindings_match_c0_control_char_events() {
assert!(
ctrl(KeyCode::Char('r'))
.is_press(KeyEvent::new(KeyCode::Char('\u{0012}'), KeyModifiers::NONE))
);
assert!(
ctrl(KeyCode::Char('s'))
.is_press(KeyEvent::new(KeyCode::Char('\u{0013}'), KeyModifiers::NONE))
);
}
#[test]
fn ctrl_alt_sets_both_modifiers() {
assert_eq!(
ctrl_alt(KeyCode::Char('v')).parts(),
(
KeyCode::Char('v'),
KeyModifiers::CONTROL | KeyModifiers::ALT
)
);
}
#[test]
fn has_ctrl_or_alt_checks_supported_modifier_combinations() {
assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL));
assert!(has_ctrl_or_alt(KeyModifiers::ALT));
#[cfg(windows)]
assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
#[cfg(not(windows))]
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
}
}