diff --git a/src/main/java/com/maddyhome/idea/vim/group/ProcessGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/ProcessGroup.kt
index 07e3788ca..e45bb0adc 100644
--- a/src/main/java/com/maddyhome/idea/vim/group/ProcessGroup.kt
+++ b/src/main/java/com/maddyhome/idea/vim/group/ProcessGroup.kt
@@ -20,6 +20,7 @@ import com.intellij.openapi.progress.ProgressManager
 import com.intellij.util.execution.ParametersListUtil
 import com.intellij.util.text.CharSequenceReader
 import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
+import com.maddyhome.idea.vim.KeyProcessResult
 import com.maddyhome.idea.vim.VimPlugin
 import com.maddyhome.idea.vim.api.ExecutionContext
 import com.maddyhome.idea.vim.api.VimEditor
@@ -90,19 +91,22 @@ public class ProcessGroup : VimProcessGroupBase() {
     panel.activate(editor.ij, context.ij, ":", initText, 1)
   }
 
-  public override fun processExKey(editor: VimEditor, stroke: KeyStroke): Boolean {
+  public override fun processExKey(editor: VimEditor, stroke: KeyStroke, processResultBuilder: KeyProcessResult.KeyProcessResultBuilder): Boolean {
     // This will only get called if somehow the key focus ended up in the editor while the ex entry window
     // is open. So I'll put focus back in the editor and process the key.
 
     val panel = ExEntryPanel.getInstance()
     if (panel.isActive) {
-      requestFocus(panel.entry)
-      panel.handleKey(stroke)
-
+      processResultBuilder.addExecutionStep { _, _, _ ->
+        requestFocus(panel.entry)
+        panel.handleKey(stroke)
+      }
       return true
     } else {
-      editor.mode = NORMAL()
-      getInstance().reset(editor)
+      processResultBuilder.addExecutionStep { _, lambdaEditor, _ ->
+        lambdaEditor.mode = NORMAL()
+        getInstance().reset(lambdaEditor)
+      }
       return false
     }
   }
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/KeyHandler.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/KeyHandler.kt
index 7e6448c0e..9cf87a638 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/KeyHandler.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/KeyHandler.kt
@@ -86,6 +86,19 @@ public class KeyHandler {
     mappingCompleted: Boolean,
     keyState: KeyHandlerState,
   ) {
+    val result = processKey(key, editor, allowKeyMappings, mappingCompleted, KeyProcessResult.SynchronousKeyProcessBuilder(keyState))
+    if (result is KeyProcessResult.Executable) {
+      result.execute(editor, context)
+    }
+  }
+
+  private fun processKey(
+    key: KeyStroke,
+    editor: VimEditor,
+    allowKeyMappings: Boolean,
+    mappingCompleted: Boolean,
+    processBuilder: KeyProcessResult.KeyProcessResultBuilder,
+  ): KeyProcessResult {
     LOG.trace {
       """
         ------- Key Handler -------
@@ -95,59 +108,69 @@ public class KeyHandler {
     }
     val maxMapDepth = injector.globalOptions().maxmapdepth
     if (handleKeyRecursionCount >= maxMapDepth) {
-      injector.messages.showStatusBarMessage(editor, injector.messages.message("E223"))
-      injector.messages.indicateError()
-      LOG.warn("Key handling, maximum recursion of the key received. maxdepth=$maxMapDepth")
-      return
+      processBuilder.addExecutionStep { _, lambdaEditor, _ ->
+        LOG.warn("Key handling, maximum recursion of the key received. maxdepth=$maxMapDepth")
+        injector.messages.showStatusBarMessage(lambdaEditor, injector.messages.message("E223"))
+        injector.messages.indicateError()
+      }
+      return processBuilder.build()
     }
 
-    val newState = keyState ?: this.keyHandlerState
     injector.messages.clearError()
     val editorState = editor.vimStateMachine
-    val commandBuilder = newState.commandBuilder
+    val commandBuilder = processBuilder.state.commandBuilder
 
     // If this is a "regular" character keystroke, get the character
     val chKey: Char = if (key.keyChar == KeyEvent.CHAR_UNDEFINED) 0.toChar() else key.keyChar
 
     // We only record unmapped keystrokes. If we've recursed to handle mapping, don't record anything.
     var shouldRecord = handleKeyRecursionCount == 0 && injector.registerGroup.isRecording
+    var isProcessed = false
     handleKeyRecursionCount++
     try {
       LOG.trace("Start key processing...")
-      if (!allowKeyMappings || !MappingProcessor.handleKeyMapping(editor, key, newState, context, mappingCompleted)) {
+      if (!MappingProcessor.handleKeyMapping(key, editor, allowKeyMappings, mappingCompleted, processBuilder)) {
         LOG.trace("Mappings processed, continue processing key.")
-        if (isCommandCountKey(chKey, newState, editorState)) {
+        if (isCommandCountKey(chKey, processBuilder.state, editorState)) {
           commandBuilder.addCountCharacter(key)
-        } else if (isDeleteCommandCountKey(key, newState, editorState.mode)) {
+          isProcessed = true
+        } else if (isDeleteCommandCountKey(key, processBuilder.state, editorState.mode)) {
           commandBuilder.deleteCountCharacter()
+          isProcessed = true
         } else if (isEditorReset(key, editorState)) {
-          handleEditorReset(editor, key, newState, context)
+          processBuilder.addExecutionStep { lambdaKeyState, lambdaEditor, lambdaContext -> handleEditorReset(lambdaEditor, key, lambdaKeyState, lambdaContext) }
+          isProcessed = true
         } else if (isExpectingCharArgument(commandBuilder)) {
-          handleCharArgument(key, chKey, newState, editor)
+          handleCharArgument(key, chKey, processBuilder.state, editor)
+          isProcessed = true
         } else if (editorState.isRegisterPending) {
           LOG.trace("Pending mode.")
           commandBuilder.addKey(key)
-          handleSelectRegister(editorState, chKey, newState)
-        } else if (!handleDigraph(editor, key, newState, context)) {
+          handleSelectRegister(editorState, chKey, processBuilder.state)
+          isProcessed = true
+        } else if (!handleDigraph(editor, key, processBuilder)) {
           LOG.debug("Digraph is NOT processed")
 
           // Ask the key/action tree if this is an appropriate key at this point in the command and if so,
           // return the node matching this keystroke
-          val node: Node<LazyVimCommand>? = mapOpCommand(key, commandBuilder.getChildNode(key), editorState.mode, newState)
+          val node: Node<LazyVimCommand>? = mapOpCommand(key, commandBuilder.getChildNode(key), editorState.mode, processBuilder.state)
           LOG.trace("Get the node for the current mode")
 
           if (node is CommandNode<LazyVimCommand>) {
             LOG.trace("Node is a command node")
-            handleCommandNode(editor, context, key, node, newState, editorState)
+            handleCommandNode(key, node, processBuilder)
             commandBuilder.addKey(key)
+            isProcessed = true
           } else if (node is CommandPartNode<LazyVimCommand>) {
             LOG.trace("Node is a command part node")
             commandBuilder.setCurrentCommandPartNode(node)
             commandBuilder.addKey(key)
-          } else if (isSelectRegister(key, newState, editorState)) {
+            isProcessed = true
+          } else if (isSelectRegister(key, processBuilder.state, editorState)) {
             LOG.trace("Select register")
             editorState.isRegisterPending = true
             commandBuilder.addKey(key)
+            isProcessed = true
           } else {
             // node == null
             LOG.trace("We are not able to find a node for this key")
@@ -155,37 +178,55 @@ public class KeyHandler {
             // If we are in insert/replace mode send this key in for processing
             if (editorState.mode == Mode.INSERT || editorState.mode == Mode.REPLACE) {
               LOG.trace("Process insert or replace")
-              shouldRecord = injector.changeGroup.processKey(editor, context, key) && shouldRecord
+              shouldRecord = injector.changeGroup.processKey(editor, key, processBuilder) && shouldRecord
+              isProcessed = true
             } else if (editorState.mode is Mode.SELECT) {
               LOG.trace("Process select")
-              shouldRecord = injector.changeGroup.processKeyInSelectMode(editor, context, key) && shouldRecord
+              shouldRecord = injector.changeGroup.processKeyInSelectMode(editor, key, processBuilder) && shouldRecord
+              isProcessed = true
             } else if (editor.mode is Mode.CMD_LINE) {
               LOG.trace("Process cmd line")
-              shouldRecord = injector.processGroup.processExKey(editor, key) && shouldRecord
+              shouldRecord = injector.processGroup.processExKey(editor, key, processBuilder) && shouldRecord
+              isProcessed = true
             } else {
               LOG.trace("Set command state to bad_command")
               commandBuilder.commandState = CurrentCommandState.BAD_COMMAND
             }
-            partialReset(newState, editorState.mode)
+            partialReset(processBuilder.state, editorState.mode)
           }
+        } else {
+          isProcessed = true
+        }
+      } else {
+        isProcessed = true
+      }
+      if (isProcessed) {
+        processBuilder.addExecutionStep { lambdaKeyState, lambdaEditor, lambdaContext ->
+          finishedCommandPreparation(lambdaEditor, lambdaContext, key, shouldRecord, lambdaKeyState)
+        }
+      } else {
+        // Key wasn't processed by any of the consumers, so we reset our key state
+        // and tell IDE that the key is Unknown (handle key for us)
+        onUnknownKey(editor, processBuilder.state)
+        return KeyProcessResult.Unknown.apply {
+          handleKeyRecursionCount-- // because onFinish will now be executed for unknown
         }
       }
     } finally {
-      handleKeyRecursionCount--
+      processBuilder.onFinish = { handleKeyRecursionCount-- }
     }
-    finishedCommandPreparation(editor, context, editorState, key, shouldRecord, newState)
-    updateState(newState)
+    return processBuilder.build()
   }
 
   internal fun finishedCommandPreparation(
     editor: VimEditor,
     context: ExecutionContext,
-    editorState: VimStateMachine,
     key: KeyStroke?,
     shouldRecord: Boolean,
     keyState: KeyHandlerState,
   ) {
     // Do we have a fully entered command at this point? If so, let's execute it.
+    val editorState = editor.vimStateMachine
     val commandBuilder = keyState.commandBuilder
     if (commandBuilder.isReady) {
       LOG.trace("Ready command builder. Execute command.")
@@ -211,6 +252,21 @@ public class KeyHandler {
     LOG.trace("----------- Key Handler Finished -----------")
   }
 
+  private fun onUnknownKey(editor: VimEditor, keyState: KeyHandlerState) {
+    keyState.commandBuilder.commandState = CurrentCommandState.BAD_COMMAND
+    LOG.trace("Command builder is set to BAD")
+    editor.resetOpPending()
+    editor.vimStateMachine.resetRegisterPending()
+    editor.isReplaceCharacter = false
+    reset(keyState, editor.mode)
+  }
+
+  public fun setBadCommand(editor: VimEditor, keyState: KeyHandlerState) {
+    onUnknownKey(editor, keyState)
+    injector.messages.indicateError()
+  }
+
+
   /**
    * See the description for [com.maddyhome.idea.vim.command.DuplicableOperatorAction]
    */
@@ -357,18 +413,16 @@ public class KeyHandler {
   private fun handleDigraph(
     editor: VimEditor,
     key: KeyStroke,
-    keyState: KeyHandlerState,
-    context: ExecutionContext,
+    keyProcessResultBuilder: KeyProcessResult.KeyProcessResultBuilder,
   ): Boolean {
-    LOG.debug("Handling digraph")
     // Support starting a digraph/literal sequence if the operator accepts one as an argument, e.g. 'r' or 'f'.
     // Normally, we start the sequence (in Insert or CmdLine mode) through a VimAction that can be mapped. Our
     // VimActions don't work as arguments for operators, so we have to special case here. Helpfully, Vim appears to
     // hardcode the shortcuts, and doesn't support mapping, so everything works nicely.
+    val keyState = keyProcessResultBuilder.state
     val commandBuilder = keyState.commandBuilder
     val digraphSequence = keyState.digraphSequence
     if (commandBuilder.expectedArgumentType == Argument.Type.DIGRAPH) {
-      LOG.trace("Expected argument is digraph")
       if (digraphSequence.isDigraphStart(key)) {
         digraphSequence.startDigraphSequence()
         commandBuilder.addKey(key)
@@ -381,34 +435,54 @@ public class KeyHandler {
       }
     }
     val res = digraphSequence.processKey(key, editor)
-    if (injector.exEntryPanel.isActive()) {
-      when (res.result) {
-        DigraphResult.RES_HANDLED -> setPromptCharacterEx(if (commandBuilder.isPuttingLiteral()) '^' else key.keyChar)
-        DigraphResult.RES_DONE, DigraphResult.RES_BAD -> if (key.keyCode == KeyEvent.VK_C && key.modifiers and InputEvent.CTRL_DOWN_MASK != 0) {
-          return false
-        } else {
-          injector.exEntryPanel.clearCurrentAction()
-        }
-      }
-    }
     when (res.result) {
       DigraphResult.RES_HANDLED -> {
-        commandBuilder.addKey(key)
+        keyProcessResultBuilder.addExecutionStep { lambdaKeyState, _, _ ->
+          if (injector.exEntryPanel.isActive()) {
+            setPromptCharacterEx(if (lambdaKeyState.commandBuilder.isPuttingLiteral()) '^' else key.keyChar)
+          }
+          lambdaKeyState.commandBuilder.addKey(key)
+        }
         return true
       }
       DigraphResult.RES_DONE -> {
-        if (commandBuilder.expectedArgumentType === Argument.Type.DIGRAPH) {
-          commandBuilder.fallbackToCharacterArgument()
+        if (injector.exEntryPanel.isActive()) {
+          if (key.keyCode == KeyEvent.VK_C && key.modifiers and InputEvent.CTRL_DOWN_MASK != 0) {
+            return false
+          } else {
+            keyProcessResultBuilder.addExecutionStep { _, _, _ ->
+              injector.exEntryPanel.clearCurrentAction()
+            }
+          }
+        }
+
+        keyProcessResultBuilder.addExecutionStep { lambdaKeyState, _, _ ->
+          if (lambdaKeyState.commandBuilder.expectedArgumentType === Argument.Type.DIGRAPH) {
+            lambdaKeyState.commandBuilder.fallbackToCharacterArgument()
+          }
         }
         val stroke = res.stroke ?: return false
-        commandBuilder.addKey(key)
-        handleKey(editor, stroke, context, keyState)
+        keyProcessResultBuilder.addExecutionStep { lambdaKeyState, lambdaEditorState, lambdaContext ->
+          lambdaKeyState.commandBuilder.addKey(key)
+          handleKey(lambdaEditorState, stroke, lambdaContext, lambdaKeyState)
+        }
         return true
       }
       DigraphResult.RES_BAD -> {
-        // BAD is an error. We were expecting a valid character, and we didn't get it.
-        if (commandBuilder.expectedArgumentType != null) {
-          commandBuilder.commandState = CurrentCommandState.BAD_COMMAND
+        if (injector.exEntryPanel.isActive()) {
+          if (key.keyCode == KeyEvent.VK_C && key.modifiers and InputEvent.CTRL_DOWN_MASK != 0) {
+            return false
+          } else {
+            keyProcessResultBuilder.addExecutionStep { _, _, _ ->
+              injector.exEntryPanel.clearCurrentAction()
+            }
+          }
+        }
+        keyProcessResultBuilder.addExecutionStep { lambdaKeyState, lambdaEditor, _ ->
+          // BAD is an error. We were expecting a valid character, and we didn't get it.
+          if (lambdaKeyState.commandBuilder.expectedArgumentType != null) {
+            setBadCommand(lambdaEditor, lambdaKeyState)
+          }
         }
         return true
       }
@@ -417,7 +491,9 @@ public class KeyHandler {
         // state. E.g. waiting for {char} <BS> {char}. Let the key handler have a go at it.
         if (commandBuilder.expectedArgumentType === Argument.Type.DIGRAPH) {
           commandBuilder.fallbackToCharacterArgument()
-          handleKey(editor, key, context, keyState)
+          keyProcessResultBuilder.addExecutionStep { lambdaKeyState, lambdaEditor, lambdaContext ->
+            handleKey(lambdaEditor, key, lambdaContext, lambdaKeyState)
+          }
           return true
         }
         return false
@@ -469,58 +545,63 @@ public class KeyHandler {
   }
 
   private fun handleCommandNode(
-    editor: VimEditor,
-    context: ExecutionContext,
     key: KeyStroke,
     node: CommandNode<LazyVimCommand>,
-    keyState: KeyHandlerState,
-    editorState: VimStateMachine,
+    processBuilder: KeyProcessResult.KeyProcessResultBuilder,
   ) {
     LOG.trace("Handle command node")
     // The user entered a valid command. Create the command and add it to the stack.
     val action = node.actionHolder.instance
+    val keyState = processBuilder.state
     val commandBuilder = keyState.commandBuilder
     val expectedArgumentType = commandBuilder.expectedArgumentType
     commandBuilder.pushCommandPart(action)
     if (!checkArgumentCompatibility(expectedArgumentType, action)) {
       LOG.trace("Return from command node handling")
-      commandBuilder.commandState = CurrentCommandState.BAD_COMMAND
+      processBuilder.addExecutionStep { lamdaKeyState, lambdaEditor, _ ->
+        setBadCommand(lambdaEditor, lamdaKeyState)
+      }
       return
     }
     if (action.argumentType == null || stopMacroRecord(node)) {
       LOG.trace("Set command state to READY")
       commandBuilder.commandState = CurrentCommandState.READY
     } else {
-      LOG.trace("Set waiting for the argument")
-      val argumentType = action.argumentType
-      startWaitingForArgument(editor, context, key.keyChar, action, argumentType!!, keyState, editorState)
-      partialReset(keyState, editorState.mode)
+      processBuilder.addExecutionStep { lambdaKeyState, lambdaEditor, lambdaContext ->
+        LOG.trace("Set waiting for the argument")
+        val argumentType = action.argumentType
+        val editorState = lambdaEditor.vimStateMachine
+        startWaitingForArgument(lambdaEditor, lambdaContext, key.keyChar, action, argumentType!!, lambdaKeyState, editorState)
+        lambdaKeyState.partialReset(editorState.mode)
+      }
     }
 
-    // TODO In the name of God, get rid of EX_STRING, FLAG_COMPLETE_EX and all the related staff
-    if (expectedArgumentType === Argument.Type.EX_STRING && action.flags.contains(CommandFlags.FLAG_COMPLETE_EX)) {
-      /* The only action that implements FLAG_COMPLETE_EX is ProcessExEntryAction.
-   * When pressing ':', ExEntryAction is chosen as the command. Since it expects no arguments, it is invoked and
-     calls ProcessGroup#startExCommand, pushes CMD_LINE mode, and the action is popped. The ex handler will push
-     the final <CR> through handleKey, which chooses ProcessExEntryAction. Because we're not expecting EX_STRING,
-     this branch does NOT fire, and ProcessExEntryAction handles the ex cmd line entry.
-   * When pressing '/' or '?', SearchEntry(Fwd|Rev)Action is chosen as the command. This expects an argument of
-     EX_STRING, so startWaitingForArgument calls ProcessGroup#startSearchCommand. The ex handler pushes the final
-     <CR> through handleKey, which chooses ProcessExEntryAction, and we hit this branch. We don't invoke
-     ProcessExEntryAction, but pop it, set the search text as an argument on SearchEntry(Fwd|Rev)Action and invoke
-     that instead.
-   * When using '/' or '?' as part of a motion (e.g. "d/foo"), the above happens again, and all is good. Because
-     the text has been applied as an argument on the last command, '.' will correctly repeat it.
+    processBuilder.addExecutionStep { _, lambdaEditor, _ ->
+      // TODO In the name of God, get rid of EX_STRING, FLAG_COMPLETE_EX and all the related staff
+      if (expectedArgumentType === Argument.Type.EX_STRING && action.flags.contains(CommandFlags.FLAG_COMPLETE_EX)) {
+        /* The only action that implements FLAG_COMPLETE_EX is ProcessExEntryAction.
+     * When pressing ':', ExEntryAction is chosen as the command. Since it expects no arguments, it is invoked and
+       calls ProcessGroup#startExCommand, pushes CMD_LINE mode, and the action is popped. The ex handler will push
+       the final <CR> through handleKey, which chooses ProcessExEntryAction. Because we're not expecting EX_STRING,
+       this branch does NOT fire, and ProcessExEntryAction handles the ex cmd line entry.
+     * When pressing '/' or '?', SearchEntry(Fwd|Rev)Action is chosen as the command. This expects an argument of
+       EX_STRING, so startWaitingForArgument calls ProcessGroup#startSearchCommand. The ex handler pushes the final
+       <CR> through handleKey, which chooses ProcessExEntryAction, and we hit this branch. We don't invoke
+       ProcessExEntryAction, but pop it, set the search text as an argument on SearchEntry(Fwd|Rev)Action and invoke
+       that instead.
+     * When using '/' or '?' as part of a motion (e.g. "d/foo"), the above happens again, and all is good. Because
+       the text has been applied as an argument on the last command, '.' will correctly repeat it.
 
-   It's hard to see how to improve this. Removing EX_STRING means starting ex input has to happen in ExEntryAction
-   and SearchEntry(Fwd|Rev)Action, and the ex command invoked in ProcessExEntryAction, but that breaks any initial
-   operator, which would be invoked first (e.g. 'd' in "d/foo").
-*/
-      LOG.trace("Processing ex_string")
-      val text = injector.processGroup.endSearchCommand()
-      commandBuilder.popCommandPart() // Pop ProcessExEntryAction
-      commandBuilder.completeCommandPart(Argument(text)) // Set search text on SearchEntry(Fwd|Rev)Action
-      editor.mode = editorState.mode.returnTo()
+     It's hard to see how to improve this. Removing EX_STRING means starting ex input has to happen in ExEntryAction
+     and SearchEntry(Fwd|Rev)Action, and the ex command invoked in ProcessExEntryAction, but that breaks any initial
+     operator, which would be invoked first (e.g. 'd' in "d/foo").
+  */
+        LOG.trace("Processing ex_string")
+        val text = injector.processGroup.endSearchCommand()
+        commandBuilder.popCommandPart() // Pop ProcessExEntryAction
+        commandBuilder.completeCommandPart(Argument(text)) // Set search text on SearchEntry(Fwd|Rev)Action
+        lambdaEditor.mode = lambdaEditor.mode.returnTo()
+      }
     }
   }
 
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroup.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroup.kt
index 6e097661c..1e102d28f 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroup.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroup.kt
@@ -7,6 +7,7 @@
  */
 package com.maddyhome.idea.vim.api
 
+import com.maddyhome.idea.vim.KeyProcessResult
 import com.maddyhome.idea.vim.command.Argument
 import com.maddyhome.idea.vim.command.Command
 import com.maddyhome.idea.vim.command.OperatorArguments
@@ -65,9 +66,9 @@ public interface VimChangeGroup {
     operatorArguments: OperatorArguments,
   ): Boolean
 
-  public fun processKey(editor: VimEditor, context: ExecutionContext, key: KeyStroke): Boolean
+  public fun processKey(editor: VimEditor, key: KeyStroke, processResultBuilder: KeyProcessResult.KeyProcessResultBuilder): Boolean
 
-  public fun processKeyInSelectMode(editor: VimEditor, context: ExecutionContext, key: KeyStroke): Boolean
+  public fun processKeyInSelectMode(editor: VimEditor, key: KeyStroke, processResultBuilder: KeyProcessResult.KeyProcessResultBuilder): Boolean
 
   public fun deleteLine(editor: VimEditor, caret: VimCaret, count: Int, operatorArguments: OperatorArguments): Boolean
 
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroupBase.kt
index 20c960802..7ea3e8a24 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroupBase.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimChangeGroupBase.kt
@@ -9,6 +9,7 @@
 package com.maddyhome.idea.vim.api
 
 import com.maddyhome.idea.vim.KeyHandler
+import com.maddyhome.idea.vim.KeyProcessResult
 import com.maddyhome.idea.vim.command.Argument
 import com.maddyhome.idea.vim.command.Command
 import com.maddyhome.idea.vim.command.CommandFlags
@@ -715,18 +716,18 @@ public abstract class VimChangeGroupBase : VimChangeGroup {
    */
   override fun processKey(
     editor: VimEditor,
-    context: ExecutionContext,
     key: KeyStroke,
+    processResultBuilder: KeyProcessResult.KeyProcessResultBuilder,
   ): Boolean {
     logger.debug { "processKey($key)" }
     if (key.keyChar != KeyEvent.CHAR_UNDEFINED) {
-      type(editor, context, key.keyChar)
+      processResultBuilder.addExecutionStep { _, lambdaEditor, lambdaContext -> type(lambdaEditor, lambdaContext, key.keyChar) }
       return true
     }
 
     // Shift-space
     if (key.keyCode == 32 && key.modifiers and KeyEvent.SHIFT_DOWN_MASK != 0) {
-      type(editor, context, ' ')
+      processResultBuilder.addExecutionStep { _, lambdaEditor, lambdaContext -> type(lambdaEditor, lambdaContext, ' ') }
       return true
     }
     return false
@@ -734,16 +735,18 @@ public abstract class VimChangeGroupBase : VimChangeGroup {
 
   override fun processKeyInSelectMode(
     editor: VimEditor,
-    context: ExecutionContext,
     key: KeyStroke,
+    processResultBuilder: KeyProcessResult.KeyProcessResultBuilder
   ): Boolean {
     var res: Boolean
     SelectionVimListenerSuppressor.lock().use {
-      res = processKey(editor, context, key)
-      editor.exitSelectModeNative(false)
-      KeyHandler.getInstance().reset(editor)
-      if (isPrintableChar(key.keyChar) || activeTemplateWithLeftRightMotion(editor, key)) {
-        injector.changeGroup.insertBeforeCursor(editor, context)
+      res = processKey(editor, key, processResultBuilder)
+      processResultBuilder.addExecutionStep { _, lambdaEditor, lambdaContext ->
+        lambdaEditor.exitSelectModeNative(false)
+        KeyHandler.getInstance().reset(lambdaEditor)
+        if (isPrintableChar(key.keyChar) || activeTemplateWithLeftRightMotion(lambdaEditor, key)) {
+          injector.changeGroup.insertBeforeCursor(lambdaEditor, lambdaContext)
+        }
       }
     }
     return res
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimProcessGroup.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimProcessGroup.kt
index 4f02e8c98..954619b3c 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimProcessGroup.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimProcessGroup.kt
@@ -7,6 +7,7 @@
  */
 package com.maddyhome.idea.vim.api
 
+import com.maddyhome.idea.vim.KeyProcessResult
 import com.maddyhome.idea.vim.command.Command
 import com.maddyhome.idea.vim.state.mode.Mode
 import javax.swing.KeyStroke
@@ -18,7 +19,7 @@ public interface VimProcessGroup {
 
   public fun startSearchCommand(editor: VimEditor, context: ExecutionContext, count: Int, leader: Char)
   public fun endSearchCommand(): String
-  public fun processExKey(editor: VimEditor, stroke: KeyStroke): Boolean
+  public fun processExKey(editor: VimEditor, stroke: KeyStroke, processResultBuilder: KeyProcessResult.KeyProcessResultBuilder): Boolean
   public fun startFilterCommand(editor: VimEditor, context: ExecutionContext, cmd: Command)
   public fun startExCommand(editor: VimEditor, context: ExecutionContext, cmd: Command)
   public fun processExEntry(editor: VimEditor, context: ExecutionContext): Boolean
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/stubs/VimProcessGroupStub.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/stubs/VimProcessGroupStub.kt
index baebd1ac3..9af96ff86 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/stubs/VimProcessGroupStub.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/stubs/VimProcessGroupStub.kt
@@ -8,6 +8,7 @@
 
 package com.maddyhome.idea.vim.api.stubs
 
+import com.maddyhome.idea.vim.KeyProcessResult
 import com.maddyhome.idea.vim.api.ExecutionContext
 import com.maddyhome.idea.vim.api.VimEditor
 import com.maddyhome.idea.vim.api.VimProcessGroupBase
@@ -36,7 +37,7 @@ public class VimProcessGroupStub : VimProcessGroupBase() {
     TODO("Not yet implemented")
   }
 
-  override fun processExKey(editor: VimEditor, stroke: KeyStroke): Boolean {
+  override fun processExKey(editor: VimEditor, stroke: KeyStroke, processResultBuilder: KeyProcessResult.KeyProcessResultBuilder): Boolean {
     TODO("Not yet implemented")
   }
 
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/MappingProcessor.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/MappingProcessor.kt
index 722476ae4..2095c05fb 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/MappingProcessor.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/MappingProcessor.kt
@@ -9,6 +9,7 @@
 package com.maddyhome.idea.vim.command
 
 import com.maddyhome.idea.vim.KeyHandler
+import com.maddyhome.idea.vim.KeyProcessResult
 import com.maddyhome.idea.vim.api.ExecutionContext
 import com.maddyhome.idea.vim.api.VimEditor
 import com.maddyhome.idea.vim.api.injector
@@ -19,8 +20,8 @@ import com.maddyhome.idea.vim.diagnostic.vimLogger
 import com.maddyhome.idea.vim.helper.vimStateMachine
 import com.maddyhome.idea.vim.impl.state.toMappingMode
 import com.maddyhome.idea.vim.key.KeyMappingLayer
+import com.maddyhome.idea.vim.key.MappingInfoLayer
 import com.maddyhome.idea.vim.state.KeyHandlerState
-import com.maddyhome.idea.vim.state.VimStateMachine
 import javax.swing.KeyStroke
 
 public object MappingProcessor {
@@ -28,13 +29,16 @@ public object MappingProcessor {
   private val log = vimLogger<MappingProcessor>()
 
   internal fun handleKeyMapping(
-    editor: VimEditor,
     key: KeyStroke,
-    keyState: KeyHandlerState,
-    context: ExecutionContext,
+    editor: VimEditor,
+    allowKeyMappings: Boolean,
     mappingCompleted: Boolean,
+    keyProcessResultBuilder: KeyProcessResult.KeyProcessResultBuilder,
   ): Boolean {
+    if (!allowKeyMappings) return false
+
     log.debug("Start processing key mappings.")
+    val keyState = keyProcessResultBuilder.state
     val commandState = editor.vimStateMachine
     val mappingState = keyState.mappingState
     val commandBuilder = keyState.commandBuilder
@@ -58,9 +62,9 @@ public object MappingProcessor {
     // Returns true if any of these methods handle the key. False means that the key is unrelated to mapping and should
     // be processed as normal.
     val mappingProcessed =
-      handleUnfinishedMappingSequence(editor, keyState, mappingState, mapping, mappingCompleted) ||
-        handleCompleteMappingSequence(editor, keyState, context, mappingState, mapping, key) ||
-        handleAbandonedMappingSequence(editor, keyState, mappingState, context)
+      handleUnfinishedMappingSequence(keyProcessResultBuilder, mapping, mappingCompleted) ||
+        handleCompleteMappingSequence(keyProcessResultBuilder, mapping, key) ||
+        handleAbandonedMappingSequence(keyProcessResultBuilder)
     log.debug { "Finish mapping processing. Return $mappingProcessed" }
 
     return mappingProcessed
@@ -76,9 +80,7 @@ public object MappingProcessor {
   }
 
   private fun handleUnfinishedMappingSequence(
-    editor: VimEditor,
-    keyState: KeyHandlerState,
-    mappingState: MappingState,
+    processBuilder: KeyProcessResult.KeyProcessResultBuilder,
     mapping: KeyMappingLayer,
     mappingCompleted: Boolean,
   ): Boolean {
@@ -93,7 +95,7 @@ public object MappingProcessor {
     // mapping is a prefix, it will get evaluated when the next character is entered.
     // Note that currentlyUnhandledKeySequence is the same as the state after commandState.getMappingKeys().add(key). It
     // would be nice to tidy ths up
-    if (!mapping.isPrefix(mappingState.keys)) {
+    if (!mapping.isPrefix(processBuilder.state.mappingState.keys)) {
       log.debug("There are no mappings that start with the current sequence. Returning false.")
       return false
     }
@@ -102,6 +104,11 @@ public object MappingProcessor {
     // Every time a key is pressed and handled, the timer is stopped. E.g. if there is a mapping for "dweri", and the
     // user has typed "dw" wait for the timeout, and then replay "d" and "w" without any mapping (which will of course
     // delete a word)
+    processBuilder.addExecutionStep { lambdaKeyState, lambdaEditor, _ -> processUnfinishedMappingSequence(lambdaEditor, lambdaKeyState) }
+    return true
+  }
+
+  private fun processUnfinishedMappingSequence(editor: VimEditor, keyState: KeyHandlerState) {
     if (injector.options(editor).timeout) {
       log.trace("timeout is set. schedule a mapping timer")
       // XXX There is a strange issue that reports that mapping state is empty at the moment of the function call.
@@ -109,6 +116,7 @@ public object MappingProcessor {
       //   but before invoke later is handled. This is a rare case, so I'll just add a check to isPluginMapping.
       //   But this "unexpected behaviour" exists, and it would be better not to relay on mutable state with delays.
       //   https://youtrack.jetbrains.com/issue/VIM-2392
+      val mappingState = keyState.mappingState
       mappingState.startMappingTimer {
         injector.application.invokeLater(
           {
@@ -127,7 +135,8 @@ public object MappingProcessor {
               //  of waiting for `abc` mapping.
               val lastKeyInSequence = index == unhandledKeys.lastIndex
 
-              KeyHandler.getInstance().handleKey(
+              val keyHandler = KeyHandler.getInstance()
+              keyHandler.handleKey(
                 editor,
                 keyStroke,
                 injector.executionContextManager.onEditor(editor),
@@ -142,19 +151,16 @@ public object MappingProcessor {
       }
     }
     log.trace("Unfinished mapping processing finished")
-    return true
   }
 
   private fun handleCompleteMappingSequence(
-    editor: VimEditor,
-    keyState: KeyHandlerState,
-    context: ExecutionContext,
-    mappingState: MappingState,
+    processBuilder: KeyProcessResult.KeyProcessResultBuilder,
     mapping: KeyMappingLayer,
     key: KeyStroke,
   ): Boolean {
     log.trace("Processing complete mapping sequence...")
     // The current sequence isn't a prefix, check to see if it's a completed sequence.
+    val mappingState = processBuilder.state.mappingState
     val currentMappingInfo = mapping.getLayer(mappingState.keys)
     var mappingInfo = currentMappingInfo
     if (mappingInfo == null) {
@@ -180,6 +186,19 @@ public object MappingProcessor {
       log.trace("Cannot find any mapping info for the sequence. Return false.")
       return false
     }
+    processBuilder.addExecutionStep { b, c, d -> processCompleteMappingSequence(key, b, c, d, mappingInfo, currentMappingInfo) }
+    return true
+  }
+
+  private fun processCompleteMappingSequence(
+    key: KeyStroke,
+    keyState: KeyHandlerState,
+    editor: VimEditor,
+    context: ExecutionContext,
+    mappingInfo: MappingInfoLayer,
+    currentMappingInfo: MappingInfoLayer?,
+  ) {
+    val mappingState = keyState.mappingState
     mappingState.resetMappingSequence()
     val currentContext = context.updateEditor(editor)
     log.trace("Executing mapping info")
@@ -216,20 +235,14 @@ public object MappingProcessor {
       KeyHandler.getInstance().handleKey(editor, key, currentContext, allowKeyMappings = true, false, keyState)
     }
     log.trace("Success processing of mapping")
-    return true
   }
 
-  private fun handleAbandonedMappingSequence(
-    editor: VimEditor,
-    keyState: KeyHandlerState,
-    mappingState: MappingState,
-    context: ExecutionContext,
-  ): Boolean {
+  private fun handleAbandonedMappingSequence(processBuilder: KeyProcessResult.KeyProcessResultBuilder): Boolean {
     log.debug("Processing abandoned mapping sequence")
     // The user has terminated a mapping sequence with an unexpected key
     // E.g. if there is a mapping for "hello" and user enters command "help" the processing of "h", "e" and "l" will be
     //   prevented by this handler. Make sure the currently unhandled keys are processed as normal.
-    val unhandledKeyStrokes = mappingState.detachKeys()
+    val unhandledKeyStrokes = processBuilder.state.mappingState.detachKeys()
 
     // If there is only the current key to handle, do nothing
     if (unhandledKeyStrokes.size == 1) {
@@ -244,6 +257,12 @@ public object MappingProcessor {
     // If user enters `dI`, the first `d` will be caught be this handler because it's a prefix for `ds` command.
     //  After the user enters `I`, the caught `d` should be processed without mapping, and the rest of keys
     //  should be processed with mappings (to make I work)
+    processBuilder.addExecutionStep { lambdaKeyState, lambdaEditor, lambdaContext ->
+      processAbondonedMappingSequence(unhandledKeyStrokes, lambdaEditor, lambdaContext, lambdaKeyState) }
+    return true
+  }
+
+  private fun processAbondonedMappingSequence(unhandledKeyStrokes: List<KeyStroke>, editor: VimEditor, context: ExecutionContext, keyState: KeyHandlerState) {
     if (isPluginMapping(unhandledKeyStrokes)) {
       log.trace("This is a plugin mapping, process it")
       KeyHandler.getInstance().handleKey(
@@ -264,7 +283,6 @@ public object MappingProcessor {
       }
     }
     log.trace("Return true from abandoned keys processing.")
-    return true
   }
 
   // The <Plug>mappings are not executed if they fail to map to something.