Display case-sensitive keybindings for vim commands (#24322)

Dino and Conrad Irwin created

This Pull Request tackles the issue outline in #14287 by changing the
way `KeyBinding`s for vim mode are displayed in the command palette.
It's worth pointing out that this whole thing was pretty much
implemented by Conrad Irwin during a pairing session, I just tried to
clean up some other changes introduced for a different issue, while
improving some comments.

Here's a quick list of the changes introduced:

- Update `KeyBinding` with a new `vim_mode` field to determine whether
the keybinding should be displayed in vim mode.
- Update the way `KeyBinding` is rendered, so as to detect if the
keybinding is for vim mode, if it is, only display keys in uppercase if
they require the shift key.
- Introduce a new global state – `VimStyle(bool)` - use to determine
whether `vim_mode` should be enabled or disabled when creating a new
`KeyBinding` struct. This global state is automatically set by the `vim`
crate whenever vim mode is enabled or disabled.
- Since the app's context is now required when building a `KeyBinding` ,
update a lot of callers to correctly pass this context.

And before and after screenshots, for comparison:

| before | after |
|--------|-------|
| <img width="1050" alt="SCR-20250205-tyeq"
src="https://github.com/user-attachments/assets/e577206d-2a3d-4e06-a96f-a98899cc15c0"
/> | <img width="1050" alt="SCR-20250205-tylh"
src="https://github.com/user-attachments/assets/ebbf70a9-e838-4d32-aee5-0ffde94d65fb"
/> |

Closes #14287 

Release Notes:

- Fix rendering of vim commands to preserve case sensitivity

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                                 |   4 
crates/assistant/src/inline_assistant.rs                |  17 
crates/assistant/src/terminal_inline_assistant.rs       |   6 
crates/assistant2/src/assistant_panel.rs                |   1 
crates/assistant2/src/context_strip.rs                  |   1 
crates/assistant2/src/inline_prompt_editor.rs           |   8 
crates/assistant2/src/message_editor.rs                 |   3 
crates/assistant_context_editor/src/context_editor.rs   |   4 
crates/collab_ui/src/chat_panel.rs                      |   1 
crates/command_palette/src/command_palette.rs           |   3 
crates/editor/src/element.rs                            |   1 
crates/editor/src/proposed_changes_editor.rs            |   2 
crates/file_finder/src/file_finder.rs                   |   3 
crates/language_tools/src/key_context_view.rs           |   3 
crates/project_panel/src/project_panel.rs               |   2 
crates/prompt_library/src/prompt_library.rs             |   2 
crates/recent_projects/src/recent_projects.rs           |   4 
crates/repl/src/repl_sessions_ui.rs                     |   2 
crates/search/src/project_search.rs                     |   9 
crates/tasks_ui/src/modal.rs                            |  44 +-
crates/ui/src/components/context_menu.rs                |   3 
crates/ui/src/components/keybinding.rs                  | 181 ++++++----
crates/ui/src/components/keybinding_hint.rs             |   6 
crates/ui/src/components/stories/keybinding.rs          |  41 +-
crates/ui/src/components/tooltip.rs                     |  20 
crates/vim/src/mode_indicator.rs                        |  15 
crates/vim/src/state.rs                                 |   4 
crates/zed/src/zed/quick_action_bar/markdown_preview.rs |   5 
crates/zeta/src/rate_completion_modal.rs                |   2 
docs/src/vim.md                                         |   4 
30 files changed, 236 insertions(+), 165 deletions(-)

Detailed changes

assets/keymaps/vim.json πŸ”—

@@ -37,9 +37,9 @@
       "[ [": "vim::PreviousSectionStart",
       "[ ]": "vim::PreviousSectionEnd",
       "] m": "vim::NextMethodStart",
-      "] M": "vim::NextMethodEnd",
+      "] shift-m": "vim::NextMethodEnd",
       "[ m": "vim::PreviousMethodStart",
-      "[ M": "vim::PreviousMethodEnd",
+      "[ shift-m": "vim::PreviousMethodEnd",
       "[ *": "vim::PreviousComment",
       "[ /": "vim::PreviousComment",
       "] *": "vim::NextComment",

crates/assistant/src/inline_assistant.rs πŸ”—

@@ -1704,7 +1704,7 @@ impl PromptEditor {
             // always show the cursor (even when it isn't focused) because
             // typing in one will make what you typed appear in all of them.
             editor.set_show_cursor_when_unfocused(true, cx);
-            editor.set_placeholder_text(Self::placeholder_text(codegen.read(cx), window), cx);
+            editor.set_placeholder_text(Self::placeholder_text(codegen.read(cx), window, cx), cx);
             editor
         });
 
