gpui: Only time out multi-stroke bindings when current prefix matches (#42659)

Xipeng Jin created

Part One for Resolving #10910

### Summary
Typing prefix (partial keybinding) will behave like Vim. No timeout
until you either finish the sequence or hit Escape, while ambiguous
sequences still auto-resolve after 1s.

### Description
This follow-up tweaks the which-key system first part groundwork so our
timeout behavior matches Vim’s expectations. Then we can implement the
UI part in the next step (reference latest comments in
https://github.com/zed-industries/zed/pull/34798)
- `DispatchResult` now reports when the current keystrokes are already a
complete binding in the active context stack (`pending_has_binding`). We
only start the 1s flush timer in that case. Pure prefixes or sequences
that only match in other contexts—stay pending indefinitely, so
leader-style combos like `space f g` no longer evaporate after a second.
- `Window::dispatch_key_event` cancels any prior timer before scheduling
a new one and only spawns the background flush task when
`pending_has_binding` is true. If there’s no matching binding, we keep
the pending keystrokes and rely on an explicit Escape or more typing to
resolve them.

Release Notes:

- Fixed multi-stroke keybindings so only ambiguous prefixes auto-trigger
after 1 s; unmatched prefixes now stay pending until canceled, matching
Vim-style leader behavior.

Change summary

crates/collab/src/tests/integration_tests.rs |  6 +-
crates/editor/src/editor.rs                  |  8 --
crates/gpui/src/key_dispatch.rs              | 49 ++++++++++++++++
crates/gpui/src/window.rs                    | 65 ++++++++++++++-------
4 files changed, 95 insertions(+), 33 deletions(-)

Detailed changes

crates/collab/src/tests/integration_tests.rs 🔗

@@ -6551,12 +6551,12 @@ async fn test_pane_split_left(cx: &mut TestAppContext) {
         assert!(workspace.items(cx).collect::<Vec<_>>().len() == 2);
     });
     cx.simulate_keystrokes("cmd-k");
-    // sleep for longer than the timeout in keyboard shortcut handling
-    // to verify that it doesn't fire in this case.
+    // Sleep past the historical timeout to ensure the multi-stroke binding
+    // still fires now that unambiguous prefixes no longer auto-expire.
     cx.executor().advance_clock(Duration::from_secs(2));
     cx.simulate_keystrokes("left");
     workspace.update(cx, |workspace, cx| {
-        assert!(workspace.items(cx).collect::<Vec<_>>().len() == 2);
+        assert!(workspace.items(cx).collect::<Vec<_>>().len() == 3);
     });
 }
 

crates/editor/src/editor.rs 🔗

