diff --git a/src/main/java/com/maddyhome/idea/vim/extension/nerdtree/NerdTree.kt b/src/main/java/com/maddyhome/idea/vim/extension/nerdtree/NerdTree.kt
index e29efbcea..fccb03c80 100644
--- a/src/main/java/com/maddyhome/idea/vim/extension/nerdtree/NerdTree.kt
+++ b/src/main/java/com/maddyhome/idea/vim/extension/nerdtree/NerdTree.kt
@@ -42,13 +42,10 @@ import com.maddyhome.idea.vim.extension.VimExtension
 import com.maddyhome.idea.vim.group.KeyGroup
 import com.maddyhome.idea.vim.helper.MessageHelper
 import com.maddyhome.idea.vim.helper.runAfterGotFocus
-import com.maddyhome.idea.vim.key.CommandNode
-import com.maddyhome.idea.vim.key.CommandPartNode
+import com.maddyhome.idea.vim.key.KeyStrokeTrie
 import com.maddyhome.idea.vim.key.MappingOwner
-import com.maddyhome.idea.vim.key.Node
 import com.maddyhome.idea.vim.key.RequiredShortcut
-import com.maddyhome.idea.vim.key.RootNode
-import com.maddyhome.idea.vim.key.addLeafs
+import com.maddyhome.idea.vim.key.add
 import com.maddyhome.idea.vim.newapi.ij
 import com.maddyhome.idea.vim.newapi.vim
 import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
