Repository: vscode-neovim/vscode-neovim
Pull request #2001 (2024-05-23 04:45:02)

feat(cmdline): integrate with native neovim completions
This is my favourite type of PR, where bugs are fixed simply by deleting old crusty code :smile: 

- [x] Forward all cmdline bindings to nvim
- [x] Use the quickpick simply as a "render view" to nvim
- [x] Support wildmode
- [x] Support official nvim bindings for Up/Down/C-p/C-n/Tab (with clever behavior of history vs suggestions) 
- [x] Fix jitter when setting vscode selected item
- [x] Fix edge cases when typing/selecting quickly
- [x] Fix inccommand module
- [x] Make sure deleted code/timers aren't a regression


https://github.com/vscode-neovim/vscode-neovim/assets/16546293/e3217f41-83a5-4138-ba19-f2d7533daf02

Note that completions have to be triggered by wildchar, just like in nvim.


xiyaowong: Can we merge cmdline_controller and cmdline_text into cmdline_manager?

ollien: > Can we merge cmdline_controller and cmdline_text into cmdline_manager?

`cmdline_text` is separate to facilitate a unit test. I'd love it if we didn't have to split modules to do, but alas. 
theol0403: Really good progress made. Code refactored even more. Stupidly simple now, with very basic states. Works amazingly. Only last challenge is dealing with race conditions when rapidly selecting completions. 
theol0403: Fixed it! Nothing like a good night's sleep & a walk. 

