Skip to content

Reliable key sequence handling #85

@JimmyZJX

Description

@JimmyZJX

Problem

I constantly lose part of my key sequence when the vscode window is not very responsive.

For example, when I type "SPC f s" very fast, sometimes the which-key menu ends up in the state of "SPC s" and leave vscodevim in the "f" mode.

Analysis

Fundamentally, according to my understanding, it is caused by the multi-process design of VSCode. vscode-which-key commands are handled in the extension-host process and the user's keystrokes are asynchronously send via RPC. There's just no way to make the QuickPick.show call reliably happen before the key is "handled".

One can easily verify it via the autohotkey script (preferrably with some wrapper like adding a shortcut and some delay before)

SendInput {Space}
SendInput {p}
SendInput {f}

In my test setup, I've used a virtual machine with 1GB memory with some VSCode windows opened. I can reproduce the misbahave easily.

Solution

Given that VSCode does not allow the extension command to block key handling, we should seek for reliable state tracking within the main process. And the idea that comes to my mind is to rely on the

setContext (_setContext)

command. Calling this command via keybindings.json is extremely reliable as it's just a synchronous function called on the main process.

I wrote a tiny demo extension to prove the concept

import * as vscode from 'vscode';

const CONTEXT = "inWhichKey";

let state: string[] = [];

async function key(key: string) {
	state.push(key);
	if (state.length >= 3) {
		await vscode.commands.executeCommand("setContext", CONTEXT, false);
		console.log("which-key-ex: got keys", state);
		state = [];
	} else {
		console.log("which-key-ex state:", state);
		await vscode.commands.executeCommand("setContext", CONTEXT, true);
	}
}

export function activate(context: vscode.ExtensionContext) {
	let disposable = vscode.commands.registerCommand("whickKeyEx.key", key);

	context.subscriptions.push(disposable);
}

and set up the keybindings like this

[
	{
		"key": "space",
		"command": "runCommands",
		"args": {
			"commands": [
				{
					"command": "_setContext",
					"args": [
						"inWhichKey",
						true
					],
				},
				{
					"command": "whickKeyEx.key",
					"args": "space",
				},
			]
		},
		"when": "vim.mode == 'Normal'",
                // apparently we need better conditions here. This is just for demo.
	},
	{
		"key": "p",
		"command": "whickKeyEx.key",
		"args": "p",
		"when": "inWhichKey",
	},
	{
		"key": "f",
		"command": "whickKeyEx.key",
		"args": "f",
		"when": "inWhichKey",
	},
]

And now, no keys will ever get a chance to escape which-key. Even with no delays sending the keys with autohotkey, it is always reliable!

Limitations

I think it would be impossible to unset the context without any delay involved. My approach does not solve problems like

SPC f <optionally some delay> s SPC f s

being unreliable. I think there's much less need for that to be handled without any race condition. This is also a problem with the current implementation because the quickpick events are sent asynchronously.
In general, I think this is just impossible if you want to dynamically quit the context. (Otherwise you can keep track of all the states by encoding the key-sequence state in keybindings.json via setContext)

One possible drawback of my approach is that it can be unreliable when using together with vscodevim, since the space is not handled by vscodevim. However, I think we can just configure both and fallback to the current behavior if e.g. ESC and SPC are typed very fast when the editor was in the insert mode.

Default keybindings

We should declare all possible key strokes that which-key recognizes in VSCode shortcuts. This should be a reasonably enumerable list. Notice that we only need the inWhichKey condition in those bindings and not the complex conditions. Only the trigger key should be carefully defined with a correct set of conditions, and the user should be able to figure out the correct conditions by themselves.

One plus we get from this approch is that now keys with modifiers can also be handled, like ctrl+u, in the middle of a sequence. We just need to also register them as keybindings like any other key. Users can also define their own bindings with modifiers as long as they register the keybinding.

Possible implementations

Without Quickpick

One possibility is to completely get rid of the quickpick menu and read keys only via shortcuts. The main drawback here is that we need to decide when to "cancel" which-key, e.g. on vscode losing focus, or changing active editor. I'm not sure if this can be implemented with a clean solution.

For the UI, we can draw texts using decorations on the active editor like "SPC j w". If there's no active editor, not drawing is acceptable I think, or we fallback to the current quickpick. But that would be some work to completely rewrite the UI and behaviors that relies on the quickpick component.

Hybrid key handling

The other idea I have is to make use of the context to keep track of missing keys before the quickpick is shown while keeping the key sequence handling logic still bundled with the quickpick UI. Missing keys will be kept in a local queue and processed before the UI gets any text change event, and optionally hide the quickpick if it's already a command.

I haven't tried writing any real code on this idea, and I suspect that it might not work because we have no "onDidShown" event for the quickpick. Only by then we can safely unset the context and always handle keys with quickpick. It is also possible that calling runCommand("setContext", ...) after quickpick.show() always gurantees order of execution. If that's the case, then this approach will work and requires less changes on the existing codebase.

Thanks for reading all these text and please let me know about your thoughts!
I think making key sequence handling reliable is very valuable in general and should be a top priority.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions