Skip to content

Add which-key system#34798

Closed
Cretezy wants to merge 24 commits intozed-industries:mainfrom
Cretezy:push-vrznqwqyrsxr
Closed

Add which-key system#34798
Cretezy wants to merge 24 commits intozed-industries:mainfrom
Cretezy:push-vrznqwqyrsxr

Conversation

@Cretezy
Copy link
Contributor

@Cretezy Cretezy commented Jul 21, 2025

Closes #10910

This is a work in progress implementation, but it functionally works. I'm looking for some feedback on both implementation and design.

This PR adds a which-key-like system, which shows possible bindings to take after starting a sequence of key which leads to a binding. This is mostly useful in Vim mode, as there's multiple common sequences (such as g which leads to gv, gc, ..., or [/], etc). Currently, this shows in the main screen area (doesn't draw over panels), to be less intrusive.

Common use cases are to see what can follow ctrl+k, g, z, ctrl+w, or space/leader. g/ctrl+w are worst case scenarios, as they have tons of features built-in.

image

"location": "left_panel":
image

To do:

  • Add ability to scroll menu with keyboard

Additionally, there's some follow-up work which could enable this menu when holding down ctrl for example. This would make this feature much more useful in non-Vim mode.

Release Notes:

  • Add which-key system to show possible next actions for sequences of bindings (e.g. ctrl-w)

@cla-bot cla-bot bot added the cla-signed The user has signed the Contributor License Agreement label Jul 21, 2025
@theherk
Copy link
Contributor

theherk commented Jul 21, 2025

Looking good so far. One issue I think is that you are showing all following actions, but I think it would be preferable to show only the next key in the sequence. For example, in the first picture showing g keybindings, you are showing both:

  • r r: editor: find all references
  • r n: editor: rename

In my view, you should be showing only r in this case, or r: editor if there is sufficient metadata to do so. If not, simply a blank r that a user could optionally set a label for in configuration, which upon pressing would show for g r:

  • r: editor: find all references
  • n: editor: rename

As an aside on design, I think the key press and the command it yields should be distinguishable by color and / or font weight.

@Cretezy
Copy link
Contributor Author

Cretezy commented Jul 21, 2025

@theherk Good call for showing only the next vs showing all. I'll add support for that and make it configurable. It does currently support multi-levels (as in g r drills down the filter).

I've already got font weight for keys locally, plus some other minor design/usability chances + configuration options. I'll push that tomorrow and update the screenshots.

@rodrigopex
Copy link

Hey @Cretezy, this is a wanted feature. Congrats on submitting that! In terms of UI, I recommend increasing highlight in the key and reducing it in the description. I suppose you could add some text or indication to make the current key clearer.

I'm not sure how difficult it would be to group things. In your case, we can see when you press ctrl-w, there are g space, g t, g shift-d. These may appear in a single one under g, and when pressing g, the rest of the options could appear. At least you could make the list in alphabetical order to create a sequence. There are the actions and groups of actions under g, for example.

Take a look at the Lazyvim version of that.
image

I hope you can finish that and your PR is merged soon. Thanks a lot.

@Cretezy
Copy link
Contributor Author

Cretezy commented Jul 21, 2025

Pushed new commit with a better design (screenshot updated), as well as configuration option. I'll tackle grouping of sub-sequences next.

@Cretezy Cretezy force-pushed the push-vrznqwqyrsxr branch 3 times, most recently from f8a8f42 to 76b14cc Compare July 21, 2025 17:49
@niekdomi
Copy link
Contributor

Thank you for this great feature! After testing the branch, I have some minor suggestions:

image

For comparison, here’s how which-key.nvim looks:
image

I find two aspects of which-key that make it clearer:

  1. It uses different colors to distinguish between keys, commands, and available follow-up bindings.
  2. The commands are aligned, which improves readability.

Just wanted to share this feedback—thanks again for your work!

@Cretezy
Copy link
Contributor Author

Cretezy commented Jul 21, 2025

@domi413 Yes those improvements are planned, still working on the design!

@ConradIrwin
Copy link
Member

Nice! CC @danilo-leal for some design love.

I think we only really need one setting (which is disabled by default), and which shows the data in what we think is the best way (not yet sure if that's the full key sequence or just the next key). One thing that's maybe different in Zed vs Helix is that we have way more shortcuts showing up in here, so if we can figure out how to keep the UI from taking over the entire screen it would be nice...

In terms of the 1s timeout, we get a lot of feedback about that. We could try removing it completely, but I'm worried about people hitting ctrl-w and then switching task and hitting a key shortcut that doesn't do what they want.. We could also make it much longer, not sure if there are other examples of how apps with lots of key sequences handle the issue.

@Cretezy
Copy link
Contributor Author

Cretezy commented Jul 22, 2025

@ConradIrwin Regarding the 1s, a few thoughts:

  • We could disable it completely, similar to how Vim behaves.
  • We could disable it only when which-key is enabled.
  • We could increase it to something much higher. I think 15s or 30s seems reasonable, as we don't want the which-key menu to disappear as someone is reading the options.
  • We could increase it to 15s when which-key is disabled, and have it as 30/60s when which-key is enabled.

@bjorn-ove
Copy link

bjorn-ove commented Jul 22, 2025

EDIT: if this timeout is not about how long the menu shows before you click the next key, then my comment can be ignored

@ConradIrwin Regarding the 1s, a few thoughts:

As a helix user I would like the option to disable the timeout entirely when the menu is visible. I wouldn't want the menu to disappear whilst reading it, and any usable timeout would lead to that sometimes.

I could of course live with a workaround where I can specify the timeout length, as I could just set it to 30 minutes or something.

@ConradIrwin
Copy link
Member

Hmm. Looking into vim, they have the 1s timeout when there are ambiguous key matches (e.g. do :nmap g D, then g after 1s it'll delete the line, or :imap jk <esc> after 1s it'll insert a j); but they don't have any for when you've typed a prefix and there is no conflict.

VScode seems closer to 5s by default, but I don't think you can have the key prefix matches (it seems to always use the shorter match instantly; though I may have missed how to configure the conflict correctly).

It seems reasonable to split the two cases up:

  • In the common case that the prefix has no binding, default to a 5s timeout (to match vscode) or none (to match vim). If the which key menu is open the default should be none, because it makes it obvious in the UI that this is the state you're in; so maybe none is the right default here.
  • In the uncommon case that the prefix does have a binding (or like jk in insert mode, the key will insert a j after a bit), keep the current 1s to match vim. Not sure if we should show the which key menu or not in that case.

@Cretezy Cretezy force-pushed the push-vrznqwqyrsxr branch from bd0ada3 to 820b239 Compare July 22, 2025 05:17
@Cretezy
Copy link
Contributor Author

Cretezy commented Jul 22, 2025

I've pushed another update which overhauls the design and introduces grouping (screenshots are updated). Still work to do, but I'm happy to collect feedback at this point.

@Cretezy Cretezy force-pushed the push-vrznqwqyrsxr branch from 820b239 to f24cc9f Compare July 22, 2025 05:20
h_flex().items_center().gap_1().children([
keystroke.into_any_element(),
div()
.child(Label::new("🡒").color(Color::Muted))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely best to replace this with a proper icon

@Cretezy Cretezy force-pushed the push-vrznqwqyrsxr branch from f24cc9f to 6813fb0 Compare July 22, 2025 05:34
@bjorn-ove
Copy link

The group feature is a great start, but it would be even better if it was possible to document a group. If you look at helix, when you hit a key, you get a documented list of next keys to press.

for example, when you hit «m» you get the popup for match mode. Then you among other see «i» for inside and «a» for around. When you press that you get «f» for function and «b» for block. And so on.

With the current grouping, only the last keepers would have any sort of documentation.

@Cretezy
Copy link
Contributor Author

Cretezy commented Jul 22, 2025

@bjorn-ove Agreed! I'm not sure if this belongs in the settings file or keymap file yet. I think keymap makes more sense, although it doesn't fit in the current model of the file.

If it's in the settings, it could be under which_key.groups, which could be a mapping of prefixes to group names. Additionally, we'd probably want to ship with names for the groups that exists in the default key mappings.

@rodrigopex
Copy link

@Cretezy, another suggestion. Instead of shift-d, you could show D. The uppercase letters can be explicitly shown in uppercase, like which-key nvim:

image

@niekdomi
Copy link
Contributor

I’ve noticed that when the cursor is positioned on the bottom half of the screen, the which-key panel can end up obscuring it.

There are two possible solutions that come to mind:

A) Leave the current behavior as is.
B) Adjust the panel so that its height does not extend below the cursor position, and make the panel scrollable if needed.

@Cretezy
Copy link
Contributor Author

Cretezy commented Sep 3, 2025

Few points:

  • Fixed the minor comments, made grouping enabled by default
  • Will look into moving it to a modal. I agree that makes more sense to put it there, and move the code into the which_key crate
    • Question: How do I make the which_key crate show a modal inside of workspace?
  • After more reflection, I believe having the option to place it in the left panel would be good to keep
  • For the timeout, it seems like removing the timeout completely would do the exact same behavior, since if there's no pending keys, there's nothing to clear. Removing it completely makes it clearer and doesn't change any logic it seems

@ConradIrwin
Copy link
Member

To show a modal you can do:

if !workspace.has_active_modal() {
 workspace.toggle_modal(|window, cx| WhichKey::new(window, cx))
}

I'm still sure we shouldn't ship column mode. Two reasons:

  1. It only works sometimes, so it jumps between bottom mode and side mode depending on the width of the column
  2. It's extra work for anyone who's changing this code to test in two different configurations.

So yes, in the abstract, more flexibility is good; but it's better to have a tighter scoped feature that works well than a loose collection of things that kind of work.

Removing the timeout completely seems reasonable, it sounded like from your comments that that has effectively already happened, we just need to tidy the code up?

@Cretezy
Copy link
Contributor Author

Cretezy commented Sep 4, 2025

@ConradIrwin I've moved the implementation to a modal. It should basically behave as before (hides when there's no pending keys or when focus changes), plus it won't conflict with other modals. I've also removed the timer.

Additionally, I've discovered a small bug with the mode indicator: The pending keys are still shown when focus changes (which clears them at the moment).

_items_serializer: Task<Result<()>>,
session_id: Option<String>,
scheduled_tasks: Vec<Task<()>>,
pub which_key: Option<AnyEntity>,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% sure if this is the correct approach to preventing WhichKey from being dropped.

layout_as_root is quite expensive, so render() was taking ~16ms in a
debug build for `g`. Without too much loss of accuracy, we now just
layout as root once per column
@ConradIrwin
Copy link
Member

I pushed up a few changes, notably using a closure to retain the which key state so we really don't need to change anything in the workspace crate; and removing the new hide_modal method, and emitting the DismissEvent instead.

I also made some changes to render. It turns out that layout_as_root is quite expensive; and so I've updated the code do this once per column instead of once per item (taking the time to call render() on g from 16ms to 500µs in a debug build).

Finally I realized that we do need to keep the default timer in the case of conflicting shorter bindings, for example if you have:

  {
    "context": "vim_mode == insert",
    "bindings": {
      "j k": ["workspace::SendKeystrokes", "escape"]
    }
  },

If you hit j in insert mode, and do nothing, it should not open which-key, but it should type the j after 1 second. I didn't fix this yet though.

@Cretezy
Copy link
Contributor Author

Cretezy commented Sep 4, 2025

@ConradIrwin I think we can also revert the changes with humanize_action_name now as well, since it doesn't really belong in gpui

@ConradIrwin
Copy link
Member

Perfect. Are you going to take the next step, or should I?

@Cretezy
Copy link
Contributor Author

Cretezy commented Sep 6, 2025

Reverted the humanize_action_name, working on re-adding the timer.

For the timer, should we limit it that which-key only shows when in normal mode? I believe that would cover the case you mention. Not sure how that would fit into gpui though (which is what holds the timer).

I'm a bit confused what remaining_keystrokes.ends_with(|c| c == 'm' || c == 'w') is for.

Also, I've had to increase the 5px padding to 10px to fix this:
image

@ConradIrwin
Copy link
Member

Hmm, m and w were the problem characters when I was testing.. I wonder if we can do something less fragile than this (but without the over-head of rendering every word).

If we used the buffer font, it's probably monospace? Not sure that will look good though.

For the timeout thing:

  • If there's another thing that would happen then we shouldn't show which-key at all, we should just do the other thing. This happens in insert mode and you type a character, but also if you binding something to shift-g shift-g then you type shift-g in normal mode it will resolve to the builtin binding after 1 second.

@ConradIrwin
Copy link
Member

@Cretezy Have you had time to work on this recently? Happy to pair on the timeout thing if you need

@Cretezy
Copy link
Contributor Author

Cretezy commented Sep 22, 2025

@ConradIrwin I haven't, I'm still a bit stuck at the timeout. I booked some time on your calendar this Friday to pair, hopefully we can finish this up.

Copy link
Contributor

@xipeng-jin xipeng-jin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @Cretezy @ConradIrwin , thanks for all your contributions. I am very interested in making this PR to work so I came across all the commits and I suggest to make some changes here which I left them in the comments. The goal is to reflect the new changes in the upstream main branch in order to make the which-key feature working. Thank you.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refactor is not super relevant to this specific PR for adding which-key system. I recommend we can clean this up in another commit to avoid confusion. We can leave it as is for now and this will not cause any problems.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same comment apply here. The corresponding refactors in project_search.rs and collab.rs are not relevant to this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reflect the new changes from the upstream:main branch. I made some changes in which key settings accordingly as follows:

crates/which_key/src/which_key_settings.rs

use serde::Deserialize;
use settings::{Settings, SettingsContent, SettingsKey, WhichKeySettingsContent};

#[derive(Deserialize)]
pub struct WhichKeySettings {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_delay_ms")]
    pub delay_ms: u64,
}

fn default_delay_ms() -> u64 {
    700
}

impl Settings for WhichKeySettings {
    fn from_settings(content: &SettingsContent) -> Self {
        let which_key: WhichKeySettingsContent = content.which_key.clone().unwrap_or_default();

        Self {
            enabled: which_key.enabled.unwrap_or(false),
            delay_ms: which_key.delay_ms.unwrap_or_else(default_delay_ms),
        }
    }

    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut SettingsContent) {
        // No equivalent setting in VScode
    }
}

impl SettingsKey for WhichKeySettings {
    const KEY: Option<&'static str> = Some("which_key");
}

crates/settings/src/settings_content.rs

pub struct SettingsContent {
...
    /// Settings for the which-key popup.
    pub which_key: Option<WhichKeySettingsContent>,
    ...
}
...
/// Settings for configuring the which-key popup behaviour.
#[skip_serializing_none]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct WhichKeySettingsContent {
    /// Whether to show the which-key popup when holding down key combinations
    ///
    /// Default: false
    pub enabled: Option<bool>,
    /// Delay in milliseconds before showing the which-key popup.
    ///
    /// Default: 700
    pub delay_ms: Option<u64>,
}

@eugenesvk
Copy link

eugenesvk commented Oct 16, 2025

User customization of the layout/order (for grouping similar commands together)/description would be a great improvement