@@ -1783,7 +1783,10 @@ impl PromptEditor {
         self.editor = cx.new(|cx| {
             let mut editor = Editor::auto_height(Self::MAX_LINES as usize, window, cx);
             editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
-            editor.set_placeholder_text(Self::placeholder_text(self.codegen.read(cx), window), cx);
+            editor.set_placeholder_text(
+                Self::placeholder_text(self.codegen.read(cx), window, cx),
+                cx,
+            );
             editor.set_placeholder_text("Add a prompt…", cx);
             editor.set_text(prompt, window, cx);
             if focus {
@@ -1794,8 +1797,8 @@ impl PromptEditor {
         self.subscribe_to_editor(window, cx);
     }
 
-    fn placeholder_text(codegen: &Codegen, window: &Window) -> String {
-        let context_keybinding = text_for_action(&zed_actions::assistant::ToggleFocus, window)
+    fn placeholder_text(codegen: &Codegen, window: &Window, cx: &App) -> String {
+        let context_keybinding = text_for_action(&zed_actions::assistant::ToggleFocus, window, cx)
             .map(|keybinding| format!(" β€’ {keybinding} for context"))
             .unwrap_or_default();
 
@@ -2084,12 +2087,13 @@ impl PromptEditor {
                     .tooltip({
                         let focus_handle = self.editor.focus_handle(cx);
                         move |window, cx| {
-                            cx.new(|_| {
+                            cx.new(|cx| {
                                 let mut tooltip = Tooltip::new("Previous Alternative").key_binding(
                                     KeyBinding::for_action_in(
                                         &CyclePreviousInlineAssist,
                                         &focus_handle,
                                         window,
+                                        cx,
                                     ),
                                 );
                                 if !disabled && current_index != 0 {
@@ -2126,12 +2130,13 @@ impl PromptEditor {
                     .tooltip({
                         let focus_handle = self.editor.focus_handle(cx);
                         move |window, cx| {
-                            cx.new(|_| {
+                            cx.new(|cx| {
                                 let mut tooltip = Tooltip::new("Next Alternative").key_binding(
                                     KeyBinding::for_action_in(
                                         &CycleNextInlineAssist,
                                         &focus_handle,
                                         window,
+                                        cx,
                                     ),
                                 );
                                 if !disabled && current_index != total_models - 1 {

crates/assistant/src/terminal_inline_assistant.rs πŸ”—

@@ -725,7 +725,7 @@ impl PromptEditor {
                 cx,
             );
             editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
-            editor.set_placeholder_text(Self::placeholder_text(window), cx);
+            editor.set_placeholder_text(Self::placeholder_text(window, cx), cx);
             editor
         });
 
@@ -774,8 +774,8 @@ impl PromptEditor {
         this
     }
 
-    fn placeholder_text(window: &Window) -> String {
-        let context_keybinding = text_for_action(&zed_actions::assistant::ToggleFocus, window)
+    fn placeholder_text(window: &Window, cx: &App) -> String {
+        let context_keybinding = text_for_action(&zed_actions::assistant::ToggleFocus, window, cx)
             .map(|keybinding| format!(" β€’ {keybinding} for context"))
             .unwrap_or_default();
 

crates/assistant2/src/assistant_panel.rs πŸ”—

@@ -849,6 +849,7 @@ impl AssistantPanel {
                                     &OpenHistory,
                                     &self.focus_handle(cx),
                                     window,
+                                    cx
                                 ))
                                 .on_click(move |_event, window, cx| {
                                     window.dispatch_action(OpenHistory.boxed_clone(), cx);

crates/assistant2/src/inline_prompt_editor.rs πŸ”—

@@ -271,7 +271,7 @@ impl<T: 'static> PromptEditor<T> {
         };
 
         let assistant_panel_keybinding =
-            ui::text_for_action(&zed_actions::assistant::ToggleFocus, window)
+            ui::text_for_action(&zed_actions::assistant::ToggleFocus, window, cx)
                 .map(|keybinding| format!("{keybinding} to chat ― "))
                 .unwrap_or_default();
 
@@ -618,12 +618,13 @@ impl<T: 'static> PromptEditor<T> {
                     .tooltip({
                         let focus_handle = self.editor.focus_handle(cx);
                         move |window, cx| {
-                            cx.new(|_| {
+                            cx.new(|cx| {
                                 let mut tooltip = Tooltip::new("Previous Alternative").key_binding(
                                     KeyBinding::for_action_in(
                                         &CyclePreviousInlineAssist,
                                         &focus_handle,
                                         window,
+                                        cx,
                                     ),
                                 );
                                 if !disabled && current_index != 0 {
@@ -659,12 +660,13 @@ impl<T: 'static> PromptEditor<T> {
                     .tooltip({
                         let focus_handle = self.editor.focus_handle(cx);
                         move |window, cx| {
-                            cx.new(|_| {
+                            cx.new(|cx| {
                                 let mut tooltip = Tooltip::new("Next Alternative").key_binding(
                                     KeyBinding::for_action_in(
                                         &CycleNextInlineAssist,
                                         &focus_handle,
                                         window,
+                                        cx,
                                     ),
                                 );
                                 if !disabled && current_index != total_models - 1 {

crates/assistant2/src/message_editor.rs πŸ”—

@@ -390,6 +390,7 @@ impl Render for MessageEditor {
                                         &ChatMode,
                                         &focus_handle,
                                         window,
+                                        cx,
                                     )),
                             )
                             .child(h_flex().gap_1().child(self.model_selector.clone()).child(
@@ -419,6 +420,7 @@ impl Render for MessageEditor {
                                                         &editor::actions::Cancel,
                                                         &focus_handle,
                                                         window,
+                                                        cx,
                                                     )
                                                     .map(|binding| binding.into_any_element()),
                                                 ),
@@ -449,6 +451,7 @@ impl Render for MessageEditor {
                                                         &Chat,
                                                         &focus_handle,
                                                         window,
+                                                        cx,
                                                     )
                                                     .map(|binding| binding.into_any_element()),
                                                 ),

crates/assistant_context_editor/src/context_editor.rs πŸ”—

@@ -2290,7 +2290,7 @@ impl ContextEditor {
                 },
             ))
             .children(
-                KeyBinding::for_action_in(&Assist, &focus_handle, window)
+                KeyBinding::for_action_in(&Assist, &focus_handle, window, cx)
                     .map(|binding| binding.into_any_element()),
             )
             .on_click(move |_event, window, cx| {
@@ -2343,7 +2343,7 @@ impl ContextEditor {
             .layer(ElevationIndex::ModalSurface)
             .child(Label::new("Suggest Edits"))
             .children(
-                KeyBinding::for_action_in(&Edit, &focus_handle, window)
+                KeyBinding::for_action_in(&Edit, &focus_handle, window, cx)
                     .map(|binding| binding.into_any_element()),
             )
             .on_click(move |_event, window, cx| {

crates/collab_ui/src/chat_panel.rs πŸ”—

@@ -992,6 +992,7 @@ impl Render for ChatPanel {
                                         .key_binding(KeyBinding::for_action(
                                             &collab_panel::ToggleFocus,
                                             window,
+                                            cx,
                                         ))
                                         .on_click(|_, window, cx| {
                                             window.dispatch_action(

crates/command_palette/src/command_palette.rs πŸ”—

@@ -402,7 +402,7 @@ impl PickerDelegate for CommandPaletteDelegate {
         ix: usize,
         selected: bool,
         window: &mut Window,
-        _: &mut Context<Picker<Self>>,
+        cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let r#match = self.matches.get(ix)?;
         let command = self.commands.get(r#match.candidate_id)?;
@@ -424,6 +424,7 @@ impl PickerDelegate for CommandPaletteDelegate {
                             &*command.action,
                             &self.previous_focus_handle,
                             window,
+                            cx,
                         )),
                 ),
         )

crates/editor/src/proposed_changes_editor.rs πŸ”—

@@ -387,7 +387,7 @@ impl Render for ProposedChangesEditorToolbar {
             Some(editor) => {
                 let focus_handle = editor.focus_handle(cx);
                 let keybinding =
-                    KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, window)
+                    KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, window, cx)
                         .map(|binding| binding.into_any_element());
 
                 button_like.children(keybinding).on_click({

crates/file_finder/src/file_finder.rs πŸ”—

@@ -1317,7 +1317,7 @@ impl PickerDelegate for FileFinderDelegate {
                 .border_color(cx.theme().colors().border_variant)
                 .child(
                     Button::new("open-selection", "Open")
-                        .key_binding(KeyBinding::for_action(&menu::Confirm, window))
+                        .key_binding(KeyBinding::for_action(&menu::Confirm, window, cx))
                         .on_click(|_, window, cx| {
                             window.dispatch_action(menu::Confirm.boxed_clone(), cx)
                         }),
@@ -1334,6 +1334,7 @@ impl PickerDelegate for FileFinderDelegate {
                                     &ToggleMenu,
                                     &context,
                                     window,
+                                    cx,
                                 )),
                         )
                         .menu({

crates/language_tools/src/key_context_view.rs πŸ”—

@@ -216,6 +216,7 @@ impl Render for KeyContextView {
                             .key_binding(ui::KeyBinding::for_action(
                                 &zed_actions::OpenDefaultKeymap,
                                 window,
+                                cx
                             ))
                             .on_click(|_, window, cx| {
                                 window.dispatch_action(workspace::SplitRight.boxed_clone(), cx);
@@ -225,7 +226,7 @@ impl Render for KeyContextView {
                     .child(
                         Button::new("default", "Edit your keymap")
                             .style(ButtonStyle::Filled)
-                            .key_binding(ui::KeyBinding::for_action(&zed_actions::OpenKeymap, window))
+                            .key_binding(ui::KeyBinding::for_action(&zed_actions::OpenKeymap, window, cx))
                             .on_click(|_, window, cx| {
                                 window.dispatch_action(workspace::SplitRight.boxed_clone(), cx);
                                 window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx);

crates/project_panel/src/project_panel.rs πŸ”—

@@ -4643,7 +4643,7 @@ impl Render for ProjectPanel {
                 .child(
                     Button::new("open_project", "Open a project")
                         .full_width()
-                        .key_binding(KeyBinding::for_action(&workspace::Open, window))
+                        .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
                         .on_click(cx.listener(|this, _, window, cx| {
                             this.workspace
                                 .update(cx, |_, cx| {

crates/prompt_library/src/prompt_library.rs πŸ”—

@@ -1268,7 +1268,7 @@ impl Render for PromptLibrary {
                                                 Button::new("create-prompt", "New Prompt")
                                                     .full_width()
                                                     .key_binding(KeyBinding::for_action(
-                                                        &NewPrompt, window,
+                                                        &NewPrompt, window, cx,
                                                     ))
                                                     .on_click(|_, window, cx| {
                                                         window.dispatch_action(

crates/recent_projects/src/recent_projects.rs πŸ”—

@@ -465,14 +465,14 @@ impl PickerDelegate for RecentProjectsDelegate {
                 .border_color(cx.theme().colors().border_variant)
                 .child(
                     Button::new("remote", "Open Remote Folder")
-                        .key_binding(KeyBinding::for_action(&OpenRemote, window))
+                        .key_binding(KeyBinding::for_action(&OpenRemote, window, cx))
                         .on_click(|_, window, cx| {
                             window.dispatch_action(OpenRemote.boxed_clone(), cx)
                         }),
                 )
                 .child(
                     Button::new("local", "Open Local Folder")
-                        .key_binding(KeyBinding::for_action(&workspace::Open, window))
+                        .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
                         .on_click(|_, window, cx| {
                             window.dispatch_action(workspace::Open.boxed_clone(), cx)
                         }),

crates/repl/src/repl_sessions_ui.rs πŸ”—

@@ -249,7 +249,7 @@ impl Render for ReplSessionsPage {
             return ReplSessionsContainer::new("No Jupyter Kernel Sessions").child(
                 v_flex()
                     .child(Label::new(instructions))
-                    .children(KeyBinding::for_action(&Run, window)),
+                    .children(KeyBinding::for_action(&Run, window, cx)),
             );
         }
 

crates/search/src/project_search.rs πŸ”—

@@ -364,7 +364,7 @@ impl Render for ProjectSearchView {
                     None
                 }
             } else {
-                Some(self.landing_text_minor(window).into_any_element())
+                Some(self.landing_text_minor(window, cx).into_any_element())
             };
 
             let page_content = page_content.map(|text| div().child(text));
@@ -1231,7 +1231,7 @@ impl ProjectSearchView {
         self.active_match_index.is_some()
     }
 
-    fn landing_text_minor(&self, window: &mut Window) -> impl IntoElement {
+    fn landing_text_minor(&self, window: &mut Window, cx: &App) -> impl IntoElement {
         let focus_handle = self.focus_handle.clone();
         v_flex()
             .gap_1()
@@ -1249,6 +1249,7 @@ impl ProjectSearchView {
                         &ToggleFilters,
                         &focus_handle,
                         window,
+                        cx,
                     ))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleFilters.boxed_clone(), cx)
@@ -1263,6 +1264,7 @@ impl ProjectSearchView {
                         &ToggleReplace,
                         &focus_handle,
                         window,
+                        cx,
                     ))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleReplace.boxed_clone(), cx)
@@ -1277,6 +1279,7 @@ impl ProjectSearchView {
                         &ToggleRegex,
                         &focus_handle,
                         window,
+                        cx,
                     ))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleRegex.boxed_clone(), cx)
@@ -1291,6 +1294,7 @@ impl ProjectSearchView {
                         &ToggleCaseSensitive,
                         &focus_handle,
                         window,
+                        cx,
                     ))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleCaseSensitive.boxed_clone(), cx)
@@ -1305,6 +1309,7 @@ impl ProjectSearchView {
                         &ToggleWholeWord,
                         &focus_handle,
                         window,
+                        cx,
                     ))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleWholeWord.boxed_clone(), cx)

crates/tasks_ui/src/modal.rs πŸ”—

@@ -511,7 +511,7 @@ impl PickerDelegate for TasksModalDelegate {
                 .child(
                     left_button
                         .map(|(label, action)| {
-                            let keybind = KeyBinding::for_action(&*action, window);
+                            let keybind = KeyBinding::for_action(&*action, window, cx);
 
                             Button::new("edit-current-task", label)
                                 .label_size(LabelSize::Small)
@@ -530,7 +530,7 @@ impl PickerDelegate for TasksModalDelegate {
                             secondary: current_modifiers.secondary(),
                         }
                         .boxed_clone();
-                        this.children(KeyBinding::for_action(&*action, window).map(|keybind| {
+                        this.children(KeyBinding::for_action(&*action, window, cx).map(|keybind| {
                             let spawn_oneshot_label = if current_modifiers.secondary() {
                                 "Spawn Oneshot Without History"
                             } else {
@@ -545,26 +545,28 @@ impl PickerDelegate for TasksModalDelegate {
                                 })
                         }))
                     } else if current_modifiers.secondary() {
-                        this.children(KeyBinding::for_action(&menu::SecondaryConfirm, window).map(
-                            |keybind| {
-                                let label = if is_recent_selected {
-                                    "Rerun Without History"
-                                } else {
-                                    "Spawn Without History"
-                                };
-                                Button::new("spawn", label)
-                                    .label_size(LabelSize::Small)
-                                    .key_binding(keybind)
-                                    .on_click(move |_, window, cx| {
-                                        window.dispatch_action(
-                                            menu::SecondaryConfirm.boxed_clone(),
-                                            cx,
-                                        )
-                                    })
-                            },
-                        ))
+                        this.children(
+                            KeyBinding::for_action(&menu::SecondaryConfirm, window, cx).map(
+                                |keybind| {
+                                    let label = if is_recent_selected {
+                                        "Rerun Without History"
+                                    } else {
+                                        "Spawn Without History"
+                                    };
+                                    Button::new("spawn", label)
+                                        .label_size(LabelSize::Small)
+                                        .key_binding(keybind)
+                                        .on_click(move |_, window, cx| {
+                                            window.dispatch_action(
+                                                menu::SecondaryConfirm.boxed_clone(),
+                                                cx,
+                                            )
+                                        })
+                                },
+                            ),
+                        )
                     } else {
-                        this.children(KeyBinding::for_action(&menu::Confirm, window).map(
+                        this.children(KeyBinding::for_action(&menu::Confirm, window, cx).map(
                             |keybind| {
                                 let run_entry_label =
                                     if is_recent_selected { "Rerun" } else { "Spawn" };

crates/ui/src/components/context_menu.rs πŸ”—

@@ -716,11 +716,12 @@ impl Render for ContextMenu {
                                                                         KeyBinding::for_action_in(
                                                                             &**action, focus,
                                                                             window,
+                                                                            cx
                                                                         )
                                                                     })
                                                                     .unwrap_or_else(|| {
                                                                         KeyBinding::for_action(
-                                                                            &**action, window,
+                                                                            &**action, window, cx
                                                                         )
                                                                     })
                                                                     .map(|binding| {

crates/ui/src/components/keybinding.rs πŸ”—

@@ -2,8 +2,10 @@
 use crate::PlatformStyle;
 use crate::{h_flex, prelude::*, Icon, IconName, IconSize};
 use gpui::{
-    relative, Action, AnyElement, App, FocusHandle, IntoElement, Keystroke, Modifiers, Window,
+    relative, Action, AnyElement, App, FocusHandle, Global, IntoElement, Keystroke, Modifiers,
+    Window,
 };
+use itertools::Itertools;
 
 #[derive(Debug, IntoElement, Clone)]
 pub struct KeyBinding {
@@ -16,18 +18,24 @@ pub struct KeyBinding {
     /// The [`PlatformStyle`] to use when displaying this keybinding.
     platform_style: PlatformStyle,
     size: Option<AbsoluteLength>,
+
+    /// Determines whether the keybinding is meant for vim mode.
+    vim_mode: bool,
 }
 
+struct VimStyle(bool);
+impl Global for VimStyle {}
+
 impl KeyBinding {
     /// Returns the highest precedence keybinding for an action. This is the last binding added to
     /// the keymap. User bindings are added after built-in bindings so that they take precedence.
-    pub fn for_action(action: &dyn Action, window: &mut Window) -> Option<Self> {
+    pub fn for_action(action: &dyn Action, window: &mut Window, cx: &App) -> Option<Self> {
         let key_binding = window
             .bindings_for_action(action)
             .into_iter()
             .rev()
             .next()?;
-        Some(Self::new(key_binding))
+        Some(Self::new(key_binding, cx))
     }
 
     /// Like `for_action`, but lets you specify the context from which keybindings are matched.
@@ -35,20 +43,30 @@ impl KeyBinding {
         action: &dyn Action,
         focus: &FocusHandle,
         window: &mut Window,
+        cx: &App,
     ) -> Option<Self> {
         let key_binding = window
             .bindings_for_action_in(action, focus)
             .into_iter()
             .rev()
             .next()?;
-        Some(Self::new(key_binding))
+        Some(Self::new(key_binding, cx))
+    }
+
+    pub fn set_vim_mode(cx: &mut App, enabled: bool) {
+        cx.set_global(VimStyle(enabled));
+    }
+
+    fn is_vim_mode(cx: &App) -> bool {
+        cx.try_global::<VimStyle>().is_some_and(|g| g.0)
     }
 
-    pub fn new(key_binding: gpui::KeyBinding) -> Self {
+    pub fn new(key_binding: gpui::KeyBinding, cx: &App) -> Self {
         Self {
             key_binding,
             platform_style: PlatformStyle::platform(),
             size: None,
+            vim_mode: KeyBinding::is_vim_mode(cx),
         }
     }
 
@@ -63,6 +81,30 @@ impl KeyBinding {
         self.size = Some(size.into());
         self
     }
+
+    pub fn vim_mode(mut self, enabled: bool) -> Self {
+        self.vim_mode = enabled;
+        self
+    }
+
+    fn render_key(&self, keystroke: &Keystroke, color: Option<Color>) -> AnyElement {
+        let key_icon = icon_for_key(keystroke, self.platform_style);
+        match key_icon {
+            Some(icon) => KeyIcon::new(icon, color).size(self.size).into_any_element(),
+            None => {
+                let key = if self.vim_mode {
+                    if keystroke.modifiers.shift && keystroke.key.len() == 1 {
+                        keystroke.key.to_ascii_uppercase().to_string()
+                    } else {
+                        keystroke.key.to_string()
+                    }
+                } else {
+                    util::capitalize(&keystroke.key)
+                };
+                Key::new(&key, color).size(self.size).into_any_element()
+            }
+        }
+    }
 }
 
 impl RenderOnce for KeyBinding {
@@ -94,28 +136,11 @@ impl RenderOnce for KeyBinding {
                         self.size,
                         true,
                     ))
-                    .map(|el| {
-                        el.child(render_key(&keystroke, self.platform_style, None, self.size))
-                    })
+                    .map(|el| el.child(self.render_key(&keystroke, None)))
             }))
     }
 }
 
-pub fn render_key(
-    keystroke: &Keystroke,
-    platform_style: PlatformStyle,
-    color: Option<Color>,
-    size: Option<AbsoluteLength>,
-) -> AnyElement {
-    let key_icon = icon_for_key(keystroke, platform_style);
-    match key_icon {
-        Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
-        None => Key::new(util::capitalize(&keystroke.key), color)
-            .size(size)
-            .into_any_element(),
-    }
-}
-
 fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option<IconName> {
     match keystroke.key.as_str() {
         "left" => Some(IconName::ArrowLeft),
@@ -312,39 +337,33 @@ impl KeyIcon {
 }
 
 /// Returns a textual representation of the key binding for the given [`Action`].
-pub fn text_for_action(action: &dyn Action, window: &Window) -> Option<String> {
+pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option<String> {
     let bindings = window.bindings_for_action(action);
     let key_binding = bindings.last()?;
-    Some(text_for_key_binding(key_binding, PlatformStyle::platform()))
+    Some(text_for_keystrokes(key_binding.keystrokes(), cx))
 }
 
-/// Returns a textual representation of the key binding for the given [`Action`]
-/// as if the provided [`FocusHandle`] was focused.
-pub fn text_for_action_in(
-    action: &dyn Action,
-    focus: &FocusHandle,
-    window: &mut Window,
-) -> Option<String> {
-    let bindings = window.bindings_for_action_in(action, focus);
-    let key_binding = bindings.last()?;
-    Some(text_for_key_binding(key_binding, PlatformStyle::platform()))
-}
-
-/// Returns a textual representation of the given key binding for the specified platform.
-pub fn text_for_key_binding(
-    key_binding: &gpui::KeyBinding,
-    platform_style: PlatformStyle,
-) -> String {
-    key_binding
-        .keystrokes()
+pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String {
+    let platform_style = PlatformStyle::platform();
+    let vim_enabled = cx.try_global::<VimStyle>().is_some();
+    keystrokes
         .iter()
-        .map(|keystroke| text_for_keystroke(keystroke, platform_style))
-        .collect::<Vec<_>>()
+        .map(|keystroke| keystroke_text(keystroke, platform_style, vim_enabled))
         .join(" ")
 }
 
+pub fn text_for_keystroke(keystroke: &Keystroke, cx: &App) -> String {
+    let platform_style = PlatformStyle::platform();
+    let vim_enabled = cx.try_global::<VimStyle>().is_some();
+    keystroke_text(keystroke, platform_style, vim_enabled)
+}
+
 /// Returns a textual representation of the given [`Keystroke`].
-pub fn text_for_keystroke(keystroke: &Keystroke, platform_style: PlatformStyle) -> String {
+fn keystroke_text(
+    keystroke: &Keystroke,
+    platform_style: PlatformStyle,
+    vim_enabled: bool,
+) -> String {
     let mut text = String::new();
 
     let delimiter = match platform_style {
@@ -354,7 +373,7 @@ pub fn text_for_keystroke(keystroke: &Keystroke, platform_style: PlatformStyle)
 
     if keystroke.modifiers.function {
         match platform_style {
-            PlatformStyle::Mac => text.push_str("fn"),
+            PlatformStyle::Mac => text.push_str("Fn"),
             PlatformStyle::Linux | PlatformStyle::Windows => text.push_str("Fn"),
         }
 
@@ -390,18 +409,26 @@ pub fn text_for_keystroke(keystroke: &Keystroke, platform_style: PlatformStyle)
     }
 
     if keystroke.modifiers.shift {
-        match platform_style {
-            PlatformStyle::Mac | PlatformStyle::Linux | PlatformStyle::Windows => {
-                text.push_str("Shift")
+        if !(vim_enabled && keystroke.key.len() == 1) {
+            match platform_style {
+                PlatformStyle::Mac | PlatformStyle::Linux | PlatformStyle::Windows => {
+                    text.push_str("Shift")
+                }
             }
+            text.push(delimiter);
         }
-
-        text.push(delimiter);
     }
 
     let key = match keystroke.key.as_str() {
         "pageup" => "PageUp",
         "pagedown" => "PageDown",
+        key if vim_enabled => {
+            if !keystroke.modifiers.shift && key.len() == 1 {
+                key
+            } else {
+                &util::capitalize(key)
+            }
+        }
         key => &util::capitalize(key),
     };
 
@@ -417,58 +444,76 @@ mod tests {
     #[test]
     fn test_text_for_keystroke() {
         assert_eq!(
-            text_for_keystroke(&Keystroke::parse("cmd-c").unwrap(), PlatformStyle::Mac),
+            keystroke_text(
+                &Keystroke::parse("cmd-c").unwrap(),
+                PlatformStyle::Mac,
+                false
+            ),
             "Command-C".to_string()
         );
         assert_eq!(
-            text_for_keystroke(&Keystroke::parse("cmd-c").unwrap(), PlatformStyle::Linux),
+            keystroke_text(
+                &Keystroke::parse("cmd-c").unwrap(),
+                PlatformStyle::Linux,
+                false
+            ),
             "Super+C".to_string()
         );
         assert_eq!(
-            text_for_keystroke(&Keystroke::parse("cmd-c").unwrap(), PlatformStyle::Windows),
+            keystroke_text(
+                &Keystroke::parse("cmd-c").unwrap(),
+                PlatformStyle::Windows,
+                false
+            ),
             "Win+C".to_string()
         );
 
         assert_eq!(
-            text_for_keystroke(
+            keystroke_text(
                 &Keystroke::parse("ctrl-alt-delete").unwrap(),
-                PlatformStyle::Mac
+                PlatformStyle::Mac,
+                false
             ),
             "Control-Option-Delete".to_string()
         );
         assert_eq!(
-            text_for_keystroke(
+            keystroke_text(
                 &Keystroke::parse("ctrl-alt-delete").unwrap(),
-                PlatformStyle::Linux
+                PlatformStyle::Linux,
+                false
             ),
             "Ctrl+Alt+Delete".to_string()
         );
         assert_eq!(
-            text_for_keystroke(
+            keystroke_text(
                 &Keystroke::parse("ctrl-alt-delete").unwrap(),
-                PlatformStyle::Windows
+                PlatformStyle::Windows,
+                false
             ),
             "Ctrl+Alt+Delete".to_string()
         );
 
         assert_eq!(
-            text_for_keystroke(
+            keystroke_text(
                 &Keystroke::parse("shift-pageup").unwrap(),
-                PlatformStyle::Mac
+                PlatformStyle::Mac,
+                false
             ),
             "Shift-PageUp".to_string()
         );
         assert_eq!(
-            text_for_keystroke(
+            keystroke_text(
                 &Keystroke::parse("shift-pageup").unwrap(),
-                PlatformStyle::Linux
+                PlatformStyle::Linux,
+                false,
             ),
             "Shift+PageUp".to_string()
         );
         assert_eq!(
-            text_for_keystroke(
+            keystroke_text(
                 &Keystroke::parse("shift-pageup").unwrap(),
-                PlatformStyle::Windows
+                PlatformStyle::Windows,
+                false
             ),
             "Shift+PageUp".to_string()
         );

crates/ui/src/components/keybinding_hint.rs πŸ”—

@@ -207,10 +207,10 @@ impl RenderOnce for KeybindingHint {
 
 // View this component preview using `workspace: open component-preview`
 impl ComponentPreview for KeybindingHint {
-    fn preview(window: &mut Window, _cx: &App) -> AnyElement {
+    fn preview(window: &mut Window, cx: &App) -> AnyElement {
         let enter_fallback = gpui::KeyBinding::new("enter", menu::Confirm, None);
-        let enter = KeyBinding::for_action(&menu::Confirm, window)
-            .unwrap_or(KeyBinding::new(enter_fallback));
+        let enter = KeyBinding::for_action(&menu::Confirm, window, cx)
+            .unwrap_or(KeyBinding::new(enter_fallback, cx));
 
         v_flex()
             .gap_6()

crates/ui/src/components/stories/keybinding.rs πŸ”—

@@ -12,22 +12,22 @@ pub fn binding(key: &str) -> gpui::KeyBinding {
 }
 
 impl Render for KeybindingStory {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let all_modifier_permutations = ["ctrl", "alt", "cmd", "shift"].into_iter().permutations(2);
 
         Story::container()
             .child(Story::title_for::<KeyBinding>())
             .child(Story::label("Single Key"))
-            .child(KeyBinding::new(binding("Z")))
+            .child(KeyBinding::new(binding("Z"), cx))
             .child(Story::label("Single Key with Modifier"))
             .child(
                 div()
                     .flex()
                     .gap_3()
-                    .child(KeyBinding::new(binding("ctrl-c")))
-                    .child(KeyBinding::new(binding("alt-c")))
-                    .child(KeyBinding::new(binding("cmd-c")))
-                    .child(KeyBinding::new(binding("shift-c"))),
+                    .child(KeyBinding::new(binding("ctrl-c"), cx))
+                    .child(KeyBinding::new(binding("alt-c"), cx))
+                    .child(KeyBinding::new(binding("cmd-c"), cx))
+                    .child(KeyBinding::new(binding("shift-c"), cx)),
             )
             .child(Story::label("Single Key with Modifier (Permuted)"))
             .child(
@@ -41,39 +41,42 @@ impl Render for KeybindingStory {
                                 .gap_4()
                                 .py_3()
                                 .children(chunk.map(|permutation| {
-                                    KeyBinding::new(binding(&(permutation.join("-") + "-x")))
+                                    KeyBinding::new(binding(&(permutation.join("-") + "-x")), cx)
                                 }))
                         }),
                 ),
             )
             .child(Story::label("Single Key with All Modifiers"))
-            .child(KeyBinding::new(binding("ctrl-alt-cmd-shift-z")))
+            .child(KeyBinding::new(binding("ctrl-alt-cmd-shift-z"), cx))
             .child(Story::label("Chord"))
-            .child(KeyBinding::new(binding("a z")))
+            .child(KeyBinding::new(binding("a z"), cx))
             .child(Story::label("Chord with Modifier"))
-            .child(KeyBinding::new(binding("ctrl-a shift-z")))
-            .child(KeyBinding::new(binding("fn-s")))
+            .child(KeyBinding::new(binding("ctrl-a shift-z"), cx))
+            .child(KeyBinding::new(binding("fn-s"), cx))
             .child(Story::label("Single Key with All Modifiers (Linux)"))
             .child(
-                KeyBinding::new(binding("ctrl-alt-cmd-shift-z"))
+                KeyBinding::new(binding("ctrl-alt-cmd-shift-z"), cx)
                     .platform_style(PlatformStyle::Linux),
             )
             .child(Story::label("Chord (Linux)"))
-            .child(KeyBinding::new(binding("a z")).platform_style(PlatformStyle::Linux))
+            .child(KeyBinding::new(binding("a z"), cx).platform_style(PlatformStyle::Linux))
             .child(Story::label("Chord with Modifier (Linux)"))
-            .child(KeyBinding::new(binding("ctrl-a shift-z")).platform_style(PlatformStyle::Linux))
-            .child(KeyBinding::new(binding("fn-s")).platform_style(PlatformStyle::Linux))
+            .child(
+                KeyBinding::new(binding("ctrl-a shift-z"), cx).platform_style(PlatformStyle::Linux),
+            )
+            .child(KeyBinding::new(binding("fn-s"), cx).platform_style(PlatformStyle::Linux))
             .child(Story::label("Single Key with All Modifiers (Windows)"))
             .child(
-                KeyBinding::new(binding("ctrl-alt-cmd-shift-z"))
+                KeyBinding::new(binding("ctrl-alt-cmd-shift-z"), cx)
                     .platform_style(PlatformStyle::Windows),
             )
             .child(Story::label("Chord (Windows)"))
-            .child(KeyBinding::new(binding("a z")).platform_style(PlatformStyle::Windows))
+            .child(KeyBinding::new(binding("a z"), cx).platform_style(PlatformStyle::Windows))
             .child(Story::label("Chord with Modifier (Windows)"))
             .child(
-                KeyBinding::new(binding("ctrl-a shift-z")).platform_style(PlatformStyle::Windows),
+                KeyBinding::new(binding("ctrl-a shift-z"), cx)
+                    .platform_style(PlatformStyle::Windows),
             )
-            .child(KeyBinding::new(binding("fn-s")).platform_style(PlatformStyle::Windows))
+            .child(KeyBinding::new(binding("fn-s"), cx).platform_style(PlatformStyle::Windows))
     }
 }

crates/ui/src/components/tooltip.rs πŸ”—

@@ -43,10 +43,10 @@ impl Tooltip {
         let title = title.into();
         let action = action.boxed_clone();
         move |window, cx| {
-            cx.new(|_| Self {
+            cx.new(|cx| Self {
                 title: title.clone(),
                 meta: None,
-                key_binding: KeyBinding::for_action(action.as_ref(), window),
+                key_binding: KeyBinding::for_action(action.as_ref(), window, cx),
             })
             .into()
         }
@@ -58,10 +58,10 @@ impl Tooltip {
         window: &mut Window,
         cx: &mut App,
     ) -> AnyView {
-        cx.new(|_| Self {
+        cx.new(|cx| Self {
             title: title.into(),
             meta: None,
-            key_binding: KeyBinding::for_action(action, window),
+            key_binding: KeyBinding::for_action(action, window, cx),
         })
         .into()
     }
@@ -73,10 +73,10 @@ impl Tooltip {
         window: &mut Window,
         cx: &mut App,
     ) -> AnyView {
-        cx.new(|_| Self {
+        cx.new(|cx| Self {
             title: title.into(),
             meta: None,
-            key_binding: KeyBinding::for_action_in(action, focus_handle, window),
+            key_binding: KeyBinding::for_action_in(action, focus_handle, window, cx),
         })
         .into()
     }
@@ -88,10 +88,10 @@ impl Tooltip {
         window: &mut Window,
         cx: &mut App,
     ) -> AnyView {
-        cx.new(|_| Self {
+        cx.new(|cx| Self {
             title: title.into(),
             meta: Some(meta.into()),
-            key_binding: action.and_then(|action| KeyBinding::for_action(action, window)),
+            key_binding: action.and_then(|action| KeyBinding::for_action(action, window, cx)),
         })
         .into()
     }
@@ -104,11 +104,11 @@ impl Tooltip {
         window: &mut Window,
         cx: &mut App,
     ) -> AnyView {
-        cx.new(|_| Self {
+        cx.new(|cx| Self {
             title: title.into(),
             meta: Some(meta.into()),
             key_binding: action
-                .and_then(|action| KeyBinding::for_action_in(action, focus_handle, window)),
+                .and_then(|action| KeyBinding::for_action_in(action, focus_handle, window, cx)),
         })
         .into()
     }

crates/vim/src/mode_indicator.rs πŸ”—

@@ -1,5 +1,5 @@
 use gpui::{div, Context, Element, Entity, Render, Subscription, WeakEntity, Window};
-use itertools::Itertools;
+use ui::text_for_keystrokes;
 use workspace::{item::ItemHandle, ui::prelude::*, StatusItemView};
 
 use crate::{Vim, VimEvent, VimGlobals};
@@ -15,7 +15,7 @@ impl ModeIndicator {
     /// Construct a new mode indicator in this window.
     pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
         cx.observe_pending_input(window, |this: &mut Self, window, cx| {
-            this.update_pending_keys(window);
+            this.update_pending_keys(window, cx);
             cx.notify();
         })
         .detach();
@@ -50,13 +50,10 @@ impl ModeIndicator {
         }
     }
 
-    fn update_pending_keys(&mut self, window: &mut Window) {
-        self.pending_keys = window.pending_input_keystrokes().map(|keystrokes| {
-            keystrokes
-                .iter()
-                .map(|keystroke| format!("{}", keystroke))
-                .join(" ")
-        });
+    fn update_pending_keys(&mut self, window: &mut Window, cx: &App) {
+        self.pending_keys = window
+            .pending_input_keystrokes()
+            .map(|keystrokes| text_for_keystrokes(keystrokes, cx));
     }
 
     fn vim(&self) -> Option<Entity<Vim>> {

crates/vim/src/state.rs πŸ”—

@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use std::borrow::BorrowMut;
 use std::{fmt::Display, ops::Range, sync::Arc};
-use ui::{Context, SharedString};
+use ui::{Context, KeyBinding, SharedString};
 use workspace::searchable::Direction;
 
 #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
@@ -217,6 +217,7 @@ impl VimGlobals {
 
         cx.observe_global::<SettingsStore>(move |cx| {
             if Vim::enabled(cx) {
+                KeyBinding::set_vim_mode(cx, true);
                 CommandPaletteFilter::update_global(cx, |filter, _| {
                     filter.show_namespace(Vim::NAMESPACE);
                 });
@@ -224,6 +225,7 @@ impl VimGlobals {
                     interceptor.set(Box::new(command_interceptor));
                 });
             } else {
+                KeyBinding::set_vim_mode(cx, true);
                 *Vim::globals(cx) = VimGlobals::default();
                 CommandPaletteInterceptor::update_global(cx, |interceptor, _| {
                     interceptor.clear();

crates/zed/src/zed/quick_action_bar/markdown_preview.rs πŸ”—

@@ -41,10 +41,7 @@ impl QuickActionBar {
                 Tooltip::with_meta(
                     "Preview Markdown",
                     Some(&markdown_preview::OpenPreview),
-                    format!(
-                        "{} to open in a split",
-                        text_for_keystroke(&alt_click, PlatformStyle::platform())
-                    ),
+                    format!("{} to open in a split", text_for_keystroke(&alt_click, cx)),
                     window,
                     cx,
                 )

crates/zeta/src/rate_completion_modal.rs πŸ”—

@@ -489,6 +489,7 @@ impl RateCompletionModal {
                                             &ThumbsDownActiveCompletion,
                                             focus_handle,
                                             window,
+                                            cx
                                         ))
                                         .on_click(cx.listener(move |this, _, window, cx| {
                                             this.thumbs_down_active(
@@ -507,6 +508,7 @@ impl RateCompletionModal {
                                             &ThumbsUpActiveCompletion,
                                             focus_handle,
                                             window,
+                                            cx
                                         ))
                                         .on_click(cx.listener(move |this, _, window, cx| {
                                             this.thumbs_up_active(&ThumbsUpActiveCompletion, window, cx);

docs/src/vim.md πŸ”—

@@ -408,8 +408,8 @@ The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for qui
 {
   "context": "vim_mode == normal || vim_mode == visual",
   "bindings": {
-    "s": ["vim::PushSneak", {}],
-    "S": ["vim::PushSneakBackward", {}]
+    "s": "vim::PushSneak",
+    "shift-s": "vim::PushSneakBackward"
   }
 }
 ```