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

+25 -6
View File
@@ -27,8 +27,9 @@ use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -398,7 +399,7 @@ impl BottomPane {
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn);
let interrupt_binding = keymap.primary_hint(KeymapContext::Chat, "interrupt_turn");
self.pending_input_preview
.set_interrupt_binding(interrupt_binding);
if let Some(status) = self.status.as_mut() {
@@ -485,7 +486,10 @@ impl BottomPane {
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option<KeyBinding>) {
pub(crate) fn set_queued_message_edit_binding(
&mut self,
binding: Option<crate::key_hint::ShortcutHint>,
) {
self.pending_input_preview.set_edit_binding(binding);
self.request_redraw();
}
@@ -693,6 +697,15 @@ impl BottomPane {
}
}
/// Return the contexts whose ordinary handlers can consume the next key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if let Some(view) = self.view_stack.last() {
view.keymap_contexts()
} else {
self.composer.keymap_contexts()
}
}
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
@@ -1047,7 +1060,10 @@ impl BottomPane {
}
if let Some(status) = self.status.as_mut() {
status.set_interrupt_hint_visible(/*visible*/ true);
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
status.set_interrupt_binding(
self.keymap
.primary_hint(KeymapContext::Chat, "interrupt_turn"),
);
}
self.sync_status_inline_message();
self.request_redraw();
@@ -1077,7 +1093,10 @@ impl BottomPane {
self.animations_enabled,
));
if let Some(status) = self.status.as_mut() {
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
status.set_interrupt_binding(
self.keymap
.primary_hint(KeymapContext::Chat, "interrupt_turn"),
);
}
self.sync_status_inline_message();
self.request_redraw();
@@ -1553,7 +1572,7 @@ impl BottomPane {
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
self.keymap.list.clone(),
self.keymap.clone(),
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(