Skip to content
Open
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 129 additions & 2 deletions codex-rs/tui/src/bottom_pane/list_selection_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,36 @@ impl ListSelectionView {
impl BottomPaneView for ListSelectionView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
// Some terminals (or configurations) send Control key chords as
// C0 control characters without reporting the CONTROL modifier.
// Handle fallbacks for Ctrl-P/N here so navigation works everywhere.
KeyEvent {
code: KeyCode::Up, ..
} => self.move_up(),
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
} => self.move_down(),
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
Expand Down Expand Up @@ -713,4 +736,108 @@ mod tests {
render_lines_with_width(&view, 24)
);
}

#[test]
fn ctrl_n_moves_selection_down() {
let mut view = make_selection_view(None);
// Initial selection is on first item (Read Only which is_current)
let initial = render_lines(&view);
assert!(
initial.contains("› 1. Read Only"),
"expected first item selected initially"
);

// Press Ctrl+n to move down
view.handle_key_event(KeyEvent::new(
KeyCode::Char('n'),
KeyModifiers::CONTROL,
));
let after_ctrl_n = render_lines(&view);
assert!(
after_ctrl_n.contains("› 2. Full Access"),
"expected second item selected after Ctrl+n"
);
}

#[test]
fn ctrl_p_moves_selection_up() {
let mut view = make_selection_view(None);
// Move down first so we can move up
view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let after_down = render_lines(&view);
assert!(
after_down.contains("› 2. Full Access"),
"expected second item selected after Down"
);

// Press Ctrl+p to move up
view.handle_key_event(KeyEvent::new(
KeyCode::Char('p'),
KeyModifiers::CONTROL,
));
let after_ctrl_p = render_lines(&view);
assert!(
after_ctrl_p.contains("› 1. Read Only"),
"expected first item selected after Ctrl+p"
);
}

#[test]
fn ctrl_n_and_ctrl_p_wrap_around() {
let mut view = make_selection_view(None);
// Move to second item
view.handle_key_event(KeyEvent::new(
KeyCode::Char('n'),
KeyModifiers::CONTROL,
));
// Move past last item - should wrap to first
view.handle_key_event(KeyEvent::new(
KeyCode::Char('n'),
KeyModifiers::CONTROL,
));
let wrapped_forward = render_lines(&view);
assert!(
wrapped_forward.contains("› 1. Read Only"),
"expected selection to wrap to first item"
);

// Move up from first item - should wrap to last
view.handle_key_event(KeyEvent::new(
KeyCode::Char('p'),
KeyModifiers::CONTROL,
));
let wrapped_back = render_lines(&view);
assert!(
wrapped_back.contains("› 2. Full Access"),
"expected selection to wrap to last item"
);
}

#[test]
fn c0_control_chars_navigate_selection() {
let mut view = make_selection_view(None);
// Initial selection is on first item
let initial = render_lines(&view);
assert!(
initial.contains("› 1. Read Only"),
"expected first item selected initially"
);

// Simulate terminals that send C0 control chars without CONTROL modifier.
// ^N (U+000E) should move down
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{000e}'), KeyModifiers::NONE));
let after_c0_n = render_lines(&view);
assert!(
after_c0_n.contains("› 2. Full Access"),
"expected second item selected after ^N (C0)"
);

// ^P (U+0010) should move up
view.handle_key_event(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE));
let after_c0_p = render_lines(&view);
assert!(
after_c0_p.contains("› 1. Read Only"),
"expected first item selected after ^P (C0)"
);
}
}
Loading