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
2026-05-13 12:33:27 -03:00
committed by GitHub
Unverified
parent 441c2f818f
commit 3d517fbd00
37 changed files with 1635 additions and 426 deletions
+12
View File
@@ -306,6 +306,18 @@ pub struct TuiListKeymap {
pub move_up: Option<KeybindingsSpec>,
/// Move list selection down.
pub move_down: Option<KeybindingsSpec>,
/// Move horizontally left in list pickers that support horizontal actions.
pub move_left: Option<KeybindingsSpec>,
/// Move horizontally right in list pickers that support horizontal actions.
pub move_right: Option<KeybindingsSpec>,
/// Move list selection up by one page.
pub page_up: Option<KeybindingsSpec>,
/// Move list selection down by one page.
pub page_down: Option<KeybindingsSpec>,
/// Jump to the first list item.
pub jump_top: Option<KeybindingsSpec>,
/// Jump to the last list item.
pub jump_bottom: Option<KeybindingsSpec>,
/// Accept current selection.
pub accept: Option<KeybindingsSpec>,
/// Cancel and close selection view.
+62 -2
View File
@@ -2606,8 +2606,14 @@
"list": {
"accept": null,
"cancel": null,
"jump_bottom": null,
"jump_top": null,
"move_down": null,
"move_up": null
"move_left": null,
"move_right": null,
"move_up": null,
"page_down": null,
"page_up": null
},
"pager": {
"close": null,
@@ -3252,8 +3258,14 @@
"default": {
"accept": null,
"cancel": null,
"jump_bottom": null,
"jump_top": null,
"move_down": null,
"move_up": null
"move_left": null,
"move_right": null,
"move_up": null,
"page_down": null,
"page_up": null
}
},
"pager": {
@@ -3350,6 +3362,22 @@
],
"description": "Cancel and close selection view."
},
"jump_bottom": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Jump to the last list item."
},
"jump_top": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Jump to the first list item."
},
"move_down": {
"allOf": [
{
@@ -3358,6 +3386,22 @@
],
"description": "Move list selection down."
},
"move_left": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move horizontally left in list pickers that support horizontal actions."
},
"move_right": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move horizontally right in list pickers that support horizontal actions."
},
"move_up": {
"allOf": [
{
@@ -3365,6 +3409,22 @@
}
],
"description": "Move list selection up."
},
"page_down": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move list selection down by one page."
},
"page_up": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move list selection up by one page."
}
},
"type": "object"
+87 -18
View File
@@ -29,6 +29,8 @@ use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
@@ -238,10 +240,24 @@ pub(crate) struct AppLinkView {
screen: AppLinkScreen,
selected_action: usize,
complete: bool,
list_keymap: ListKeymap,
}
impl AppLinkView {
#[cfg(test)]
pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self {
Self::new_with_keymap(
params,
app_event_tx,
crate::keymap::RuntimeKeymap::defaults().list,
)
}
pub(crate) fn new_with_keymap(
params: AppLinkViewParams,
app_event_tx: AppEventSender,
list_keymap: ListKeymap,
) -> Self {
let AppLinkViewParams {
app_id,
title,
@@ -269,6 +285,7 @@ impl AppLinkView {
screen: AppLinkScreen::Link,
selected_action: 0,
complete: false,
list_keymap,
}
}
@@ -679,10 +696,6 @@ impl BottomPaneView for AppLinkView {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Left,
..
}
| KeyEvent {
code: KeyCode::BackTab,
..
@@ -691,20 +704,12 @@ impl BottomPaneView for AppLinkView {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_prev(),
_ if self.list_keymap.move_left.is_pressed(key_event) => self.move_selection_prev(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Right,
..
}
| KeyEvent {
code: KeyCode::Tab, ..
}
@@ -712,12 +717,8 @@ impl BottomPaneView for AppLinkView {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_next(),
_ if self.list_keymap.move_right.is_pressed(key_event) => self.move_selection_next(),
KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
@@ -1087,6 +1088,74 @@ mod tests {
assert!(view.terminal_title_requires_action());
}
#[test]
fn horizontal_list_keys_move_action_selection() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
assert_eq!(view.selected_action, 0);
view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert_eq!(view.selected_action, 1);
view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(view.selected_action, 0);
}
#[test]
fn remapped_horizontal_list_keys_control_action_selection() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut list_keymap = crate::keymap::RuntimeKeymap::defaults().list;
list_keymap.move_left = vec![key_hint::plain(KeyCode::Char('x'))];
list_keymap.move_right = vec![key_hint::plain(KeyCode::Char('z'))];
let mut view = AppLinkView::new_with_keymap(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
list_keymap,
);
assert_eq!(view.selected_action, 0);
view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
assert_eq!(view.selected_action, 0);
view.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(view.selected_action, 0);
view.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
assert_eq!(view.selected_action, 1);
view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert_eq!(view.selected_action, 1);
view.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(view.selected_action, 1);
view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(view.selected_action, 0);
}
#[test]
fn toggle_action_sends_set_app_enabled_and_updates_label() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
@@ -13,6 +13,8 @@ use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
@@ -43,12 +45,14 @@ pub(crate) struct ExperimentalFeaturesView {
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
footer_hint: Line<'static>,
keymap: ListKeymap,
}
impl ExperimentalFeaturesView {
pub(crate) fn new(
features: Vec<ExperimentalFeatureItem>,
app_event_tx: AppEventSender,
keymap: ListKeymap,
) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
@@ -63,6 +67,7 @@ impl ExperimentalFeaturesView {
app_event_tx,
header: Box::new(header),
footer_hint: experimental_popup_hint_line(),
keymap,
};
view.initialize_selection();
view
@@ -119,6 +124,30 @@ impl ExperimentalFeaturesView {
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn page_up(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.state.page_up_clamped(len, visible);
}
fn page_down(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.state.page_down_clamped(len, visible);
}
fn jump_top(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.state.jump_top(len, visible);
}
fn jump_bottom(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.state.jump_bottom(len, visible);
}
fn toggle_selected(&mut self) {
let Some(selected_idx) = self.state.selected_idx else {
return;
@@ -137,56 +166,20 @@ impl ExperimentalFeaturesView {
impl BottomPaneView for ExperimentalFeaturesView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
_ if self.keymap.move_down.is_pressed(key_event) => self.move_down(),
_ if self.keymap.page_up.is_pressed(key_event) => self.page_up(),
_ if self.keymap.page_down.is_pressed(key_event) => self.page_down(),
_ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(),
_ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(),
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Esc, ..
} => {
_ if self.keymap.accept.is_pressed(key_event)
|| self.keymap.cancel.is_pressed(key_event) =>
{
self.on_ctrl_c();
}
_ => {}
@@ -29,6 +29,8 @@ use crate::app_event_sender::AppEventSender;
use crate::hooks_rpc::HookTrustUpdate;
use crate::hooks_rpc::hook_needs_review;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
use crate::status::format_directory_display;
@@ -50,6 +52,7 @@ pub(crate) struct HooksBrowserView {
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
keymap: ListKeymap,
}
impl HooksBrowserView {
@@ -68,10 +71,15 @@ impl HooksBrowserView {
errors,
},
app_event_tx,
crate::keymap::RuntimeKeymap::defaults().list,
)
}
pub(crate) fn from_entry(mut entry: HooksListEntry, app_event_tx: AppEventSender) -> Self {
pub(crate) fn from_entry(
mut entry: HooksListEntry,
app_event_tx: AppEventSender,
keymap: ListKeymap,
) -> Self {
entry.hooks.sort_by_key(|hook| hook.display_order);
let mut view = Self {
entry,
@@ -79,6 +87,7 @@ impl HooksBrowserView {
state: ScrollState::new(),
complete: false,
app_event_tx,
keymap,
};
if view.page_len() > 0 {
view.state.selected_idx = Some(
@@ -165,6 +174,26 @@ impl HooksBrowserView {
self.state.ensure_visible(len, self.max_visible_rows());
}
fn page_up(&mut self) {
let len = self.page_len();
self.state.page_up_clamped(len, self.max_visible_rows());
}
fn page_down(&mut self) {
let len = self.page_len();
self.state.page_down_clamped(len, self.max_visible_rows());
}
fn jump_top(&mut self) {
let len = self.page_len();
self.state.jump_top(len, self.max_visible_rows());
}
fn jump_bottom(&mut self) {
let len = self.page_len();
self.state.jump_bottom(len, self.max_visible_rows());
}
fn page_len(&self) -> usize {
match self.page {
HooksBrowserPage::Events => codex_protocol::protocol::HookEventName::iter().count(),
@@ -533,33 +562,18 @@ impl HooksBrowserView {
impl BottomPaneView for HooksBrowserView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
_ if self.keymap.move_down.is_pressed(key_event) => self.move_down(),
_ if self.keymap.page_up.is_pressed(key_event) => self.page_up(),
_ if self.keymap.page_down.is_pressed(key_event) => self.page_down(),
_ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(),
_ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(),
_ if self.keymap.accept.is_pressed(key_event)
&& self.page == HooksBrowserPage::Events =>
{
self.open_selected_event()
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} if self.page == HooksBrowserPage::Events => self.open_selected_event(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
_ if self.keymap.accept.is_pressed(key_event) => {
if let HooksBrowserPage::Handlers(event_name) = self.page {
self.toggle_selected_hook(event_name);
}
@@ -581,9 +595,7 @@ impl BottomPaneView for HooksBrowserView {
HooksBrowserPage::Events => self.trust_all_hooks(),
HooksBrowserPage::Handlers(event_name) => self.trust_selected_hook(event_name),
},
KeyEvent {
code: KeyCode::Esc, ..
} => match self.page {
_ if self.keymap.cancel.is_pressed(key_event) => match self.page {
HooksBrowserPage::Events => self.close(),
HooksBrowserPage::Handlers(_) => self.return_to_events(),
},
@@ -17,6 +17,7 @@ use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListKeymap;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -655,8 +656,8 @@ impl ListSelectionView {
let len = self.visible_len();
self.state.move_up_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
self.skip_disabled_up();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
self.fire_selection_changed();
}
@@ -667,8 +668,56 @@ impl ListSelectionView {
let len = self.visible_len();
self.state.move_down_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
self.skip_disabled_down();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
self.fire_selection_changed();
}
}
fn page_up(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.page_up_clamped(len, visible);
self.skip_disabled_up_clamped();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
self.fire_selection_changed();
}
}
fn page_down(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.page_down_clamped(len, visible);
self.skip_disabled_down_clamped();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
self.fire_selection_changed();
}
}
fn jump_top(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.jump_top(len, visible);
self.skip_disabled_down_clamped();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
self.fire_selection_changed();
}
}
fn jump_bottom(&mut self) {
let before = self.selected_actual_idx();
let len = self.visible_len();
let visible = Self::max_visible_rows(len);
self.state.jump_bottom(len, visible);
self.skip_disabled_up_clamped();
self.state.ensure_visible(len, visible);
if self.selected_actual_idx() != before {
self.fire_selection_changed();
}
@@ -788,13 +837,7 @@ impl ListSelectionView {
fn skip_disabled_down(&mut self) {
let len = self.visible_len();
for _ in 0..len {
if let Some(idx) = self.state.selected_idx
&& let Some(actual_idx) = self.filtered_indices.get(idx)
&& self
.active_items()
.get(*actual_idx)
.is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled)
{
if self.selected_visible_idx_is_disabled() {
self.state.move_down_wrap(len);
} else {
break;
@@ -805,34 +848,70 @@ impl ListSelectionView {
fn skip_disabled_up(&mut self) {
let len = self.visible_len();
for _ in 0..len {
if let Some(idx) = self.state.selected_idx
&& let Some(actual_idx) = self.filtered_indices.get(idx)
&& self
.active_items()
.get(*actual_idx)
.is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled)
{
if self.selected_visible_idx_is_disabled() {
self.state.move_up_wrap(len);
} else {
break;
}
}
}
fn skip_disabled_down_clamped(&mut self) {
let Some(start) = self.state.selected_idx else {
return;
};
if !self.visible_idx_is_disabled(start) {
return;
}
let len = self.visible_len();
self.state.selected_idx = ((start + 1)..len)
.find(|idx| !self.visible_idx_is_disabled(*idx))
.or_else(|| {
(0..start)
.rev()
.find(|idx| !self.visible_idx_is_disabled(*idx))
})
.or(Some(start));
}
fn skip_disabled_up_clamped(&mut self) {
let Some(start) = self.state.selected_idx else {
return;
};
if !self.visible_idx_is_disabled(start) {
return;
}
let len = self.visible_len();
self.state.selected_idx = (0..start)
.rev()
.find(|idx| !self.visible_idx_is_disabled(*idx))
.or_else(|| ((start + 1)..len).find(|idx| !self.visible_idx_is_disabled(*idx)))
.or(Some(start));
}
fn selected_visible_idx_is_disabled(&self) -> bool {
self.state
.selected_idx
.is_some_and(|idx| self.visible_idx_is_disabled(idx))
}
fn visible_idx_is_disabled(&self, idx: usize) -> bool {
self.filtered_indices
.get(idx)
.and_then(|actual_idx| self.active_items().get(*actual_idx))
.is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled)
}
}
impl BottomPaneView for ListSelectionView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
let is_plain_text_char = matches!(
key_event,
KeyEvent {
code: KeyCode::Char(ch),
modifiers,
..
} if !ch.is_ascii_control()
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT)
);
let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char;
// Searchable lists reserve printable characters for query input. This
// keeps vim-style plain j/k/h/l useful in non-search lists without
// making those letters impossible to type into a filter.
let allow_plain_char_navigation =
!self.is_searchable || !is_plain_text_key_event(key_event);
match key_event {
_ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => {
@@ -841,14 +920,30 @@ impl BottomPaneView for ListSelectionView {
_ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => {
self.move_down()
}
KeyEvent {
code: KeyCode::Left,
..
} if self.tabs_enabled() => self.switch_tab(/*step*/ -1),
KeyEvent {
code: KeyCode::Right,
..
} if self.tabs_enabled() => self.switch_tab(/*step*/ 1),
_ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => {
self.page_up()
}
_ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => {
self.page_down()
}
_ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => {
self.jump_top()
}
_ if allow_plain_char_navigation && self.keymap.jump_bottom.is_pressed(key_event) => {
self.jump_bottom()
}
_ if allow_plain_char_navigation
&& self.tabs_enabled()
&& self.keymap.move_left.is_pressed(key_event) =>
{
self.switch_tab(/*step*/ -1)
}
_ if allow_plain_char_navigation
&& self.tabs_enabled()
&& self.keymap.move_right.is_pressed(key_event) =>
{
self.switch_tab(/*step*/ 1)
}
KeyEvent {
code: KeyCode::Backspace,
..
@@ -1618,6 +1713,10 @@ mod tests {
assert_eq!(view.active_tab_id(), Some("alpha"));
assert_eq!(view.search_query, "");
view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert_eq!(view.active_tab_id(), Some("beta"));
view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(view.active_tab_id(), Some("alpha"));
let rendered = render_lines(&view);
assert!(
rendered.contains("Alpha Item") && !rendered.contains("Beta Item"),
@@ -2059,6 +2158,77 @@ mod tests {
assert_eq!(view.selected_actual_idx(), Some(1));
}
#[test]
fn page_and_jump_navigation_use_list_keymap() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))];
keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))];
keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('e'))];
keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))];
let mut view = ListSelectionView::new(
SelectionViewParams {
items: (0..12)
.map(|idx| SelectionItem {
name: format!("Item {idx}"),
..Default::default()
})
.collect(),
..Default::default()
},
tx,
keymap,
);
view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
assert_eq!(view.selected_actual_idx(), Some(0));
view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_eq!(view.selected_actual_idx(), Some(8));
view.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert_eq!(view.selected_actual_idx(), Some(0));
view.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert_eq!(view.selected_actual_idx(), Some(11));
view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(view.selected_actual_idx(), Some(0));
}
#[test]
fn page_and_jump_navigation_skip_trailing_disabled_rows_without_wrapping() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = ListSelectionView::new(
SelectionViewParams {
items: (0..12)
.map(|idx| SelectionItem {
name: format!("Item {idx}"),
is_disabled: idx >= 8,
..Default::default()
})
.collect(),
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
view.handle_key_event(KeyEvent::from(KeyCode::PageDown));
assert_eq!(view.selected_actual_idx(), Some(7));
let selected = view.state.selected_idx.expect("selection should be set");
assert!(view.state.scroll_top <= selected);
assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12));
view.handle_key_event(KeyEvent::from(KeyCode::End));
assert_eq!(view.selected_actual_idx(), Some(7));
let selected = view.state.selected_idx.expect("selection should be set");
assert!(view.state.scroll_top <= selected);
assert!(selected < view.state.scroll_top + ListSelectionView::max_visible_rows(/*len*/ 12));
}
#[test]
fn wraps_long_option_without_overflowing_columns() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -49,6 +49,8 @@ use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::render::renderable::Renderable;
use crate::text_formatting::format_json_compact;
use crate::text_formatting::truncate_text;
@@ -711,15 +713,35 @@ pub(crate) struct McpServerElicitationOverlay {
current_idx: usize,
done: bool,
validation_error: Option<String>,
list_keymap: ListKeymap,
}
impl McpServerElicitationOverlay {
#[cfg(test)]
pub(crate) fn new(
request: McpServerElicitationFormRequest,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
) -> Self {
Self::new_with_keymap(
request,
app_event_tx,
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
crate::keymap::RuntimeKeymap::defaults().list,
)
}
pub(crate) fn new_with_keymap(
request: McpServerElicitationFormRequest,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
list_keymap: ListKeymap,
) -> Self {
let mut composer = ChatComposer::new_with_config(
has_input_focus,
@@ -739,6 +761,7 @@ impl McpServerElicitationOverlay {
current_idx: 0,
done: false,
validation_error: None,
list_keymap,
};
overlay.reset_for_request();
overlay.restore_current_draft();
@@ -1529,18 +1552,40 @@ impl BottomPaneView for McpServerElicitationOverlay {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::NONE,
..
} if self.current_field_is_select() => {
self.move_field(/*next*/ false);
return;
}
_ if self.current_field_is_select()
&& self.list_keymap.move_left.is_pressed(key_event) =>
{
self.move_field(/*next*/ false);
return;
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::NONE,
..
} if self.current_field_is_select() => {
self.move_field(/*next*/ true);
return;
}
_ if self.current_field_is_select()
&& self.list_keymap.move_right.is_pressed(key_event) =>
{
self.move_field(/*next*/ true);
return;
}
_ => {}
}
@@ -2155,6 +2200,43 @@ mod tests {
);
}
#[test]
fn horizontal_list_keys_move_between_select_fields() {
let (tx, _rx) = test_sender();
let request = from_form_request(
ThreadId::default(),
form_request(
"Choose values",
serde_json::json!({
"type": "object",
"properties": {
"first": {
"type": "boolean",
"title": "First",
},
"second": {
"type": "boolean",
"title": "Second",
}
},
"required": ["first", "second"],
}),
/*meta*/ None,
),
)
.expect("expected supported form");
let mut overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert_eq!(overlay.current_idx, 0);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_idx, 1);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_idx, 0);
}
#[test]
fn empty_tool_approval_schema_session_choice_sets_persist_meta() {
let (tx, mut rx) = test_sender();
@@ -14,6 +14,8 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
@@ -62,6 +64,7 @@ pub(crate) struct MemoriesSettingsView {
complete: bool,
app_event_tx: AppEventSender,
docs_link: Line<'static>,
keymap: ListKeymap,
}
impl MemoriesSettingsView {
@@ -69,6 +72,7 @@ impl MemoriesSettingsView {
use_memories: bool,
generate_memories: bool,
app_event_tx: AppEventSender,
keymap: ListKeymap,
) -> Self {
let mut view = Self {
items: vec![
@@ -98,6 +102,7 @@ impl MemoriesSettingsView {
"Learn more: ".dim(),
MEMORIES_DOC_URL.cyan().underlined(),
]),
keymap,
};
view.initialize_selection();
view
@@ -216,6 +221,30 @@ impl MemoriesSettingsView {
state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn page_up(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.active_state_mut().page_up_clamped(len, visible);
}
fn page_down(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.active_state_mut().page_down_clamped(len, visible);
}
fn jump_top(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.active_state_mut().jump_top(len, visible);
}
fn jump_bottom(&mut self) {
let len = self.visible_len();
let visible = MAX_POPUP_ROWS.min(len);
self.active_state_mut().jump_bottom(len, visible);
}
fn toggle_selected(&mut self) {
if self.reset_confirmation.is_some() {
return;
@@ -271,56 +300,19 @@ impl MemoriesSettingsView {
impl BottomPaneView for MemoriesSettingsView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
_ if self.keymap.move_down.is_pressed(key_event) => self.move_down(),
_ if self.keymap.page_up.is_pressed(key_event) => self.page_up(),
_ if self.keymap.page_down.is_pressed(key_event) => self.page_down(),
_ if self.keymap.jump_top.is_pressed(key_event) => self.jump_top(),
_ if self.keymap.jump_bottom.is_pressed(key_event) => self.jump_bottom(),
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.save(),
KeyEvent {
code: KeyCode::Esc, ..
} => self.cancel(),
_ if self.keymap.accept.is_pressed(key_event) => self.save(),
_ if self.keymap.cancel.is_pressed(key_event) => self.cancel(),
_ => {}
}
}
+10 -3
View File
@@ -1082,6 +1082,10 @@ impl BottomPane {
popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list)
}
pub(crate) fn list_keymap(&self) -> crate::keymap::ListKeymap {
self.keymap.list.clone()
}
/// Replace one or more active views whose IDs are in `view_ids` with a
/// generic list selection view.
pub(crate) fn replace_active_views_with_selection_view(
@@ -1298,12 +1302,13 @@ impl BottomPane {
request
};
let modal = RequestUserInputOverlay::new(
let modal = RequestUserInputOverlay::new_with_keymap(
request,
self.app_event_tx.clone(),
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
self.keymap.list.clone(),
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
@@ -1342,7 +1347,7 @@ impl BottomPane {
tool_suggestion.suggest_type,
mcp_server_elicitation::ToolSuggestionType::Enable
);
let view = AppLinkView::new(
let view = AppLinkView::new_with_keymap(
AppLinkViewParams {
app_id: tool_suggestion.tool_id.clone(),
title: tool_suggestion.tool_name.clone(),
@@ -1373,6 +1378,7 @@ impl BottomPane {
}),
},
self.app_event_tx.clone(),
self.keymap.list.clone(),
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
@@ -1383,12 +1389,13 @@ impl BottomPane {
return;
}
let modal = McpServerElicitationOverlay::new(
let modal = McpServerElicitationOverlay::new_with_keymap(
request,
self.app_event_tx.clone(),
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
self.keymap.list.clone(),
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
@@ -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));
}
}
@@ -29,6 +29,8 @@ use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::history_cell;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::render::renderable::Renderable;
#[cfg(test)]
@@ -138,15 +140,35 @@ pub(crate) struct RequestUserInputOverlay {
done: bool,
pending_submission_draft: Option<ComposerDraft>,
confirm_unanswered: Option<ScrollState>,
list_keymap: ListKeymap,
}
impl RequestUserInputOverlay {
#[cfg(test)]
pub(crate) fn new(
request: ToolRequestUserInputParams,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
) -> Self {
Self::new_with_keymap(
request,
app_event_tx,
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
crate::keymap::RuntimeKeymap::defaults().list,
)
}
pub(crate) fn new_with_keymap(
request: ToolRequestUserInputParams,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
list_keymap: ListKeymap,
) -> Self {
// Use the same composer widget, but disable popups/slash-commands and
// image-path attachment so it behaves like a focused notes field.
@@ -171,6 +193,7 @@ impl RequestUserInputOverlay {
done: false,
pending_submission_draft: None,
confirm_unanswered: None,
list_keymap,
};
overlay.reset_for_request();
overlay.ensure_focus_available();
@@ -1063,11 +1086,8 @@ impl BottomPaneView for RequestUserInputOverlay {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::NONE,
..
} if self.has_options() && matches!(self.focus, Focus::Options) => {
self.move_question(/*next*/ false);
return;
}
KeyEvent {
| KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
@@ -1075,19 +1095,30 @@ impl BottomPaneView for RequestUserInputOverlay {
self.move_question(/*next*/ false);
return;
}
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_left.is_pressed(key_event) =>
{
self.move_question(/*next*/ false);
return;
}
KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} if self.has_options() && matches!(self.focus, Focus::Options) => {
self.move_question(/*next*/ true);
return;
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} if self.has_options() && matches!(self.focus, Focus::Options) => {
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_right.is_pressed(key_event) =>
{
self.move_question(/*next*/ true);
return;
}
@@ -1886,6 +1917,30 @@ mod tests {
assert_eq!(overlay.current_index(), 0);
}
#[test]
fn horizontal_list_keys_move_between_questions_in_options() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_with_options("q1", "Pick one"),
question_with_options("q2", "Pick two"),
],
),
tx,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert_eq!(overlay.current_index(), 0);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_index(), 1);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_index(), 0);
}
#[test]
fn options_notes_focus_hides_question_navigation_tip() {
let (tx, _rx) = test_sender();
+101 -12
View File
@@ -4,6 +4,13 @@
/// - Optional selection (None when list is empty)
/// - Wrap-around navigation on Up/Down
/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
///
/// Callers own the filtered row count and the visible window size. Every
/// mutation method takes those values instead of caching them here, so list
/// views can apply filters, pagination, or density changes without this helper
/// knowing about their data model. Passing a stale length after filtering would
/// leave selection pointing at the wrong row, so callers should clamp or move
/// through this type immediately after changing their visible row set.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ScrollState {
pub selected_idx: Option<usize>,
@@ -26,20 +33,15 @@ impl ScrollState {
/// Clamp selection to be within the [0, len-1] range, or None when empty.
pub fn clamp_selection(&mut self, len: usize) {
self.selected_idx = match len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(len - 1)),
};
if len == 0 {
self.scroll_top = 0;
if self.clear_if_empty(len) {
return;
}
self.selected_idx = Some(self.selected_idx.unwrap_or(0).min(len - 1));
}
/// Move selection up by one, wrapping to the bottom when necessary.
pub fn move_up_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
if self.clear_if_empty(len) {
return;
}
self.selected_idx = Some(match self.selected_idx {
@@ -51,9 +53,7 @@ impl ScrollState {
/// Move selection down by one, wrapping to the top when necessary.
pub fn move_down_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
if self.clear_if_empty(len) {
return;
}
self.selected_idx = Some(match self.selected_idx {
@@ -62,6 +62,63 @@ impl ScrollState {
});
}
/// Move selection up by one visible page, clamping at the first row.
///
/// Page movement intentionally does not wrap. It mirrors terminal list
/// behavior where repeated page-up/page-down converges at the nearest edge
/// while still keeping the selected row visible.
pub fn page_up_clamped(&mut self, len: usize, visible_rows: usize) {
if self.clear_if_empty(len) {
return;
}
let step = visible_rows.max(1);
let current = self.selected_idx.unwrap_or(0).min(len - 1);
self.selected_idx = Some(current.saturating_sub(step));
self.ensure_visible(len, visible_rows);
}
/// Move selection down by one visible page, clamping at the last row.
///
/// Page movement intentionally does not wrap. It mirrors terminal list
/// behavior where repeated page-up/page-down converges at the nearest edge
/// while still keeping the selected row visible.
pub fn page_down_clamped(&mut self, len: usize, visible_rows: usize) {
if self.clear_if_empty(len) {
return;
}
let step = visible_rows.max(1);
let current = self.selected_idx.unwrap_or(0).min(len - 1);
self.selected_idx = Some(current.saturating_add(step).min(len - 1));
self.ensure_visible(len, visible_rows);
}
/// Jump selection to the first row.
pub fn jump_top(&mut self, len: usize, visible_rows: usize) {
if self.clear_if_empty(len) {
return;
}
self.selected_idx = Some(0);
self.ensure_visible(len, visible_rows);
}
/// Jump selection to the last row.
pub fn jump_bottom(&mut self, len: usize, visible_rows: usize) {
if self.clear_if_empty(len) {
return;
}
self.selected_idx = Some(len - 1);
self.ensure_visible(len, visible_rows);
}
fn clear_if_empty(&mut self, len: usize) -> bool {
if len != 0 {
return false;
}
self.selected_idx = None;
self.scroll_top = 0;
true
}
/// Adjust `scroll_top` so that the current `selected_idx` is visible within
/// the window of `visible_rows`.
pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
@@ -112,4 +169,36 @@ mod tests {
assert_eq!(s.selected_idx, Some(0));
assert_eq!(s.scroll_top, 0);
}
#[test]
fn page_and_jump_navigation_clamps() {
let mut s = ScrollState::new();
let len = 10;
let vis = 4;
s.clamp_selection(len);
s.page_down_clamped(len, vis);
assert_eq!(s.selected_idx, Some(4));
assert_eq!(s.scroll_top, 1);
s.page_down_clamped(len, vis);
assert_eq!(s.selected_idx, Some(8));
assert_eq!(s.scroll_top, 5);
s.page_down_clamped(len, vis);
assert_eq!(s.selected_idx, Some(9));
assert_eq!(s.scroll_top, 6);
s.page_up_clamped(len, vis);
assert_eq!(s.selected_idx, Some(5));
assert_eq!(s.scroll_top, 5);
s.jump_top(len, vis);
assert_eq!(s.selected_idx, Some(0));
assert_eq!(s.scroll_top, 0);
s.jump_bottom(len, vis);
assert_eq!(s.selected_idx, Some(9));
assert_eq!(s.scroll_top, 6);
}
}
@@ -14,6 +14,10 @@ use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
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::primary_binding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
@@ -49,10 +53,15 @@ pub(crate) struct SkillsToggleView {
footer_hint: Line<'static>,
search_query: String,
filtered_indices: Vec<usize>,
keymap: ListKeymap,
}
impl SkillsToggleView {
pub(crate) fn new(items: Vec<SkillsToggleItem>, app_event_tx: AppEventSender) -> Self {
pub(crate) fn new(
items: Vec<SkillsToggleItem>,
app_event_tx: AppEventSender,
keymap: ListKeymap,
) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Enable/Disable Skills".bold()));
header.push(Line::from(
@@ -65,9 +74,10 @@ impl SkillsToggleView {
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: skills_toggle_hint_line(),
footer_hint: skills_toggle_hint_line(&keymap),
search_query: String::new(),
filtered_indices: Vec::new(),
keymap,
};
view.apply_filter();
view
@@ -161,6 +171,30 @@ impl SkillsToggleView {
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);
}
fn toggle_selected(&mut self) {
let Some(idx) = self.state.selected_idx else {
return;
@@ -200,34 +234,29 @@ impl SkillsToggleView {
impl BottomPaneView for SkillsToggleView {
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::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('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
_ if allow_plain_char_navigation && self.keymap.page_up.is_pressed(key_event) => {
self.page_up()
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
_ if allow_plain_char_navigation && self.keymap.page_down.is_pressed(key_event) => {
self.page_down()
}
_ if allow_plain_char_navigation && self.keymap.jump_top.is_pressed(key_event) => {
self.jump_top()
}
_ 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,
..
@@ -239,15 +268,9 @@ impl BottomPaneView for SkillsToggleView {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
_ if self.keymap.accept.is_pressed(key_event) => self.toggle_selected(),
_ if self.keymap.cancel.is_pressed(key_event) => {
self.on_ctrl_c();
}
KeyEvent {
@@ -364,16 +387,37 @@ impl Renderable for SkillsToggleView {
}
}
fn skills_toggle_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
fn skills_toggle_hint_line(keymap: &ListKeymap) -> Line<'static> {
let space = key_hint::plain(KeyCode::Char(' '));
let accept = primary_binding(&keymap.accept).filter(|binding| *binding != space);
let cancel = primary_binding(&keymap.cancel);
match (accept, cancel) {
(Some(accept), Some(cancel)) => Line::from(vec![
"Press ".into(),
space.into(),
" or ".into(),
accept.into(),
" to toggle; ".into(),
cancel.into(),
" to close".into(),
]),
(Some(accept), None) => Line::from(vec![
"Press ".into(),
space.into(),
" or ".into(),
accept.into(),
" to toggle".into(),
]),
(None, Some(cancel)) => Line::from(vec![
"Press ".into(),
space.into(),
" to toggle; ".into(),
cancel.into(),
" to close".into(),
]),
(None, None) => Line::from(vec!["Press ".into(), space.into(), " to toggle".into()]),
}
}
#[cfg(test)]
@@ -429,7 +473,23 @@ mod tests {
path: test_path_buf("/tmp/skills/changelog_writer.toml").abs(),
},
];
let view = SkillsToggleView::new(items, tx);
let view = SkillsToggleView::new(items, tx, crate::keymap::RuntimeKeymap::defaults().list);
assert_snapshot!("skills_toggle_basic", render_lines(&view, /*width*/ 72));
}
#[test]
fn footer_hint_uses_list_keymap_accept_and_cancel() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults().list;
keymap.accept = vec![key_hint::ctrl(KeyCode::Char('t'))];
keymap.cancel = vec![key_hint::ctrl(KeyCode::Char('x'))];
let view = SkillsToggleView::new(Vec::new(), tx, keymap);
let rendered = render_lines(&view, /*width*/ 72);
assert!(rendered.contains("ctrl + t"));
assert!(rendered.contains("ctrl + x"));
assert!(!rendered.contains("enter"));
assert!(!rendered.contains("esc"));
}
}
@@ -18,4 +18,4 @@ expression: "render_lines(&view, 72)"
[ ] pull-request-number Open pull request number for the current …
gpt-5-codex · ~/codex-rs · jif/statusline-preview
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc
Press space to toggle;/→ to move; enter to confirm and close; esc to
@@ -18,4 +18,4 @@ expression: "render_lines(&view, 84)"
[ ] context-remaining Percentage of context window remaining (omitted when unk…
[ ! ] Action Required | my-project | Working | thread title
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
Press space to toggle;/→ to move; enter to confirm and close; esc to close
@@ -35,6 +35,7 @@ use crate::bottom_pane::multi_select_picker::MultiSelectItem;
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData;
use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem;
use crate::keymap::ListKeymap;
use crate::render::renderable::Renderable;
const STATUS_LINE_USE_THEME_COLORS_ITEM_ID: &str = "status-line-use-theme-colors";
@@ -246,6 +247,7 @@ impl StatusLineSetupView {
use_theme_colors: bool,
preview_data: StatusSurfacePreviewData,
app_event_tx: AppEventSender,
list_keymap: ListKeymap,
) -> Self {
let mut used_ids = HashSet::new();
let mut items = vec![MultiSelectItem {
@@ -284,10 +286,7 @@ impl StatusLineSetupView {
Some("Select which items to display in the status line.".to_string()),
app_event_tx,
)
.instructions(vec![
"Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel."
.into(),
])
.list_keymap(list_keymap)
.items(items)
.enable_ordering()
.on_preview(move |items| {
@@ -621,6 +620,7 @@ mod tests {
),
]),
AppEventSender::new(tx_raw),
crate::keymap::RuntimeKeymap::defaults().list,
);
assert_snapshot!(render_lines(&view, /*width*/ 72));
+9 -6
View File
@@ -26,6 +26,7 @@ use crate::bottom_pane::multi_select_picker::MultiSelectItem;
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewData;
use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem;
use crate::keymap::ListKeymap;
use crate::render::renderable::Renderable;
/// Available items that can be displayed in the terminal title.
@@ -244,6 +245,7 @@ impl TerminalTitleSetupView {
title_items: Option<&[String]>,
preview_data: StatusSurfacePreviewData,
app_event_tx: AppEventSender,
list_keymap: ListKeymap,
) -> Self {
let selected_items = title_items
.into_iter()
@@ -271,10 +273,7 @@ impl TerminalTitleSetupView {
Some("Select which items to display in the terminal title.".to_string()),
app_event_tx,
)
.instructions(vec![
"Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel."
.into(),
])
.list_keymap(list_keymap)
.items(items)
.enable_ordering()
.on_preview(move |items| {
@@ -387,8 +386,12 @@ mod tests {
"run-state".to_string(),
"thread-title".to_string(),
];
let view =
TerminalTitleSetupView::new(Some(&selected), StatusSurfacePreviewData::default(), tx);
let view = TerminalTitleSetupView::new(
Some(&selected),
StatusSurfacePreviewData::default(),
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
assert_snapshot!(
"terminal_title_setup_basic",
render_lines(&view, /*width*/ 84)
+13 -2
View File
@@ -1441,7 +1441,11 @@ impl ChatWidget {
}
pub(crate) fn open_app_link_view(&mut self, params: crate::bottom_pane::AppLinkViewParams) {
let view = crate::bottom_pane::AppLinkView::new(params, self.app_event_tx.clone());
let view = crate::bottom_pane::AppLinkView::new_with_keymap(
params,
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
@@ -1899,6 +1903,7 @@ impl ChatWidget {
self.config.memories.use_memories,
self.config.memories.generate_memories,
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
@@ -4981,6 +4986,7 @@ impl ChatWidget {
self.config.tui_status_line_use_colors,
self.status_surface_preview_data(),
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
@@ -4992,6 +4998,7 @@ impl ChatWidget {
Some(configured_terminal_title_items.as_slice()),
self.terminal_title_preview_data(),
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
@@ -6517,7 +6524,11 @@ impl ChatWidget {
})
.collect();
let view = ExperimentalFeaturesView::new(features, self.app_event_tx.clone());
let view = ExperimentalFeaturesView::new(
features,
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
+1
View File
@@ -36,6 +36,7 @@ impl ChatWidget {
.show_view(Box::new(HooksBrowserView::from_entry(
entry,
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
)));
self.request_redraw();
}
+5 -1
View File
@@ -94,7 +94,11 @@ impl ChatWidget {
})
.collect();
let view = SkillsToggleView::new(items, self.app_event_tx.clone());
let view = SkillsToggleView::new(
items,
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
self.bottom_pane.show_view(Box::new(view));
}
@@ -17,4 +17,4 @@ expression: status_line_popup_snapshot(&mut chat)
[ ] current-dir Current working directory
my-project · feat/awesome-feature · thread title
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
Press space to toggle;/→ to move; enter to confirm and close; esc to close
@@ -17,4 +17,4 @@ expression: status_line_popup_snapshot(&mut chat)
[ ] current-dir Current working directory
preview-live-root · feature/live-preview-branch · Live preview thread
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
Press space to toggle;/→ to move; enter to confirm and close; esc to close
@@ -17,4 +17,4 @@ expression: status_line_popup_snapshot(&mut chat)
[ ] current-dir Current working directory
my-project · feature/mixed-preview · Mixed preview thread
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
Press space to toggle;/→ to move; enter to confirm and close; esc to close
@@ -17,4 +17,4 @@ expression: terminal_title_popup_snapshot(&mut chat)
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
thread title | feat/awesome-feature | Tasks 0/0
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
Press space to toggle;/→ to move; enter to confirm and close; esc to close
@@ -17,4 +17,4 @@ expression: terminal_title_popup_snapshot(&mut chat)
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
Press space to toggle;/→ to move; enter to confirm and close; esc to close
@@ -17,4 +17,4 @@ expression: terminal_title_popup_snapshot(&mut chat)
[ ] git-branch Current Git branch (omitted when unavailable)
project | Mixed preview thread | Tasks 0/0
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
Press space to toggle;/→ to move; enter to confirm and close; esc to close
@@ -2181,7 +2181,11 @@ async fn experimental_features_popup_snapshot() {
enabled: true,
},
];
let view = ExperimentalFeaturesView::new(features, chat.app_event_tx.clone());
let view = ExperimentalFeaturesView::new(
features,
chat.app_event_tx.clone(),
crate::keymap::RuntimeKeymap::defaults().list,
);
chat.bottom_pane.show_view(Box::new(view));
let popup = render_bottom_popup(&chat, /*width*/ 80);
@@ -2201,6 +2205,7 @@ async fn experimental_features_toggle_saves_on_exit() {
enabled: false,
}],
chat.app_event_tx.clone(),
crate::keymap::RuntimeKeymap::defaults().list,
);
chat.bottom_pane.show_view(Box::new(view));
+27
View File
@@ -5,6 +5,12 @@
//! 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.
@@ -122,6 +128,27 @@ impl KeyBindingListExt for [KeyBinding] {
}
}
/// 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)
}
+338 -16
View File
@@ -201,10 +201,24 @@ pub(crate) struct PagerKeymap {
}
/// Generic list picker keybindings shared across popup list views.
///
/// These actions describe list intent rather than a specific widget layout.
/// Vertical actions move the highlighted row, page and jump actions move within
/// the current filtered row set, and horizontal actions are available to views
/// that expose adjacent choices such as tabs, toolbar values, or ordered item
/// movement. Views that also accept search text are responsible for checking
/// `is_plain_text_key_event` before dispatching plain-character bindings so a
/// configured `j`, `k`, `h`, or `l` does not steal query input.
#[derive(Clone, Debug)]
pub(crate) struct ListKeymap {
pub(crate) move_up: Vec<KeyBinding>,
pub(crate) move_down: Vec<KeyBinding>,
pub(crate) move_left: Vec<KeyBinding>,
pub(crate) move_right: Vec<KeyBinding>,
pub(crate) page_up: Vec<KeyBinding>,
pub(crate) page_down: Vec<KeyBinding>,
pub(crate) jump_top: Vec<KeyBinding>,
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) accept: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
}
@@ -505,13 +519,6 @@ impl RuntimeKeymap {
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
};
let list = ListKeymap {
move_up: resolve_local!(keymap, defaults, list, move_up),
move_down: resolve_local!(keymap, defaults, list, move_down),
accept: resolve_local!(keymap, defaults, list, accept),
cancel: resolve_local!(keymap, defaults, list, cancel),
};
let approval = ApprovalKeymap {
open_fullscreen: resolve_local!(keymap, defaults, approval, open_fullscreen),
open_thread: resolve_local!(keymap, defaults, approval, open_thread),
@@ -523,6 +530,121 @@ impl RuntimeKeymap {
cancel: resolve_local!(keymap, defaults, approval, cancel),
};
let list_move_up = resolve_local!(keymap, defaults, list, move_up);
let list_move_down = resolve_local!(keymap, defaults, list, move_down);
let list_accept = resolve_local!(keymap, defaults, list, accept);
let list_cancel = resolve_local!(keymap, defaults, list, cancel);
let mut configured_bindings_to_preserve = Vec::new();
for (configured, resolved) in [
(
keymap.global.open_transcript.as_ref(),
app.open_transcript.as_slice(),
),
(
keymap.global.open_external_editor.as_ref(),
app.open_external_editor.as_slice(),
),
(keymap.global.copy.as_ref(), app.copy.as_slice()),
(
keymap.global.clear_terminal.as_ref(),
app.clear_terminal.as_slice(),
),
(
keymap.global.toggle_vim_mode.as_ref(),
app.toggle_vim_mode.as_slice(),
),
(
keymap.global.toggle_fast_mode.as_ref(),
app.toggle_fast_mode.as_slice(),
),
(
keymap.global.toggle_raw_output.as_ref(),
app.toggle_raw_output.as_slice(),
),
(keymap.list.move_up.as_ref(), list_move_up.as_slice()),
(keymap.list.move_down.as_ref(), list_move_down.as_slice()),
(keymap.list.accept.as_ref(), list_accept.as_slice()),
(keymap.list.cancel.as_ref(), list_cancel.as_slice()),
(
keymap.approval.open_fullscreen.as_ref(),
approval.open_fullscreen.as_slice(),
),
(
keymap.approval.open_thread.as_ref(),
approval.open_thread.as_slice(),
),
(
keymap.approval.approve.as_ref(),
approval.approve.as_slice(),
),
(
keymap.approval.approve_for_session.as_ref(),
approval.approve_for_session.as_slice(),
),
(
keymap.approval.approve_for_prefix.as_ref(),
approval.approve_for_prefix.as_slice(),
),
(keymap.approval.deny.as_ref(), approval.deny.as_slice()),
(
keymap.approval.decline.as_ref(),
approval.decline.as_slice(),
),
(keymap.approval.cancel.as_ref(), approval.cancel.as_slice()),
] {
if configured.is_none() {
continue;
}
for binding in resolved {
if !configured_bindings_to_preserve.contains(binding) {
configured_bindings_to_preserve.push(*binding);
}
}
}
let list = ListKeymap {
move_up: list_move_up,
move_down: list_move_down,
move_left: resolve_new_list_bindings(
keymap.list.move_left.as_ref(),
&defaults.list.move_left,
&configured_bindings_to_preserve,
"tui.keymap.list.move_left",
)?,
move_right: resolve_new_list_bindings(
keymap.list.move_right.as_ref(),
&defaults.list.move_right,
&configured_bindings_to_preserve,
"tui.keymap.list.move_right",
)?,
page_up: resolve_new_list_bindings(
keymap.list.page_up.as_ref(),
&defaults.list.page_up,
&configured_bindings_to_preserve,
"tui.keymap.list.page_up",
)?,
page_down: resolve_new_list_bindings(
keymap.list.page_down.as_ref(),
&defaults.list.page_down,
&configured_bindings_to_preserve,
"tui.keymap.list.page_down",
)?,
jump_top: resolve_new_list_bindings(
keymap.list.jump_top.as_ref(),
&defaults.list.jump_top,
&configured_bindings_to_preserve,
"tui.keymap.list.jump_top",
)?,
jump_bottom: resolve_new_list_bindings(
keymap.list.jump_bottom.as_ref(),
&defaults.list.jump_bottom,
&configured_bindings_to_preserve,
"tui.keymap.list.jump_bottom",
)?,
accept: list_accept,
cancel: list_cancel,
};
let resolved = Self {
app,
chat,
@@ -711,13 +833,21 @@ impl RuntimeKeymap {
move_up: default_bindings![
plain(KeyCode::Up),
ctrl(KeyCode::Char('p')),
ctrl(KeyCode::Char('k')),
plain(KeyCode::Char('k'))
],
move_down: default_bindings![
plain(KeyCode::Down),
ctrl(KeyCode::Char('n')),
ctrl(KeyCode::Char('j')),
plain(KeyCode::Char('j'))
],
move_left: default_bindings![plain(KeyCode::Left), ctrl(KeyCode::Char('h'))],
move_right: default_bindings![plain(KeyCode::Right), ctrl(KeyCode::Char('l'))],
page_up: default_bindings![plain(KeyCode::PageUp), ctrl(KeyCode::Char('b'))],
page_down: default_bindings![plain(KeyCode::PageDown), ctrl(KeyCode::Char('f'))],
jump_top: default_bindings![plain(KeyCode::Home)],
jump_bottom: default_bindings![plain(KeyCode::End)],
accept: default_bindings![plain(KeyCode::Enter)],
cancel: default_bindings![plain(KeyCode::Esc)],
},
@@ -835,7 +965,7 @@ impl RuntimeKeymap {
MAIN_RESERVED_BINDINGS,
)?;
validate_no_shadow(
validate_no_shadow_with_allowed_overlaps(
"app",
[
("open_transcript", self.app.open_transcript.as_slice()),
@@ -852,6 +982,12 @@ impl RuntimeKeymap {
[
("list.move_up", self.list.move_up.as_slice()),
("list.move_down", self.list.move_down.as_slice()),
("list.move_left", self.list.move_left.as_slice()),
("list.move_right", self.list.move_right.as_slice()),
("list.page_up", self.list.page_up.as_slice()),
("list.page_down", self.list.page_down.as_slice()),
("list.jump_top", self.list.jump_top.as_slice()),
("list.jump_bottom", self.list.jump_bottom.as_slice()),
("list.accept", self.list.accept.as_slice()),
("list.cancel", self.list.cancel.as_slice()),
(
@@ -872,6 +1008,11 @@ impl RuntimeKeymap {
("approval.decline", self.approval.decline.as_slice()),
("approval.cancel", self.approval.cancel.as_slice()),
],
[(
"clear_terminal",
"list.move_right",
key_hint::ctrl(KeyCode::Char('l')),
)],
)?;
// While the composer is focused, these main-surface handlers always
@@ -1123,6 +1264,12 @@ impl RuntimeKeymap {
[
("move_up", self.list.move_up.as_slice()),
("move_down", self.list.move_down.as_slice()),
("move_left", self.list.move_left.as_slice()),
("move_right", self.list.move_right.as_slice()),
("page_up", self.list.page_up.as_slice()),
("page_down", self.list.page_down.as_slice()),
("jump_top", self.list.jump_top.as_slice()),
("jump_bottom", self.list.jump_bottom.as_slice()),
("accept", self.list.accept.as_slice()),
("cancel", self.list.cancel.as_slice()),
],
@@ -1152,6 +1299,12 @@ impl RuntimeKeymap {
for (action, bindings) in [
("list.move_up", self.list.move_up.as_slice()),
("list.move_down", self.list.move_down.as_slice()),
("list.move_left", self.list.move_left.as_slice()),
("list.move_right", self.list.move_right.as_slice()),
("list.page_up", self.list.page_up.as_slice()),
("list.page_down", self.list.page_down.as_slice()),
("list.jump_top", self.list.jump_top.as_slice()),
("list.jump_bottom", self.list.jump_bottom.as_slice()),
("list.accept", self.list.accept.as_slice()),
("list.cancel", self.list.cancel.as_slice()),
(
@@ -1221,14 +1374,6 @@ See the Codex keymap documentation for supported actions and examples."
Ok(())
}
fn validate_no_shadow<const N: usize, const M: usize>(
context: &str,
primary: [(&'static str, &[KeyBinding]); N],
shadowed: [(&'static str, &[KeyBinding]); M],
) -> Result<(), String> {
validate_no_shadow_with_allowed_overlaps(context, primary, shadowed, [])
}
fn validate_no_shadow_with_allowed_overlaps<const N: usize, const M: usize, const A: usize>(
context: &str,
primary: [(&'static str, &[KeyBinding]); N],
@@ -1372,6 +1517,22 @@ fn resolve_bindings(
parse_bindings(spec, path)
}
fn resolve_new_list_bindings(
configured: Option<&KeybindingsSpec>,
fallback: &[KeyBinding],
configured_bindings_to_preserve: &[KeyBinding],
path: &str,
) -> Result<Vec<KeyBinding>, String> {
let Some(spec) = configured else {
return Ok(fallback
.iter()
.copied()
.filter(|binding| !configured_bindings_to_preserve.contains(binding))
.collect());
};
parse_bindings(spec, path)
}
/// Parse one keybinding value (`string` or `list[string]`) into concrete bindings.
///
/// Duplicate entries are de-duplicated while preserving first-seen order so the
@@ -1659,6 +1820,152 @@ mod tests {
assert_eq!(runtime.editor.kill_whole_line, Vec::new());
}
#[test]
fn defaults_include_list_page_and_jump_actions() {
let runtime = RuntimeKeymap::defaults();
assert_eq!(
runtime.list.move_up,
vec![
key_hint::plain(KeyCode::Up),
key_hint::ctrl(KeyCode::Char('p')),
key_hint::ctrl(KeyCode::Char('k')),
key_hint::plain(KeyCode::Char('k')),
]
);
assert_eq!(
runtime.list.move_down,
vec![
key_hint::plain(KeyCode::Down),
key_hint::ctrl(KeyCode::Char('n')),
key_hint::ctrl(KeyCode::Char('j')),
key_hint::plain(KeyCode::Char('j')),
]
);
assert_eq!(
runtime.list.move_left,
vec![
key_hint::plain(KeyCode::Left),
key_hint::ctrl(KeyCode::Char('h')),
]
);
assert_eq!(
runtime.list.move_right,
vec![
key_hint::plain(KeyCode::Right),
key_hint::ctrl(KeyCode::Char('l')),
]
);
assert_eq!(
runtime.list.page_up,
vec![
key_hint::plain(KeyCode::PageUp),
key_hint::ctrl(KeyCode::Char('b')),
]
);
assert_eq!(
runtime.list.page_down,
vec![
key_hint::plain(KeyCode::PageDown),
key_hint::ctrl(KeyCode::Char('f')),
]
);
assert_eq!(runtime.list.jump_top, vec![key_hint::plain(KeyCode::Home)]);
assert_eq!(
runtime.list.jump_bottom,
vec![key_hint::plain(KeyCode::End)]
);
}
#[test]
fn configured_legacy_list_bindings_prune_new_default_overlaps() {
let mut keymap = TuiKeymap::default();
keymap.list.move_up = Some(one("page-up"));
keymap.list.move_down = Some(one("page-down"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.list.move_up, vec![key_hint::plain(KeyCode::PageUp)]);
assert_eq!(
runtime.list.move_down,
vec![key_hint::plain(KeyCode::PageDown)]
);
assert_eq!(
runtime.list.page_up,
vec![key_hint::ctrl(KeyCode::Char('b'))]
);
assert_eq!(
runtime.list.page_down,
vec![key_hint::ctrl(KeyCode::Char('f'))]
);
}
#[test]
fn configured_legacy_list_bindings_can_prune_all_new_default_keys() {
let mut keymap = TuiKeymap::default();
keymap.list.move_up = Some(KeybindingsSpec::Many(vec![
KeybindingSpec("page-up".to_string()),
KeybindingSpec("ctrl-b".to_string()),
]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.list.move_up,
vec![
key_hint::plain(KeyCode::PageUp),
key_hint::ctrl(KeyCode::Char('b')),
]
);
assert_eq!(runtime.list.page_up, Vec::new());
}
#[test]
fn explicit_new_list_bindings_still_conflict_with_legacy_bindings() {
let mut keymap = TuiKeymap::default();
keymap.list.move_up = Some(one("page-up"));
keymap.list.page_up = Some(one("page-up"));
expect_conflict(&keymap, "move_up", "page_up");
}
#[test]
fn configured_app_bindings_prune_new_list_default_overlaps() {
let mut keymap = TuiKeymap::default();
keymap.global.copy = Some(one("page-down"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(runtime.app.copy, vec![key_hint::plain(KeyCode::PageDown)]);
assert_eq!(
runtime.list.page_down,
vec![key_hint::ctrl(KeyCode::Char('f'))]
);
}
#[test]
fn configured_approval_bindings_prune_new_list_default_overlaps() {
let mut keymap = TuiKeymap::default();
keymap.approval.approve = Some(one("home"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert_eq!(
runtime.approval.approve,
vec![key_hint::plain(KeyCode::Home)]
);
assert_eq!(runtime.list.jump_top, Vec::new());
}
#[test]
fn explicit_new_list_bindings_still_conflict_with_configured_approval_bindings() {
let mut keymap = TuiKeymap::default();
keymap.approval.approve = Some(one("home"));
keymap.list.jump_top = Some(one("home"));
expect_conflict(&keymap, "list.jump_top", "approval.approve");
}
#[test]
fn vim_normal_defaults_include_insert_and_arrow_aliases() {
let runtime = RuntimeKeymap::defaults();
@@ -1734,6 +2041,21 @@ mod tests {
keymap.list.move_down = Some(one("up"));
expect_conflict(&keymap, "move_up", "move_down");
let mut keymap = TuiKeymap::default();
keymap.list.move_left = Some(one("left"));
keymap.list.move_right = Some(one("left"));
expect_conflict(&keymap, "move_left", "move_right");
}
#[test]
fn rejects_conflicting_list_page_and_jump_bindings() {
let mut keymap = TuiKeymap::default();
keymap.list.page_up = Some(one("home"));
keymap.list.jump_top = Some(one("home"));
expect_conflict(&keymap, "page_up", "jump_top");
}
#[test]
+18
View File
@@ -164,6 +164,12 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("pager", "Pager", "close_transcript", "Close the transcript overlay."),
action("list", "List", "move_up", "Move list selection up."),
action("list", "List", "move_down", "Move list selection down."),
action("list", "List", "move_left", "Move horizontally left in list pickers."),
action("list", "List", "move_right", "Move horizontally right in list pickers."),
action("list", "List", "page_up", "Move list selection up by one page."),
action("list", "List", "page_down", "Move list selection down by one page."),
action("list", "List", "jump_top", "Jump to the first list item."),
action("list", "List", "jump_bottom", "Jump to the last list item."),
action("list", "List", "accept", "Accept the current list selection."),
action("list", "List", "cancel", "Cancel and close selection views."),
action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."),
@@ -286,6 +292,12 @@ pub(super) fn binding_slot<'a>(
("pager", "close_transcript") => Some(&mut keymap.pager.close_transcript),
("list", "move_up") => Some(&mut keymap.list.move_up),
("list", "move_down") => Some(&mut keymap.list.move_down),
("list", "move_left") => Some(&mut keymap.list.move_left),
("list", "move_right") => Some(&mut keymap.list.move_right),
("list", "page_up") => Some(&mut keymap.list.page_up),
("list", "page_down") => Some(&mut keymap.list.page_down),
("list", "jump_top") => Some(&mut keymap.list.jump_top),
("list", "jump_bottom") => Some(&mut keymap.list.jump_bottom),
("list", "accept") => Some(&mut keymap.list.accept),
("list", "cancel") => Some(&mut keymap.list.cancel),
("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen),
@@ -390,6 +402,12 @@ pub(super) fn bindings_for_action<'a>(
("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()),
("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()),
("list", "move_down") => Some(runtime_keymap.list.move_down.as_slice()),
("list", "move_left") => Some(runtime_keymap.list.move_left.as_slice()),
("list", "move_right") => Some(runtime_keymap.list.move_right.as_slice()),
("list", "page_up") => Some(runtime_keymap.list.page_up.as_slice()),
("list", "page_down") => Some(runtime_keymap.list.page_down.as_slice()),
("list", "jump_top") => Some(runtime_keymap.list.jump_top.as_slice()),
("list", "jump_bottom") => Some(runtime_keymap.list.jump_bottom.as_slice()),
("list", "accept") => Some(runtime_keymap.list.accept.as_slice()),
("list", "cancel") => Some(runtime_keymap.list.cancel.as_slice()),
("approval", "open_fullscreen") => Some(runtime_keymap.approval.open_fullscreen.as_slice()),
+50 -11
View File
@@ -1,6 +1,9 @@
use std::io;
use std::sync::LazyLock;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::legacy_core::config::set_default_oss_provider;
use codex_model_provider_info::DEFAULT_LMSTUDIO_PORT;
use codex_model_provider_info::DEFAULT_OLLAMA_PORT;
@@ -10,6 +13,7 @@ use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::event::{self};
use crossterm::execute;
use crossterm::terminal::EnterAlternateScreen;
@@ -77,6 +81,18 @@ static OSS_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
]
});
// This startup wizard runs before the main TUI runtime keymap is available, so
// it mirrors the built-in horizontal list defaults instead of reading config.
// The shared matcher still covers raw C0 Ctrl-H/Ctrl-L terminal reports.
const MOVE_LEFT_KEYS: [KeyBinding; 2] = [
key_hint::plain(KeyCode::Left),
key_hint::ctrl(KeyCode::Char('h')),
];
const MOVE_RIGHT_KEYS: [KeyBinding; 2] = [
key_hint::plain(KeyCode::Right),
key_hint::ctrl(KeyCode::Char('l')),
];
pub struct OssSelectionWidget<'a> {
select_options: &'a Vec<SelectOption>,
confirmation_prompt: Paragraph<'a>,
@@ -178,29 +194,35 @@ impl OssSelectionWidget<'_> {
}
fn handle_select_key(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('c')
if key_event
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
match key_event {
KeyEvent {
code: KeyCode::Char('c'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => {
self.send_decision("__CANCELLED__".to_string());
}
KeyCode::Left => {
_ if MOVE_LEFT_KEYS.is_pressed(key_event) => {
self.selected_option = (self.selected_option + self.select_options.len() - 1)
% self.select_options.len();
}
KeyCode::Right => {
_ if MOVE_RIGHT_KEYS.is_pressed(key_event) => {
self.selected_option = (self.selected_option + 1) % self.select_options.len();
}
KeyCode::Enter => {
KeyEvent {
code: KeyCode::Enter,
..
} => {
let opt = &self.select_options[self.selected_option];
self.send_decision(opt.provider_id.to_string());
}
KeyCode::Esc => {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.send_decision(LMSTUDIO_OSS_PROVIDER_ID.to_string());
}
other => {
KeyEvent { code, .. } => {
let other = code;
let normalized = Self::normalize_keycode(other);
if let Some(opt) = self
.select_options
@@ -371,3 +393,20 @@ async fn check_port_status(port: u16) -> io::Result<bool> {
Err(_) => Ok(false), // Connection failed = not running
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ctrl_h_l_move_provider_selection() {
let mut widget = OssSelectionWidget::new(ProviderStatus::Unknown, ProviderStatus::Unknown)
.expect("widget should initialize");
assert_eq!(widget.selected_option, 0);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert_eq!(widget.selected_option, 1);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(widget.selected_option, 0);
}
}
+120 -70
View File
@@ -10,6 +10,9 @@ use crate::app_server_session::AppServerSession;
use crate::color::blend;
use crate::color::is_light;
use crate::git_action_directives::parse_assistant_markdown;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListKeymap;
use crate::keymap::PagerKeymap;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::Config;
@@ -282,6 +285,7 @@ struct SessionPickerRunOptions {
initial_density: SessionListDensity,
view_persistence: Option<SessionPickerViewPersistence>,
pager_keymap: PagerKeymap,
list_keymap: ListKeymap,
}
/// Interactive session picker that lists app-server threads with simple search,
@@ -354,7 +358,7 @@ async fn run_resume_picker_with_launch_context(
);
let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote);
let provider_filter = picker_provider_filter(config, is_remote);
let pager_keymap = picker_pager_keymap(config)?;
let runtime_keymap = picker_runtime_keymap(config)?;
let options = SessionPickerRunOptions {
show_all,
filter_cwd: cwd_filter,
@@ -367,7 +371,8 @@ async fn run_resume_picker_with_launch_context(
codex_home: config.codex_home.to_path_buf(),
active_profile: config.active_profile.clone(),
}),
pager_keymap,
pager_keymap: runtime_keymap.pager,
list_keymap: runtime_keymap.list,
};
run_session_picker_with_loader(
tui,
@@ -399,7 +404,7 @@ pub async fn run_fork_picker_with_app_server(
);
let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote);
let provider_filter = picker_provider_filter(config, is_remote);
let pager_keymap = picker_pager_keymap(config)?;
let runtime_keymap = picker_runtime_keymap(config)?;
let options = SessionPickerRunOptions {
show_all,
filter_cwd: cwd_filter,
@@ -412,7 +417,8 @@ pub async fn run_fork_picker_with_app_server(
codex_home: config.codex_home.to_path_buf(),
active_profile: config.active_profile.clone(),
}),
pager_keymap,
pager_keymap: runtime_keymap.pager,
list_keymap: runtime_keymap.list,
};
run_session_picker_with_loader(
tui,
@@ -447,6 +453,7 @@ async fn run_session_picker_with_loader(
state.density = options.initial_density;
state.view_persistence = options.view_persistence;
state.pager_keymap = options.pager_keymap;
state.list_keymap = options.list_keymap;
state.launch_context = options.launch_context;
state.start_initial_load();
state.request_frame();
@@ -516,9 +523,8 @@ fn picker_provider_filter(config: &Config, is_remote: bool) -> ProviderFilter {
}
}
fn picker_pager_keymap(config: &Config) -> Result<PagerKeymap> {
fn picker_runtime_keymap(config: &Config) -> Result<RuntimeKeymap> {
RuntimeKeymap::from_config(&config.tui_keymap)
.map(|keymap| keymap.pager)
.map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}"))
}
@@ -656,6 +662,7 @@ struct PickerState {
transcript_loading_frame_shown: bool,
overlay: Option<Overlay>,
pager_keymap: PagerKeymap,
list_keymap: ListKeymap,
}
struct PaginationState {
@@ -928,6 +935,7 @@ impl PickerState {
transcript_loading_frame_shown: false,
overlay: None,
pager_keymap: RuntimeKeymap::defaults().pager,
list_keymap: RuntimeKeymap::defaults().list,
}
}
@@ -1028,18 +1036,14 @@ impl PickerState {
if self.is_transcript_loading() {
return Ok(self.handle_transcript_loading_key(key));
}
if !matches!(key.code, KeyCode::PageDown) {
if !self.list_keymap.page_down.is_pressed(key) {
self.pending_page_down_target = None;
}
// The session picker is always searchable, so plain text belongs to
// the query first. Modified list bindings still route through the
// runtime keymap below.
let allow_plain_char_navigation = !is_plain_text_key_event(key);
match key {
KeyEvent {
code: KeyCode::Esc, ..
} => {
if self.query.is_empty() {
return Ok(Some(SessionSelection::StartFresh));
}
self.clear_query_preserving_selection();
}
KeyEvent {
code: KeyCode::Char('c'),
modifiers,
@@ -1047,6 +1051,12 @@ impl PickerState {
} if modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(Some(SessionSelection::Exit));
}
_ if self.list_keymap.cancel.is_pressed(key) => {
if self.query.is_empty() {
return Ok(Some(SessionSelection::StartFresh));
}
self.clear_query_preserving_selection();
}
KeyEvent {
code: KeyCode::Char('t'),
modifiers,
@@ -1089,10 +1099,7 @@ impl PickerState {
} /* ^O */ => {
self.toggle_density().await;
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
_ if self.list_keymap.accept.is_pressed(key) => {
if let Some(row) = self.filtered_rows.get(self.selected) {
let path = row.path.clone();
let thread_id = match row.thread_id {
@@ -1119,39 +1126,14 @@ impl PickerState {
self.request_frame();
}
}
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => {
_ if allow_plain_char_navigation && self.list_keymap.move_up.is_pressed(key) => {
if self.selected > 0 {
self.selected -= 1;
self.ensure_selected_visible();
}
self.request_frame();
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => {
_ if allow_plain_char_navigation && self.list_keymap.move_down.is_pressed(key) => {
if self.selected + 1 < self.filtered_rows.len() {
self.selected += 1;
self.ensure_selected_visible();
@@ -1159,10 +1141,7 @@ impl PickerState {
self.maybe_load_more_for_scroll();
self.request_frame();
}
KeyEvent {
code: KeyCode::PageUp,
..
} => {
_ if allow_plain_char_navigation && self.list_keymap.page_up.is_pressed(key) => {
let step = self.view_rows.unwrap_or(10).max(1);
if self.selected > 0 {
self.selected = self.selected.saturating_sub(step);
@@ -1170,19 +1149,14 @@ impl PickerState {
self.request_frame();
}
}
KeyEvent {
code: KeyCode::Home,
..
} => {
_ if allow_plain_char_navigation && self.list_keymap.jump_top.is_pressed(key) => {
if !self.filtered_rows.is_empty() {
self.selected = 0;
self.ensure_selected_visible();
self.request_frame();
}
}
KeyEvent {
code: KeyCode::End, ..
} => {
_ if allow_plain_char_navigation && self.list_keymap.jump_bottom.is_pressed(key) => {
if !self.filtered_rows.is_empty() {
self.selected = self.filtered_rows.len().saturating_sub(1);
self.ensure_selected_visible();
@@ -1190,10 +1164,7 @@ impl PickerState {
self.request_frame();
}
}
KeyEvent {
code: KeyCode::PageDown,
..
} => {
_ if allow_plain_char_navigation && self.list_keymap.page_down.is_pressed(key) => {
if !self.filtered_rows.is_empty() {
let step = self.view_rows.unwrap_or(10).max(1);
let target = self.selected.saturating_add(step);
@@ -1222,14 +1193,10 @@ impl PickerState {
self.focus_previous_toolbar_control();
self.request_frame();
}
KeyEvent {
code: KeyCode::Left,
..
}
| KeyEvent {
code: KeyCode::Right,
..
} => {
_ if allow_plain_char_navigation
&& (self.list_keymap.move_left.is_pressed(key)
|| self.list_keymap.move_right.is_pressed(key)) =>
{
self.change_focused_toolbar_value();
self.request_frame();
}
@@ -5382,7 +5349,7 @@ session_picker_view = "dense"
.await
.unwrap();
state
.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE))
.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL))
.await
.unwrap();
@@ -5554,6 +5521,89 @@ session_picker_view = "dense"
assert_eq!(state.selected, 0);
}
#[tokio::test]
async fn page_and_jump_navigation_use_list_keymap() {
let loader = page_only_loader(|_| {});
let mut state = PickerState::new(
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.list_keymap.page_down = vec![crate::key_hint::ctrl(KeyCode::Char('d'))];
state.list_keymap.page_up = vec![crate::key_hint::ctrl(KeyCode::Char('u'))];
state.list_keymap.jump_bottom = vec![crate::key_hint::ctrl(KeyCode::Char('y'))];
state.list_keymap.jump_top = vec![crate::key_hint::ctrl(KeyCode::Char('a'))];
let mut items = Vec::new();
for idx in 0..20 {
let ts = format!("2025-01-{:02}T00:00:00Z", idx + 1);
let preview = format!("item-{idx}");
let path = format!("/tmp/item-{idx}.jsonl");
items.push(make_row(&path, &ts, &preview));
}
state.reset_pagination();
state.ingest_page(page(
items, /*next_cursor*/ None, /*num_scanned_files*/ 20,
/*reached_scan_cap*/ false,
));
state.update_viewport(/*rows*/ 5, /*width*/ 80);
state
.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
.await
.unwrap();
assert_eq!(state.selected, 0);
state
.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
.await
.unwrap();
assert_eq!(state.selected, 5);
state
.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL))
.await
.unwrap();
assert_eq!(state.selected, 0);
state
.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL))
.await
.unwrap();
assert_eq!(state.selected, 19);
state
.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL))
.await
.unwrap();
assert_eq!(state.selected, 0);
}
#[tokio::test]
async fn ctrl_c_exits_even_when_cancel_is_remapped_to_ctrl_c() {
let loader = page_only_loader(|_| {});
let mut state = PickerState::new(
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.list_keymap.cancel = vec![crate::key_hint::ctrl(KeyCode::Char('c'))];
let selection = state
.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
.await
.unwrap();
assert!(matches!(selection, Some(SessionSelection::Exit)));
}
#[tokio::test]
async fn end_jumps_to_last_known_row_and_starts_loading_more() {
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = Arc::new(Mutex::new(Vec::new()));
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
87 actions, 1 customized, 2 unbound.
93 actions, 1 customized, 2 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
88 actions, 0 customized, 3 unbound.
94 actions, 0 customized, 3 unbound.
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
@@ -2,7 +2,7 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (87 selectable)
tab: All (93 selectable)
tab: Common (19 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (2) (2 selectable)
@@ -10,7 +10,7 @@ tab: App (9 selectable)
tab: Composer (5 selectable)
tab: Editor (17 selectable)
tab: Vim (34 selectable)
tab: Navigation (14 selectable)
tab: Navigation (20 selectable)
tab: Approval (8 selectable)
tab: Debug (1 selectable)
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
87 actions, 0 customized, 2 unbound.
93 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
Navigation Approval Debug
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
87 actions, 0 customized, 2 unbound.
93 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug