Support two-stroke TUI key chords (#36511)

## What changed

- Accept two-stroke bindings such as `ctrl-x ctrl-s` in TUI keymap configuration while preserving arrays as alternative bindings.
- Route chords through the active TUI context, show pending and configured chord hints, and cancel pending chords on `esc`, context changes, or a one-second timeout.
- Extend `/keymap` to capture, add, replace, and display key chords.
- Reject chords that conflict with active single-key bindings, overlapping-context chords, or reserved terminal shortcuts.

## Testing

- Add coverage for chord parsing, validation, matching, timeout and cancellation behavior, context dispatch, key capture, and rendered shortcut hints.

GitOrigin-RevId: dfdede03f518d4936ad8621ebf5537026f2b282e
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-08-01 17:43:03 +00:00
1 parent a1dd74b535
commit 1e85ca099e
61 files changed
+3503 -660

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ impl ChatWidget {
.map(|keymap| keymap.chat.clone())
.unwrap_or_else(|| default_keymap.chat.clone());
let queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&chat_keymap.edit_queued_message,
runtime_keymap.as_ref().unwrap_or(&default_keymap),
current_terminal_info,
);
let pet_http_client = codex_http_client::RouteAwareClientPool::new(
@@ -3,6 +3,10 @@
use super::*;
impl ChatWidget {
pub(crate) fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
self.bottom_pane.keymap_contexts()
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.bottom_pane.has_active_view()
&& !matches!(
+5 -4
View File
@@ -16,6 +16,7 @@ use codex_terminal_detection::terminal_info;
use super::ChatWidget;
use super::queued_message_edit_hint_binding;
use crate::app_event::KeymapCaptureMode;
use crate::app_event::KeymapEditIntent;
use crate::keymap::RuntimeKeymap;
use crate::keymap_setup;
@@ -73,12 +74,14 @@ impl ChatWidget {
context: String,
action: String,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
runtime_keymap: &RuntimeKeymap,
) {
let view = keymap_setup::build_keymap_capture_view(
context,
action,
intent,
capture_mode,
runtime_keymap,
self.app_event_tx.clone(),
);
@@ -169,10 +172,8 @@ impl ChatWidget {
self.config.tui_keymap = keymap_config;
self.copy_last_response_binding = runtime_keymap.app.copy.clone();
self.chat_keymap = runtime_keymap.chat.clone();
self.queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&self.chat_keymap.edit_queued_message,
terminal_info(),
);
self.queued_message_edit_hint_binding =
queued_message_edit_hint_binding(runtime_keymap, terminal_info());
self.bottom_pane
.set_queued_message_edit_binding(self.queued_message_edit_hint_binding);
self.bottom_pane.set_keymap_bindings(runtime_keymap);
+2 -2
View File
@@ -468,14 +468,14 @@ impl ChatWidget {
let items = vec![
SelectionItem {
name: "Yes".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y')).into()),
actions: send_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "No".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n')).into()),
is_default: true,
dismiss_on_select: true,
..Default::default()
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Experimental features
Toggle experimental features. Changes are saved to config.toml.
› [x] Shell tool Allow the model to run shell commands.
Press space to select or ctrl + x enter to save for next conversation
@@ -1363,7 +1363,7 @@ async fn restore_thread_input_state_applies_running_state_policy() {
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)];
chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up));
chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up).into());
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
@@ -1462,6 +1462,42 @@ async fn shift_left_edits_most_recent_queued_message_in_tmux() {
.await;
}
#[test]
fn queued_message_edit_hint_displays_configured_chords() {
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
let terminal_info = || TerminalInfo {
name: TerminalName::Iterm2,
term_program: None,
version: None,
term: None,
multiplexer: None,
};
let mut config = TuiKeymap::default();
config.chat.edit_queued_message = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x up".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid queued edit chord");
assert_eq!(
queued_message_edit_hint_binding(&keymap, terminal_info()),
Some(crate::key_hint::ShortcutHint::Chord {
prefix: crate::key_hint::ctrl(KeyCode::Char('x')),
completion: crate::key_hint::plain(KeyCode::Up),
})
);
let default_keymap = RuntimeKeymap::defaults();
assert_eq!(
queued_message_edit_hint_binding(&default_keymap, terminal_info()),
Some(crate::key_hint::ShortcutHint::Single(crate::key_hint::alt(
KeyCode::Up,
)))
);
}
#[test]
fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() {
assert_eq!(
+1 -1
View File
@@ -1173,7 +1173,7 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
) {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.queued_message_edit_hint_binding =
Some(queued_message_edit_binding_for_terminal(terminal_info));
Some(queued_message_edit_binding_for_terminal(terminal_info).into());
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
@@ -2896,6 +2896,26 @@ async fn experimental_features_popup_snapshot() {
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("experimental_features_popup", popup);
let mut config = codex_config::types::TuiKeymap::default();
config.list.accept = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("ctrl-x enter".to_string()),
));
let keymap = crate::keymap::RuntimeKeymap::from_config(&config)
.expect("valid experimental-feature chord");
let view = ExperimentalFeaturesView::new(
vec![ExperimentalFeatureItem {
feature: Feature::ShellTool,
name: "Shell tool".to_string(),
description: "Allow the model to run shell commands.".to_string(),
enabled: true,
}],
chat.app_event_tx.clone(),
keymap.list,
);
chat.bottom_pane.show_view(Box::new(view));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("experimental_features_popup_configured_key_chords", popup);
}
#[tokio::test]
@@ -1780,6 +1780,7 @@ async fn keymap_capture_can_capture_current_copy_shortcut() {
"composer".to_string(),
"submit".to_string(),
crate::app_event::KeymapEditIntent::ReplaceAll,
crate::app_event::KeymapCaptureMode::SingleKey,
&runtime_keymap,
);
@@ -1814,6 +1815,7 @@ async fn slash_keymap_capture_can_capture_app_shortcuts() {
"global".to_string(),
"open_transcript".to_string(),
crate::app_event::KeymapEditIntent::ReplaceAll,
crate::app_event::KeymapCaptureMode::SingleKey,
&runtime_keymap,
);