mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
feat(tui): add configurable keymap support (#18593)
## Why The TUI currently handles keyboard shortcuts as hard-coded event matches spread across app, composer, pager, list, approval, and navigation code. That makes shortcuts hard to customize, makes displayed hints easy to drift from actual behavior, and makes future keymap work riskier because there is no central action inventory. This PR adds the foundation for configurable, action-based keymaps without adding the interactive remapping UI yet. Onboarding intentionally stays on fixed startup shortcuts because users cannot reasonably configure keymaps before completing onboarding. This is PR1 in the keymap stack: - PR1: #18593: configurable keymap foundation - PR2: #18594: `/keymap` picker and guided remapping UI - PR3: #18595: Vim composer mode and the remap option ## Design Notes The new model resolves named actions into concrete runtime bindings once from config, then passes those bindings to the UI surfaces that handle input or render shortcut hints. The main concepts are: - **Context**: a scope where an action is active, such as `global`, `chat`, `composer`, `editor`, `pager`, `list`, or `approval`. - **Action**: a named operation inside a context, such as `global.open_transcript`, `composer.submit`, or `pager.close`. - **Binding**: one or more single-key shortcuts assigned to an action, written as config strings such as `ctrl-t`, `alt-backspace`, or `page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or leader-key flows are not part of this PR. - **Resolution order**: context-specific config wins first, supported global fallbacks come next, and built-in defaults fill in anything unset. - **Explicit unbinding**: an empty array removes an action binding in that scope and does not fall through to a fallback binding. - **Conflict validation**: a resolved keymap rejects duplicate active bindings inside the same scope so one keypress cannot dispatch two actions. ## What Changed - Added `TuiKeymap` config support under `[tui.keymap]`, including typed contexts/actions, key alias normalization, generated schema coverage, and user-facing config errors. - Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`, including fallback precedence, built-in defaults, explicit unbinding, and per-context conflict validation. - Rewired existing TUI handlers to consume resolved keymap actions instead of directly matching hard-coded keys in each component. - Updated key hint rendering and footer/pager/list surfaces so displayed shortcuts follow the resolved keymap. - Kept onboarding shortcuts fixed in `codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through `[tui.keymap]`. ## Validation The branch includes focused coverage for config parsing, key normalization, runtime fallback resolution, explicit unbinding, duplicate-key conflict validation, default keymap consistency, onboarding startup key behavior, and UI hint snapshots affected by resolved key bindings.
This commit is contained in:
1 parent
a61c785040
commit
5e737372ee
63 files changed
+8142
-877
No files matched your search
@@ -10,7 +10,10 @@
|
||||
//! This module does not implement an Emacs-style multi-entry kill ring. It keeps only the most
|
||||
//! recent killed span.
|
||||
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_altgr;
|
||||
use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -92,6 +95,7 @@ pub(crate) struct TextArea {
|
||||
elements: Vec<TextElement>,
|
||||
next_element_id: u64,
|
||||
kill_buffer: String,
|
||||
editor_keymap: EditorKeymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -116,9 +120,20 @@ impl TextArea {
|
||||
elements: Vec::new(),
|
||||
next_element_id: 1,
|
||||
kill_buffer: String::new(),
|
||||
editor_keymap: RuntimeKeymap::defaults().editor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the editor keymap used by subsequent text-editing input.
|
||||
///
|
||||
/// This method intentionally swaps only the keymap cache. It does not
|
||||
/// reinterpret pending input, move the cursor, or mutate the kill buffer, so
|
||||
/// callers can safely apply a live config update while preserving the
|
||||
/// current draft exactly as typed.
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) {
|
||||
self.editor_keymap = keymap.clone();
|
||||
}
|
||||
|
||||
/// Replace the visible textarea text and clear any existing text elements.
|
||||
///
|
||||
/// This is the "fresh buffer" path for callers that want plain text with no placeholder
|
||||
@@ -319,243 +334,123 @@ impl TextArea {
|
||||
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
// Some terminals (or configurations) send Control key chords as
|
||||
// C0 control characters without reporting the CONTROL modifier.
|
||||
// Handle common fallbacks for Ctrl-B/F/P/N here so they don't get
|
||||
// inserted as literal control bytes.
|
||||
KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => {
|
||||
self.move_cursor_down();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
|
||||
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
|
||||
// for word navigation. Those are handled explicitly below.
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j' | 'm'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => self.insert_str("\n"),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
self.delete_backward_word()
|
||||
},
|
||||
// Windows AltGr generates ALT|CONTROL; treat as a plain character input unless
|
||||
// we match a specific Control+Alt binding above.
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if is_altgr(modifiers) => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => self.delete_backward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.delete_backward(/*n*/ 1),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => self.delete_forward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.delete_forward(/*n*/ 1),
|
||||
let keymap = self.editor_keymap.clone();
|
||||
self.input_with_keymap(event, &keymap);
|
||||
}
|
||||
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('w'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.delete_backward_word();
|
||||
}
|
||||
// Meta-b -> move to beginning of previous word
|
||||
// Meta-f -> move to end of next word
|
||||
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('u'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.kill_to_beginning_of_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.kill_to_end_of_line();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('y'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.yank();
|
||||
}
|
||||
|
||||
// Cursor movement
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_left();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
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)
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => {
|
||||
self.move_cursor_up();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_down();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Home,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true);
|
||||
}
|
||||
|
||||
KeyEvent {
|
||||
code: KeyCode::End, ..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
|
||||
}
|
||||
_ => {}
|
||||
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
|
||||
if keymap.insert_newline.is_pressed(event) {
|
||||
self.insert_str("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if keymap.delete_backward_word.is_pressed(event) {
|
||||
self.delete_backward_word();
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows AltGr generates ALT|CONTROL. Preserve typed characters for AltGr users
|
||||
// unless a specific shortcut already matched above.
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} = event
|
||||
&& is_altgr(modifiers)
|
||||
{
|
||||
self.insert_str(&c.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
if keymap.delete_backward.is_pressed(event) {
|
||||
self.delete_backward(/*n*/ 1);
|
||||
return;
|
||||
}
|
||||
if keymap.delete_forward_word.is_pressed(event) {
|
||||
self.delete_forward_word();
|
||||
return;
|
||||
}
|
||||
if keymap.delete_forward.is_pressed(event) {
|
||||
self.delete_forward(/*n*/ 1);
|
||||
return;
|
||||
}
|
||||
if keymap.kill_line_start.is_pressed(event) {
|
||||
self.kill_to_beginning_of_line();
|
||||
return;
|
||||
}
|
||||
if keymap.kill_line_end.is_pressed(event) {
|
||||
self.kill_to_end_of_line();
|
||||
return;
|
||||
}
|
||||
if keymap.yank.is_pressed(event) {
|
||||
self.yank();
|
||||
return;
|
||||
}
|
||||
if keymap.move_word_left.is_pressed(event) {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
return;
|
||||
}
|
||||
if keymap.move_word_right.is_pressed(event) {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
return;
|
||||
}
|
||||
if keymap.move_left.is_pressed(event) {
|
||||
self.move_cursor_left();
|
||||
return;
|
||||
}
|
||||
if keymap.move_right.is_pressed(event) {
|
||||
self.move_cursor_right();
|
||||
return;
|
||||
}
|
||||
if keymap.move_up.is_pressed(event) {
|
||||
self.move_cursor_up();
|
||||
return;
|
||||
}
|
||||
if keymap.move_down.is_pressed(event) {
|
||||
self.move_cursor_down();
|
||||
return;
|
||||
}
|
||||
if keymap.move_line_start.is_pressed(event) {
|
||||
let move_up_at_bol = matches!(
|
||||
event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
);
|
||||
self.move_cursor_to_beginning_of_line(move_up_at_bol);
|
||||
return;
|
||||
}
|
||||
if keymap.move_line_end.is_pressed(event) {
|
||||
let move_down_at_eol = matches!(
|
||||
event,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
}
|
||||
);
|
||||
self.move_cursor_to_end_of_line(move_down_at_eol);
|
||||
return;
|
||||
}
|
||||
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// Insert plain characters (and Shift-modified). Do not insert when ALT is held,
|
||||
// because many terminals map Option/Meta combos to ALT+<char>.
|
||||
if c.is_ascii_control() {
|
||||
return;
|
||||
}
|
||||
self.insert_str(&c.to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = event;
|
||||
}
|
||||
|
||||
// ####### Input Functions #######
|
||||
@@ -1941,6 +1836,37 @@ mod tests {
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_control_chars_respect_unbound_editor_movement() {
|
||||
let mut t = ta_with("a\nb");
|
||||
t.set_cursor(/*pos*/ 2);
|
||||
let mut keymap = RuntimeKeymap::defaults().editor;
|
||||
keymap.move_up.clear();
|
||||
|
||||
t.input_with_keymap(
|
||||
KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
|
||||
&keymap,
|
||||
);
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c0_control_chars_respect_remapped_editor_movement() {
|
||||
let mut t = ta_with("a\nb");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
let mut keymap = RuntimeKeymap::defaults().editor;
|
||||
keymap.move_up.clear();
|
||||
keymap.move_down = vec![crate::key_hint::ctrl(KeyCode::Char('p'))];
|
||||
|
||||
t.input_with_keymap(
|
||||
KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::NONE),
|
||||
&keymap,
|
||||
);
|
||||
|
||||
assert_eq!(t.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_backward_word_alt_keys() {
|
||||
// Test the custom Alt+Ctrl+h binding
|
||||
|
||||
Reference in new issue
Block a user