jk (#4189)

Conrad Irwin created

Add support for mapping `jk` to escape in vim mode.

This changes the behaviour of the keymatches when there are pending
matches.

Before: Even if there was a pending match, any complete matches would be
triggered and the pending state lost.

After: If there is a pending match, any complete matches are delayed by
1s, or until more keys are typed.

Release Notes:

- Added support for mapping `jk` in vim mode
([#2378](https://github.com/zed-industries/community/issues/2378)),
([#176](https://github.com/zed-industries/community/issues/176))

Change summary

.github/workflows/ci.yml                          | 277 +++++-----
crates/collab/src/tests/integration_tests.rs      |  24 
crates/collab/src/tests/test_server.rs            |   5 
crates/gpui/src/key_dispatch.rs                   | 108 +++
crates/gpui/src/keymap/binding.rs                 |   2 
crates/gpui/src/keymap/matcher.rs                 | 420 +---------------
crates/gpui/src/platform.rs                       |   9 
crates/gpui/src/platform/keystroke.rs             |  26 
crates/gpui/src/platform/mac/window.rs            |   4 
crates/gpui/src/platform/test/window.rs           |  19 
crates/gpui/src/window.rs                         | 169 +++++-
crates/vim/src/motion.rs                          |   4 
crates/vim/src/test.rs                            |  75 ++
crates/vim/src/test/neovim_backed_test_context.rs |   2 
crates/vim/src/test/neovim_connection.rs          |  27 +
crates/vim/test_data/test_comma_w.json            |  15 
crates/vim/test_data/test_jk.json                 |   8 
17 files changed, 581 insertions(+), 613 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -1,149 +1,148 @@
 name: CI
 
 on:
-    push:
-        branches:
-            - main
-            - "v[0-9]+.[0-9]+.x"
-        tags:
-            - "v*"
-    pull_request:
-        branches:
-            - "**"
+  push:
+    branches:
+      - main
+      - "v[0-9]+.[0-9]+.x"
+    tags:
+      - "v*"
+  pull_request:
+    branches:
+      - "**"
 
 concurrency:
-    # Allow only one workflow per any non-`main` branch.
-    group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
-    cancel-in-progress: true
+  # Allow only one workflow per any non-`main` branch.
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  cancel-in-progress: true
 
 env:
-    CARGO_TERM_COLOR: always
-    CARGO_INCREMENTAL: 0
-    RUST_BACKTRACE: 1
+  CARGO_TERM_COLOR: always
+  CARGO_INCREMENTAL: 0
+  RUST_BACKTRACE: 1
 
 jobs:
-    style:
-        name: Check formatting, Clippy lints, and spelling
-        runs-on:
-            - self-hosted
-            - test
-        steps:
-            - name: Checkout repo
-              uses: actions/checkout@v3
-              with:
-                  clean: false
-                  submodules: "recursive"
-                  fetch-depth: 0
-
-            - name: Set up default .cargo/config.toml
-              run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
-
-            - name: Check spelling
-              run: |
-                  if ! which typos > /dev/null; then
-                    cargo install typos-cli
-                  fi
-                  typos
-
-            - name: Run style checks
-              uses: ./.github/actions/check_style
-
-    tests:
-        name: Run tests
-        runs-on:
-            - self-hosted
-            - test
-        needs: style
-        steps:
-            - name: Checkout repo
-              uses: actions/checkout@v3
-              with:
-                  clean: false
-                  submodules: "recursive"
-
-            - name: Run tests
-              uses: ./.github/actions/run_tests
-
-            - name: Build collab
-              run: cargo build -p collab
-
-            - name: Build other binaries
-              run: cargo build --workspace --bins --all-features
-
-    bundle:
-        name: Bundle app
-        runs-on:
-            - self-hosted
-            - bundle
-        if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
-        needs: tests
+  style:
+    name: Check formatting, Clippy lints, and spelling
+    runs-on:
+      - self-hosted
+      - test
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v3
+        with:
+          clean: false
+          submodules: "recursive"
+          fetch-depth: 0
+
+      - name: Set up default .cargo/config.toml
+        run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
+
+      - name: Check spelling
+        run: |
+          if ! which typos > /dev/null; then
+            cargo install typos-cli
+          fi
+          typos
+
+      - name: Run style checks
+        uses: ./.github/actions/check_style
+
+  tests:
+    name: Run tests
+    runs-on:
+      - self-hosted
+      - test
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v3
+        with:
+          clean: false
+          submodules: "recursive"
+
+      - name: Run tests
+        uses: ./.github/actions/run_tests
+
+      - name: Build collab
+        run: cargo build -p collab
+
+      - name: Build other binaries
+        run: cargo build --workspace --bins --all-features
+
+  bundle:
+    name: Bundle app
+    runs-on:
+      - self-hosted
+      - bundle
+    if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
+    needs: tests
+    env:
+      MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
+      MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+      APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
+      APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
+    steps:
+      - name: Install Node
+        uses: actions/setup-node@v3
+        with:
+          node-version: "18"
+
+      - name: Checkout repo
+        uses: actions/checkout@v3
+        with:
+          clean: false
+          submodules: "recursive"
+
+      - name: Limit target directory size
+        run: script/clear-target-dir-if-larger-than 100
+
+      - name: Determine version and release channel
+        if: ${{ startsWith(github.ref, 'refs/tags/v') }}
+        run: |
+          set -eu
+
+          version=$(script/get-crate-version zed)
+          channel=$(cat crates/zed/RELEASE_CHANNEL)
+          echo "Publishing version: ${version} on release channel ${channel}"
+          echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
+
+          expected_tag_name=""
+          case ${channel} in
+            stable)
+              expected_tag_name="v${version}";;
+            preview)
+              expected_tag_name="v${version}-pre";;
+            nightly)
+              expected_tag_name="v${version}-nightly";;
+            *)
+              echo "can't publish a release on channel ${channel}"
+              exit 1;;
+          esac
+          if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
+            echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
+            exit 1
+          fi
+
+      - name: Generate license file
+        run: script/generate-licenses
+
+      - name: Create app bundle
+        run: script/bundle
+
+      - name: Upload app bundle to workflow run if main branch or specific label
+        uses: actions/upload-artifact@v3
+        if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
+        with:
+          name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
+          path: target/release/Zed.dmg
+
+      - uses: softprops/action-gh-release@v1
+        name: Upload app bundle to release
+        if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
+        with:
+          draft: true
+          prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
+          files: target/release/Zed.dmg
+          body: ""
         env:
-            MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
-            MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
-            APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
-            APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
-        steps:
-            - name: Install Node
-              uses: actions/setup-node@v3
-              with:
-                  node-version: "18"
-
-            - name: Checkout repo
-              uses: actions/checkout@v3
-              with:
-                  clean: false
-                  submodules: "recursive"
-
-            - name: Limit target directory size
-              run: script/clear-target-dir-if-larger-than 100
-
-            - name: Determine version and release channel
-              if: ${{ startsWith(github.ref, 'refs/tags/v') }}
-              run: |
-                  set -eu
-
-                  version=$(script/get-crate-version zed)
-                  channel=$(cat crates/zed/RELEASE_CHANNEL)
-                  echo "Publishing version: ${version} on release channel ${channel}"
-                  echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
-
-                  expected_tag_name=""
-                  case ${channel} in
-                    stable)
-                      expected_tag_name="v${version}";;
-                    preview)
-                      expected_tag_name="v${version}-pre";;
-                    nightly)
-                      expected_tag_name="v${version}-nightly";;
-                    *)
-                      echo "can't publish a release on channel ${channel}"
-                      exit 1;;
-                  esac
-                  if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
-                    echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
-                    exit 1
-                  fi
-
-            - name: Generate license file
-              run: script/generate-licenses
-
-            - name: Create app bundle
-              run: script/bundle
-
-            - name: Upload app bundle to workflow run if main branch or specific label
-              uses: actions/upload-artifact@v3
-              if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
-              with:
-                  name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
-                  path: target/release/Zed.dmg
-
-            - uses: softprops/action-gh-release@v1
-              name: Upload app bundle to release
-              if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
-              with:
-                  draft: true
-                  prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
-                  files: target/release/Zed.dmg
-                  body: ""
-              env:
-                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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

