Files
codex/codex-rs/tui/src/bottom_pane/skills_toggle_view.rs
T
Felipe Coury 3d517fbd00 feat(tui): standardize picker navigation keys (#22347)
## Why

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

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

## What Changed

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

## How to Test

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

Targeted tests run:

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

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

Note: `cargo test -p codex-tui` was attempted and still aborts in the
pre-existing
`tests::fork_last_filters_latest_session_by_cwd_unless_show_all` stack
overflow, which is unrelated to this branch.
2026-05-13 15:33:27 +00:00

496 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
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;
use crate::render::renderable::Renderable;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::truncate_skill_name;
use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows_single_line;
const SEARCH_PLACEHOLDER: &str = "Type to search skills";
const SEARCH_PROMPT_PREFIX: &str = "> ";
pub(crate) struct SkillsToggleItem {
pub name: String,
pub skill_name: String,
pub description: String,
pub enabled: bool,
pub path: AbsolutePathBuf,
}
pub(crate) struct SkillsToggleView {
items: Vec<SkillsToggleItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
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,
keymap: ListKeymap,
) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Enable/Disable Skills".bold()));
header.push(Line::from(
"Turn skills on or off. Your changes are saved automatically.".dim(),
));
let mut view = Self {
items,
state: ScrollState::new(),
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: skills_toggle_hint_line(&keymap),
search_query: String::new(),
filtered_indices: Vec::new(),
keymap,
};
view.apply_filter();
view
}
fn visible_len(&self) -> usize {
self.filtered_indices.len()
}
fn max_visible_rows(len: usize) -> usize {
MAX_POPUP_ROWS.min(len.max(1))
}
fn apply_filter(&mut self) {
// Filter + sort while preserving the current selection when possible.
let previously_selected = self
.state
.selected_idx
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied());
let filter = self.search_query.trim();
if filter.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
} else {
let mut matches: Vec<(usize, i32)> = Vec::new();
for (idx, item) in self.items.iter().enumerate() {
let display_name = item.name.as_str();
if let Some((_indices, score)) = match_skill(filter, display_name, &item.skill_name)
{
matches.push((idx, score));
}
}
matches.sort_by(|a, b| {
a.1.cmp(&b.1).then_with(|| {
let an = self.items[a.0].name.as_str();
let bn = self.items[b.0].name.as_str();
an.cmp(bn)
})
});
self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect();
}
let len = self.filtered_indices.len();
self.state.selected_idx = previously_selected
.and_then(|actual_idx| {
self.filtered_indices
.iter()
.position(|idx| *idx == actual_idx)
})
.or_else(|| (len > 0).then_some(0));
let visible = Self::max_visible_rows(len);
self.state.clamp_selection(len);
self.state.ensure_visible(len, visible);
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '›' } else { ' ' };
let marker = if item.enabled { 'x' } else { ' ' };
let item_name = truncate_skill_name(&item.name);
let name = format!("{prefix} [{marker}] {item_name}");
GenericDisplayRow {
name,
description: Some(item.description.clone()),
..Default::default()
}
})
})
.collect()
}
fn move_up(&mut self) {
let len = self.visible_len();
self.state.move_up_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
fn move_down(&mut self) {
let len = self.visible_len();
self.state.move_down_wrap(len);
let visible = Self::max_visible_rows(len);
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;
};
let Some(actual_idx) = self.filtered_indices.get(idx).copied() else {
return;
};
let Some(item) = self.items.get_mut(actual_idx) else {
return;
};
item.enabled = !item.enabled;
self.app_event_tx.send(AppEvent::SetSkillEnabled {
path: item.path.clone(),
enabled: item.enabled,
});
}
fn close(&mut self) {
if self.complete {
return;
}
self.complete = true;
self.app_event_tx.send(AppEvent::ManageSkillsClosed);
self.app_event_tx
.list_skills(Vec::new(), /*force_reload*/ true);
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 {
rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1)
}
}
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 {
_ if allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event) => {
self.move_up()
}
_ if allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event) => {
self.move_down()
}
_ 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()
}
KeyEvent {
code: KeyCode::Backspace,
..
} => {
self.search_query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
_ if self.keymap.accept.is_pressed(key_event) => self.toggle_selected(),
_ if self.keymap.cancel.is_pressed(key_event) => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.search_query.push(c);
self.apply_filter();
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.close();
CancellationEvent::Handled
}
}
impl Renderable for SkillsToggleView {
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_height = self.rows_height(&rows);
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height = height.saturating_add(2);
height.saturating_add(1)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
// Reserve the footer line for the key-hint row.
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = self.rows_height(&rows);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(2),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)));
self.header.render(header_area, buf);
// Render the search prompt as two lines to mimic the composer.
if search_area.height >= 2 {
let [placeholder_area, input_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area);
Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf);
let line = if self.search_query.is_empty() {
Line::from(vec![SEARCH_PROMPT_PREFIX.dim()])
} else {
Line::from(vec![
SEARCH_PROMPT_PREFIX.dim(),
self.search_query.clone().into(),
])
};
line.render(input_area, buf);
} else if search_area.height > 0 {
let query_span = if self.search_query.is_empty() {
SEARCH_PLACEHOLDER.dim()
} else {
self.search_query.clone().into()
};
Line::from(query_span).render(search_area, buf);
}
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows_single_line(
render_area,
buf,
&rows,
&self.state,
render_area.height as usize,
"no matches",
);
}
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}
}
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)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use insta::assert_snapshot;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
fn render_lines(view: &SkillsToggleView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line
})
.collect();
lines.join("\n")
}
#[test]
fn renders_basic_popup() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let items = vec![
SkillsToggleItem {
name: "Repo Scout".to_string(),
skill_name: "repo_scout".to_string(),
description: "Summarize the repo layout".to_string(),
enabled: true,
path: test_path_buf("/tmp/skills/repo_scout.toml").abs(),
},
SkillsToggleItem {
name: "Changelog Writer".to_string(),
skill_name: "changelog_writer".to_string(),
description: "Draft release notes".to_string(),
enabled: false,
path: test_path_buf("/tmp/skills/changelog_writer.toml").abs(),
},
];
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"));
}
}