Patch:
diff --git a/README.md b/README.md
index 361fac0..2e5aa8b 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,6 @@ mode and editor commands, making the best use of both editors.
     -   [Troubleshooting](#troubleshooting)
     -   [Composite escape keys](#composite-escape-keys)
     -   [Jumplist](#jumplist)
-    -   [Wildmenu completion](#wildmenu-completion)
     -   [Multiple cursors](#multiple-cursors)
     -   [Remote development](#remote-development)
 -   [⚡️ API](#%EF%B8%8F-api)
@@ -256,13 +255,6 @@ definition, etc) navigable through the jumplist.
 Make sure to bind to `workbench.action.navigateBack` / `workbench.action.navigateForward` if you're using custom
 mappings. Marks (both upper & lowercased) should work fine.
 
-### Wildmenu completion
-
-Command menu has the wildmenu completion on type. The completion options appear after 1.5s (to not bother you when you
-write `:w` or `:noh`). <kbd>Up</kbd>/<kbd>Down</kbd> selects the option and <kbd>Tab</kbd> accepts it. See the gif:
-
-![wildmenu](/images/wildmenu.gif)
-
 ### Multiple cursors
 
 Multiple cursors work in:
diff --git a/package.json b/package.json
index 6a49cf8..714be9f 100644
--- a/package.json
+++ b/package.json
@@ -73,19 +73,19 @@
                     "type": "string",
                     "default": "",
                     "title": "Custom init.vim path on Windows",
-                    "markdownDescription": "Path to alternative `init.vim` file on Windows, equivalent to Nvim startup option `-u`. If `useWSL` is enabled, the value of the `neovimInitVimPaths.linux` setting will be used to find the init.vim file instead. You can also use `exists('g:vscode')` in your `init.vim` to check if Nvim is running in vscode."
+                    "markdownDescription": "Path to alternative `init.vim` file on Windows, equivalent to Nvim startup option `-u`. If `useWSL` is enabled, the value of the `neovimInitVimPaths.linux` setting will be used to find the init.vim file instead. You can also use `exists('g:vscode')` in your `init.vim` (or `vim.g.vscode` in `init.lua`) to check if Nvim is running in vscode."
                 },
                 "vscode-neovim.neovimInitVimPaths.darwin": {
                     "type": "string",
                     "default": "",
                     "title": "Custom init.vim path on macOS",
-                    "markdownDescription": "Path to alternative `init.vim` file on macOS, equivalent to Nvim startup option `-u`. You can also use `exists('g:vscode')` in your `init.vim` to check if Nvim is running in vscode."
+                    "markdownDescription": "Path to alternative `init.vim` file on macOS, equivalent to Nvim startup option `-u`. You can also use `exists('g:vscode')` in your `init.vim` (or `vim.g.vscode` in `init.lua`) to check if Nvim is running in vscode."
                 },
                 "vscode-neovim.neovimInitVimPaths.linux": {
                     "type": "string",
                     "default": "",
                     "title": "Custom init.vim path on Linux",
-                    "markdownDescription": "Path to alternative `init.vim` file on Linux or default WSL distro, equivalent to Nvim startup option `-u`. If the `useWSL` setting is checked, this path will be used to find `init.vim` in Windows Subsystem for Linux. You can also use `exists('g:vscode')` in your `init.vim` to check if Nvim is running in vscode."
+                    "markdownDescription": "Path to alternative `init.vim` file on Linux or default WSL distro, equivalent to Nvim startup option `-u`. If the `useWSL` setting is checked, this path will be used to find `init.vim` in Windows Subsystem for Linux. You can also use `exists('g:vscode')` in your `init.vim` (or `vim.g.vscode` in `init.lua`) to check if Nvim is running in vscode."
                 },
                 "vscode-neovim.afterInitConfig": {
                     "type": "array",
@@ -112,12 +112,6 @@
                     "title": "Neovim width",
                     "description": "Neovim viewport width. Increase if you're working with long lines and have horizontal scroll problems. Larger values will affect neovim performance"
                 },
-                "vscode-neovim.completionDelay": {
-                    "type": "number",
-                    "default": 1000,
-                    "title": "Completion Delay",
-                    "description": "The delay in ms until the wildmenu completions are shown. This is to not bother you when you write :w or :noh."
-                },
                 "vscode-neovim.neovimViewportHeightExtend": {
                     "type": "number",
                     "default": 1,
@@ -995,6 +989,42 @@
                 "when": "editorTextFocus && neovim.init && neovim.mode == insert && neovim.ctrlKeysInsert.delete && editorLangId not in neovim.editorLangIdExclusions",
                 "args": "<C-Del>"
             },
+            {
+                "command": "vscode-neovim.send-cmdline",
+                "key": "tab",
+                "when": "neovim.init && neovim.mode == cmdline",
+                "args": "<tab>"
+            },
+            {
+                "command": "vscode-neovim.send-cmdline",
+                "key": "shift+tab",
+                "when": "neovim.init && neovim.mode == cmdline",
+                "args": "<S-tab>"
+            },
+            {
+                "command": "vscode-neovim.send-cmdline",
+                "key": "down",
+                "when": "neovim.init && neovim.mode == cmdline",
+                "args": "<down>"
+            },
+            {
+                "command": "vscode-neovim.send-cmdline",
+                "key": "up",
+                "when": "neovim.init && neovim.mode == cmdline",
+                "args": "<up>"
+            },
+            {
+                "command": "vscode-neovim.send-cmdline",
+                "key": "shift+down",
+                "when": "neovim.init && neovim.mode == cmdline",
+                "args": "<S-down>"
+            },
+            {
+                "command": "vscode-neovim.send-cmdline",
+                "key": "shift+up",
+                "when": "neovim.init && neovim.mode == cmdline",
+                "args": "<S-up>"
+            },
             {
                 "command": "vscode-neovim.send-cmdline",
                 "key": "ctrl+h",
@@ -1017,13 +1047,13 @@
                 "command": "vscode-neovim.send-cmdline",
                 "key": "ctrl+n",
                 "when": "neovim.init && neovim.mode == cmdline",
-                "args": "<Down>"
+                "args": "<C-n>"
             },
             {
                 "command": "vscode-neovim.send-cmdline",
                 "key": "ctrl+p",
                 "when": "neovim.init && neovim.mode == cmdline",
-                "args": "<Up>"
+                "args": "<C-p>"
             },
             {
                 "command": "vscode-neovim.send-cmdline",
@@ -1445,11 +1475,6 @@
                 "when": "neovim.init && neovim.mode == cmdline",
                 "args": "<C-\\>e"
             },
-            {
-                "command": "vscode-neovim.complete-selection-cmdline",
-                "key": "tab",
-                "when": "neovim.init && neovim.mode == cmdline"
-            },
             {
                 "key": "j",
                 "command": "list.focusDown",
@@ -1643,12 +1668,12 @@
             {
                 "command": "workbench.action.quickOpenSelectNext",
                 "key": "ctrl+n",
-                "when": "inQuickOpen && (neovim.mode != cmdline || neovim.wildMenuVisible)"
+                "when": "inQuickOpen && neovim.mode != cmdline"
             },
             {
                 "command": "workbench.action.quickOpenSelectPrevious",
                 "key": "ctrl+p",
-                "when": "inQuickOpen && (neovim.mode != cmdline || neovim.wildMenuVisible)"
+                "when": "inQuickOpen && neovim.mode != cmdline"
             },
             {
                 "key": "ctrl+n",
diff --git a/runtime/lua/vscode-neovim/force-options.lua b/runtime/lua/vscode-neovim/force-options.lua
index 74c06b9..025afab 100644
--- a/runtime/lua/vscode-neovim/force-options.lua
+++ b/runtime/lua/vscode-neovim/force-options.lua
@@ -3,8 +3,8 @@ local api = vim.api
 
 -- ------------------------- forced global options ------------------------- --
 vim.opt.cmdheight = 1
-vim.opt.wildmode = "list"
-vim.cmd([[set wildchar=<C-e>]])
+vim.opt.wildmode = "longest:full,full"
+vim.cmd([[set wildchar=<Tab>]])
 vim.opt.mouse = "a"
 
 vim.opt.backup = false
diff --git a/runtime/modules/inccommand.lua b/runtime/modules/inccommand.lua
index 8412d1f..b44c9f6 100644
--- a/runtime/modules/inccommand.lua
+++ b/runtime/modules/inccommand.lua
@@ -2,7 +2,7 @@
 
 local api, fn = vim.api, vim.fn
 
-local function get_items()
+local function get_lines()
   -- Find preview buffer --
 
   local preview_buf
@@ -38,17 +38,19 @@ local function refresh_completion()
   then
     return
   end
-  local items = get_items()
-  if items then
-    vim.rpcnotify(vim.g.vscode_channel, "redraw", { "wildmenu_show", { items } })
+  local lines = get_lines()
+  if lines then
+    local items = {}
+    for _, line in ipairs(lines) do
+      table.insert(items, { line, "", "", "" })
+    end
+    vim.rpcnotify(vim.g.vscode_channel, "redraw", { "popupmenu_show", { items, -1, 0, 0, -1 } })
   end
 end
 
 api.nvim_create_autocmd({ "CmdlineChanged" }, {
   group = api.nvim_create_augroup("vscode.inccommand", {}),
   callback = function()
-    refresh_completion()
-    -- Sometimes CmdlineChanged not correctly triggered
-    vim.defer_fn(refresh_completion, 500)
+    vim.schedule(refresh_completion)
   end,
 })
diff --git a/scripts/keybindings/4_cmdline.cjs b/scripts/keybindings/4_cmdline.cjs
index 2a1570f..1b04c0f 100644
--- a/scripts/keybindings/4_cmdline.cjs
+++ b/scripts/keybindings/4_cmdline.cjs
@@ -5,11 +5,13 @@ const [_add, keybinds] = addKeybinds();
 const add = (key, args, cmd = "vscode-neovim.send-cmdline") =>
     _add(key, "neovim.init && neovim.mode == cmdline", args, cmd);
 
+// special keys
+["tab", "shift+tab", "down", "up", "shift+down", "shift+up"].forEach((key) => add(key, key2arg(key)));
+
 // ctrl keys
 [..."hwunplgtmj", "up", "down"].forEach((k) => {
     let key = `ctrl+${k}`;
     let args = key2arg(key);
-    let cmd = "vscode-neovim.send-cmdline";
 
     switch (k) {
         case "b": {
@@ -22,17 +24,9 @@ const add = (key, args, cmd = "vscode-neovim.send-cmdline") =>
             cmd = "cursorEnd";
             break;
         }
-        case "n": {
-            args = "<Down>";
-            break;
-        }
-        case "p": {
-            args = "<Up>";
-            break;
-        }
     }
 
-    add(key, args, cmd);
+    add(key, args);
 });
 
 // ctrl+r number
@@ -79,6 +73,5 @@ add("ctrl+r -", "<C-r>-");
 add("ctrl+r .", "<C-r>.");
 add("ctrl+r =", "<C-r>=");
 add("ctrl+\\ e", "<C-\\>e");
-add("tab", null, "vscode-neovim.complete-selection-cmdline");
 
 module.exports = keybinds;
diff --git a/scripts/keybindings/5_widgets.cjs b/scripts/keybindings/5_widgets.cjs
index 26a6592..ae4a520 100644
--- a/scripts/keybindings/5_widgets.cjs
+++ b/scripts/keybindings/5_widgets.cjs
@@ -192,12 +192,12 @@ const keybinds = [
     {
         command: "workbench.action.quickOpenSelectNext",
         key: "ctrl+n",
-        when: "inQuickOpen && (neovim.mode != cmdline || neovim.wildMenuVisible)",
+        when: "inQuickOpen && neovim.mode != cmdline",
     },
     {
         command: "workbench.action.quickOpenSelectPrevious",
         key: "ctrl+p",
-        when: "inQuickOpen && (neovim.mode != cmdline || neovim.wildMenuVisible)",
+        when: "inQuickOpen && neovim.mode != cmdline",
     },
     {
         key: "ctrl+n",
diff --git a/src/cmdline/cmdline_controller.ts b/src/cmdline/cmdline_controller.ts
deleted file mode 100644
index 08b6309..0000000
--- a/src/cmdline/cmdline_controller.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import { NeovimClient } from "neovim";
-import { Disposable, QuickPick, QuickPickItem, commands, window } from "vscode";
-
-import { GlyphChars } from "../constants";
-import { createLogger } from "../logger";
-import { VSCodeContext, disposeAll } from "../utils";
-
-import { commandInputIsCompletable } from "./cmdline_text";
-
-const logger = createLogger("CmdLine");
-
-export interface CommandLineCallbacks {
-    onAccepted(): void;
-    onChanged(str: string, complete: boolean): void;
-    onCanceled(): void;
-}
-
-export class CommandLineController implements Disposable {
-    public isDisplayed = false;
-
-    private input: QuickPick<QuickPickItem>;
-
-    private disposables: Disposable[] = [];
-
-    private completionAllowed = false;
-
-    private completionTimer?: NodeJS.Timeout;
-
-    private completionItems: QuickPickItem[] = [];
-
-    private mode = "";
-
-    private ignoreHideEvent = false;
-
-    private redrawExpected = false; // whether to accept incoming cmdline_show
-
-    private updatedFromNvim = false; // whether to replace nvim cmdline with new content
-
-    private wildMenuVisible = false; // indicates if the wildmenu is visible
-
-    public constructor(
-        private client: NeovimClient,
-        private callbacks: CommandLineCallbacks,
-        private completionDelay: number,
-    ) {
-        this.callbacks = callbacks;
-        this.input = window.createQuickPick();
-        this.input.ignoreFocusOut = true;
-        this.disposables.push(
-            this.input,
-            this.input.onDidAccept(this.onAccept),
-            this.input.onDidChangeValue(this.onChange),
-            this.input.onDidHide(this.onHide),
-            commands.registerCommand("vscode-neovim.commit-cmdline", this.onAccept),
-            commands.registerCommand("vscode-neovim.complete-selection-cmdline", this.acceptSelection),
-            commands.registerCommand("vscode-neovim.send-cmdline", this.sendRedraw),
-            commands.registerCommand("vscode-neovim.test-cmdline", this.testCmdline),
-        );
-    }
-
-    public show(content = "", mode: string, prompt = ""): void {
-        if (!this.isDisplayed) {
-            this.input.value = "";
-            this.isDisplayed = true;
-            this.mode = mode;
-            this.input.title = prompt || this.getTitle(mode);
-            this.input.show();
-            // display content after cmdline appears - otherwise it will be preselected that is not good when calling from visual mode
-            if (content) {
-                this.input.value = content;
-            }
-            // Display completions only after a configurable amount of time (1.5s default), so it won't bother for simple things like ":w" or ":noh"
-            this.completionAllowed = false;
-            this.completionItems = [];
-            this.input.items = [];
-        } else {
-            const newTitle = prompt || this.getTitle(mode);
-            if (newTitle !== this.input.title) {
-                this.input.title = newTitle;
-            }
-            // only redraw if triggered from a known keybinding. Otherwise, delayed nvim
-            // cmdline_show could replace fast typing. Also ignores completion artifacts.
-            if (this.redrawExpected && this.input.value !== content) {
-                this.input.value = content;
-                this.redrawExpected = false;
-                this.updatedFromNvim = true;
-            } else {
-                logger.debug(`Ignoring cmdline_show because no redraw expected: ${content}`);
-            }
-        }
-        if (!this.completionAllowed && !this.completionTimer) {
-            this.completionTimer = setTimeout(this.processCompletionTimer, this.completionDelay);
-        }
-    }
-
-    public setCompletionItems(items: string[]): void {
-        this.completionItems = items.map((i) => ({ label: i, alwaysShow: true }));
-        if (this.completionAllowed) {
-            this.input.items = this.completionItems;
-            // When deleting the input text to empty, the wildmenu displays all the candidate commands.
-            // However, the wildmenu is not actually useful in this situation, so it is forced to be invisible.
-            // This allows Ctrl+n and Ctrl+p to input normally(navigating history) instead of selecting candidates in quickOpen.
-            const wildMenuVisible = this.input.value.length > 0 && this.completionItems.length > 0;
-            if (this.wildMenuVisible !== wildMenuVisible) {
-                this.wildMenuVisible = wildMenuVisible;
-                VSCodeContext.set("neovim.wildMenuVisible", this.wildMenuVisible);
-            }
-        }
-    }
-
-    public cancel(ignoreHideEvent = false): void {
-        this.ignoreHideEvent = ignoreHideEvent;
-        this.input.hide();
-    }
-
-    public dispose(): void {
-        disposeAll(this.disposables);
-    }
-
-    private onAccept = (): void => {
-        if (!this.isDisplayed) {
-            return;
-        }
-        this.callbacks.onChanged(this.input.value, false);
-        this.callbacks.onAccepted();
-    };
-
-    private onChange = (e: string): void => {
-        if (!this.isDisplayed) {
-            return;
-        }
-        const mode = this.mode;
-        if (mode === ":" && (e.charAt(0) === "?" || e.charAt(0) === "/") && this.input.items.length) {
-            this.input.items = [];
-            this.completionItems = [];
-        }
-        const useCompletion = mode === ":" && commandInputIsCompletable(e);
-        if (!useCompletion) {
-            this.cancelCompletions();
-        }
-        if (this.updatedFromNvim) {
-            this.updatedFromNvim = false;
-            logger.debug(`Skipped updating cmdline because change originates from nvim: ${e}`);
-        } else {
-            logger.debug(`Sending cmdline to nvim: ${e}`);
-            this.callbacks.onChanged(e, useCompletion);
-        }
-    };
-
-    private onHide = (): void => {
-        if (!this.isDisplayed) {
-            return;
-        }
-        this.clean();
-        if (this.ignoreHideEvent) {
-            this.ignoreHideEvent = false;
-            return;
-        }
-        this.callbacks.onCanceled();
-    };
-
-    private processCompletionTimer = (): void => {
-        this.completionAllowed = true;
-        if (this.isDisplayed && this.completionItems.length) {
-            this.input.items = this.completionItems;
-        }
-        this.completionTimer = undefined;
-    };
-
-    private cancelCompletions = (): void => {
-        if (this.completionTimer) {
-            clearTimeout(this.completionTimer);
-            this.completionTimer = undefined;
-        }
-        this.input.items = [];
-    };
-
-    private getTitle(modeOrPrompt: string): string {
-        switch (modeOrPrompt) {
-            case "/":
-                return `${GlyphChars.SEARCH_FORWARD} Forward Search:`;
-            case "?":
-                return `${GlyphChars.SEARCH_BACKWARD} Backward Search:`;
-            case ":":
-                return `${GlyphChars.COMMAND} VIM Command Line:`;
-            default:
-                return modeOrPrompt;
-        }
-    }
-
-    private clean(): void {
-        if (this.completionTimer) {
-            clearTimeout(this.completionTimer);
-        }
-        this.isDisplayed = false;
-        this.input.value = "";
-        this.input.title = "";
-        this.mode = "";
-        this.completionAllowed = false;
-        this.input.items = [];
-        this.completionItems = [];
-    }
-
-    private acceptSelection = (): void => {
-        if (!this.isDisplayed) {
-            return;
-        }
-        const sel = this.input.activeItems[0];
-        if (!sel) {
-            return;
-        }
-        const selected = sel.label;
-        let lastInputEl = this.input.value;
-        // if there is more than one command, get the last one (command, path or space delimited)
-        const symbolCheck = /[\s/\\!@#$:<'>%]/g;
-        if (symbolCheck.test(lastInputEl)) {
-            lastInputEl = lastInputEl.split(symbolCheck).pop()!;
-        }
-        const isSubstring = selected.search(lastInputEl);
-        if ((lastInputEl && isSubstring !== -1) || lastInputEl === "~") {
-            this.input.value = this.input.value.replace(lastInputEl, selected);
-        } else {
-            this.input.value += selected;
-        }
-        this.onChange(this.input.value);
-    };
-
-    // use this function for keybindings in command line that cause content to update
-    private sendRedraw = (keys: string): void => {
-        this.redrawExpected = true;
-        this.client.input(keys);
-    };
-
-    private testCmdline = (e: string): void => {
-        this.input.value += e;
-    };
-}
diff --git a/src/cmdline_manager.ts b/src/cmdline_manager.ts
index 8ef5d46..c888815 100644
--- a/src/cmdline_manager.ts
+++ b/src/cmdline_manager.ts
@@ -1,128 +1,209 @@
-import { Disposable } from "vscode";
+import { Disposable, QuickInputButton, QuickPick, QuickPickItem, ThemeIcon, commands, window } from "vscode";
 
-import { CommandLineController } from "./cmdline/cmdline_controller";
-import { config } from "./config";
 import { EventBusData, eventBus } from "./eventBus";
 import { MainController } from "./main_controller";
 import { disposeAll } from "./utils";
-import { calculateInputAfterTextChange } from "./cmdline/cmdline_text";
+import { createLogger } from "./logger";
+import { calculateInputAfterTextChange } from "./utils/cmdline_text";
+import { GlyphChars } from "./constants";
+
+const logger = createLogger("CmdLine", false);
+
+class CmdlineState {
+    // The last text typed in the UI, used to calculate changes
+    lastTypedText: string = "";
+
+    // On suggestion selection, we don't want to send <CR> to nvim, so we ignore the accept event.
+    ignoreAcceptEvent = false;
+    // On cmdline_hide, we close the quickpick. This flag is used to ignore that event so we don't send an <Esc> to nvim.
+    ignoreHideEvent = false;
+
+    // When we type, we send updates to nvim. We want to ignore updates coming from nvim, because it may interfere with typing.
+    // However, bindings are expected to cause the cmdline content to change, so we use this flag to listen to those updates.
+    redrawExpected = true;
+
+    // When updates come from nvim, we write to the input field.
+    // We don't want to send those updates back to nvim, so we use this counter to keep track of the number of onChange to ignore.
+    pendingNvimUpdates = 0;
+}
 
 export class CommandLineManager implements Disposable {
     private disposables: Disposable[] = [];
-    /**
-     * Simple command line UI
-     */
-    private commandLine?: CommandLineController;
-    /**
-     * Commandline timeout
-     */
-    private cmdlineTimer?: NodeJS.Timeout;
-
-    /**
-     * The last text typed in the UI, used to calculate changes
-     */
-    private lastTypedText: string = "";
-
-    private get client() {
-        return this.main.client;
-    }
+
+    private input: QuickPick<QuickPickItem>;
+    private state = new CmdlineState();
 
     public constructor(private main: MainController) {
         eventBus.on("redraw", this.handleRedraw, this, this.disposables);
+        this.input = window.createQuickPick();
+        (this.input as any).sortByLabel = false;
+        this.input.ignoreFocusOut = true;
+        this.input.buttons = [
+            {
+                iconPath: new ThemeIcon("close"),
+                tooltip: "Cancel",
+            },
+            {
+                iconPath: new ThemeIcon("check"),
+                tooltip: "Accept",
+            },
+        ];
+        this.disposables.push(
+            this.input,
+            this.input.onDidAccept(this.onAccept),
+            this.input.onDidChangeValue(this.onChange),
+            this.input.onDidHide(this.onHide),
+            this.input.onDidChangeSelection(this.onSelection),
+            this.input.onDidTriggerButton(this.onButton),
+            commands.registerCommand("vscode-neovim.commit-cmdline", this.onAccept),
+            commands.registerCommand("vscode-neovim.send-cmdline", this.sendRedraw),
+            commands.registerCommand("vscode-neovim.test-cmdline", this.testCmdline),
+        );
     }
 
     public dispose() {
-        this.commandLine?.dispose();
         disposeAll(this.disposables);
     }
 
+    private reset() {
+        this.state = new CmdlineState();
+        this.input.value = "";
+        this.input.title = "";
+        this.input.items = [];
+        this.input.activeItems = [];
+    }
+
     private handleRedraw({ name, args }: EventBusData<"redraw">) {
         switch (name) {
             case "cmdline_show": {
                 const [content, _pos, firstc, prompt, _indent, _level] = args[0];
                 const allContent = content.map(([, str]) => str).join("");
-                // !note: neovim can send cmdline_hide followed by cmdline_show events
-                // !since quickpick can be destroyed slightly at later time after handling cmdline_hide we want to create new command line
-                // !controller and input for every visible cmdline_show event
-                // !otherwise we may hit cmdline_show when it's being hidden
-                // as alternative, it's possible to process batch and determine if we need show/hide or just redraw the command_line
-                // but this won't handle the case when cmdline_show comes in next flush batch (is it possible?)
-                // btw, easier to just recreate whole command line (and quickpick inside)
-                if (this.cmdlineTimer) {
-                    clearTimeout(this.cmdlineTimer);
-                    this.cmdlineTimer = undefined;
-                    this.showCmd(allContent, firstc, prompt);
-                } else {
-                    // if there is initial content and it's not currently displayed then it may come
-                    // from some mapping. to prevent bad UI commandline transition we delay cmdline appearing here
-                    if (allContent !== "" && allContent !== "'<,'>" && !this.commandLine) {
-                        this.cmdlineTimer = setTimeout(() => this.showCmdOnTimer(allContent, firstc, prompt), 200);
-                    } else {
-                        this.showCmd(allContent, firstc, prompt);
-                    }
-                }
+                logger.debug(`cmdline_show: "${content}"`);
+                this.cmdlineShow(allContent, firstc, prompt);
+                break;
+            }
+            case "popupmenu_show": {
+                const [items, selected, _row, _col, _grid] = args[0];
+                logger.debug(`popupmenu_show: ${items.length} items`);
+                this.input.items = items.map((item) => ({ label: item[0], alwaysShow: true }));
+                this.setSelection(selected);
                 break;
             }
-            case "wildmenu_show": {
-                this.commandLine?.setCompletionItems(args[0][0]);
+            case "popupmenu_select": {
+                const [selected] = args[0];
+                logger.debug(`popupmenu_select: "${selected}"`);
+                this.setSelection(selected);
                 break;
             }
-            case "wildmenu_hide": {
-                this.commandLine?.setCompletionItems([]);
+            case "popupmenu_hide": {
+                logger.debug(`popupmenu_hide`);
+                this.input.items = [];
                 break;
             }
             case "cmdline_hide": {
-                if (this.cmdlineTimer) {
-                    clearTimeout(this.cmdlineTimer);
-                    this.cmdlineTimer = undefined;
-                } else if (this.commandLine) {
-                    this.commandLine.cancel(true);
-                    this.commandLine.dispose();
-                    this.commandLine = undefined;
-                }
+                logger.debug(`cmdline_hide`);
+                this.state.ignoreHideEvent = true;
+                this.input.hide();
                 break;
             }
         }
     }
 
-    private showCmd = (content: string, firstc: string, prompt: string): void => {
-        this.lastTypedText = content;
-
-        if (!this.commandLine) {
-            this.commandLine = new CommandLineController(
-                this.client,
-                {
-                    onAccepted: this.onCmdAccept,
-                    onCanceled: this.onCmdCancel,
-                    onChanged: this.onCmdChange,
-                },
-                config.completionDelay,
-            );
+    private cmdlineShow = (content: string, firstc: string, prompt: string): void => {
+        this.state.lastTypedText = content;
+        this.input.title = prompt || this.getTitle(firstc);
+        // only redraw if triggered from a known keybinding. Otherwise, delayed nvim cmdline_show could replace fast typing.
+        if (!this.state.redrawExpected) {
+            logger.debug(`cmdline_show: ignoring cmdline_show because no redraw expected: "${content}"`);
+            return;
+        }
+        this.state.redrawExpected = false;
+        this.input.show();
+        if (this.input.value !== content) {
+            this.state.pendingNvimUpdates++;
+            const activeItems = this.input.activeItems; // backup selections
+            this.input.value = content; // update content
+            this.input.activeItems = activeItems; // restore selections
         }
-        this.commandLine.show(content, firstc, prompt);
     };
 
-    private showCmdOnTimer = (initialContent: string, firstc: string, prompt: string): void => {
-        this.showCmd(initialContent, firstc, prompt);
-        this.cmdlineTimer = undefined;
+    private setSelection = (index: number): void => {
+        if (index === -1) {
+            this.input.activeItems = [];
+        } else {
+            this.input.activeItems = [this.input.items[index]];
+        }
     };
 
-    private onCmdChange = async (text: string, complete: boolean): Promise<void> => {
-        let toType = calculateInputAfterTextChange(this.lastTypedText, text);
-        this.lastTypedText = text;
+    private onAccept = async (): Promise<void> => {
+        if (!this.state.ignoreAcceptEvent) {
+            await this.main.client.input("<CR>");
+        }
+        this.state.ignoreAcceptEvent = false;
+    };
+
+    private onChange = async (text: string): Promise<void> => {
+        if (this.state.pendingNvimUpdates) {
+            this.state.pendingNvimUpdates = Math.max(0, this.state.pendingNvimUpdates - 1);
+            logger.debug(
+                `onChange: skip updating cmdline because change originates from nvim: "${this.state.lastTypedText}"`,
+            );
+            return;
+        }
+        const toType = calculateInputAfterTextChange(this.state.lastTypedText, text);
+        logger.debug(`onChange: sending cmdline to nvim: "${this.state.lastTypedText}" + "${toType}" -> "${text}"`);
+        await this.main.client.input(toType);
+        this.state.lastTypedText = text;
+    };
+
+    private onHide = async (): Promise<void> => {
+        this.reset();
+        if (!this.state.ignoreHideEvent) {
+            await this.main.client.input("<Esc>");
+        }
+        this.state.ignoreHideEvent = false;
+    };
 
-        if (complete) {
-            toType += "<C-e>";
+    private onSelection = async (e: readonly QuickPickItem[]): Promise<void> => {
+        if (e.length === 0) {
+            return;
         }
+        logger.debug(`onSelection: "${e[0].label}"`);
+        this.state.ignoreAcceptEvent = true;
+        this.state.redrawExpected = true;
+        const index = this.input.items.indexOf(e[0]);
+        await this.main.client.request("nvim_select_popupmenu_item", [index, false, false, {}]);
+    };
 
-        await this.client.input(toType);
+    private onButton = async (button: QuickInputButton): Promise<void> => {
+        if (button.tooltip === "Cancel") {
+            this.input.hide();
+        } else if (button.tooltip === "Accept") {
+            await this.onAccept();
+        }
     };
 
-    private onCmdCancel = async (): Promise<void> => {
-        await this.client.input("<Esc>");
+    // use this function for keybindings in command line that cause content to update
+    private sendRedraw = (keys: string): void => {
+        logger.debug(`sendRedraw: "${keys}"`);
+        this.state.redrawExpected = true;
+        this.main.client.input(keys);
     };
 
-    private onCmdAccept = (): void => {
-        this.client.input("<CR>");
+    private testCmdline = (e: string): void => {
+        this.input.value += e;
     };
+
+    private getTitle(modeOrPrompt: string): string {
+        switch (modeOrPrompt) {
+            case "/":
+                return `${GlyphChars.SEARCH_FORWARD} Forward Search:`;
+            case "?":
+                return `${GlyphChars.SEARCH_BACKWARD} Backward Search:`;
+            case ":":
+                return `${GlyphChars.COMMAND} VIM Command Line:`;
+            default:
+                return modeOrPrompt;
+        }
+    }
 }
diff --git a/src/config.ts b/src/config.ts
index 5528b4e..f737a79 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -143,9 +143,6 @@ export class Config implements Disposable {
     get neovimViewportWidth() {
         return this.neovimWidth;
     }
-    get completionDelay() {
-        return this.cfg.get("completionDelay", 1000);
-    }
     get neovimViewportHeightExtend() {
         return this.cfg.get("neovimViewportHeightExtend", 1);
     }
diff --git a/src/eventBus.ts b/src/eventBus.ts
index c588b15..ab4317f 100644
--- a/src/eventBus.ts
+++ b/src/eventBus.ts
@@ -94,8 +94,9 @@ type RedrawEventArgs =
     | IRedrawEventArg<"mouse_on">
     // ["mouse_off"]
     | IRedrawEventArg<"mouse_off">
-    | IRedrawEventArg<"wildmenu_show", [string[]]>
-    | IRedrawEventArg<"wildmenu_hide">;
+    | IRedrawEventArg<"popupmenu_show", [[string, string, string, string][], number, number, number, number]>
+    | IRedrawEventArg<"popupmenu_select", [number]>
+    | IRedrawEventArg<"popupmenu_hide">;
 // #endregion
 
 interface BufferInfo {
diff --git a/src/main_controller.ts b/src/main_controller.ts
index f3c90c8..e4b29c2 100644
--- a/src/main_controller.ts
+++ b/src/main_controller.ts
@@ -149,7 +149,6 @@ export class MainController implements vscode.Disposable {
             ext_multigrid: true,
             ext_popupmenu: true,
             ext_tabline: true,
-            ext_wildmenu: true,
         });
 
         await this.bufferManager.forceSyncLayout();
diff --git a/src/cmdline/cmdline_text.ts b/src/utils/cmdline_text.ts
similarity index 73%
rename from src/cmdline/cmdline_text.ts
rename to src/utils/cmdline_text.ts
index be7eeef..591ba60 100644
--- a/src/cmdline/cmdline_text.ts
+++ b/src/utils/cmdline_text.ts
@@ -1,4 +1,4 @@
-import { normalizeInputString } from "../utils/text";
+import { normalizeInputString } from "./text";
 
 type TextChange = NoTextChange | TextEndChange | OtherTextChange;
 
@@ -31,20 +31,6 @@ export function calculateInputAfterTextChange(oldText: string, newText: string):
     }
 }
 
-export function commandInputIsCompletable(command: string): boolean {
-    return (
-        command.charAt(0) !== "?" &&
-        command.charAt(0) !== "/" &&
-        !command.includes("s/") &&
-        !command.includes("su/") &&
-        !command.includes("substitute/") &&
-        !command.includes("g/") &&
-        !command.includes("global/") &&
-        !command.includes("v/") &&
-        !command.includes("vglobal/")
-    );
-}
-
 function diffLineText(oldLine: string, newLine: string): TextChange {
     const lengthDifference = newLine.length - oldLine.length;
 


Test patch:
diff --git a/src/test/unit/cmdline/cmdline_text.test.ts b/src/test/unit/cmdline/cmdline_text.test.ts
index f66c725..24c0619 100644
--- a/src/test/unit/cmdline/cmdline_text.test.ts
+++ b/src/test/unit/cmdline/cmdline_text.test.ts
@@ -1,6 +1,6 @@
 import { strict as assert } from "assert";
 
-import { calculateInputAfterTextChange, commandInputIsCompletable } from "../../../cmdline/cmdline_text";
+import { calculateInputAfterTextChange } from "../../../utils/cmdline_text";
 
 describe("calculateInputAfterTextChange", () => {
     [
@@ -34,28 +34,3 @@ describe("calculateInputAfterTextChange", () => {
         });
     });
 });
-
-describe("commandInputIsCompletable", () => {
-    [
-        "substitute/blah",
-        "substitute/",
-        "s/blah",
-        "s/",
-        "g/blah",
-        "global/",
-        "v/",
-        "v/blah",
-        "vglobal/",
-        "vglobal/blah",
-    ].forEach((input) => {
-        it(`should not produce completions for user-input commands: '${input}'`, () => {
-            assert.equal(commandInputIsCompletable(input), false);
-        });
-    });
-
-    ["p", "Ins", "nno"].forEach((input) => {
-        it(`should produce completions for normal commands: '${input}'`, () => {
-            assert.equal(commandInputIsCompletable(input), true);
-        });
-    });
-});