vim: Don't steal focus from non-pane panels for search commands (#54012)

Richard Feldman created

When the Agent panel (or any dock panel without its own pane) is focused
and a file is open in the center editor, pressing `/` in vim mode would
steal focus to the buffer's search bar instead of staying on the panel.

## Root cause

`Vim::pane()` calls `workspace.focused_pane()`, which falls back to the
center pane when a dock panel without its own `pane()` method is
focused. Vim search commands then open the `BufferSearchBar` on the
center pane and focus it, stealing focus from the panel.

## Fix

Add a guard in `Vim::pane()` that returns `None` when the resolved pane
doesn't actually contain focus. This prevents all vim search/match
commands (`/`, `?`, `n`, `N`, `*`, `#`, etc.) from stealing focus from
non-pane panels.

All 497 vim tests and 253 agent_ui tests pass.

Release Notes:

- Fixed vim search (`/`) stealing focus from the Agent panel when a file
is open in the editor.

Change summary

Cargo.lock                         |  2 
crates/agent_ui/Cargo.toml         |  2 
crates/agent_ui/src/agent_panel.rs | 97 ++++++++++++++++++++++++++++++++
crates/vim/src/vim.rs              | 17 ++++-
4 files changed, 114 insertions(+), 4 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -391,6 +391,7 @@ dependencies = [
  "rope",
  "rules_library",
  "schemars",
+ "search",
  "semver",
  "serde",
  "serde_json",
@@ -415,6 +416,7 @@ dependencies = [
  "url",
  "util",
  "uuid",
+ "vim",
  "watch",
  "workspace",
  "zed_actions",

crates/agent_ui/Cargo.toml 🔗

@@ -135,8 +135,10 @@ remote = { workspace = true, features = ["test-support"] }
 remote_connection = { workspace = true, features = ["test-support"] }
 remote_server = { workspace = true, features = ["test-support"] }
 
+search = { workspace = true, features = ["test-support"] }
 semver.workspace = true
 reqwest_client.workspace = true
 tempfile.workspace = true
+vim.workspace = true
 tree-sitter-md.workspace = true
 unindent.workspace = true

crates/agent_ui/src/agent_panel.rs 🔗

@@ -8052,6 +8052,103 @@ mod tests {
             );
         });
     }
+    #[gpui::test]
+    async fn test_vim_search_does_not_steal_focus_from_agent_panel(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            agent::ThreadStore::init_global(cx);
+            language_model::LanguageModelRegistry::test(cx);
+            vim::init(cx);
+            search::init(cx);
+
+            // Enable vim mode
+            settings::SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings(cx, |s| s.vim_mode = Some(true));
+            });
+
+            // Load vim keybindings
+            let mut vim_key_bindings =
+                settings::KeymapFile::load_asset_allow_partial_failure("keymaps/vim.json", cx)
+                    .unwrap();
+            for key_binding in &mut vim_key_bindings {
+                key_binding.set_meta(settings::KeybindSource::Vim.meta());
+            }
+            cx.bind_keys(vim_key_bindings);
+        });
+
+        // Create a project with a file so we have a buffer in the center pane.
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project", json!({ "file.txt": "hello world" }))
+            .await;
+        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+
+        let multi_workspace =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace
+            .read_with(cx, |mw, _cx| mw.workspace().clone())
+            .unwrap();
+        let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
+
+        // Open a file in the center pane.
+        workspace
+            .update_in(&mut cx, |workspace, window, cx| {
+                workspace.open_paths(
+                    vec![PathBuf::from("/project/file.txt")],
+                    workspace::OpenOptions::default(),
+                    None,
+                    window,
+                    cx,
+                )
+            })
+            .await;
+        cx.run_until_parked();
+
+        // Add a BufferSearchBar to the center pane's toolbar, as a real
+        // workspace would have.
+        workspace.update_in(&mut cx, |workspace, window, cx| {
+            workspace.active_pane().update(cx, |pane, cx| {
+                pane.toolbar().update(cx, |toolbar, cx| {
+                    let search_bar = cx.new(|cx| search::BufferSearchBar::new(None, window, cx));
+                    toolbar.add_item(search_bar, window, cx);
+                });
+            });
+        });
+
+        // Create the agent panel and add it to the workspace.
+        let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
+            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
+            workspace.add_panel(panel.clone(), window, cx);
+            panel
+        });
+
+        // Open a thread so the panel has an active editor.
+        open_thread_with_connection(&panel, StubAgentConnection::new(), &mut cx);
+
+        // Focus the agent panel.
+        workspace.update_in(&mut cx, |workspace, window, cx| {
+            workspace.focus_panel::<AgentPanel>(window, cx);
+        });
+        cx.run_until_parked();
+
+        // Verify the agent panel has focus.
+        workspace.update_in(&mut cx, |_, window, cx| {
+            assert!(
+                panel.read(cx).focus_handle(cx).contains_focused(window, cx),
+                "Agent panel should be focused before pressing '/'"
+            );
+        });
+
+        // Press '/' — the vim search keybinding.
+        cx.simulate_keystrokes("/");
+
+        // Focus should remain on the agent panel.
+        workspace.update_in(&mut cx, |_, window, cx| {
+            assert!(
+                panel.read(cx).focus_handle(cx).contains_focused(window, cx),
+                "Focus should remain on the agent panel after pressing '/'"
+            );
+        });
+    }
 
     /// Connection that tracks closed sessions and detects prompts against
     /// sessions that no longer exist, used to reproduce session disassociation.

crates/vim/src/vim.rs 🔗

@@ -29,8 +29,8 @@ use editor::{
     movement::{self, FindRange},
 };
 use gpui::{
-    Action, App, AppContext, Axis, Context, Entity, EventEmitter, KeyContext, KeystrokeEvent,
-    Render, Subscription, Task, WeakEntity, Window, actions,
+    Action, App, AppContext, Axis, Context, Entity, EventEmitter, Focusable, KeyContext,
+    KeystrokeEvent, Render, Subscription, Task, WeakEntity, Window, actions,
 };
 use insert::{NormalBefore, TemporaryNormal};
 use language::{
@@ -1043,8 +1043,17 @@ impl Vim {
     }
 
     pub fn pane(&self, window: &Window, cx: &Context<Self>) -> Option<Entity<Pane>> {
-        self.workspace(window, cx)
-            .map(|workspace| workspace.read(cx).focused_pane(window, cx))
+        let pane = self
+            .workspace(window, cx)
+            .map(|workspace| workspace.read(cx).focused_pane(window, cx))?;
+        // `focused_pane` falls back to the center pane when a dock panel
+        // without its own pane (e.g. the Agent panel) has focus. Guard
+        // against that so vim search/match commands don't steal focus.
+        if pane.read(cx).focus_handle(cx).contains_focused(window, cx) {
+            Some(pane)
+        } else {
+            None
+        }
     }
 
     pub fn enabled(cx: &mut App) -> bool {