diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 5d5dbf0f33..a6a951e000 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -108,6 +108,16 @@ impl ListSelectionView { impl BottomPaneView for ListSelectionView { fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { match key_event { + KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Char('\u{000E}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_down(), KeyEvent { code: KeyCode::Up, .. } => self.move_up(), @@ -115,6 +125,16 @@ impl BottomPaneView for ListSelectionView { code: KeyCode::Down, .. } => self.move_down(), + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.move_down(), KeyEvent { code: KeyCode::Esc, .. } => self.cancel(), @@ -279,10 +299,25 @@ mod tests { use super::BottomPaneView; use super::*; use crate::app_event::AppEvent; + use crate::bottom_pane::BottomPane; + use crate::bottom_pane::BottomPaneParams; + use crate::tui::FrameRequester; use insta::assert_snapshot; use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; + fn dummy_bottom_pane() -> BottomPane { + let (tx_raw, _rx) = unbounded_channel::(); + BottomPane::new(BottomPaneParams { + app_event_tx: AppEventSender::new(tx_raw), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: true, + placeholder_text: "".to_string(), + disable_paste_burst: false, + }) + } + fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -347,4 +382,42 @@ mod tests { let view = make_selection_view(Some("Switch between Codex approval presets")); assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + + #[test] + fn control_p_and_n_move_selection() { + let mut view = make_selection_view(None); + let mut pane = dummy_bottom_pane(); + + assert_eq!(view.state.selected_idx, Some(0)); + + view.handle_key_event( + &mut pane, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + ); + assert_eq!(view.state.selected_idx, Some(1)); + + view.handle_key_event( + &mut pane, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + ); + assert_eq!(view.state.selected_idx, Some(0)); + } + + #[test] + fn control_p_n_fallback_control_chars_move_selection() { + let mut view = make_selection_view(None); + let mut pane = dummy_bottom_pane(); + + view.handle_key_event( + &mut pane, + KeyEvent::new(KeyCode::Char('\u{000E}'), KeyModifiers::NONE), + ); + assert_eq!(view.state.selected_idx, Some(1)); + + view.handle_key_event( + &mut pane, + KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE), + ); + assert_eq!(view.state.selected_idx, Some(0)); + } } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 9166b8c7f6..675c660e94 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -214,6 +214,12 @@ impl TextArea { KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { self.move_cursor_right(); } + KeyEvent { code: KeyCode::Char('\u{000E}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { + self.move_cursor_down(); + } + KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { + self.move_cursor_up(); + } KeyEvent { code: KeyCode::Char(c), // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, @@ -335,6 +341,20 @@ impl TextArea { } => { self.move_cursor_right(); } + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_down(); + } // Some terminals send Alt+Arrow for word-wise movement: // Option/Left -> Alt+Left (previous word start) // Option/Right -> Alt+Right (next word end) @@ -1227,6 +1247,18 @@ mod tests { assert_eq!(t.cursor(), 1); } + #[test] + fn control_p_and_n_move_cursor() { + let mut t = ta_with("ab\ncd"); + t.set_cursor(3); + + t.input(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); + assert_eq!(t.cursor(), 0); + + t.input(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + assert_eq!(t.cursor(), 3); + } + #[test] fn control_b_f_fallback_control_chars_move_cursor() { let mut t = ta_with("abcd"); @@ -1242,6 +1274,20 @@ mod tests { assert_eq!(t.cursor(), 2); } + #[test] + fn control_p_n_fallback_control_chars_move_cursor() { + let mut t = ta_with("ab\ncd"); + t.set_cursor(3); + + // ^P (U+0010) should move up + t.input(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 0); + + // ^N (U+000E) should move down + t.input(KeyEvent::new(KeyCode::Char('\u{000E}'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), 3); + } + #[test] fn delete_backward_word_alt_keys() { // Test the custom Alt+Ctrl+h binding