For example, you could add icons to your commands in your user config "icon" field for better readability (so add an "icon" field to keybind config

(Similar to #34798 (comment))

In a better config system you could even have it as simple as

wj   pane::SplitDown   i=🗗↓    des="Split ↓ (Horizontal)" №=3

With your whic-key showing you 🗗↓ as an icon and Split ↓ (Horizontal) as your description in the 3rd row.

Then you could also have a "cheatsheet" version popup first: instead of a huge table with a lot of text you could only show a "keyboard layout" view with icons, any only if that's still not enough you could mouseover for a detailed tooltip or press a key to show the full text table

the ability to search the table would also help in long tables

@xipeng-jin
Copy link
Contributor

For the current iteration, I have several thoughts and comments:

  1. When the user only has few of keybindings, showing the entire large block at the bottom waste a lot of spaces and it is not looking good. See the following screenshot. And I feel this popup window will block a large view of code editor space, so that it would be inconvenience when you in the middle of editing workflow and trying to executing a keybinding real quick. The delay_ms parameter will try to reduce this problem by don't open this pop window if you type the keybinding really fast. But in the other hand, if I do need to check this keybinding cheatsheet, instead I need to wait for 700ms to let it open. It become unnecessary.
Screenshot 2025-10-16 at 1 27 16 PM
  1. I am more prefer a minimal or compact form of this popup window, something like positioning it at the corner of the editor. We can do a similar way as Neovim like the following picture which I took from my LazyVim setup.
Screenshot 2025-10-16 at 1 20 33 PM
  1. We might need a way to let the user to configure the name of each keybinding group or the action name at least, and potentially we can also add the icon at front.

  2. We can also consider to automatically close the window and clear the pending keybinding after the timeout configuration.

@ConradIrwin
Copy link
Member

I think we should probably pause on this branch (as it's going stale) and open a first PR to fix the key timeout problem.

Currently if you type a key that is a prefix of a keyboard shortcut and then don't finish the shortcut it is cancelled after 1 second. We should change that logic to keep it pending forever (or until escape is pressed) in the case that there is no binding (and no input). For things like j in insert mode, we do want to still insert that after 1s if you have bound jk to escape.

Once we have that problem solved, then the remaining work is "just render the UI" and we can re-use the code from this branch to do that.

Going to close for now, but I am still interested in PRs for this (as it is very highly requested), and I think the code here is heading the right direction (it's just very conflicted).

@Cretezy
Copy link
Contributor Author

Cretezy commented Oct 21, 2025

Hi, I've been on vacation for the last few weeks, and should be able to get back to work on this soon. I'll focus on updating the PR, and try to meet with @ConradIrwin to discuss the timeout issue when possible.

ConradIrwin pushed a commit that referenced this pull request Nov 21, 2025
…#42659)

Part One for Resolving #10910

### Summary
Typing prefix (partial keybinding) will behave like Vim. No timeout
until you either finish the sequence or hit Escape, while ambiguous
sequences still auto-resolve after 1s.

### Description
This follow-up tweaks the which-key system first part groundwork so our
timeout behavior matches Vim’s expectations. Then we can implement the
UI part in the next step (reference latest comments in
#34798)
- `DispatchResult` now reports when the current keystrokes are already a
complete binding in the active context stack (`pending_has_binding`). We
only start the 1s flush timer in that case. Pure prefixes or sequences
that only match in other contexts—stay pending indefinitely, so
leader-style combos like `space f g` no longer evaporate after a second.
- `Window::dispatch_key_event` cancels any prior timer before scheduling
a new one and only spawns the background flush task when
`pending_has_binding` is true. If there’s no matching binding, we keep
the pending keystrokes and rely on an explicit Escape or more typing to
resolve them.

Release Notes:

- Fixed multi-stroke keybindings so only ambiguous prefixes auto-trigger
after 1 s; unmatched prefixes now stay pending until canceled, matching
Vim-style leader behavior.
11happy pushed a commit to 11happy/zed that referenced this pull request Dec 1, 2025
…zed-industries#42659)

Part One for Resolving zed-industries#10910

### Summary
Typing prefix (partial keybinding) will behave like Vim. No timeout
until you either finish the sequence or hit Escape, while ambiguous
sequences still auto-resolve after 1s.

### Description
This follow-up tweaks the which-key system first part groundwork so our
timeout behavior matches Vim’s expectations. Then we can implement the
UI part in the next step (reference latest comments in
zed-industries#34798)
- `DispatchResult` now reports when the current keystrokes are already a
complete binding in the active context stack (`pending_has_binding`). We
only start the 1s flush timer in that case. Pure prefixes or sequences
that only match in other contexts—stay pending indefinitely, so
leader-style combos like `space f g` no longer evaporate after a second.
- `Window::dispatch_key_event` cancels any prior timer before scheduling
a new one and only spawns the background flush task when
`pending_has_binding` is true. If there’s no matching binding, we keep
the pending keystrokes and rely on an explicit Escape or more typing to
resolve them.

Release Notes:

- Fixed multi-stroke keybindings so only ambiguous prefixes auto-trigger
after 1 s; unmatched prefixes now stay pending until canceled, matching
Vim-style leader behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Which-Key like menu