@@ -34,6 +34,7 @@ use std::{
         atomic::{AtomicBool, Ordering::SeqCst},
         Arc,
     },
+    time::Duration,
 };
 use unindent::Unindent as _;
 
@@ -5945,3 +5946,26 @@ async fn test_right_click_menu_behind_collab_panel(cx: &mut TestAppContext) {
     });
     assert!(cx.debug_bounds("MENU_ITEM-Close").is_some());
 }
+
+#[gpui::test]
+async fn test_cmd_k_left(cx: &mut TestAppContext) {
+    let client = TestServer::start1(cx).await;
+    let (workspace, cx) = client.build_test_workspace(cx).await;
+
+    cx.simulate_keystrokes("cmd-n");
+    workspace.update(cx, |workspace, cx| {
+        assert!(workspace.items(cx).collect::<Vec<_>>().len() == 1);
+    });
+    cx.simulate_keystrokes("cmd-k left");
+    workspace.update(cx, |workspace, cx| {
+        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.
+    cx.executor().advance_clock(Duration::from_secs(2));
+    cx.simulate_keystrokes("left");
+    workspace.update(cx, |workspace, cx| {
+        assert!(workspace.items(cx).collect::<Vec<_>>().len() == 3);
+    });
+}

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

@@ -127,6 +127,11 @@ impl TestServer {
         (client_a, client_b, channel_id)
     }
 
+    pub async fn start1<'a>(cx: &'a mut TestAppContext) -> TestClient {
+        let mut server = Self::start(cx.executor().clone()).await;
+        server.create_client(cx, "user_a").await
+    }
+
     pub async fn reset(&self) {
         self.app_state.db.reset();
         let epoch = self

crates/gpui/src/key_dispatch.rs 🔗

@@ -1,6 +1,57 @@
+/// KeyDispatch is where GPUI deals with binding actions to key events.
+///
+/// The key pieces to making a key binding work are to define an action,
+/// implement a method that takes that action as a type parameter,
+/// and then to register the action during render on a focused node
+/// with a keymap context:
+///
+/// ```rust
+/// actions!(editor,[Undo, Redo]);;
+///
+/// impl Editor {
+///   fn undo(&mut self, _: &Undo, _cx: &mut ViewContext<Self>) { ... }
+///   fn redo(&mut self, _: &Redo, _cx: &mut ViewContext<Self>) { ... }
+/// }
+///
+/// impl Render for Editor {
+///   fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+///     div()
+///       .track_focus(&self.focus_handle)
+///       .keymap_context("Editor")
+///       .on_action(cx.listener(Editor::undo))
+///       .on_action(cx.listener(Editor::redo))
+///     ...
+///    }
+/// }
+///```
+///
+/// The keybindings themselves are managed independently by calling cx.bind_keys().
+/// (Though mostly when developing Zed itself, you just need to add a new line to
+///  assets/keymaps/default.json).
+///
+/// ```rust
+/// cx.bind_keys([
+///   KeyBinding::new("cmd-z", Editor::undo, Some("Editor")),
+///   KeyBinding::new("cmd-shift-z", Editor::redo, Some("Editor")),
+/// ])
+/// ```
+///
+/// With all of this in place, GPUI will ensure that if you have an Editor that contains
+/// the focus, hitting cmd-z will Undo.
+///
+/// In real apps, it is a little more complicated than this, because typically you have
+/// several nested views that each register keyboard handlers. In this case action matching
+/// bubbles up from the bottom. For example in Zed, the Workspace is the top-level view, which contains Pane's, which contain Editors. If there are conflicting keybindings defined
+/// then the Editor's bindings take precedence over the Pane's bindings, which take precedence over the Workspace.
+///
+/// In GPUI, keybindings are not limited to just single keystrokes, you can define
+/// sequences by separating the keys with a space:
+///
+///  KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane"))
+///
 use crate::{
     Action, ActionRegistry, DispatchPhase, ElementContext, EntityId, FocusId, KeyBinding,
-    KeyContext, KeyMatch, Keymap, Keystroke, KeystrokeMatcher, WindowContext,
+    KeyContext, Keymap, KeymatchResult, Keystroke, KeystrokeMatcher, WindowContext,
 };
 use collections::FxHashMap;
 use parking_lot::Mutex;
@@ -272,30 +323,51 @@ impl DispatchTree {
             .collect()
     }
 
+    // dispatch_key pushses the next keystroke into any key binding matchers.
+    // any matching bindings are returned in the order that they should be dispatched:
+    // * First by length of binding (so if you have a binding for "b" and "ab", the "ab" binding fires first)
+    // * Secondly by depth in the tree (so if Editor has a binding for "b" and workspace a
+    // binding for "b", the Editor action fires first).
     pub fn dispatch_key(
         &mut self,
         keystroke: &Keystroke,
-        context: &[KeyContext],
-    ) -> Vec<Box<dyn Action>> {
-        if !self.keystroke_matchers.contains_key(context) {
-            let keystroke_contexts = context.iter().cloned().collect();
-            self.keystroke_matchers.insert(
-                keystroke_contexts,
-                KeystrokeMatcher::new(self.keymap.clone()),
-            );
-        }
+        dispatch_path: &SmallVec<[DispatchNodeId; 32]>,
+    ) -> KeymatchResult {
+        let mut bindings = SmallVec::<[KeyBinding; 1]>::new();
+        let mut pending = false;
+
+        let mut context_stack: SmallVec<[KeyContext; 4]> = SmallVec::new();
+        for node_id in dispatch_path {
+            let node = self.node(*node_id);
 
-        let keystroke_matcher = self.keystroke_matchers.get_mut(context).unwrap();
-        if let KeyMatch::Some(actions) = keystroke_matcher.match_keystroke(keystroke, context) {
-            // Clear all pending keystrokes when an action has been found.
-            for keystroke_matcher in self.keystroke_matchers.values_mut() {
-                keystroke_matcher.clear_pending();
+            if let Some(context) = node.context.clone() {
+                context_stack.push(context);
             }
+        }
 
-            actions
-        } else {
-            vec![]
+        while !context_stack.is_empty() {
+            let keystroke_matcher = self
+                .keystroke_matchers
+                .entry(context_stack.clone())
+                .or_insert_with(|| KeystrokeMatcher::new(self.keymap.clone()));
+
+            let result = keystroke_matcher.match_keystroke(keystroke, &context_stack);
+            pending = result.pending || pending;
+            for new_binding in result.bindings {
+                match bindings
+                    .iter()
+                    .position(|el| el.keystrokes.len() < new_binding.keystrokes.len())
+                {
+                    Some(idx) => {
+                        bindings.insert(idx, new_binding);
+                    }
+                    None => bindings.push(new_binding),
+                }
+            }
+            context_stack.pop();
         }
+
+        KeymatchResult { bindings, pending }
     }
 
     pub fn has_pending_keystrokes(&self) -> bool {

crates/gpui/src/keymap/binding.rs 🔗

@@ -50,7 +50,7 @@ impl KeyBinding {
         if self.keystrokes.as_ref().starts_with(pending_keystrokes) {
             // If the binding is completed, push it onto the matches list
             if self.keystrokes.as_ref().len() == pending_keystrokes.len() {
-                KeyMatch::Some(vec![self.action.boxed_clone()])
+                KeyMatch::Matched
             } else {
                 KeyMatch::Pending
             }

crates/gpui/src/keymap/matcher.rs 🔗

@@ -1,5 +1,6 @@
-use crate::{Action, KeyContext, Keymap, KeymapVersion, Keystroke};
+use crate::{KeyBinding, KeyContext, Keymap, KeymapVersion, Keystroke};
 use parking_lot::Mutex;
+use smallvec::SmallVec;
 use std::sync::Arc;
 
 pub(crate) struct KeystrokeMatcher {
@@ -8,6 +9,11 @@ pub(crate) struct KeystrokeMatcher {
     keymap_version: KeymapVersion,
 }
 
+pub struct KeymatchResult {
+    pub bindings: SmallVec<[KeyBinding; 1]>,
+    pub pending: bool,
+}
+
 impl KeystrokeMatcher {
     pub fn new(keymap: Arc<Mutex<Keymap>>) -> Self {
         let keymap_version = keymap.lock().version();
@@ -18,10 +24,6 @@ impl KeystrokeMatcher {
         }
     }
 
-    pub fn clear_pending(&mut self) {
-        self.pending_keystrokes.clear();
-    }
-
     pub fn has_pending_keystrokes(&self) -> bool {
         !self.pending_keystrokes.is_empty()
     }
@@ -39,7 +41,7 @@ impl KeystrokeMatcher {
         &mut self,
         keystroke: &Keystroke,
         context_stack: &[KeyContext],
-    ) -> KeyMatch {
+    ) -> KeymatchResult {
         let keymap = self.keymap.lock();
         // Clear pending keystrokes if the keymap has changed since the last matched keystroke.
         if keymap.version() != self.keymap_version {
@@ -48,7 +50,7 @@ impl KeystrokeMatcher {
         }
 
         let mut pending_key = None;
-        let mut found_actions = Vec::new();
+        let mut bindings = SmallVec::new();
 
         for binding in keymap.bindings().rev() {
             if !keymap.binding_enabled(binding, context_stack) {
@@ -58,8 +60,8 @@ impl KeystrokeMatcher {
             for candidate in keystroke.match_candidates() {
                 self.pending_keystrokes.push(candidate.clone());
                 match binding.match_keystrokes(&self.pending_keystrokes) {
-                    KeyMatch::Some(mut actions) => {
-                        found_actions.append(&mut actions);
+                    KeyMatch::Matched => {
+                        bindings.push(binding.clone());
                     }
                     KeyMatch::Pending => {
                         pending_key.get_or_insert(candidate);
@@ -70,16 +72,21 @@ impl KeystrokeMatcher {
             }
         }
 
-        if !found_actions.is_empty() {
-            self.pending_keystrokes.clear();
-            return KeyMatch::Some(found_actions);
-        } else if let Some(pending_key) = pending_key {
+        if bindings.len() == 0 && pending_key.is_none() && self.pending_keystrokes.len() > 0 {
+            drop(keymap);
+            self.pending_keystrokes.remove(0);
+            return self.match_keystroke(keystroke, context_stack);
+        }
+
+        let pending = if let Some(pending_key) = pending_key {
             self.pending_keystrokes.push(pending_key);
-            KeyMatch::Pending
+            true
         } else {
             self.pending_keystrokes.clear();
-            KeyMatch::None
-        }
+            false
+        };
+
+        KeymatchResult { bindings, pending }
     }
 }
 
@@ -87,386 +94,9 @@ impl KeystrokeMatcher {
 /// - KeyMatch::None => No match is valid for this key given any pending keystrokes.
 /// - KeyMatch::Pending => There exist bindings that is still waiting for more keys.
 /// - KeyMatch::Some(matches) => One or more bindings have received the necessary key presses.
-#[derive(Debug)]
+#[derive(Debug, PartialEq)]
 pub enum KeyMatch {
     None,
     Pending,
-    Some(Vec<Box<dyn Action>>),
-}
-
-impl KeyMatch {
-    /// Returns true if the match is complete.
-    pub fn is_some(&self) -> bool {
-        matches!(self, KeyMatch::Some(_))
-    }
-
-    /// Get the matches if the match is complete.
-    pub fn matches(self) -> Option<Vec<Box<dyn Action>>> {
-        match self {
-            KeyMatch::Some(matches) => Some(matches),
-            _ => None,
-        }
-    }
-}
-
-impl PartialEq for KeyMatch {
-    fn eq(&self, other: &Self) -> bool {
-        match (self, other) {
-            (KeyMatch::None, KeyMatch::None) => true,
-            (KeyMatch::Pending, KeyMatch::Pending) => true,
-            (KeyMatch::Some(a), KeyMatch::Some(b)) => {
-                if a.len() != b.len() {
-                    return false;
-                }
-
-                for (a, b) in a.iter().zip(b.iter()) {
-                    if !a.partial_eq(b.as_ref()) {
-                        return false;
-                    }
-                }
-
-                true
-            }
-            _ => false,
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-
-    use serde_derive::Deserialize;
-
-    use super::*;
-    use crate::{self as gpui, KeyBindingContextPredicate, Modifiers};
-    use crate::{actions, KeyBinding};
-
-    #[test]
-    fn test_keymap_and_view_ordering() {
-        actions!(test, [EditorAction, ProjectPanelAction]);
-
-        let mut editor = KeyContext::default();
-        editor.add("Editor");
-
-        let mut project_panel = KeyContext::default();
-        project_panel.add("ProjectPanel");
-
-        // Editor 'deeper' in than project panel
-        let dispatch_path = vec![project_panel, editor];
-
-        // But editor actions 'higher' up in keymap
-        let keymap = Keymap::new(vec![
-            KeyBinding::new("left", EditorAction, Some("Editor")),
-            KeyBinding::new("left", ProjectPanelAction, Some("ProjectPanel")),
-        ]);
-
-        let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap)));
-
-        let matches = matcher
-            .match_keystroke(&Keystroke::parse("left").unwrap(), &dispatch_path)
-            .matches()
-            .unwrap();
-
-        assert!(matches[0].partial_eq(&EditorAction));
-        assert!(matches.get(1).is_none());
-    }
-
-    #[test]
-    fn test_multi_keystroke_match() {
-        actions!(test, [B, AB, C, D, DA, E, EF]);
-
-        let mut context1 = KeyContext::default();
-        context1.add("1");
-
-        let mut context2 = KeyContext::default();
-        context2.add("2");
-
-        let dispatch_path = vec![context2, context1];
-
-        let keymap = Keymap::new(vec![
-            KeyBinding::new("a b", AB, Some("1")),
-            KeyBinding::new("b", B, Some("2")),
-            KeyBinding::new("c", C, Some("2")),
-            KeyBinding::new("d", D, Some("1")),
-            KeyBinding::new("d", D, Some("2")),
-            KeyBinding::new("d a", DA, Some("2")),
-        ]);
-
-        let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap)));
-
-        // Binding with pending prefix always takes precedence
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &dispatch_path),
-            KeyMatch::Pending,
-        );
-        // B alone doesn't match because a was pending, so AB is returned instead
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &dispatch_path),
-            KeyMatch::Some(vec![Box::new(AB)]),
-        );
-        assert!(!matcher.has_pending_keystrokes());
-
-        // Without an a prefix, B is dispatched like expected
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &dispatch_path[0..1]),
-            KeyMatch::Some(vec![Box::new(B)]),
-        );
-        assert!(!matcher.has_pending_keystrokes());
-
-        // If a is prefixed, C will not be dispatched because there
-        // was a pending binding for it
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &dispatch_path),
-            KeyMatch::Pending,
-        );
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("c").unwrap(), &dispatch_path),
-            KeyMatch::None,
-        );
-        assert!(!matcher.has_pending_keystrokes());
-
-        // If a single keystroke matches multiple bindings in the tree
-        // only one of them is returned.
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("d").unwrap(), &dispatch_path),
-            KeyMatch::Some(vec![Box::new(D)]),
-        );
-    }
-
-    #[test]
-    fn test_keystroke_parsing() {
-        assert_eq!(
-            Keystroke::parse("ctrl-p").unwrap(),
-            Keystroke {
-                key: "p".into(),
-                modifiers: Modifiers {
-                    control: true,
-                    alt: false,
-                    shift: false,
-                    command: false,
-                    function: false,
-                },
-                ime_key: None,
-            }
-        );
-
-        assert_eq!(
-            Keystroke::parse("alt-shift-down").unwrap(),
-            Keystroke {
-                key: "down".into(),
-                modifiers: Modifiers {
-                    control: false,
-                    alt: true,
-                    shift: true,
-                    command: false,
-                    function: false,
-                },
-                ime_key: None,
-            }
-        );
-
-        assert_eq!(
-            Keystroke::parse("shift-cmd--").unwrap(),
-            Keystroke {
-                key: "-".into(),
-                modifiers: Modifiers {
-                    control: false,
-                    alt: false,
-                    shift: true,
-                    command: true,
-                    function: false,
-                },
-                ime_key: None,
-            }
-        );
-    }
-
-    #[test]
-    fn test_context_predicate_parsing() {
-        use KeyBindingContextPredicate::*;
-
-        assert_eq!(
-            KeyBindingContextPredicate::parse("a && (b == c || d != e)").unwrap(),
-            And(
-                Box::new(Identifier("a".into())),
-                Box::new(Or(
-                    Box::new(Equal("b".into(), "c".into())),
-                    Box::new(NotEqual("d".into(), "e".into())),
-                ))
-            )
-        );
-
-        assert_eq!(
-            KeyBindingContextPredicate::parse("!a").unwrap(),
-            Not(Box::new(Identifier("a".into())),)
-        );
-    }
-
-    #[test]
-    fn test_context_predicate_eval() {
-        let predicate = KeyBindingContextPredicate::parse("a && b || c == d").unwrap();
-
-        let mut context = KeyContext::default();
-        context.add("a");
-        assert!(!predicate.eval(&[context]));
-
-        let mut context = KeyContext::default();
-        context.add("a");
-        context.add("b");
-        assert!(predicate.eval(&[context]));
-
-        let mut context = KeyContext::default();
-        context.add("a");
-        context.set("c", "x");
-        assert!(!predicate.eval(&[context]));
-
-        let mut context = KeyContext::default();
-        context.add("a");
-        context.set("c", "d");
-        assert!(predicate.eval(&[context]));
-
-        let predicate = KeyBindingContextPredicate::parse("!a").unwrap();
-        assert!(predicate.eval(&[KeyContext::default()]));
-    }
-
-    #[test]
-    fn test_context_child_predicate_eval() {
-        let predicate = KeyBindingContextPredicate::parse("a && b > c").unwrap();
-        let contexts = [
-            context_set(&["a", "b"]),
-            context_set(&["c", "d"]), // match this context
-            context_set(&["e", "f"]),
-        ];
-
-        assert!(!predicate.eval(&contexts[..=0]));
-        assert!(predicate.eval(&contexts[..=1]));
-        assert!(!predicate.eval(&contexts[..=2]));
-
-        let predicate = KeyBindingContextPredicate::parse("a && b > c && !d > e").unwrap();
-        let contexts = [
-            context_set(&["a", "b"]),
-            context_set(&["c", "d"]),
-            context_set(&["e"]),
-            context_set(&["a", "b"]),
-            context_set(&["c"]),
-            context_set(&["e"]), // only match this context
-            context_set(&["f"]),
-        ];
-
-        assert!(!predicate.eval(&contexts[..=0]));
-        assert!(!predicate.eval(&contexts[..=1]));
-        assert!(!predicate.eval(&contexts[..=2]));
-        assert!(!predicate.eval(&contexts[..=3]));
-        assert!(!predicate.eval(&contexts[..=4]));
-        assert!(predicate.eval(&contexts[..=5]));
-        assert!(!predicate.eval(&contexts[..=6]));
-
-        fn context_set(names: &[&str]) -> KeyContext {
-            let mut keymap = KeyContext::default();
-            names.iter().for_each(|name| keymap.add(name.to_string()));
-            keymap
-        }
-    }
-
-    #[test]
-    fn test_matcher() {
-        #[derive(Clone, Deserialize, PartialEq, Eq, Debug)]
-        pub struct A(pub String);
-        impl_actions!(test, [A]);
-        actions!(test, [B, Ab, Dollar, Quote, Ess, Backtick]);
-
-        #[derive(Clone, Debug, Eq, PartialEq)]
-        struct ActionArg {
-            a: &'static str,
-        }
-
-        let keymap = Keymap::new(vec![
-            KeyBinding::new("a", A("x".to_string()), Some("a")),
-            KeyBinding::new("b", B, Some("a")),
-            KeyBinding::new("a b", Ab, Some("a || b")),
-            KeyBinding::new("$", Dollar, Some("a")),
-            KeyBinding::new("\"", Quote, Some("a")),
-            KeyBinding::new("alt-s", Ess, Some("a")),
-            KeyBinding::new("ctrl-`", Backtick, Some("a")),
-        ]);
-
-        let mut context_a = KeyContext::default();
-        context_a.add("a");
-
-        let mut context_b = KeyContext::default();
-        context_b.add("b");
-
-        let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap)));
-
-        // Basic match
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_a.clone()]),
-            KeyMatch::Some(vec![Box::new(A("x".to_string()))])
-        );
-        matcher.clear_pending();
-
-        // Multi-keystroke match
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_b.clone()]),
-            KeyMatch::Pending
-        );
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &[context_b.clone()]),
-            KeyMatch::Some(vec![Box::new(Ab)])
-        );
-        matcher.clear_pending();
-
-        // Failed matches don't interfere with matching subsequent keys
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("x").unwrap(), &[context_a.clone()]),
-            KeyMatch::None
-        );
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_a.clone()]),
-            KeyMatch::Some(vec![Box::new(A("x".to_string()))])
-        );
-        matcher.clear_pending();
-
-        let mut context_c = KeyContext::default();
-        context_c.add("c");
-
-        assert_eq!(
-            matcher.match_keystroke(
-                &Keystroke::parse("a").unwrap(),
-                &[context_c.clone(), context_b.clone()]
-            ),
-            KeyMatch::Pending
-        );
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &[context_b.clone()]),
-            KeyMatch::Some(vec![Box::new(Ab)])
-        );
-
-        // handle Czech $ (option + 4 key)
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("alt-ç->$").unwrap(), &[context_a.clone()]),
-            KeyMatch::Some(vec![Box::new(Dollar)])
-        );
-
-        // handle Brazilian quote (quote key then space key)
-        assert_eq!(
-            matcher.match_keystroke(
-                &Keystroke::parse("space->\"").unwrap(),
-                &[context_a.clone()]
-            ),
-            KeyMatch::Some(vec![Box::new(Quote)])
-        );
-
-        // handle ctrl+` on a brazilian keyboard
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("ctrl-->`").unwrap(), &[context_a.clone()]),
-            KeyMatch::Some(vec![Box::new(Backtick)])
-        );
-
-        // handle alt-s on a US keyboard
-        assert_eq!(
-            matcher.match_keystroke(&Keystroke::parse("alt-s->ß").unwrap(), &[context_a.clone()]),
-            KeyMatch::Some(vec![Box::new(Ess)])
-        );
-    }
+    Matched,
 }

