mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
3d517fbd00
## 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.
496 lines
16 KiB
Rust
496 lines
16 KiB
Rust
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"));
|
||
}
|
||
}
|