@@ -22165,13 +22165,7 @@ impl Editor {
             .pending_input_keystrokes()
             .into_iter()
             .flatten()
-            .filter_map(|keystroke| {
-                if keystroke.modifiers.is_subset_of(&Modifiers::shift()) {
-                    keystroke.key_char.clone()
-                } else {
-                    None
-                }
-            })
+            .filter_map(|keystroke| keystroke.key_char.clone())
             .collect();
 
         if !self.input_enabled || self.read_only || !self.focus_handle.is_focused(window) {

crates/gpui/src/key_dispatch.rs 🔗

@@ -121,6 +121,7 @@ pub(crate) struct Replay {
 #[derive(Default, Debug)]
 pub(crate) struct DispatchResult {
     pub(crate) pending: SmallVec<[Keystroke; 1]>,
+    pub(crate) pending_has_binding: bool,
     pub(crate) bindings: SmallVec<[KeyBinding; 1]>,
     pub(crate) to_replay: SmallVec<[Replay; 1]>,
     pub(crate) context_stack: Vec<KeyContext>,
@@ -480,6 +481,7 @@ impl DispatchTree {
         if pending {
             return DispatchResult {
                 pending: input,
+                pending_has_binding: !bindings.is_empty(),
                 context_stack,
                 ..Default::default()
             };
@@ -608,9 +610,11 @@ impl DispatchTree {
 #[cfg(test)]
 mod tests {
     use crate::{
-        self as gpui, Element, ElementId, GlobalElementId, InspectorElementId, LayoutId, Style,
+        self as gpui, DispatchResult, Element, ElementId, GlobalElementId, InspectorElementId,
+        Keystroke, LayoutId, Style,
     };
     use core::panic;
+    use smallvec::SmallVec;
     use std::{cell::RefCell, ops::Range, rc::Rc};
 
     use crate::{
@@ -676,6 +680,49 @@ mod tests {
         assert!(keybinding[0].action.partial_eq(&TestAction))
     }
 
+    #[test]
+    fn test_pending_has_binding_state() {
+        let bindings = vec![
+            KeyBinding::new("ctrl-b h", TestAction, None),
+            KeyBinding::new("space", TestAction, Some("ContextA")),
+            KeyBinding::new("space f g", TestAction, Some("ContextB")),
+        ];
+        let keymap = Rc::new(RefCell::new(Keymap::new(bindings)));
+        let mut registry = ActionRegistry::default();
+        registry.load_action::<TestAction>();
+        let mut tree = DispatchTree::new(keymap, Rc::new(registry));
+
+        type DispatchPath = SmallVec<[super::DispatchNodeId; 32]>;
+        fn dispatch(
+            tree: &mut DispatchTree,
+            pending: SmallVec<[Keystroke; 1]>,
+            key: &str,
+            path: &DispatchPath,
+        ) -> DispatchResult {
+            tree.dispatch_key(pending, Keystroke::parse(key).unwrap(), path)
+        }
+
+        let dispatch_path: DispatchPath = SmallVec::new();
+        let result = dispatch(&mut tree, SmallVec::new(), "ctrl-b", &dispatch_path);
+        assert_eq!(result.pending.len(), 1);
+        assert!(!result.pending_has_binding);
+
+        let result = dispatch(&mut tree, result.pending, "h", &dispatch_path);
+        assert_eq!(result.pending.len(), 0);
+        assert_eq!(result.bindings.len(), 1);
+        assert!(!result.pending_has_binding);
+
+        let node_id = tree.push_node();
+        tree.set_key_context(KeyContext::parse("ContextB").unwrap());
+        tree.pop_node();
+
+        let dispatch_path = tree.dispatch_path(node_id);
+        let result = dispatch(&mut tree, SmallVec::new(), "space", &dispatch_path);
+
+        assert_eq!(result.pending.len(), 1);
+        assert!(!result.pending_has_binding);
+    }
+
     #[crate::test]
     fn test_input_handler_pending(cx: &mut TestAppContext) {
         #[derive(Clone)]

crates/gpui/src/window.rs 🔗

@@ -909,6 +909,7 @@ struct PendingInput {
     keystrokes: SmallVec<[Keystroke; 1]>,
     focus: Option<FocusId>,
     timer: Option<Task<()>>,
+    needs_timeout: bool,
 }
 
 pub(crate) struct ElementStateBox {
@@ -3896,32 +3897,52 @@ impl Window {
         }
 
         if !match_result.pending.is_empty() {
+            currently_pending.timer.take();
             currently_pending.keystrokes = match_result.pending;
             currently_pending.focus = self.focus;
-            currently_pending.timer = Some(self.spawn(cx, async move |cx| {
-                cx.background_executor.timer(Duration::from_secs(1)).await;
-                cx.update(move |window, cx| {
-                    let Some(currently_pending) = window
-                        .pending_input
-                        .take()
-                        .filter(|pending| pending.focus == window.focus)
-                    else {
-                        return;
-                    };
 
-                    let node_id = window.focus_node_id_in_rendered_frame(window.focus);
-                    let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id);
-
-                    let to_replay = window
-                        .rendered_frame
-                        .dispatch_tree
-                        .flush_dispatch(currently_pending.keystrokes, &dispatch_path);
+            let text_input_requires_timeout = event
+                .downcast_ref::<KeyDownEvent>()
+                .filter(|key_down| key_down.keystroke.key_char.is_some())
+                .and_then(|_| self.platform_window.take_input_handler())
+                .map_or(false, |mut input_handler| {
+                    let accepts = input_handler.accepts_text_input(self, cx);
+                    self.platform_window.set_input_handler(input_handler);
+                    accepts
+                });
 
-                    window.pending_input_changed(cx);
-                    window.replay_pending_input(to_replay, cx)
-                })
-                .log_err();
-            }));
+            currently_pending.needs_timeout |=
+                match_result.pending_has_binding || text_input_requires_timeout;
+
+            if currently_pending.needs_timeout {
+                currently_pending.timer = Some(self.spawn(cx, async move |cx| {
+                    cx.background_executor.timer(Duration::from_secs(1)).await;
+                    cx.update(move |window, cx| {
+                        let Some(currently_pending) = window
+                            .pending_input
+                            .take()
+                            .filter(|pending| pending.focus == window.focus)
+                        else {
+                            return;
+                        };
+
+                        let node_id = window.focus_node_id_in_rendered_frame(window.focus);
+                        let dispatch_path =
+                            window.rendered_frame.dispatch_tree.dispatch_path(node_id);
+
+                        let to_replay = window
+                            .rendered_frame
+                            .dispatch_tree
+                            .flush_dispatch(currently_pending.keystrokes, &dispatch_path);
+
+                        window.pending_input_changed(cx);
+                        window.replay_pending_input(to_replay, cx)
+                    })
+                    .log_err();
+                }));
+            } else {
+                currently_pending.timer = None;
+            }
             self.pending_input = Some(currently_pending);
             self.pending_input_changed(cx);
             cx.propagate_event = false;