crates/gpui/src/platform.rs 🔗

@@ -359,7 +359,7 @@ impl PlatformInputHandler {
         self.cx
             .update(|cx| {
                 self.handler
-                    .replace_text_in_range(replacement_range, text, cx)
+                    .replace_text_in_range(replacement_range, text, cx);
             })
             .ok();
     }
@@ -392,6 +392,13 @@ impl PlatformInputHandler {
             .ok()
             .flatten()
     }
+
+    pub(crate) fn flush_pending_input(&mut self, input: &str, cx: &mut WindowContext) {
+        let Some(range) = self.handler.selected_text_range(cx) else {
+            return;
+        };
+        self.handler.replace_text_in_range(Some(range), &input, cx);
+    }
 }
 
 /// Zed's interface for handling text input from the platform's IME system

crates/gpui/src/platform/keystroke.rs 🔗

@@ -30,24 +30,26 @@ impl Keystroke {
     pub(crate) fn match_candidates(&self) -> SmallVec<[Keystroke; 2]> {
         let mut possibilities = SmallVec::new();
         match self.ime_key.as_ref() {
-            None => possibilities.push(self.clone()),
             Some(ime_key) => {
-                possibilities.push(Keystroke {
-                    modifiers: Modifiers {
-                        control: self.modifiers.control,
-                        alt: false,
-                        shift: false,
-                        command: false,
-                        function: false,
-                    },
-                    key: ime_key.to_string(),
-                    ime_key: None,
-                });
+                if ime_key != &self.key {
+                    possibilities.push(Keystroke {
+                        modifiers: Modifiers {
+                            control: self.modifiers.control,
+                            alt: false,
+                            shift: false,
+                            command: false,
+                            function: false,
+                        },
+                        key: ime_key.to_string(),
+                        ime_key: None,
+                    });
+                }
                 possibilities.push(Keystroke {
                     ime_key: None,
                     ..self.clone()
                 });
             }
+            None => possibilities.push(self.clone()),
         }
         possibilities
     }

crates/gpui/src/platform/mac/window.rs 🔗

@@ -1542,9 +1542,7 @@ extern "C" fn insert_text(this: &Object, _: Sel, text: id, replacement_range: NS
                 replacement_range,
                 text: text.to_string(),
             });
