From 87d2235b5470d8969b51d80278a46aa30703eeab Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 4 May 2026 17:16:41 -0300 Subject: [PATCH] fix(tui): support modified backspace/delete keys (#21058) ## Why Fixes #21046. Codex TUI 0.128.0 can show Backspace/Delete-related editor shortcuts in `/keymap`, but Windows-style modified Backspace/Delete events were still dropped by the composer because the default editor keymap did not include those modified special-key variants. On Windows/CMD this meant `Shift+Backspace` and `Shift+Delete` did not fall through to normal character deletion, and `Ctrl+Backspace` / `Ctrl+Delete` did not perform the word deletion users expect from Windows text inputs. ## What Changed - Added default editor bindings for `shift-backspace` and `shift-delete` so shifted delete keys keep normal grapheme deletion behavior. - Added default editor bindings for `ctrl-backspace`, `ctrl-shift-backspace`, `ctrl-delete`, and `ctrl-shift-delete` so Windows-style word deletion works when terminals preserve those modifiers. - Added regression coverage for the resolved default keymap and textarea behavior. ## How to Test 1. Start Codex in the TUI on Windows CMD or another terminal that reports modified Backspace/Delete keys distinctly. 2. Type `hello world` in the composer. 3. Press `Ctrl+Backspace`; confirm `world` is removed and `hello ` remains. 4. Type `world` again, move the cursor before it, then press `Ctrl+Delete`; confirm the next word is removed. 5. Type a few characters and press `Shift+Backspace` and `Shift+Delete`; confirm they delete one character in the expected direction instead of doing nothing. 6. Open `/keymap`, inspect the Editor deletion actions, and confirm the modified Backspace/Delete aliases are visible as configurable defaults. Targeted tests: - `cargo test -p codex-tui keymap::tests` - `cargo test -p codex-tui bottom_pane::textarea::tests` - `cargo test -p codex-tui keymap_setup::tests` --- codex-rs/tui/src/bottom_pane/textarea.rs | 47 +++++++++++++++++ codex-rs/tui/src/keymap.rs | 65 +++++++++++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index c6c756e3e..063841211 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -2779,6 +2779,53 @@ mod tests { assert_eq!(t.cursor(), 6); } + #[test] + fn shift_backspace_and_shift_delete_keep_grapheme_delete_behavior() { + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 2); + + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SHIFT)); + assert_eq!(t.text(), "ac"); + assert_eq!(t.cursor(), 1); + + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 1); + + t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::SHIFT)); + assert_eq!(t.text(), "ac"); + assert_eq!(t.cursor(), 1); + } + + #[test] + fn control_backspace_variants_delete_backward_word() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + + t.input(KeyEvent::new(KeyCode::Backspace, modifiers)); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + } + } + + #[test] + fn control_delete_variants_delete_forward_word() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 0); + + t.input(KeyEvent::new(KeyCode::Delete, modifiers)); + assert_eq!(t.text(), " world"); + assert_eq!(t.cursor(), 0); + } + } + #[test] fn delete_backward_word_handles_narrow_no_break_space() { let mut t = ta_with("32\u{202F}AM"); diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 419a5ae8c..5e88423b2 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -588,11 +588,21 @@ impl RuntimeKeymap { move_line_end: default_bindings![plain(KeyCode::End), ctrl(KeyCode::Char('e'))], delete_backward: default_bindings![ plain(KeyCode::Backspace), + shift(KeyCode::Backspace), ctrl(KeyCode::Char('h')) ], - delete_forward: default_bindings![plain(KeyCode::Delete), ctrl(KeyCode::Char('d'))], + delete_forward: default_bindings![ + plain(KeyCode::Delete), + shift(KeyCode::Delete), + ctrl(KeyCode::Char('d')) + ], delete_backward_word: default_bindings![ alt(KeyCode::Backspace), + ctrl(KeyCode::Backspace), + raw(KeyBinding::new( + KeyCode::Backspace, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )), ctrl(KeyCode::Char('w')), raw(KeyBinding::new( KeyCode::Char('h'), @@ -601,6 +611,11 @@ impl RuntimeKeymap { ], delete_forward_word: default_bindings![ alt(KeyCode::Delete), + ctrl(KeyCode::Delete), + raw(KeyBinding::new( + KeyCode::Delete, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )), alt(KeyCode::Char('d')) ], kill_line_start: default_bindings![ctrl(KeyCode::Char('u'))], @@ -1919,6 +1934,54 @@ mod tests { ); } + #[test] + fn default_editor_deletion_includes_modified_backspace_delete_aliases() { + let runtime = RuntimeKeymap::defaults(); + + assert!( + runtime + .editor + .delete_backward + .contains(&key_hint::shift(KeyCode::Backspace)) + ); + assert!( + runtime + .editor + .delete_forward + .contains(&key_hint::shift(KeyCode::Delete)) + ); + assert!( + runtime + .editor + .delete_backward_word + .contains(&key_hint::ctrl(KeyCode::Backspace)) + ); + assert!( + runtime + .editor + .delete_backward_word + .contains(&KeyBinding::new( + KeyCode::Backspace, + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )) + ); + assert!( + runtime + .editor + .delete_forward_word + .contains(&key_hint::ctrl(KeyCode::Delete)) + ); + assert!( + runtime + .editor + .delete_forward_word + .contains(&KeyBinding::new( + KeyCode::Delete, + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )) + ); + } + #[test] fn default_composer_toggle_shortcuts_includes_shift_question_mark() { let runtime = RuntimeKeymap::defaults();