@@ -198,6 +195,8 @@ internal class NerdTree : VimExtension {
     internal var waitForSearch = false
     internal var speedSearchListenerInstalled = false
 
+    private val keys = mutableListOf<KeyStroke>()
+
     override fun actionPerformed(e: AnActionEvent) {
       var keyStroke = getKeyStroke(e) ?: return
       val keyChar = keyStroke.keyChar
@@ -205,20 +204,14 @@ internal class NerdTree : VimExtension {
         keyStroke = KeyStroke.getKeyStroke(keyChar)
       }
 
-      val nextNode = currentNode[keyStroke]
-
-      when (nextNode) {
-        null -> currentNode = actionsRoot
-        is CommandNode<NerdAction> -> {
-          currentNode = actionsRoot
-
-          val action = nextNode.actionHolder
-          when (action) {
-            is NerdAction.ToIj -> Util.callAction(null, action.name, e.dataContext.vim)
-            is NerdAction.Code -> e.project?.let { action.action(it, e.dataContext, e) }
-          }
+      keys.add(keyStroke)
+      actionsRoot.getData(keys)?.let { action ->
+        when (action) {
+          is NerdAction.ToIj -> Util.callAction(null, action.name, e.dataContext.vim)
+          is NerdAction.Code -> e.project?.let { action.action(it, e.dataContext, e) }
         }
-        is CommandPartNode<NerdAction> -> currentNode = nextNode
+
+        keys.clear()
       }
     }
 
@@ -540,38 +533,29 @@ private fun addCommand(alias: String, handler: CommandAliasHandler) {
   VimPlugin.getCommand().setAlias(alias, CommandAlias.Call(0, -1, alias, handler))
 }
 
-private fun registerCommand(variable: String, default: String, action: NerdAction) {
+private fun registerCommand(variable: String, defaultMapping: String, action: NerdAction) {
   val variableValue = VimPlugin.getVariableService().getGlobalVariableValue(variable)
-  val mappings = if (variableValue is VimString) {
+  val mapping = if (variableValue is VimString) {
     variableValue.value
   } else {
-    default
+    defaultMapping
   }
-  actionsRoot.addLeafs(mappings, action)
+  registerCommand(mapping, action)
 }
 
-private fun registerCommand(default: String, action: NerdAction) {
-  actionsRoot.addLeafs(default, action)
-}
-
-
-private val actionsRoot: RootNode<NerdAction> = RootNode("NERDTree")
-private var currentNode: CommandPartNode<NerdAction> = actionsRoot
-
-private fun collectShortcuts(node: Node<NerdAction>): Set<KeyStroke> {
-  return if (node is CommandPartNode<NerdAction>) {
-    val res = node.children.keys.toMutableSet()
-    res += node.children.values.map { collectShortcuts(it) }.flatten()
-    res
-  } else {
-    emptySet()
+private fun registerCommand(mapping: String, action: NerdAction) {
+  actionsRoot.add(mapping, action)
+  injector.parser.parseKeys(mapping).forEach {
+    distinctShortcuts.add(it)
   }
 }
 
+private val actionsRoot: KeyStrokeTrie<NerdAction> = KeyStrokeTrie<NerdAction>("NERDTree")
+private val distinctShortcuts = mutableSetOf<KeyStroke>()
+
 private fun installDispatcher(project: Project) {
   val dispatcher = NerdTree.NerdDispatcher.getInstance(project)
-  val shortcuts =
-    collectShortcuts(actionsRoot).map { RequiredShortcut(it, MappingOwner.Plugin.get(NerdTree.pluginName)) }
+  val shortcuts = distinctShortcuts.map { RequiredShortcut(it, MappingOwner.Plugin.get(NerdTree.pluginName)) }
   dispatcher.registerCustomShortcutSet(
     KeyGroup.toShortcutSet(shortcuts),
     (ProjectView.getInstance(project) as ProjectViewImpl).component,
diff --git a/src/main/java/com/maddyhome/idea/vim/group/KeyGroup.java b/src/main/java/com/maddyhome/idea/vim/group/KeyGroup.java
index e0aa81a1b..004a8b1e3 100644
--- a/src/main/java/com/maddyhome/idea/vim/group/KeyGroup.java
+++ b/src/main/java/com/maddyhome/idea/vim/group/KeyGroup.java
@@ -18,7 +18,6 @@ import com.intellij.openapi.components.PersistentStateComponent;
 import com.intellij.openapi.components.State;
 import com.intellij.openapi.components.Storage;
 import com.intellij.openapi.diagnostic.Logger;
-import com.intellij.openapi.editor.Editor;
 import com.intellij.openapi.keymap.Keymap;
 import com.intellij.openapi.keymap.KeymapManager;
 import com.intellij.openapi.keymap.ex.KeymapManagerEx;
@@ -28,7 +27,6 @@ import com.maddyhome.idea.vim.action.VimShortcutKeyAction;
 import com.maddyhome.idea.vim.action.change.LazyVimCommand;
 import com.maddyhome.idea.vim.api.*;
 import com.maddyhome.idea.vim.command.MappingMode;
-import com.maddyhome.idea.vim.ex.ExOutputModel;
 import com.maddyhome.idea.vim.key.*;
 import com.maddyhome.idea.vim.newapi.IjNativeAction;
 import com.maddyhome.idea.vim.newapi.IjVimEditor;
@@ -199,8 +197,7 @@ public class KeyGroup extends VimKeyGroupBase implements PersistentStateComponen
       registerRequiredShortcut(keyStrokes, MappingOwner.IdeaVim.System.INSTANCE);
 
       for (MappingMode mappingMode : command.getModes()) {
-        Node<LazyVimCommand> node = getKeyRoot(mappingMode);
-        NodesKt.addLeafs(node, keyStrokes, command);
+        getBuiltinCommandsTrie(mappingMode).add(keyStrokes, command);
       }
     }
   }
diff --git a/src/main/java/com/maddyhome/idea/vim/key/NodesHelper.kt b/src/main/java/com/maddyhome/idea/vim/key/NodesHelper.kt
deleted file mode 100644
index 70deaaa32..000000000
--- a/src/main/java/com/maddyhome/idea/vim/key/NodesHelper.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * Copyright 2003-2023 The IdeaVim authors
- *
- * Use of this source code is governed by an MIT-style
- * license that can be found in the LICENSE.txt file or at
- * https://opensource.org/licenses/MIT.
- */
-
-package com.maddyhome.idea.vim.key
-
-import com.maddyhome.idea.vim.api.injector
-
-internal fun <T> Node<T>.addLeafs(keys: String, actionHolder: T) {
-  addLeafs(injector.parser.parseKeys(keys), actionHolder)
-}
diff --git a/tests/property-tests/src/test/kotlin/org/jetbrains/plugins/ideavim/propertybased/RandomActionsPropertyTest.kt b/tests/property-tests/src/test/kotlin/org/jetbrains/plugins/ideavim/propertybased/RandomActionsPropertyTest.kt
index dbd66c539..01a75dd21 100644
--- a/tests/property-tests/src/test/kotlin/org/jetbrains/plugins/ideavim/propertybased/RandomActionsPropertyTest.kt
+++ b/tests/property-tests/src/test/kotlin/org/jetbrains/plugins/ideavim/propertybased/RandomActionsPropertyTest.kt
@@ -14,7 +14,6 @@ import com.intellij.testFramework.PlatformTestUtil
 import com.maddyhome.idea.vim.KeyHandler
 import com.maddyhome.idea.vim.api.injector
 import com.maddyhome.idea.vim.api.key
-import com.maddyhome.idea.vim.key.CommandNode
 import com.maddyhome.idea.vim.newapi.vim
 import org.jetbrains.jetCheck.Generator
 import org.jetbrains.jetCheck.ImperativeCommand
@@ -92,19 +91,23 @@ class RandomActionsPropertyTest : VimPropertyTestBase() {
 
 private class AvailableActions(private val editor: Editor) : ImperativeCommand {
   override fun performCommand(env: ImperativeCommand.Environment) {
-    val currentNode = KeyHandler.getInstance().keyHandlerState.commandBuilder.getCurrentTrie()
+    val trie = KeyHandler.getInstance().keyHandlerState.commandBuilder.getCurrentTrie()
+    val currentKeys = KeyHandler.getInstance().keyHandlerState.commandBuilder.getCurrentCommandKeys()
 
     // Note: esc is always an option
-    val possibleKeys = (currentNode.children.keys.toList() + esc).sortedBy { injector.parser.toKeyNotation(it) }
-    println("Keys: ${possibleKeys.joinToString(", ")}")
+    val possibleKeys: List<KeyStroke> = buildList {
+      add(esc)
+      trie.getTrieNode(currentKeys)?.visit { stroke, _ -> add(stroke) }
+    }.sortedBy { injector.parser.toKeyNotation(it) }
+
+//    println("Keys: ${possibleKeys.joinToString(", ")}")
     val keyGenerator = Generator.integers(0, possibleKeys.lastIndex)
       .suchThat { injector.parser.toKeyNotation(possibleKeys[it]) !in stinkyKeysList }
       .map { possibleKeys[it] }
 
     val usedKey = env.generateValue(keyGenerator, null)
-    val node = currentNode[usedKey]
-
-    env.logMessage("Use command: ${injector.parser.toKeyNotation(usedKey)}. ${if (node is CommandNode) "Action: ${node.actionHolder.actionId}" else ""}")
+    val node = trie.getTrieNode(currentKeys + usedKey)
+    env.logMessage("Use command: ${injector.parser.toKeyNotation(currentKeys + usedKey)}. ${if (node?.data != null) "Action: ${node.data!!.actionId}" else ""}")
     VimNoWriteActionTestCase.typeText(listOf(usedKey), editor, editor.project)
 
     IdeEventQueue.getInstance().flushQueue()
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 b1a43aad0..6d058466b 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
@@ -7,14 +7,12 @@
  */
 package com.maddyhome.idea.vim
 
-import com.maddyhome.idea.vim.action.change.LazyVimCommand
 import com.maddyhome.idea.vim.api.ExecutionContext
 import com.maddyhome.idea.vim.api.VimEditor
 import com.maddyhome.idea.vim.api.globalOptions
 import com.maddyhome.idea.vim.api.injector
 import com.maddyhome.idea.vim.command.Command
 import com.maddyhome.idea.vim.command.CommandFlags
-import com.maddyhome.idea.vim.command.MappingMode
 import com.maddyhome.idea.vim.command.MappingProcessor
 import com.maddyhome.idea.vim.command.OperatorArguments
 import com.maddyhome.idea.vim.diagnostic.VimLogger
@@ -23,7 +21,6 @@ import com.maddyhome.idea.vim.diagnostic.vimLogger
 import com.maddyhome.idea.vim.impl.state.toMappingMode
 import com.maddyhome.idea.vim.key.KeyConsumer
 import com.maddyhome.idea.vim.key.KeyStack
-import com.maddyhome.idea.vim.key.RootNode
 import com.maddyhome.idea.vim.key.consumers.CharArgumentConsumer
 import com.maddyhome.idea.vim.key.consumers.CommandConsumer
 import com.maddyhome.idea.vim.key.consumers.CommandCountConsumer
@@ -269,7 +266,7 @@ class KeyHandler {
     editor.isReplaceCharacter = false
     editor.resetOpPending()
     keyHandlerState.partialReset(editor.mode)
-    keyHandlerState.commandBuilder.resetAll(getKeyRoot(editor.mode.toMappingMode()))
+    keyHandlerState.commandBuilder.resetAll(injector.keyGroup.getBuiltinCommandsTrie(editor.mode.toMappingMode()))
   }
 
   // TODO we should have a single reset method
@@ -277,11 +274,7 @@ class KeyHandler {
     logger.trace { "Reset is executed" }
     injector.commandLine.getActiveCommandLine()?.clearCurrentAction()
     keyHandlerState.partialReset(mode)
-    keyState.commandBuilder.resetAll(getKeyRoot(mode.toMappingMode()))
-  }
-
-  private fun getKeyRoot(mappingMode: MappingMode): RootNode<LazyVimCommand> {
-    return injector.keyGroup.getKeyRoot(mappingMode)
+    keyState.commandBuilder.resetAll(injector.keyGroup.getBuiltinCommandsTrie(mode.toMappingMode()))
   }
 
   fun updateState(keyState: KeyHandlerState) {
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroup.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroup.kt
index 5733bbc45..43c3d29fa 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroup.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroup.kt
@@ -12,15 +12,19 @@ import com.maddyhome.idea.vim.command.MappingMode
 import com.maddyhome.idea.vim.extension.ExtensionHandler
 import com.maddyhome.idea.vim.key.KeyMapping
 import com.maddyhome.idea.vim.key.KeyMappingLayer
+import com.maddyhome.idea.vim.key.KeyStrokeTrie
 import com.maddyhome.idea.vim.key.MappingInfo
 import com.maddyhome.idea.vim.key.MappingOwner
-import com.maddyhome.idea.vim.key.RootNode
 import com.maddyhome.idea.vim.key.ShortcutOwnerInfo
 import com.maddyhome.idea.vim.vimscript.model.expressions.Expression
 import javax.swing.KeyStroke
 
 interface VimKeyGroup {
-  fun getKeyRoot(mappingMode: MappingMode): RootNode<LazyVimCommand>
+  @Suppress("DEPRECATION")
+  @Deprecated("Use getBuiltinCommandTrie", ReplaceWith("getBuiltinCommandsTrie(mappingMode)"))
+  fun getKeyRoot(mappingMode: MappingMode): com.maddyhome.idea.vim.key.CommandPartNode<LazyVimCommand>
+
+  fun getBuiltinCommandsTrie(mappingMode: MappingMode): KeyStrokeTrie<LazyVimCommand>
   fun getKeyMappingLayer(mode: MappingMode): KeyMappingLayer
   fun getActions(editor: VimEditor, keyStroke: KeyStroke): List<NativeAction>
   fun getKeymapConflicts(keyStroke: KeyStroke): List<NativeAction>
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroupBase.kt
index 5395e9c83..456fffa13 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroupBase.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimKeyGroupBase.kt
@@ -14,6 +14,7 @@ import com.maddyhome.idea.vim.extension.ExtensionHandler
 import com.maddyhome.idea.vim.handler.EditorActionHandlerBase
 import com.maddyhome.idea.vim.key.KeyMapping
 import com.maddyhome.idea.vim.key.KeyMappingLayer
+import com.maddyhome.idea.vim.key.KeyStrokeTrie
 import com.maddyhome.idea.vim.key.MappingInfo
 import com.maddyhome.idea.vim.key.MappingOwner
 import com.maddyhome.idea.vim.key.RequiredShortcut
@@ -29,7 +30,7 @@ abstract class VimKeyGroupBase : VimKeyGroup {
   @JvmField
   val myShortcutConflicts: MutableMap<KeyStroke, ShortcutOwnerInfo> = LinkedHashMap()
   val requiredShortcutKeys: MutableSet<RequiredShortcut> = HashSet(300)
-  val keyRoots: MutableMap<MappingMode, RootNode<LazyVimCommand>> = EnumMap(MappingMode::class.java)
+  val builtinCommands: MutableMap<MappingMode, KeyStrokeTrie<LazyVimCommand>> = EnumMap(MappingMode::class.java)
   val keyMappings: MutableMap<MappingMode, KeyMapping> = EnumMap(MappingMode::class.java)
 
   override fun removeKeyMapping(modes: Set<MappingMode>, keys: List<KeyStroke>) {
@@ -56,13 +57,19 @@ abstract class VimKeyGroupBase : VimKeyGroup {
     keyMappings.clear()
   }
 
+  @Suppress("DEPRECATION")
+  @Deprecated("Use getBuiltinCommandTrie", ReplaceWith("getBuiltinCommandsTrie(mappingMode)"))
+  override fun getKeyRoot(mappingMode: MappingMode): com.maddyhome.idea.vim.key.CommandPartNode<LazyVimCommand> =
+    RootNode(getBuiltinCommandsTrie(mappingMode))
+
   /**
-   * Returns the root of the key mapping for the given mapping mode
+   * Returns the root node of the builtin command keystroke trie
    *
    * @param mappingMode The mapping mode
-   * @return The key mapping tree root
+   * @return The root node of the builtin command trie
    */
-  override fun getKeyRoot(mappingMode: MappingMode): RootNode<LazyVimCommand> = keyRoots.getOrPut(mappingMode) { RootNode(mappingMode.name.get(0).lowercase()) }
+  override fun getBuiltinCommandsTrie(mappingMode: MappingMode): KeyStrokeTrie<LazyVimCommand> =
+    builtinCommands.getOrPut(mappingMode) { KeyStrokeTrie<LazyVimCommand>(mappingMode.name[0].lowercase()) }
 
   override fun getKeyMappingLayer(mode: MappingMode): KeyMappingLayer = getKeyMapping(mode)
 
@@ -75,6 +82,7 @@ abstract class VimKeyGroupBase : VimKeyGroup {
     for (mappingMode in mappingModes) {
       checkIdentity(mappingMode, action.id, keys)
     }
+    @Suppress("DEPRECATION")
     checkCorrectCombination(action, keys)
   }
 
@@ -236,6 +244,6 @@ abstract class VimKeyGroupBase : VimKeyGroup {
   }
 
   override fun unregisterCommandActions() {
-    keyRoots.clear()
+    builtinCommands.clear()
   }
 }
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/CommandBuilder.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/CommandBuilder.kt
index 1bf45cb41..1e1dcf036 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/CommandBuilder.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/command/CommandBuilder.kt
@@ -20,20 +20,19 @@ import com.maddyhome.idea.vim.handler.MotionActionHandler
 import com.maddyhome.idea.vim.handler.TextObjectActionHandler
 import com.maddyhome.idea.vim.helper.StrictMode
 import com.maddyhome.idea.vim.helper.noneOfEnum
-import com.maddyhome.idea.vim.key.CommandNode
-import com.maddyhome.idea.vim.key.CommandPartNode
-import com.maddyhome.idea.vim.key.RootNode
+import com.maddyhome.idea.vim.key.KeyStrokeTrie
 import org.jetbrains.annotations.TestOnly
 import javax.swing.KeyStroke
 
 class CommandBuilder private constructor(
-  private var currentCommandPartNode: CommandPartNode<LazyVimCommand>,
+  private var keyStrokeTrie: KeyStrokeTrie<LazyVimCommand>,
   private val counts: MutableList<Int>,
-  private val keyList: MutableList<KeyStroke>,
+  private val typedKeyStrokes: MutableList<KeyStroke>,
+  private val commandKeyStrokes: MutableList<KeyStroke>
 ) : Cloneable {
 
-  constructor(rootNode: RootNode<LazyVimCommand>, initialUncommittedRawCount: Int = 0)
-    : this(rootNode, mutableListOf(initialUncommittedRawCount), mutableListOf())
+  constructor(keyStrokeTrie: KeyStrokeTrie<LazyVimCommand>, initialUncommittedRawCount: Int = 0)
+    : this(keyStrokeTrie, mutableListOf(initialUncommittedRawCount), mutableListOf(), mutableListOf())
 
   private var commandState: CurrentCommandState = CurrentCommandState.NEW_COMMAND
   private var selectedRegister: Char? = null
@@ -51,7 +50,7 @@ class CommandBuilder private constructor(
     }
 
   /** Provide the typed keys for `'showcmd'` */
-  val keys: Iterable<KeyStroke> get() = keyList
+  val keys: Iterable<KeyStroke> get() = typedKeyStrokes
 
   /** Returns true if the command builder is clean and ready to start building */
   val isEmpty
@@ -167,12 +166,12 @@ class CommandBuilder private constructor(
     if (currentCount < 0) {
       currentCount = 999999999
     }
-    addKey(key)
+    addTypedKeyStroke(key)
   }
 
   fun deleteCountCharacter() {
     currentCount /= 10
-    keyList.removeAt(keyList.size - 1)
+    typedKeyStrokes.removeLast()
   }
 
   var isRegisterPending: Boolean = false
@@ -180,7 +179,7 @@ class CommandBuilder private constructor(
 
   fun startWaitingForRegister(key: KeyStroke) {
     isRegisterPending = true
-    addKey(key)
+    addTypedKeyStroke(key)
   }
 
   fun selectRegister(register: Char) {
@@ -197,9 +196,9 @@ class CommandBuilder private constructor(
    * Only public use is when entering a digraph/literal, where each key isn't handled by [CommandBuilder], but should
    * be added to the `'showcmd'` output.
    */
-  fun addKey(key: KeyStroke) {
+  fun addTypedKeyStroke(key: KeyStroke) {
     logger.trace { "added key to command builder: $key" }
-    keyList.add(key)
+    typedKeyStrokes.add(key)
   }
 
   /**
@@ -268,24 +267,26 @@ class CommandBuilder private constructor(
    * part node.
    */
   fun processKey(key: KeyStroke, processor: (EditorActionHandlerBase) -> Unit): Boolean {
-    val node = currentCommandPartNode[key]
-    when (node) {
-      is CommandNode -> {
-        logger.trace { "Found full command node ($key) - ${node.debugString}" }
-        addKey(key)
-        processor(node.actionHolder.instance)
-        return true
-      }
-      is CommandPartNode -> {
-        logger.trace { "Found command part node ($key) - ${node.debugString}" }
-        currentCommandPartNode = node
-        addKey(key)
-        return true
-      }
+    commandKeyStrokes.add(key)
+    val node = keyStrokeTrie.getTrieNode(commandKeyStrokes)
+    if (node == null) {
+      logger.trace { "No command or part command for key sequence: ${injector.parser.toPrintableString(commandKeyStrokes)}" }
+      commandKeyStrokes.clear()
+      return false
     }
 
-    logger.trace { "No command/command part node found for key: $key" }
-    return false
+    addTypedKeyStroke(key)
+
+    val command = node.data
+    if (command == null) {
+      logger.trace { "Found unfinished key sequence for ${injector.parser.toPrintableString(commandKeyStrokes)} - ${node.debugString}"}
+      return true
+    }
+
+    logger.trace { "Found command for ${injector.parser.toPrintableString(commandKeyStrokes)} - ${node.debugString}"}
+    commandKeyStrokes.clear()
+    processor(command.instance)
+    return true
   }
 
   /**
@@ -319,8 +320,8 @@ class CommandBuilder private constructor(
     //   Similarly, nmap <C-W>a <C-W>s should not try to map the second <C-W> in <C-W><C-W>
     // Note that we might still be at RootNode if we're handling a prefix, because we might be buffering keys until we
     // get a match. This means we'll still process the rest of the keys of the prefix.
-    val isMultikey = currentCommandPartNode !is RootNode
-    logger.debug { "Building multikey command: $isMultikey" }
+    val isMultikey = commandKeyStrokes.isNotEmpty()
+    logger.debug { "Building multikey command: $commandKeyStrokes" }
     return isMultikey
   }
 
@@ -332,21 +333,22 @@ class CommandBuilder private constructor(
   fun buildCommand(): Command {
     val rawCount = calculateCount0Snapshot()
     val command = Command(selectedRegister, rawCount, action!!, argument, action!!.type, action?.flags ?: noneOfEnum())
-    resetAll(currentCommandPartNode.root as RootNode<LazyVimCommand>)
+    resetAll(keyStrokeTrie)
     return command
   }
 
-  fun resetAll(rootNode: RootNode<LazyVimCommand>) {
+  fun resetAll(keyStrokeTrie: KeyStrokeTrie<LazyVimCommand>) {
     logger.trace("resetAll is executed")
-    currentCommandPartNode = rootNode
+    this.keyStrokeTrie = keyStrokeTrie
     commandState = CurrentCommandState.NEW_COMMAND
+    commandKeyStrokes.clear()
     counts.clear()
     counts.add(0)
     isRegisterPending = false
     selectedRegister = null
     action = null
     argument = null
-    keyList.clear()
+    typedKeyStrokes.clear()
     fallbackArgumentType = null
   }
 
@@ -357,13 +359,16 @@ class CommandBuilder private constructor(
    * mode - this is handled by [resetAll]. This function allows us to change the root node without executing a command
    * or fully resetting the command builder, such as when switching to Op-pending while entering an operator+motion.
    */
-  fun resetCommandTrieRootNode(rootNode: RootNode<LazyVimCommand>) {
+  fun resetCommandTrie(keyStrokeTrie: KeyStrokeTrie<LazyVimCommand>) {
     logger.trace("resetCommandTrieRootNode is executed")
-    currentCommandPartNode = rootNode
+    this.keyStrokeTrie = keyStrokeTrie
   }
 
   @TestOnly
-  fun getCurrentTrie(): CommandPartNode<LazyVimCommand> = currentCommandPartNode
+  fun getCurrentTrie(): KeyStrokeTrie<LazyVimCommand> = keyStrokeTrie
+
+  @TestOnly
+  fun getCurrentCommandKeys(): List<KeyStroke> = commandKeyStrokes
 
   override fun equals(other: Any?): Boolean {
     if (this === other) return true
@@ -371,12 +376,12 @@ class CommandBuilder private constructor(
 
     other as CommandBuilder
 
-    if (currentCommandPartNode != other.currentCommandPartNode) return false
+    if (keyStrokeTrie != other.keyStrokeTrie) return false
     if (counts != other.counts) return false
     if (selectedRegister != other.selectedRegister) return false
     if (action != other.action) return false
     if (argument != other.argument) return false
-    if (keyList != other.keyList) return false
+    if (typedKeyStrokes != other.typedKeyStrokes) return false
     if (commandState != other.commandState) return false
     if (expectedArgumentType != other.expectedArgumentType) return false
     if (fallbackArgumentType != other.fallbackArgumentType) return false
@@ -385,12 +390,12 @@ class CommandBuilder private constructor(
   }
 
   override fun hashCode(): Int {
-    var result = currentCommandPartNode.hashCode()
+    var result = keyStrokeTrie.hashCode()
     result = 31 * result + counts.hashCode()
     result = 31 * result + selectedRegister.hashCode()
     result = 31 * result + action.hashCode()
     result = 31 * result + argument.hashCode()
-    result = 31 * result + keyList.hashCode()
+    result = 31 * result + typedKeyStrokes.hashCode()
     result = 31 * result + commandState.hashCode()
     result = 31 * result + expectedArgumentType.hashCode()
     result = 31 * result + fallbackArgumentType.hashCode()
@@ -399,9 +404,10 @@ class CommandBuilder private constructor(
 
   public override fun clone(): CommandBuilder {
     val result = CommandBuilder(
-      currentCommandPartNode,
+      keyStrokeTrie,
       counts.toMutableList(),
-      keyList.toMutableList()
+      typedKeyStrokes.toMutableList(),
+      commandKeyStrokes.toMutableList()
     )
     result.selectedRegister = selectedRegister
     result.action = action
@@ -413,12 +419,12 @@ class CommandBuilder private constructor(
 
   override fun toString(): String {
     return "Command state = $commandState, " +
-      "key list = ${ injector.parser.toKeyNotation(keyList) }, " +
+      "key list = ${ injector.parser.toKeyNotation(typedKeyStrokes) }, " +
       "selected register = $selectedRegister, " +
       "counts = $counts, " +
       "action = $action, " +
       "argument = $argument, " +
-      "command part node - $currentCommandPartNode"
+      "command part node - $keyStrokeTrie"
   }
 
   companion object {
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/KeyStrokeTrie.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/KeyStrokeTrie.kt
new file mode 100644
index 000000000..ca4d35b5b
--- /dev/null
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/KeyStrokeTrie.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2003-2024 The IdeaVim authors
+ *
+ * Use of this source code is governed by an MIT-style
+ * license that can be found in the LICENSE.txt file or at
+ * https://opensource.org/licenses/MIT.
+ */
+
+package com.maddyhome.idea.vim.key
+
+import com.maddyhome.idea.vim.api.injector
+import javax.swing.KeyStroke
+
+/**
+ * A trie data structure for storing and retrieving values associated with sequences of keystrokes
+ *
+ * All leaves will have data, but it is not a requirement for nodes with data to have no children.
+ *
+ * @param name The name of this KeyStrokeTrie instance (for debug purposes)
+ */
+class KeyStrokeTrie<T>(private val name: String) {
+  interface TrieNode<T> {
+    val data: T?
+
+    fun visit(visitor: (KeyStroke, TrieNode<T>) -> Unit)
+
+    val debugString: String
+  }
+
+  private class TrieNodeImpl<T>(val name: String, val depth: Int, override val data: T?)
+    : TrieNode<T> {
+
+    val children = lazy { mutableMapOf<KeyStroke, TrieNodeImpl<T>>() }
+
+    override fun visit(visitor: (KeyStroke, TrieNode<T>) -> Unit) {
+      if (!children.isInitialized()) return
+      children.value.forEach { visitor(it.key, it.value) }
+    }
+
+    /**
+     * Debug helpers to dump this node and its children
+     */
+    override val debugString
+      get() = buildString { dump(this) }
+
+    private fun dump(builder: StringBuilder) {
+      builder.run {
+        append("TrieNode('")
+        append(name)
+        append("'")
+        if (data != null) {
+          append(", ")
+          append(data)
+        }
+        if (children.isInitialized() && children.value.isNotEmpty()) {
+          appendLine()
+          children.value.forEach {
+            repeat(depth + 1) { append(" ") }
+            append("'")
+            append(injector.parser.toKeyNotation(it.key))
+            append("' - ")
+            it.value.dump(this)
+            if (children.value.size > 1 || depth > 0) appendLine()
+          }
+          repeat(depth) { append(" ") }
+        }
+        append(")")
+      }
+    }
+
+    override fun toString() = "TrieNode('$name', ${children.value.size} children): $data"
+  }
+
+  private val root = TrieNodeImpl<T>("", 0, null)
+
+  fun visit(visitor: (KeyStroke, TrieNode<T>) -> Unit) {
+    // Does not visit the (empty) root node
+    root.visit(visitor)
+  }
+
+  fun add(keyStrokes: List<KeyStroke>, data: T) {
+    var current = root
+    keyStrokes.forEachIndexed { i, stroke ->
+      current = current.children.value.getOrPut(stroke) {
+        val name = current.name + injector.parser.toKeyNotation(stroke)
+        TrieNodeImpl(name, current.depth + 1, if (i == keyStrokes.lastIndex) data else null)
+      }
+    }
+  }
+
+  /**
+   * Get the data for the given key sequence if it exists
+   *
+   * @return Returns null if the key sequence does not exist, or if the data at the node is empty
+   */
+  fun getData(keyStrokes: List<KeyStroke>): T? {
+    var current = root
+    keyStrokes.forEach {
+      if (!current.children.isInitialized()) return null
+      current = current.children.value[it] ?: return null
+    }
+    return current.data
+  }
+
+  /**
+   * Get the node for the given key sequence if it exists
+   *
+   * Like [getData] but will return a node even if that node's data is empty. Will return something useful in the case
+   * of a matching sequence, or a matching prefix. If it's only a matching prefix, the [TrieNode.data] value will be
+   * null.
+   */
+  fun getTrieNode(keyStrokes: List<KeyStroke>): TrieNode<T>? {
+    var current = root
+    keyStrokes.forEach {
+      if (!current.children.isInitialized()) return null
+      current = current.children.value[it] ?: return null
+    }
+    return current
+  }
+
+  override fun toString(): String {
+    val children = if (root.children.isInitialized()) {
+      "${root.children.value.size} children"
+    }
+    else {
+      "0 children (not initialized)"
+    }
+    return "KeyStrokeTrie - '$name', $children"
+  }
+}
+
+fun <T> KeyStrokeTrie<T>.add(keys: String, data: T) {
+  add(injector.parser.parseKeys(keys), data)
+}
+
+/**
+ * Returns a map containing all keystroke sequences that start with the given prefix
+ *
+ * This only returns keystroke sequences that have associated data. A keystroke sequence without data is considered a
+ * prefix and not included in the map.
+ */
+fun <T> KeyStrokeTrie<T>.getPrefixed(prefix: List<KeyStroke>): Map<List<KeyStroke>, T> {
+  fun visitor(prefix: List<KeyStroke>, map: MutableMap<List<KeyStroke>, T>) {
+    getTrieNode(prefix)?.let { node ->
+      node.data?.let { map[prefix] = it }
+      node.visit { key, value -> visitor(prefix + key, map) }
+    }
+  }
+
+  return buildMap { visitor(prefix, this) }
+}
+
+/**
+ * Returns all keystroke sequences with associated data
+ */
+fun <T> KeyStrokeTrie<T>.getAll(): Map<List<KeyStroke>, T> = getPrefixed(emptyList())
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/Nodes.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/Nodes.kt
index 1c01bf243..99009b5d4 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/Nodes.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/Nodes.kt
@@ -8,12 +8,15 @@
 
 package com.maddyhome.idea.vim.key
 
+import com.maddyhome.idea.vim.api.VimKeyGroup
 import com.maddyhome.idea.vim.api.injector
 import javax.swing.KeyStroke
 
 /**
  * COMPATIBILITY-LAYER: Moved from common package to this one
  * Please see: https://jb.gg/zo8n0r
+ *
+ * Used by idea-which-key (latest is currently 0.10.3)
  */
 
 /**
@@ -39,36 +42,28 @@ import javax.swing.KeyStroke
  * If the command is complete, it's represented as a [CommandNode]. If this character is a part of command
  *   and the user should complete the sequence, it's [CommandPartNode]
  */
-@Suppress("GrazieInspection")
-interface Node<T> {
-  val debugString: String
-  val parent: Node<T>?
-
-  val root: Node<T>
-    get() = parent?.root ?: this
-}
+@Deprecated("Use KeyStrokeTrie and VimKeyGroup.getBuiltinCommandsTrie instead")
+interface Node<T>
 
 /** Represents a complete command */
-data class CommandNode<T>(override val parent: Node<T>, val actionHolder: T, private val name: String) : Node<T> {
-  override val debugString: String
-    get() = toString()
-
-  override fun toString() = "COMMAND NODE ($name - ${actionHolder.toString()})"
+@Suppress("DEPRECATION")
+@Deprecated("Use KeyStrokeTrie and VimKeyGroup.getBuiltinCommandsTrie instead")
+data class CommandNode<T>(val actionHolder: T) : Node<T> {
+  override fun toString(): String {
+    return "COMMAND NODE (${ actionHolder.toString() })"
+  }
 }
 
-/** Represents a part of the command */
-open class CommandPartNode<T>(
-  override val parent: Node<T>?,
-  internal val name: String,
-  internal val depth: Int) : Node<T> {
-
-  val children = mutableMapOf<KeyStroke, Node<T>>()
-
-  operator fun set(stroke: KeyStroke, node: Node<T>) {
-    children[stroke] = node
-  }
-
-  operator fun get(stroke: KeyStroke): Node<T>? = children[stroke]
+/**
+ * Represents a part of the command
+ *
+ * Vim-which-key uses this to get a map of all builtin Vim actions. Sadly, there is on Vim equivalent, so we can't
+ * provide a Vim script function as an API. After retrieving with [VimKeyGroup.getKeyRoot], the node is iterated
+ */
+@Suppress("DEPRECATION")
+@Deprecated("Use KeyStrokeTrie and VimKeyGroup.getBuiltinCommandsTrie instead")
+open class CommandPartNode<T> internal constructor(private val trieNode: KeyStrokeTrie.TrieNode<T>)
+  : Node<T>, AbstractMap<KeyStroke, Node<T>>() {
 
   override fun equals(other: Any?): Boolean {
     if (this === other) return true
@@ -79,57 +74,29 @@ open class CommandPartNode<T>(
 
   override fun hashCode() = super.hashCode()
 
-  override fun toString() = "COMMAND PART NODE ($name - ${children.size} children)"
+  override fun toString(): String {
+    return """
+      COMMAND PART NODE(
+      ${entries.joinToString(separator = "\n") { "    " + injector.parser.toKeyNotation(it.key) + " - " + it.value }}
+      )
+      """.trimIndent()
+  }
 
-  override val debugString
-    get() = buildString {
-      append("COMMAND PART NODE(")
-      appendLine(name)
-      children.entries.forEach {
-        repeat(depth + 1) { append(" ") }
-        append(injector.parser.toKeyNotation(it.key))
-        append(" - ")
-        appendLine(it.value.debugString)
-      }
-      repeat(depth) { append(" ") }
-      append(")")
+  override val entries: Set<Map.Entry<KeyStroke, Node<T>>>
+    get() {
+      return buildMap {
+        trieNode.visit { key, value ->
+          val node: Node<T> = if (value.data == null) {
+            CommandPartNode<T>(value)
+          }
+          else {
+            CommandNode(value.data!!)
+          }
+          put(key, node)
+        }
+      }.entries
     }
 }
 
-/** Represents a root node for the mode */
-class RootNode<T>(name: String) : CommandPartNode<T>(null, name, 0) {
-  override val debugString: String
-    get() = "ROOT NODE ($name)\n" + super.debugString
-
-  override fun toString() = "ROOT NODE ($name - ${children.size} children)"
-}
-
-fun <T> Node<T>.addLeafs(keyStrokes: List<KeyStroke>, actionHolder: T) {
-  var node: Node<T> = this
-  val len = keyStrokes.size
-  // Add a child for each keystroke in the shortcut for this action
-  for (i in 0 until len) {
-    if (node !is CommandPartNode<*>) {
-      error("Error in tree constructing")
-    }
-    node = addNode(node as CommandPartNode<T>, actionHolder, keyStrokes[i], i == len - 1)
-  }
-}
-
-private fun <T> addNode(base: CommandPartNode<T>, actionHolder: T, key: KeyStroke, isLastInSequence: Boolean): Node<T> {
-  val existing = base[key]
-  if (existing != null) return existing
-
-  val childName = injector.parser.toKeyNotation(key)
-  val name = when (base) {
-    is RootNode -> base.name + "_" + childName
-    else -> base.name + childName
-  }
-  val newNode: Node<T> = if (isLastInSequence) {
-    CommandNode(base, actionHolder, name)
-  } else {
-    CommandPartNode(base, name, base.depth + 1)
-  }
-  base[key] = newNode
-  return newNode
-}
+@Suppress("DEPRECATION")
+internal class RootNode<T>(trieNode: KeyStrokeTrie<T>) : CommandPartNode<T>(trieNode.getTrieNode(emptyList())!!)
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/DigraphConsumer.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/DigraphConsumer.kt
index b5c5dce8d..fb1af1741 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/DigraphConsumer.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/DigraphConsumer.kt
@@ -47,12 +47,12 @@ class DigraphConsumer : KeyConsumer {
       logger.trace("Expected argument is digraph")
       if (digraphSequence.isDigraphStart(key)) {
         digraphSequence.startDigraphSequence()
-        commandBuilder.addKey(key)
+        commandBuilder.addTypedKeyStroke(key)
         return true
       }
       if (digraphSequence.isLiteralStart(key)) {
         digraphSequence.startLiteralSequence()
-        commandBuilder.addKey(key)
+        commandBuilder.addTypedKeyStroke(key)
         return true
       }
     }
@@ -63,7 +63,7 @@ class DigraphConsumer : KeyConsumer {
       is DigraphResult.Handled -> {
         keyProcessResultBuilder.addExecutionStep { lambdaKeyState, _, _ ->
           keyHandler.setPromptCharacterEx(res.promptCharacter)
-          lambdaKeyState.commandBuilder.addKey(key)
+          lambdaKeyState.commandBuilder.addTypedKeyStroke(key)
         }
         return true
       }
@@ -87,7 +87,7 @@ class DigraphConsumer : KeyConsumer {
         }
         val stroke = res.stroke ?: return false
         keyProcessResultBuilder.addExecutionStep { lambdaKeyState, lambdaEditorState, lambdaContext ->
-          lambdaKeyState.commandBuilder.addKey(key)
+          lambdaKeyState.commandBuilder.addTypedKeyStroke(key)
           keyHandler.handleKey(lambdaEditorState, stroke, lambdaContext, lambdaKeyState)
         }
         return true
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/RegisterConsumer.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/RegisterConsumer.kt
index 34fe4c4f8..21a91264a 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/RegisterConsumer.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/key/consumers/RegisterConsumer.kt
@@ -35,7 +35,7 @@ class RegisterConsumer : KeyConsumer {
     if (!commandBuilder.isRegisterPending) return false
 
     logger.trace("Pending mode.")
-    commandBuilder.addKey(key)
+    commandBuilder.addTypedKeyStroke(key)
 
     val chKey: Char = if (key.keyChar == KeyEvent.CHAR_UNDEFINED) 0.toChar() else key.keyChar
     handleSelectRegister(chKey, keyProcessResultBuilder)
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/state/KeyHandlerState.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/state/KeyHandlerState.kt
index 41b2c0a18..b78b5d637 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/state/KeyHandlerState.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/state/KeyHandlerState.kt
@@ -23,7 +23,7 @@ data class KeyHandlerState(
   val editorCommandBuilder: CommandBuilder,
   var commandLineCommandBuilder: CommandBuilder?,
 ): Cloneable {
-  constructor() : this(MappingState(), DigraphSequence(), CommandBuilder(injector.keyGroup.getKeyRoot(MappingMode.NORMAL)), null)
+  constructor() : this(MappingState(), DigraphSequence(), CommandBuilder(injector.keyGroup.getBuiltinCommandsTrie(MappingMode.NORMAL)), null)
 
   companion object {
     private val logger = vimLogger<KeyHandlerState>()
@@ -57,7 +57,7 @@ data class KeyHandlerState(
     // argument with the search string. The command has a count of `6`. And a command such as `3:p` becomes an action to
     // process Ex entry with an argument of `.,.+2p` and a count of 3. The count is ignored by this action.
     // Note that we use the calculated count. In Vim, `2"a3"b:` transforms to `:.,.+5`, which is the same behaviour
-    commandLineCommandBuilder = CommandBuilder(injector.keyGroup.getKeyRoot(MappingMode.CMD_LINE),
+    commandLineCommandBuilder = CommandBuilder(injector.keyGroup.getBuiltinCommandsTrie(MappingMode.CMD_LINE),
       editorCommandBuilder.calculateCount0Snapshot())
   }
 
@@ -68,7 +68,7 @@ data class KeyHandlerState(
   fun partialReset(mode: Mode) {
     logger.trace("entered partialReset. mode: $mode")
     mappingState.resetMappingSequence()
-    commandBuilder.resetCommandTrieRootNode(injector.keyGroup.getKeyRoot(mode.toMappingMode()))
+    commandBuilder.resetCommandTrie(injector.keyGroup.getBuiltinCommandsTrie(mode.toMappingMode()))
   }
 
   fun reset(mode: Mode) {
@@ -77,7 +77,7 @@ data class KeyHandlerState(
     mappingState.resetMappingSequence()
 
     commandLineCommandBuilder = null
-    editorCommandBuilder.resetAll(injector.keyGroup.getKeyRoot(mode.toMappingMode()))
+    editorCommandBuilder.resetAll(injector.keyGroup.getBuiltinCommandsTrie(mode.toMappingMode()))
   }
 
   public override fun clone(): KeyHandlerState {