-            if text.to_string().to_ascii_lowercase() != pending_key_down.0.keystroke.key {
-                pending_key_down.0.keystroke.ime_key = Some(text.to_string());
-            }
+            pending_key_down.0.keystroke.ime_key = Some(text.to_string());
             window_state.lock().pending_key_down = Some(pending_key_down);
         }
     }

crates/gpui/src/platform/test/window.rs 🔗

@@ -96,7 +96,19 @@ impl TestWindow {
         result
     }
 
-    pub fn simulate_keystroke(&mut self, keystroke: Keystroke, is_held: bool) {
+    pub fn simulate_keystroke(&mut self, mut keystroke: Keystroke, is_held: bool) {
+        if keystroke.ime_key.is_none()
+            && !keystroke.modifiers.command
+            && !keystroke.modifiers.control
+            && !keystroke.modifiers.function
+        {
+            keystroke.ime_key = Some(if keystroke.modifiers.shift {
+                keystroke.key.to_ascii_uppercase().clone()
+            } else {
+                keystroke.key.clone()
+            })
+        }
+
         if self.simulate_input(PlatformInput::KeyDown(KeyDownEvent {
             keystroke: keystroke.clone(),
             is_held,
@@ -112,8 +124,9 @@ impl TestWindow {
             );
         };
         drop(lock);
-        let text = keystroke.ime_key.unwrap_or(keystroke.key);
-        input_handler.replace_text_in_range(None, &text);
+        if let Some(text) = keystroke.ime_key.as_ref() {
+            input_handler.replace_text_in_range(None, &text);
+        }
 
         self.0.lock().input_handler = Some(input_handler);
     }

crates/gpui/src/window.rs 🔗

@@ -2,11 +2,11 @@ use crate::{
     px, size, transparent_black, Action, AnyDrag, AnyView, AppContext, Arena, AsyncWindowContext,
     AvailableSpace, Bounds, Context, Corners, CursorStyle, DispatchActionListener, DispatchNodeId,
     DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, Flatten,
-    GlobalElementId, Hsla, KeyBinding, KeyContext, KeyDownEvent, KeystrokeEvent, Model,
-    ModelContext, Modifiers, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas,
-    PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptLevel, Render, ScaledPixels,
-    SharedString, Size, SubscriberSet, Subscription, TaffyLayoutEngine, Task, View, VisualContext,
-    WeakView, WindowBounds, WindowOptions,
+    GlobalElementId, Hsla, KeyBinding, KeyContext, KeyDownEvent, KeyMatch, KeymatchResult,
+    Keystroke, KeystrokeEvent, Model, ModelContext, Modifiers, MouseButton, MouseMoveEvent,
+    MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point,
+    PromptLevel, Render, ScaledPixels, SharedString, Size, SubscriberSet, Subscription,
+    TaffyLayoutEngine, Task, View, VisualContext, WeakView, WindowBounds, WindowOptions,
 };
 use anyhow::{anyhow, Context as _, Result};
 use collections::FxHashSet;
@@ -33,6 +33,7 @@ use std::{
         atomic::{AtomicUsize, Ordering::SeqCst},
         Arc,
     },
+    time::Duration,
 };
 use util::{measure, ResultExt};
 
@@ -273,11 +274,47 @@ pub struct Window {
     activation_observers: SubscriberSet<(), AnyObserver>,
     pub(crate) focus: Option<FocusId>,
     focus_enabled: bool,
+    pending_input: Option<PendingInput>,
 
     #[cfg(any(test, feature = "test-support"))]
     pub(crate) focus_invalidated: bool,
 }
 
+#[derive(Default, Debug)]
+struct PendingInput {
+    keystrokes: SmallVec<[Keystroke; 1]>,
+    bindings: SmallVec<[KeyBinding; 1]>,
+    focus: Option<FocusId>,
+    timer: Option<Task<()>>,
+}
+
+impl PendingInput {
+    fn is_noop(&self) -> bool {
+        self.bindings.is_empty() && (self.keystrokes.iter().all(|k| k.ime_key.is_none()))
+    }
+
+    fn input(&self) -> String {
+        self.keystrokes
+            .iter()
+            .flat_map(|k| k.ime_key.clone())
+            .collect::<Vec<String>>()
+            .join("")
+    }
+
+    fn used_by_binding(&self, binding: &KeyBinding) -> bool {
+        if self.keystrokes.is_empty() {
+            return true;
+        }
+        let keystroke = &self.keystrokes[0];
+        for candidate in keystroke.match_candidates() {
+            if binding.match_keystrokes(&[candidate]) == KeyMatch::Pending {
+                return true;
+            }
+        }
+        false
+    }
+}
+
 pub(crate) struct ElementStateBox {
     pub(crate) inner: Box<dyn Any>,
     pub(crate) parent_view_id: EntityId,
@@ -379,6 +416,7 @@ impl Window {
             activation_observers: SubscriberSet::new(),
             focus: None,
             focus_enabled: true,
+            pending_input: None,
 
             #[cfg(any(test, feature = "test-support"))]
             focus_invalidated: false,
@@ -1175,44 +1213,67 @@ impl<'a> WindowContext<'a> {
             .dispatch_tree
             .dispatch_path(node_id);
 
-        let mut actions: Vec<Box<dyn Action>> = Vec::new();
-
-        let mut context_stack: SmallVec<[KeyContext; 16]> = SmallVec::new();
-        for node_id in &dispatch_path {
-            let node = self.window.rendered_frame.dispatch_tree.node(*node_id);
-
-            if let Some(context) = node.context.clone() {
-                context_stack.push(context);
-            }
-        }
+        if let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() {
+            let KeymatchResult { bindings, pending } = self
+                .window
+                .rendered_frame
+                .dispatch_tree
+                .dispatch_key(&key_down_event.keystroke, &dispatch_path);
+
+            if pending {
+                let mut currently_pending = self.window.pending_input.take().unwrap_or_default();
+                if currently_pending.focus.is_some() && currently_pending.focus != self.window.focus
+                {
+                    currently_pending = PendingInput::default();
+                }
+                currently_pending.focus = self.window.focus;
+                currently_pending
+                    .keystrokes
+                    .push(key_down_event.keystroke.clone());
+                for binding in bindings {
+                    currently_pending.bindings.push(binding);
+                }
 
-        for node_id in dispatch_path.iter().rev() {
-            // Match keystrokes
-            let node = self.window.rendered_frame.dispatch_tree.node(*node_id);
-            if node.context.is_some() {
-                if let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() {
-                    let mut new_actions = self
-                        .window
-                        .rendered_frame
-                        .dispatch_tree
-                        .dispatch_key(&key_down_event.keystroke, &context_stack);
-                    actions.append(&mut new_actions);
+                // for vim compatibility, we also should check "is input handler enabled"
+                if !currently_pending.is_noop() {
+                    currently_pending.timer = Some(self.spawn(|mut cx| async move {
+                        cx.background_executor.timer(Duration::from_secs(1)).await;
+                        cx.update(move |cx| {
+                            cx.clear_pending_keystrokes();
+                            let Some(currently_pending) = cx.window.pending_input.take() else {
+                                return;
+                            };
+                            cx.replay_pending_input(currently_pending)
+                        })
+                        .log_err();
+                    }));
+                } else {
+                    currently_pending.timer = None;
                 }
+                self.window.pending_input = Some(currently_pending);
 
-                context_stack.pop();
+                self.propagate_event = false;
+                return;
+            } else if let Some(currently_pending) = self.window.pending_input.take() {
+                if bindings
+                    .iter()
+                    .all(|binding| !currently_pending.used_by_binding(&binding))
+                {
+                    self.replay_pending_input(currently_pending)
+                }
             }
-        }
 
-        if !actions.is_empty() {
-            self.clear_pending_keystrokes();
-        }
+            if !bindings.is_empty() {
+                self.clear_pending_keystrokes();
+            }
 
-        self.propagate_event = true;
-        for action in actions {
-            self.dispatch_action_on_node(node_id, action.boxed_clone());
-            if !self.propagate_event {
-                self.dispatch_keystroke_observers(event, Some(action));
-                return;
+            self.propagate_event = true;
+            for binding in bindings {
+                self.dispatch_action_on_node(node_id, binding.action.boxed_clone());
+                if !self.propagate_event {
+                    self.dispatch_keystroke_observers(event, Some(binding.action));
+                    return;
+                }
             }
         }
 
@@ -1255,6 +1316,40 @@ impl<'a> WindowContext<'a> {
             .has_pending_keystrokes()
     }
 
+    fn replay_pending_input(&mut self, currently_pending: PendingInput) {
+        let node_id = self
+            .window
+            .focus
+            .and_then(|focus_id| {
+                self.window
+                    .rendered_frame
+                    .dispatch_tree
+                    .focusable_node_id(focus_id)
+            })
+            .unwrap_or_else(|| self.window.rendered_frame.dispatch_tree.root_node_id());
+
+        if self.window.focus != currently_pending.focus {
+            return;
+        }
+
+        let input = currently_pending.input();
+
+        self.propagate_event = true;
+        for binding in currently_pending.bindings {
+            self.dispatch_action_on_node(node_id, binding.action.boxed_clone());
+            if !self.propagate_event {
+                return;
+            }
+        }
+
+        if !input.is_empty() {
+            if let Some(mut input_handler) = self.window.platform_window.take_input_handler() {
+                input_handler.flush_pending_input(&input, self);
+                self.window.platform_window.set_input_handler(input_handler)
+            }
+        }
+    }
+
     fn dispatch_action_on_node(&mut self, node_id: DispatchNodeId, action: Box<dyn Action>) {
         let dispatch_path = self
             .window

crates/vim/src/motion.rs 🔗

@@ -73,9 +73,9 @@ pub(crate) struct Up {
 
 #[derive(Clone, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
-struct Down {
+pub(crate) struct Down {
     #[serde(default)]
-    display_lines: bool,
+    pub(crate) display_lines: bool,
 }
 
 #[derive(Clone, Deserialize, PartialEq)]

crates/vim/src/test.rs 🔗

@@ -3,8 +3,11 @@ mod neovim_backed_test_context;
 mod neovim_connection;
 mod vim_test_context;
 
+use std::time::Duration;
+
 use command_palette::CommandPalette;
 use editor::DisplayPoint;
+use gpui::KeyBinding;
 pub use neovim_backed_binding_test_context::*;
 pub use neovim_backed_test_context::*;
 pub use vim_test_context::*;
@@ -12,7 +15,7 @@ pub use vim_test_context::*;
 use indoc::indoc;
 use search::BufferSearchBar;
 
-use crate::{state::Mode, ModeIndicator};
+use crate::{insert::NormalBefore, motion, state::Mode, ModeIndicator};
 
 #[gpui::test]
 async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
@@ -774,3 +777,73 @@ async fn test_select_all_issue_2170(cx: &mut gpui::TestAppContext) {
         Mode::Visual,
     );
 }
+
+#[gpui::test]
+async fn test_jk(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.update(|cx| {
+        cx.bind_keys([KeyBinding::new(
+            "j k",
+            NormalBefore,
+            Some("vim_mode == insert"),
+        )])
+    });
+    cx.neovim.exec("imap jk <esc>").await;
+
+    cx.set_shared_state("ˇhello").await;
+    cx.simulate_shared_keystrokes(["i", "j", "o", "j", "k"])
+        .await;
+    cx.assert_shared_state("jˇohello").await;
+}
+
+#[gpui::test]
+async fn test_jk_delay(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    cx.update(|cx| {
+        cx.bind_keys([KeyBinding::new(
+            "j k",
+            NormalBefore,
+            Some("vim_mode == insert"),
+        )])
+    });
+
+    cx.set_state("ˇhello", Mode::Normal);
+    cx.simulate_keystrokes(["i", "j"]);
+    cx.executor().advance_clock(Duration::from_millis(500));
+    cx.run_until_parked();
+    cx.assert_state("ˇhello", Mode::Insert);
+    cx.executor().advance_clock(Duration::from_millis(500));
+    cx.run_until_parked();
+    cx.assert_state("jˇhello", Mode::Insert);
+    cx.simulate_keystrokes(["k", "j", "k"]);
+    cx.assert_state("jˇkhello", Mode::Normal);
+}
+
+#[gpui::test]
+async fn test_comma_w(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.update(|cx| {
+        cx.bind_keys([KeyBinding::new(
+            ", w",
+            motion::Down {
+                display_lines: false,
+            },
+            Some("vim_mode == normal"),
+        )])
+    });
+    cx.neovim.exec("map ,w j").await;
+
+    cx.set_shared_state("ˇhello hello\nhello hello").await;
+    cx.simulate_shared_keystrokes(["f", "o", ";", ",", "w"])
+        .await;
+    cx.assert_shared_state("hello hello\nhello hellˇo").await;
+
+    cx.set_shared_state("ˇhello hello\nhello hello").await;
+    cx.simulate_shared_keystrokes(["f", "o", ";", ",", "i"])
+        .await;
+    cx.assert_shared_state("hellˇo hello\nhello hello").await;
+    cx.assert_shared_mode(Mode::Insert).await;
+}

crates/vim/src/test/neovim_backed_test_context.rs 🔗

@@ -52,7 +52,7 @@ pub struct NeovimBackedTestContext {
     // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
     // bindings are exempted. If None, all bindings are ignored for that insertion text.
     exemptions: HashMap<String, Option<HashSet<String>>>,
-    neovim: NeovimConnection,
+    pub(crate) neovim: NeovimConnection,
 
     last_set_state: Option<String>,
     recent_keystrokes: Vec<String>,

crates/vim/src/test/neovim_connection.rs 🔗

@@ -42,6 +42,7 @@ pub enum NeovimData {
     Key(String),
     Get { state: String, mode: Option<Mode> },
     ReadRegister { name: char, value: String },
+    Exec { command: String },
     SetOption { value: String },
 }
 
@@ -269,6 +270,32 @@ impl NeovimConnection {
         );
     }
 
+    #[cfg(feature = "neovim")]
+    pub async fn exec(&mut self, value: &str) {
+        self.nvim
+            .command_output(format!("{}", value).as_str())
+            .await
+            .unwrap();
+
+        self.data.push_back(NeovimData::Exec {
+            command: value.to_string(),
+        })
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn exec(&mut self, value: &str) {
+        if let Some(NeovimData::Get { .. }) = self.data.front() {
+            self.data.pop_front();
+        };
+        assert_eq!(
+            self.data.pop_front(),
+            Some(NeovimData::Exec {
+                command: value.to_string(),
+            }),
+            "operation does not match recorded script. re-record with --features=neovim"
+        );
+    }
+
     #[cfg(not(feature = "neovim"))]
     pub async fn read_register(&mut self, register: char) -> String {
         if let Some(NeovimData::Get { .. }) = self.data.front() {

crates/vim/test_data/test_comma_w.json 🔗

@@ -0,0 +1,15 @@
+{"Exec":{"command":"map ,w j"}}
+{"Put":{"state":"ˇhello hello\nhello hello"}}
+{"Key":"f"}
+{"Key":"o"}
+{"Key":";"}
+{"Key":","}
+{"Key":"w"}
+{"Get":{"state":"hello hello\nhello hellˇo","mode":"Normal"}}
+{"Put":{"state":"ˇhello hello\nhello hello"}}
+{"Key":"f"}
+{"Key":"o"}
+{"Key":";"}
+{"Key":","}
+{"Key":"i"}
+{"Get":{"state":"hellˇo hello\nhello hello","mode":"Insert"}}

crates/vim/test_data/test_jk.json 🔗

@@ -0,0 +1,8 @@
+{"Exec":{"command":"imap jk <esc>"}}
+{"Put":{"state":"ˇhello"}}
+{"Key":"i"}
+{"Key":"j"}
+{"Key":"o"}
+{"Key":"j"}
+{"Key":"k"}
+{"Get":{"state":"jˇohello","mode":"Normal"}}