feat(tui): improve TUI keymap coverage (#20798)

## Summary
- normalize terminal-emitted C0 control characters through configurable
editor keymaps, covering raw control-key fallbacks like
Shift+Enter-as-LF in terminals from #20555 and #20898, plus part of the
modified-Enter behavior in #20580
- add default-unbound keymap actions for toggling Fast mode and killing
the current composer line, giving #20698 users a configurable zsh-style
Ctrl+U option without changing the existing default Ctrl+U behavior
- wire the new actions through gated /keymap picker entries, schema
generation, and snapshot coverage

Fixes #20555.
Fixes #20898.

## Testing
- just write-config-schema
- just fmt
- cargo test -p codex-config
- cargo test -p codex-tui keymap::tests
- cargo test -p codex-tui bottom_pane::textarea::tests
- cargo test -p codex-tui keymap_setup::tests
- cargo insta pending-snapshots
- just fix -p codex-tui
- git diff --check
- just argument-comment-lint
This commit is contained in:
Felipe Coury
2026-05-04 16:18:56 -03:00
committed by GitHub
Unverified
parent cc16995cc6
commit 48402be6fa
18 changed files with 572 additions and 51 deletions
+4
View File
@@ -104,6 +104,8 @@ pub struct TuiGlobalKeymap {
pub toggle_shortcuts: Option<KeybindingsSpec>,
/// Toggle Vim mode for the composer input.
pub toggle_vim_mode: Option<KeybindingsSpec>,
/// Toggle Fast mode.
pub toggle_fast_mode: Option<KeybindingsSpec>,
}
/// Chat context keybindings.
@@ -169,6 +171,8 @@ pub struct TuiEditorKeymap {
pub delete_forward_word: Option<KeybindingsSpec>,
/// Kill text from cursor to line start.
pub kill_line_start: Option<KeybindingsSpec>,
/// Kill the current line.
pub kill_whole_line: Option<KeybindingsSpec>,
/// Kill text from cursor to line end.
pub kill_line_end: Option<KeybindingsSpec>,
/// Yank the kill buffer.
+20
View File
@@ -2441,6 +2441,7 @@
"insert_newline": null,
"kill_line_end": null,
"kill_line_start": null,
"kill_whole_line": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
@@ -2458,6 +2459,7 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
},
@@ -2811,6 +2813,14 @@
],
"description": "Kill text from cursor to line start."
},
"kill_whole_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Kill the current line."
},
"move_down": {
"allOf": [
{
@@ -2938,6 +2948,14 @@
],
"description": "Submit the current composer draft."
},
"toggle_fast_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Toggle Fast mode."
},
"toggle_shortcuts": {
"allOf": [
{
@@ -3018,6 +3036,7 @@
"insert_newline": null,
"kill_line_end": null,
"kill_line_start": null,
"kill_whole_line": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
@@ -3042,6 +3061,7 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
}
+8
View File
@@ -129,6 +129,14 @@ impl App {
return;
}
if app_keymap_shortcuts_available
&& self.keymap.app.toggle_fast_mode.is_pressed(key_event)
&& self.chat_widget.can_toggle_fast_mode_from_keybinding()
{
self.chat_widget.toggle_fast_mode_from_ui();
return;
}
if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
+62 -2
View File
@@ -569,6 +569,10 @@ impl TextArea {
self.kill_to_beginning_of_line();
return;
}
if keymap.kill_whole_line.is_pressed(event) {
self.kill_current_line();
return;
}
if keymap.kill_line_end.is_pressed(event) {
self.kill_to_end_of_line();
return;
@@ -780,7 +784,7 @@ impl TextArea {
fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool {
if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) {
self.delete_current_line();
self.kill_current_line();
return true;
}
if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) {
@@ -1116,7 +1120,7 @@ impl TextArea {
self.yank_line_range(range);
}
fn delete_current_line(&mut self) {
fn kill_current_line(&mut self) {
let range = self.current_line_range_with_newline();
self.kill_line_range(range);
}
@@ -2447,6 +2451,51 @@ mod tests {
assert_eq!(t.cursor(), 3);
}
#[test]
fn kill_current_line_removes_current_line_linewise() {
let mut t = ta_with("abc\ndef\nghi");
t.set_cursor(/*pos*/ 5);
t.kill_current_line();
assert_eq!(t.text(), "abc\nghi");
assert_eq!(t.cursor(), 4);
assert_eq!(t.kill_buffer, "def\n");
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
}
#[test]
fn kill_current_line_keeps_previous_newline_for_final_line() {
let mut t = ta_with("abc\ndef");
t.set_cursor(/*pos*/ 5);
t.kill_current_line();
assert_eq!(t.text(), "abc\n");
assert_eq!(t.cursor(), 4);
assert_eq!(t.kill_buffer, "def");
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
}
#[test]
fn kill_whole_line_keymap_dispatch_uses_linewise_kill() {
let mut t = ta_with("abc\ndef\nghi");
t.set_cursor(/*pos*/ 5);
let mut keymap = RuntimeKeymap::defaults().editor;
keymap.kill_line_start.clear();
keymap.kill_whole_line = vec![key_hint::ctrl(KeyCode::Char('u'))];
t.input_with_keymap(
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
&keymap,
);
assert_eq!(t.text(), "abc\nghi");
assert_eq!(t.cursor(), 4);
assert_eq!(t.kill_buffer, "def\n");
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
}
#[test]
fn delete_forward_word_variants() {
let mut t = ta_with("hello world ");
@@ -2668,6 +2717,17 @@ mod tests {
assert_eq!(t.cursor(), 2);
}
#[test]
fn c0_line_feed_inserts_newline_through_insert_newline_keymap() {
let mut t = ta_with("ab");
t.set_cursor(/*pos*/ 1);
t.input(KeyEvent::new(KeyCode::Char('\u{000a}'), KeyModifiers::NONE));
assert_eq!(t.text(), "a\nb");
assert_eq!(t.cursor(), 2);
}
#[test]
fn c0_control_chars_respect_unbound_editor_movement() {
let mut t = ta_with("a\nb");
+15
View File
@@ -9313,6 +9313,12 @@ impl ChatWidget {
self.config.features.enabled(Feature::FastMode)
}
pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool {
self.fast_mode_enabled()
&& !self.is_user_turn_pending_or_running()
&& self.bottom_pane.no_modal_or_popup_active()
}
pub(crate) fn set_realtime_audio_device(
&mut self,
kind: RealtimeAudioDeviceKind,
@@ -9367,6 +9373,15 @@ impl ChatWidget {
.send(AppEvent::PersistServiceTierSelection { service_tier });
}
pub(crate) fn toggle_fast_mode_from_ui(&mut self) {
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
}
pub(crate) fn current_model(&self) -> &str {
if !self.collaboration_modes_enabled() {
return self.current_collaboration_mode.model();
+12 -3
View File
@@ -30,9 +30,10 @@ impl ChatWidget {
pub(crate) fn open_keymap_picker(&mut self) {
match RuntimeKeymap::from_config(&self.config.tui_keymap) {
Ok(runtime_keymap) => {
let params = keymap_setup::build_keymap_picker_params(
let params = keymap_setup::build_keymap_picker_params_with_filter(
&runtime_keymap,
&self.config.tui_keymap,
self.keymap_action_filter(),
);
self.bottom_pane.show_selection_view(params);
}
@@ -120,9 +121,10 @@ impl ChatWidget {
action: &str,
runtime_keymap: &RuntimeKeymap,
) {
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter(
runtime_keymap,
&self.config.tui_keymap,
self.keymap_action_filter(),
context,
action,
);
@@ -135,9 +137,10 @@ impl ChatWidget {
params,
);
if !replaced {
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter(
runtime_keymap,
&self.config.tui_keymap,
self.keymap_action_filter(),
context,
action,
);
@@ -146,6 +149,12 @@ impl ChatWidget {
self.request_redraw();
}
fn keymap_action_filter(&self) -> keymap_setup::KeymapActionFilter {
keymap_setup::KeymapActionFilter {
fast_mode_enabled: self.fast_mode_enabled(),
}
}
/// Applies a committed keymap edit to the live chat widget.
///
/// The caller is responsible for persisting the config file before invoking this method. This
@@ -178,12 +178,7 @@ impl ChatWidget {
self.open_model_popup();
}
SlashCommand::Fast => {
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
self.toggle_fast_mode_from_ui();
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
@@ -1817,6 +1817,51 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.toggle_fast_mode_from_ui();
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
..
})
)),
"expected fast-mode override app event; events: {events:?}"
);
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection {
service_tier: Some(ServiceTier::Fast),
}
)),
"expected fast-mode persistence app event; events: {events:?}"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn fast_keybinding_toggle_requires_feature_and_idle_surface() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false);
assert!(!chat.can_toggle_fast_mode_from_keybinding());
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
assert!(chat.can_toggle_fast_mode_from_keybinding());
chat.bottom_pane.set_task_running(/*running*/ true);
assert!(!chat.can_toggle_fast_mode_from_keybinding());
}
#[tokio::test]
async fn user_turn_carries_service_tier_after_fast_toggle() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
+77 -16
View File
@@ -2,7 +2,8 @@
//!
//! This module provides `KeyBinding`, the runtime representation of a single
//! keybinding (key code + modifier set), along with matching logic that handles
//! cross-terminal inconsistencies in how shifted letters are reported.
//! cross-terminal inconsistencies in how shifted letters and raw C0 control
//! characters are reported.
//!
//! It also supplies rendering helpers that convert bindings into styled
//! `ratatui::text::Span` values for UI hint display.
@@ -26,10 +27,10 @@ const SHIFT_PREFIX: &str = "shift + ";
/// One concrete key event that can trigger a TUI action.
///
/// Matching via `is_press` handles both exact equality and a shifted-letter
/// compatibility fallback for terminals that report uppercase letters without
/// the SHIFT modifier flag. This means a binding defined as `shift-a` will
/// match a terminal event of either `Shift+a` or plain `A`.
/// Matching via `is_press` handles exact equality plus compatibility fallbacks
/// for terminals that report uppercase letters without SHIFT and Ctrl keys as
/// raw C0 control characters. This means a binding defined as `shift-a` will
/// match either `Shift+a` or plain `A`, and `ctrl-j` will match raw LF.
///
/// This does not model multi-key chords or partial matches; callers that need
/// sequences must keep that state outside this type.
@@ -45,13 +46,13 @@ impl KeyBinding {
}
pub(crate) fn from_event(event: KeyEvent) -> Self {
let (key, modifiers) = normalize_shifted_ascii_char(event.code, event.modifiers);
let (key, modifiers) = normalize_key_parts(event.code, event.modifiers);
Self { key, modifiers }
}
pub fn is_press(&self, event: KeyEvent) -> bool {
normalize_shifted_ascii_char(self.key, self.modifiers)
== normalize_shifted_ascii_char(event.code, event.modifiers)
normalize_key_parts(self.key, self.modifiers)
== normalize_key_parts(event.code, event.modifiers)
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
}
@@ -76,7 +77,7 @@ impl KeyBinding {
}
}
fn normalize_shifted_ascii_char(
pub(crate) fn normalize_key_parts(
key: KeyCode,
mut modifiers: KeyModifiers,
) -> (KeyCode, KeyModifiers) {
@@ -96,13 +97,11 @@ fn normalize_shifted_ascii_char(
}
fn c0_control_char_to_ctrl_char(ch: char) -> Option<char> {
match ch {
'\u{0002}' => Some('b'),
'\u{0006}' => Some('f'),
'\u{000e}' => Some('n'),
'\u{0010}' => Some('p'),
'\u{0012}' => Some('r'),
'\u{0013}' => Some('s'),
let code = u32::from(ch);
match code {
0x00 => Some(' '),
0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')),
0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')),
_ => None,
}
}
@@ -256,6 +255,68 @@ mod tests {
assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT)));
}
#[test]
fn ctrl_bindings_match_all_supported_c0_control_char_events() {
let cases = [
(' ', '\u{0000}'),
('a', '\u{0001}'),
('b', '\u{0002}'),
('c', '\u{0003}'),
('d', '\u{0004}'),
('e', '\u{0005}'),
('f', '\u{0006}'),
('g', '\u{0007}'),
('h', '\u{0008}'),
('i', '\u{0009}'),
('j', '\u{000a}'),
('k', '\u{000b}'),
('l', '\u{000c}'),
('m', '\u{000d}'),
('n', '\u{000e}'),
('o', '\u{000f}'),
('p', '\u{0010}'),
('q', '\u{0011}'),
('r', '\u{0012}'),
('s', '\u{0013}'),
('t', '\u{0014}'),
('u', '\u{0015}'),
('v', '\u{0016}'),
('w', '\u{0017}'),
('x', '\u{0018}'),
('y', '\u{0019}'),
('z', '\u{001a}'),
('4', '\u{001c}'),
('5', '\u{001d}'),
('6', '\u{001e}'),
('7', '\u{001f}'),
];
for (ctrl_char, c0_char) in cases {
assert!(
ctrl(KeyCode::Char(ctrl_char))
.is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::NONE)),
"expected raw C0 {c0_char:?} to match ctrl-{ctrl_char}"
);
assert!(
!ctrl(KeyCode::Char(ctrl_char))
.is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::ALT)),
"expected modified raw C0 {c0_char:?} not to match ctrl-{ctrl_char}"
);
}
}
#[test]
fn ctrl_binding_does_not_match_ambiguous_c0_escape_or_delete() {
assert!(
!ctrl(KeyCode::Char('['))
.is_press(KeyEvent::new(KeyCode::Char('\u{001b}'), KeyModifiers::NONE,))
);
assert!(
!ctrl(KeyCode::Char('?'))
.is_press(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE,))
);
}
#[test]
fn history_search_ctrl_bindings_match_c0_control_char_events() {
assert!(
+95
View File
@@ -63,6 +63,8 @@ pub(crate) struct AppKeymap {
pub(crate) clear_terminal: Vec<KeyBinding>,
/// Toggle Vim mode for the composer input.
pub(crate) toggle_vim_mode: Vec<KeyBinding>,
/// Toggle Fast mode.
pub(crate) toggle_fast_mode: Vec<KeyBinding>,
}
/// Chat-level keybindings evaluated at the app event layer.
@@ -120,6 +122,7 @@ pub(crate) struct EditorKeymap {
pub(crate) delete_backward_word: Vec<KeyBinding>,
pub(crate) delete_forward_word: Vec<KeyBinding>,
pub(crate) kill_line_start: Vec<KeyBinding>,
pub(crate) kill_whole_line: Vec<KeyBinding>,
pub(crate) kill_line_end: Vec<KeyBinding>,
pub(crate) yank: Vec<KeyBinding>,
}
@@ -369,6 +372,11 @@ impl RuntimeKeymap {
&defaults.app.toggle_vim_mode,
"tui.keymap.global.toggle_vim_mode",
)?,
toggle_fast_mode: resolve_bindings(
keymap.global.toggle_fast_mode.as_ref(),
&defaults.app.toggle_fast_mode,
"tui.keymap.global.toggle_fast_mode",
)?,
};
let chat = ChatKeymap {
@@ -417,6 +425,7 @@ impl RuntimeKeymap {
delete_backward_word: resolve_local!(keymap, defaults, editor, delete_backward_word),
delete_forward_word: resolve_local!(keymap, defaults, editor, delete_forward_word),
kill_line_start: resolve_local!(keymap, defaults, editor, kill_line_start),
kill_whole_line: resolve_local!(keymap, defaults, editor, kill_whole_line),
kill_line_end: resolve_local!(keymap, defaults, editor, kill_line_end),
yank: resolve_local!(keymap, defaults, editor, yank),
};
@@ -536,6 +545,7 @@ impl RuntimeKeymap {
copy: default_bindings![ctrl(KeyCode::Char('o'))],
clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))],
toggle_vim_mode: default_bindings![],
toggle_fast_mode: default_bindings![],
},
chat: ChatKeymap {
decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))],
@@ -594,6 +604,7 @@ impl RuntimeKeymap {
alt(KeyCode::Char('d'))
],
kill_line_start: default_bindings![ctrl(KeyCode::Char('u'))],
kill_whole_line: default_bindings![],
kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))],
yank: default_bindings![ctrl(KeyCode::Char('y'))],
},
@@ -727,6 +738,7 @@ impl RuntimeKeymap {
("copy", self.app.copy.as_slice()),
("clear_terminal", self.app.clear_terminal.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
(
"chat.decrease_reasoning_effort",
self.chat.decrease_reasoning_effort.as_slice(),
@@ -767,6 +779,7 @@ impl RuntimeKeymap {
("copy", self.app.copy.as_slice()),
("clear_terminal", self.app.clear_terminal.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
(
"chat.decrease_reasoning_effort",
self.chat.decrease_reasoning_effort.as_slice(),
@@ -808,6 +821,7 @@ impl RuntimeKeymap {
("copy", self.app.copy.as_slice()),
("clear_terminal", self.app.clear_terminal.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
],
[
("list.move_up", self.list.move_up.as_slice()),
@@ -856,6 +870,7 @@ impl RuntimeKeymap {
),
("composer.submit", self.composer.submit.as_slice()),
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
(
"composer.history_search_previous",
self.composer.history_search_previous.as_slice(),
@@ -903,6 +918,10 @@ impl RuntimeKeymap {
"editor.kill_line_start",
self.editor.kill_line_start.as_slice(),
),
(
"editor.kill_whole_line",
self.editor.kill_whole_line.as_slice(),
),
("editor.kill_line_end", self.editor.kill_line_end.as_slice()),
("editor.yank", self.editor.yank.as_slice()),
],
@@ -936,6 +955,7 @@ impl RuntimeKeymap {
self.editor.delete_forward_word.as_slice(),
),
("kill_line_start", self.editor.kill_line_start.as_slice()),
("kill_whole_line", self.editor.kill_whole_line.as_slice()),
("kill_line_end", self.editor.kill_line_end.as_slice()),
("yank", self.editor.yank.as_slice()),
],
@@ -1389,6 +1409,7 @@ fn parse_keybinding(spec: &str) -> Option<KeyBinding> {
"page-up" => KeyCode::PageUp,
"page-down" => KeyCode::PageDown,
"space" => KeyCode::Char(' '),
"minus" => KeyCode::Char('-'),
other if other.len() == 1 => KeyCode::Char(char::from(other.as_bytes()[0])),
other if other.starts_with('f') => {
let number = other[1..].parse::<u8>().ok()?;
@@ -1587,6 +1608,7 @@ mod tests {
runtime.app.clear_terminal,
vec![key_hint::ctrl(KeyCode::Char('l'))]
);
assert_eq!(runtime.app.toggle_fast_mode, Vec::new());
assert_eq!(
runtime.chat.decrease_reasoning_effort,
vec![key_hint::alt(KeyCode::Char(','))]
@@ -1607,6 +1629,7 @@ mod tests {
runtime.composer.history_search_next,
vec![key_hint::ctrl(KeyCode::Char('s'))]
);
assert_eq!(runtime.editor.kill_whole_line, Vec::new());
}
#[test]
@@ -1732,6 +1755,61 @@ mod tests {
assert_eq!(runtime.app.copy, vec![key_hint::alt(KeyCode::Char('.'))]);
}
#[test]
fn kill_whole_line_can_be_assigned_without_default_binding() {
let mut keymap = TuiKeymap::default();
keymap.editor.kill_whole_line = Some(one("ctrl-shift-u"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
assert_eq!(
runtime.editor.kill_whole_line,
vec![KeyBinding::new(
KeyCode::Char('u'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
)]
);
}
#[test]
fn kill_whole_line_conflicts_until_kill_line_start_is_unbound() {
let mut keymap = TuiKeymap::default();
keymap.editor.kill_whole_line = Some(one("ctrl-u"));
expect_conflict(&keymap, "kill_line_start", "kill_whole_line");
keymap.editor.kill_line_start = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free");
assert_eq!(
runtime.editor.kill_whole_line,
vec![key_hint::ctrl(KeyCode::Char('u'))]
);
}
#[test]
fn toggle_fast_mode_can_be_assigned_without_default_binding() {
let mut keymap = TuiKeymap::default();
keymap.global.toggle_fast_mode = Some(one("ctrl-shift-f"));
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
assert_eq!(
runtime.app.toggle_fast_mode,
vec![KeyBinding::new(
KeyCode::Char('f'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
)]
);
}
#[test]
fn toggle_fast_mode_conflicts_with_existing_main_surface_bindings() {
let mut keymap = TuiKeymap::default();
keymap.global.toggle_fast_mode = Some(one("ctrl-l"));
expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode");
}
#[test]
fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() {
let mut keymap = TuiKeymap::default();
@@ -1773,6 +1851,7 @@ mod tests {
("page-up", KeyCode::PageUp),
("page-down", KeyCode::PageDown),
("space", KeyCode::Char(' ')),
("minus", KeyCode::Char('-')),
];
for (spec, expected_key) in cases {
@@ -1790,6 +1869,22 @@ mod tests {
assert_eq!(parse_keybinding("ff"), None);
}
#[test]
fn parses_minus_alias_and_legacy_literal_minus() {
assert_eq!(
parse_keybinding("alt-minus").map(|binding| binding.parts()),
Some((KeyCode::Char('-'), KeyModifiers::ALT))
);
assert_eq!(
parse_keybinding("alt--").map(|binding| binding.parts()),
Some((KeyCode::Char('-'), KeyModifiers::ALT))
);
assert_eq!(
parse_keybinding("-").map(|binding| binding.parts()),
Some((KeyCode::Char('-'), KeyModifiers::NONE))
);
}
#[test]
fn explicit_empty_array_unbinds_action() {
let mut keymap = TuiKeymap::default();
+114 -13
View File
@@ -21,10 +21,15 @@ mod actions;
mod debug;
mod picker;
pub(crate) use actions::KeymapActionFilter;
pub(crate) use debug::build_keymap_debug_view;
pub(crate) use picker::KEYMAP_PICKER_VIEW_ID;
#[cfg(test)]
pub(crate) use picker::build_keymap_picker_params;
#[cfg(test)]
pub(crate) use picker::build_keymap_picker_params_for_selected_action;
pub(crate) use picker::build_keymap_picker_params_for_selected_action_with_filter;
pub(crate) use picker::build_keymap_picker_params_with_filter;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
@@ -708,6 +713,9 @@ fn key_parts_to_config_key_spec(
code: KeyCode,
mut modifiers: KeyModifiers,
) -> Result<String, String> {
let (code, normalized_modifiers) = crate::key_hint::normalize_key_parts(code, modifiers);
modifiers = normalized_modifiers;
let supported_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT;
if !modifiers.difference(supported_modifiers).is_empty() {
return Err(
@@ -738,7 +746,7 @@ fn key_parts_to_config_key_spec(
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(mut ch) => {
if ch == '-' {
return Err("The `-` key cannot be represented in `tui.keymap` yet.".to_string());
return Ok(format_key_spec(modifiers, "minus"));
}
if !ch.is_ascii() || ch.is_ascii_control() {
return Err("Only printable ASCII keys can be stored in `tui.keymap`.".to_string());
@@ -754,18 +762,22 @@ fn key_parts_to_config_key_spec(
}
};
Ok(format_key_spec(modifiers, &key))
}
fn format_key_spec(modifiers: KeyModifiers, key: &str) -> String {
let mut parts = Vec::new();
if modifiers.contains(KeyModifiers::CONTROL) {
parts.push("ctrl".to_string());
parts.push("ctrl");
}
if modifiers.contains(KeyModifiers::ALT) {
parts.push("alt".to_string());
parts.push("alt");
}
if modifiers.contains(KeyModifiers::SHIFT) {
parts.push("shift".to_string());
parts.push("shift");
}
parts.push(key);
Ok(parts.join("-"))
parts.join("-")
}
#[cfg(test)]
@@ -821,6 +833,12 @@ mod tests {
render_buffer(&buf)
}
fn fast_mode_action_filter() -> KeymapActionFilter {
KeymapActionFilter {
fast_mode_enabled: true,
}
}
fn render_buffer(buf: &Buffer) -> String {
let area = buf.area();
(0..area.height)
@@ -891,7 +909,11 @@ mod tests {
#[test]
fn picker_covers_every_replaceable_action() {
let runtime = RuntimeKeymap::defaults();
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
let params = build_keymap_picker_params_with_filter(
&runtime,
&TuiKeymap::default(),
fast_mode_action_filter(),
);
let all_tab = selection_tab(&params, KEYMAP_ALL_TAB_ID);
assert!(params.items.is_empty());
@@ -913,6 +935,57 @@ mod tests {
}));
}
#[test]
fn picker_hides_fast_mode_action_when_feature_is_disabled() {
let runtime = RuntimeKeymap::defaults();
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
let all_tab = selection_tab(&params, KEYMAP_ALL_TAB_ID);
assert!(
all_tab
.items
.iter()
.all(|item| item.name != "Toggle Fast Mode")
);
}
#[test]
fn picker_shows_fast_mode_action_when_feature_is_enabled() {
let runtime = RuntimeKeymap::defaults();
let params = build_keymap_picker_params_with_filter(
&runtime,
&TuiKeymap::default(),
fast_mode_action_filter(),
);
let all_tab = selection_tab(&params, KEYMAP_ALL_TAB_ID);
let common_tab = selection_tab(&params, KEYMAP_COMMON_TAB_ID);
let app_tab = selection_tab(&params, "app-shortcuts");
let unbound_tab = selection_tab(&params, KEYMAP_UNBOUND_TAB_ID);
for tab in [all_tab, common_tab, app_tab, unbound_tab] {
assert!(
tab.items.iter().any(|item| item.name == "Toggle Fast Mode"),
"expected Toggle Fast Mode in {}",
tab.label
);
}
}
#[test]
fn keymap_picker_fast_mode_enabled_snapshot() {
let runtime = RuntimeKeymap::defaults();
let params = build_keymap_picker_params_with_filter(
&runtime,
&TuiKeymap::default(),
fast_mode_action_filter(),
);
assert_snapshot!(
"keymap_picker_fast_mode_enabled",
render_picker(params, /*width*/ 120)
);
}
#[test]
fn picker_common_tab_lists_curated_actions() {
let runtime = RuntimeKeymap::defaults();
@@ -1050,10 +1123,13 @@ mod tests {
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
let unbound_tab = selection_tab(&params, KEYMAP_UNBOUND_TAB_ID);
assert_eq!(unbound_tab.items.len(), 1);
assert_eq!(unbound_tab.items.len(), 2);
assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode");
assert_eq!(unbound_tab.items[0].description.as_deref(), Some("unbound"));
assert!(!unbound_tab.items[0].is_disabled);
assert_eq!(unbound_tab.items[1].name, "Kill Whole Line");
assert_eq!(unbound_tab.items[1].description.as_deref(), Some("unbound"));
assert!(!unbound_tab.items[1].is_disabled);
}
#[test]
@@ -1534,21 +1610,46 @@ mod tests {
}
#[test]
fn key_capture_serializes_c0_control_fallbacks() {
fn key_capture_serializes_c0_control_chars_as_ctrl_bindings() {
assert_eq!(
key_event_to_config_key_spec(KeyEvent::new(
KeyCode::Char('\u{000a}'),
KeyModifiers::NONE,
)),
Ok("ctrl-j".to_string())
);
assert_eq!(
key_event_to_config_key_spec(KeyEvent::new(
KeyCode::Char('\u{0015}'),
KeyModifiers::NONE,
)),
Ok("ctrl-u".to_string())
);
assert_eq!(
key_event_to_config_key_spec(KeyEvent::new(
KeyCode::Char('\u{0010}'),
KeyModifiers::NONE
KeyModifiers::NONE,
)),
Ok("ctrl-p".to_string())
);
}
#[test]
fn key_capture_rejects_unrepresentable_keys() {
assert!(
key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE))
.is_err()
fn key_capture_serializes_minus_as_named_key() {
assert_eq!(
key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)),
Ok("minus".to_string())
);
assert_eq!(
key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::ALT)),
Ok("alt-minus".to_string())
);
assert_eq!(
key_event_to_config_key_spec(KeyEvent::new(
KeyCode::Char('-'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
)),
Ok("ctrl-alt-minus".to_string())
);
}
+44
View File
@@ -30,6 +30,8 @@ pub(super) struct KeymapActionDescriptor {
pub(super) action: &'static str,
/// Short user-facing explanation of what the action does.
pub(super) description: &'static str,
/// Feature required before the action appears in `/keymap`.
required_feature: Option<KeymapActionFeature>,
}
const fn action(
@@ -43,6 +45,42 @@ const fn action(
context_label,
action,
description,
required_feature: None,
}
}
const fn gated_action(
context: &'static str,
context_label: &'static str,
action: &'static str,
description: &'static str,
required_feature: KeymapActionFeature,
) -> KeymapActionDescriptor {
KeymapActionDescriptor {
context,
context_label,
action,
description,
required_feature: Some(required_feature),
}
}
#[derive(Clone, Copy, Debug)]
enum KeymapActionFeature {
FastMode,
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct KeymapActionFilter {
pub(crate) fast_mode_enabled: bool,
}
impl KeymapActionDescriptor {
pub(super) fn is_visible(self, filter: KeymapActionFilter) -> bool {
match self.required_feature {
None => true,
Some(KeymapActionFeature::FastMode) => filter.fast_mode_enabled,
}
}
}
@@ -53,6 +91,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("global", "Global", "copy", "Copy the last agent response to the clipboard."),
action("global", "Global", "clear_terminal", "Clear the terminal UI."),
action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."),
gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode),
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
@@ -75,6 +114,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
action("editor", "Editor", "delete_backward_word", "Delete the previous word."),
action("editor", "Editor", "delete_forward_word", "Delete the next word."),
action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."),
action("editor", "Editor", "kill_whole_line", "Delete the current line."),
action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."),
action("editor", "Editor", "yank", "Paste the kill buffer."),
action("vim_normal", "Vim normal", "enter_insert", "Enter insert mode at the cursor."),
@@ -172,6 +212,7 @@ pub(super) fn binding_slot<'a>(
("global", "copy") => Some(&mut keymap.global.copy),
("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal),
("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode),
("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode),
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
@@ -194,6 +235,7 @@ pub(super) fn binding_slot<'a>(
("editor", "delete_backward_word") => Some(&mut keymap.editor.delete_backward_word),
("editor", "delete_forward_word") => Some(&mut keymap.editor.delete_forward_word),
("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start),
("editor", "kill_whole_line") => Some(&mut keymap.editor.kill_whole_line),
("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end),
("editor", "yank") => Some(&mut keymap.editor.yank),
("vim_normal", "enter_insert") => Some(&mut keymap.vim_normal.enter_insert),
@@ -273,6 +315,7 @@ pub(super) fn bindings_for_action<'a>(
("global", "copy") => Some(runtime_keymap.app.copy.as_slice()),
("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()),
("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()),
("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()),
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()),
@@ -295,6 +338,7 @@ pub(super) fn bindings_for_action<'a>(
("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()),
("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()),
("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()),
("editor", "kill_whole_line") => Some(runtime_keymap.editor.kill_whole_line.as_slice()),
("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()),
("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()),
("vim_normal", "enter_insert") => Some(runtime_keymap.vim_normal.enter_insert.as_slice()),
+44 -2
View File
@@ -17,6 +17,7 @@ use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use super::actions::KEYMAP_ACTIONS;
use super::actions::KeymapActionFilter;
use super::actions::action_label;
use super::actions::bindings_for_action;
use super::actions::format_binding_summary;
@@ -59,6 +60,7 @@ const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[
("composer", "submit"),
("editor", "insert_newline"),
("composer", "queue"),
("global", "toggle_fast_mode"),
("global", "open_external_editor"),
("global", "copy"),
("global", "toggle_vim_mode"),
@@ -116,32 +118,69 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
},
];
#[cfg(test)]
pub(crate) fn build_keymap_picker_params(
runtime_keymap: &RuntimeKeymap,
keymap_config: &TuiKeymap,
) -> SelectionViewParams {
build_keymap_picker_params_with_filter(
runtime_keymap,
keymap_config,
KeymapActionFilter::default(),
)
}
pub(crate) fn build_keymap_picker_params_with_filter(
runtime_keymap: &RuntimeKeymap,
keymap_config: &TuiKeymap,
action_filter: KeymapActionFilter,
) -> SelectionViewParams {
build_keymap_picker_params_for_action(
runtime_keymap,
keymap_config,
action_filter,
/*selected_action*/ None,
)
}
#[cfg(test)]
pub(crate) fn build_keymap_picker_params_for_selected_action(
runtime_keymap: &RuntimeKeymap,
keymap_config: &TuiKeymap,
context: &str,
action: &str,
) -> SelectionViewParams {
build_keymap_picker_params_for_action(runtime_keymap, keymap_config, Some((context, action)))
build_keymap_picker_params_for_selected_action_with_filter(
runtime_keymap,
keymap_config,
KeymapActionFilter::default(),
context,
action,
)
}
pub(crate) fn build_keymap_picker_params_for_selected_action_with_filter(
runtime_keymap: &RuntimeKeymap,
keymap_config: &TuiKeymap,
action_filter: KeymapActionFilter,
context: &str,
action: &str,
) -> SelectionViewParams {
build_keymap_picker_params_for_action(
runtime_keymap,
keymap_config,
action_filter,
Some((context, action)),
)
}
fn build_keymap_picker_params_for_action(
runtime_keymap: &RuntimeKeymap,
keymap_config: &TuiKeymap,
action_filter: KeymapActionFilter,
selected_action: Option<(&str, &str)>,
) -> SelectionViewParams {
let rows = build_keymap_rows(runtime_keymap, keymap_config);
let rows = build_keymap_rows(runtime_keymap, keymap_config, action_filter);
let total = rows.len();
let custom_count = rows.iter().filter(|row| row.custom_binding).count();
let unbound_count = rows.iter().filter(|row| row.is_unbound()).count();
@@ -287,9 +326,12 @@ fn keymap_debug_tab() -> SelectionTab {
fn build_keymap_rows(
runtime_keymap: &RuntimeKeymap,
keymap_config: &TuiKeymap,
action_filter: KeymapActionFilter,
) -> Vec<KeymapActionRow> {
KEYMAP_ACTIONS
.iter()
.copied()
.filter(|descriptor| descriptor.is_visible(action_filter))
.map(|descriptor| {
let bindings =
bindings_for_action(runtime_keymap, descriptor.context, descriptor.action)
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
85 actions, 1 customized, 1 unbound.
86 actions, 1 customized, 2 unbound.
[All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval Debug
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
Type to search shortcuts
Global Open Transcript ctrl-t
@@ -0,0 +1,22 @@
---
source: tui/src/keymap_setup.rs
expression: "render_picker(params, 120)"
---
Keymap
All configurable shortcuts.
87 actions, 0 customized, 3 unbound.
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
Type to search shortcuts
Global Open Transcript ctrl-t
Global Open External Editor ctrl-g
Global Copy ctrl-o
Global Clear Terminal ctrl-l
Global - Toggle Vim Mode unbound
Global - Toggle Fast Mode unbound
Chat Decrease Reasoning Effort alt-,
Chat Increase Reasoning Effort alt-.
left/right group · enter edit shortcut · * custom · - unbound · esc close
@@ -2,13 +2,13 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
tab: All (85 selectable)
tab: All (86 selectable)
tab: Common (19 selectable)
tab: Customized (0) (0 selectable)
tab: Unbound (1) (1 selectable)
tab: Unbound (2) (2 selectable)
tab: App (8 selectable)
tab: Composer (5 selectable)
tab: Editor (16 selectable)
tab: Editor (17 selectable)
tab: Vim (34 selectable)
tab: Navigation (14 selectable)
tab: Approval (8 selectable)
@@ -5,9 +5,9 @@ expression: "render_picker(params, 78)"
Keymap
All configurable shortcuts.
85 actions, 0 customized, 1 unbound.
86 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (1) App Composer Editor Vim
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
Navigation Approval Debug
Type to search shortcuts
@@ -5,9 +5,9 @@ expression: "render_picker(params, 120)"
Keymap
All configurable shortcuts.
85 actions, 0 customized, 1 unbound.
86 actions, 0 customized, 2 unbound.
[All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval Debug
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
Type to search shortcuts
Global Open Transcript ctrl-t