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`
This commit is contained in:
Felipe Coury
2026-05-04 17:16:41 -03:00
committed by GitHub
Unverified
parent a6599b8202
commit 87d2235b54
2 changed files with 111 additions and 1 deletions
+47
View File
@@ -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");
+64 -1
View File
@@ -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();