From cb99ab4ac7ba4c8e60c437491ff2891e039fde26 Mon Sep 17 00:00:00 2001 From: Aleksei Gusev Date: Thu, 2 Apr 2026 21:26:18 +0300 Subject: [PATCH] Add PageUp/PageDown scrolling in agent view (#52657) Fixes #52656 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #52656 Release Notes: - Added keybindings for scrolling in agent view --------- Co-authored-by: Oleksiy Syvokon --- assets/keymaps/default-linux.json | 24 ++++ assets/keymaps/default-macos.json | 24 ++++ assets/keymaps/default-windows.json | 24 ++++ crates/agent_ui/src/agent_ui.rs | 16 +++ crates/agent_ui/src/conversation_view.rs | 7 +- .../src/conversation_view/thread_view.rs | 131 ++++++++++++++++-- crates/gpui/src/elements/list.rs | 7 + crates/vim/src/test/vim_test_context.rs | 12 +- docs/src/ai/agent-panel.md | 4 +- 9 files changed, 231 insertions(+), 18 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 98053432c5a186ecc886318f2d677f73a62295a2..4930fbea84b2b449f3b5c35fee2a390525cb3551 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -284,12 +284,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-d": "git::Diff", "shift-alt-y": "agent::KeepAll", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f0835a139a39602547d9d8da1cba93eaa7ee82a9..85c01bb33b54c30a55b5d046d03eb391d8c058c1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -327,12 +327,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-pageup": "agent::ScrollOutputPageUp", + "ctrl-pagedown": "agent::ScrollOutputPageDown", + "ctrl-home": "agent::ScrollOutputToTop", + "ctrl-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-pageup": "agent::ScrollOutputPageUp", + "ctrl-pagedown": "agent::ScrollOutputPageDown", + "ctrl-home": "agent::ScrollOutputToTop", + "ctrl-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage", "shift-ctrl-r": "agent::OpenAgentDiff", "shift-ctrl-d": "git::Diff", "shift-alt-y": "agent::KeepAll", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 41f36638e1dec40890ddecc6a808c669672e9317..0705717062ab5015de20cc3b93f651f867b5116d 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -285,12 +285,36 @@ "context": "AcpThread", "bindings": { "ctrl--": "pane::GoBack", + "pageup": "agent::ScrollOutputPageUp", + "pagedown": "agent::ScrollOutputPageDown", + "home": "agent::ScrollOutputToTop", + "end": "agent::ScrollOutputToBottom", + "up": "agent::ScrollOutputLineUp", + "down": "agent::ScrollOutputLineDown", + "shift-pageup": "agent::ScrollOutputToPreviousMessage", + "shift-pagedown": "agent::ScrollOutputToNextMessage", + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", }, }, { "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { + "ctrl-alt-pageup": "agent::ScrollOutputPageUp", + "ctrl-alt-pagedown": "agent::ScrollOutputPageDown", + "ctrl-alt-home": "agent::ScrollOutputToTop", + "ctrl-alt-end": "agent::ScrollOutputToBottom", + "ctrl-alt-up": "agent::ScrollOutputLineUp", + "ctrl-alt-down": "agent::ScrollOutputLineDown", + "ctrl-alt-shift-pageup": "agent::ScrollOutputToPreviousMessage", + "ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-d": "git::Diff", "shift-alt-y": "agent::KeepAll", diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 185a54825d3af18f16f2eb30188ea866c099bf32..e58c7eb3526cc1a53d7b8e6d449e968a5923425a 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -173,6 +173,22 @@ actions!( ToggleThinkingEffortMenu, /// Toggles fast mode for models that support it. ToggleFastMode, + /// Scroll the output by one page up. + ScrollOutputPageUp, + /// Scroll the output by one page down. + ScrollOutputPageDown, + /// Scroll the output up by three lines. + ScrollOutputLineUp, + /// Scroll the output down by three lines. + ScrollOutputLineDown, + /// Scroll the output to the top. + ScrollOutputToTop, + /// Scroll the output to the bottom. + ScrollOutputToBottom, + /// Scroll the output to the previous user message. + ScrollOutputToPreviousMessage, + /// Scroll the output to the next user message. + ScrollOutputToNextMessage, ] ); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 924f59437e51b02217289a5570f9560948c23ca2..1b9d364e9ce03702b47c63e8a856f0ba4b8aba87 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -85,8 +85,11 @@ use crate::{ AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, - RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode, - ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, + RemoveFirstQueuedMessage, ScrollOutputLineDown, ScrollOutputLineUp, ScrollOutputPageDown, + ScrollOutputPageUp, ScrollOutputToBottom, ScrollOutputToNextMessage, + ScrollOutputToPreviousMessage, ScrollOutputToTop, SendImmediately, SendNextQueuedMessage, + ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, + UndoLastReject, }; const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index c065c3de3d83c0eb5b68bf9a3610ff925762c952..c113eb0b768ee143eb69b5e705c15c91e367e6c2 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -552,17 +552,10 @@ impl ThreadView { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { if !is_following_tail { - let is_at_bottom = { - let current_offset = - list_state.scroll_px_offset_for_scrollbar().y.abs(); - let max_offset = list_state.max_offset_for_scrollbar().y; - current_offset >= max_offset - px(1.0) - }; - let is_generating = matches!(this.thread.read(cx).status(), ThreadStatus::Generating); - if is_at_bottom && is_generating { + if list_state.is_at_bottom() && is_generating { list_state.set_follow_tail(true); } } @@ -4952,7 +4945,7 @@ impl ThreadView { } pub fn scroll_to_end(&mut self, cx: &mut Context) { - self.list_state.scroll_to_end(); + self.list_state.set_follow_tail(true); cx.notify(); } @@ -4974,10 +4967,122 @@ impl ThreadView { } pub(crate) fn scroll_to_top(&mut self, cx: &mut Context) { + self.list_state.set_follow_tail(false); self.list_state.scroll_to(ListOffset::default()); cx.notify(); } + fn scroll_output_page_up( + &mut self, + _: &ScrollOutputPageUp, + _window: &mut Window, + cx: &mut Context, + ) { + let page_height = self.list_state.viewport_bounds().size.height; + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(-page_height * 0.9); + cx.notify(); + } + + fn scroll_output_page_down( + &mut self, + _: &ScrollOutputPageDown, + _window: &mut Window, + cx: &mut Context, + ) { + let page_height = self.list_state.viewport_bounds().size.height; + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(page_height * 0.9); + if self.list_state.is_at_bottom() { + self.list_state.set_follow_tail(true); + } + cx.notify(); + } + + fn scroll_output_line_up( + &mut self, + _: &ScrollOutputLineUp, + window: &mut Window, + cx: &mut Context, + ) { + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(-window.line_height() * 3.); + cx.notify(); + } + + fn scroll_output_line_down( + &mut self, + _: &ScrollOutputLineDown, + window: &mut Window, + cx: &mut Context, + ) { + self.list_state.set_follow_tail(false); + self.list_state.scroll_by(window.line_height() * 3.); + if self.list_state.is_at_bottom() { + self.list_state.set_follow_tail(true); + } + cx.notify(); + } + + fn scroll_output_to_top( + &mut self, + _: &ScrollOutputToTop, + _window: &mut Window, + cx: &mut Context, + ) { + self.scroll_to_top(cx); + } + + fn scroll_output_to_bottom( + &mut self, + _: &ScrollOutputToBottom, + _window: &mut Window, + cx: &mut Context, + ) { + self.scroll_to_end(cx); + } + + fn scroll_output_to_previous_message( + &mut self, + _: &ScrollOutputToPreviousMessage, + _window: &mut Window, + cx: &mut Context, + ) { + let entries = self.thread.read(cx).entries(); + let current_ix = self.list_state.logical_scroll_top().item_ix; + if let Some(target_ix) = (0..current_ix) + .rev() + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + { + self.list_state.set_follow_tail(false); + self.list_state.scroll_to(ListOffset { + item_ix: target_ix, + offset_in_item: px(0.), + }); + cx.notify(); + } + } + + fn scroll_output_to_next_message( + &mut self, + _: &ScrollOutputToNextMessage, + _window: &mut Window, + cx: &mut Context, + ) { + let entries = self.thread.read(cx).entries(); + let current_ix = self.list_state.logical_scroll_top().item_ix; + if let Some(target_ix) = (current_ix + 1..entries.len()) + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + { + self.list_state.set_follow_tail(false); + self.list_state.scroll_to(ListOffset { + item_ix: target_ix, + offset_in_item: px(0.), + }); + cx.notify(); + } + } + pub fn open_thread_as_markdown( &self, workspace: Entity, @@ -8541,6 +8646,14 @@ impl Render for ThreadView { .on_action(cx.listener(Self::handle_toggle_command_pattern)) .on_action(cx.listener(Self::open_permission_dropdown)) .on_action(cx.listener(Self::open_add_context_menu)) + .on_action(cx.listener(Self::scroll_output_page_up)) + .on_action(cx.listener(Self::scroll_output_page_down)) + .on_action(cx.listener(Self::scroll_output_line_up)) + .on_action(cx.listener(Self::scroll_output_line_down)) + .on_action(cx.listener(Self::scroll_output_to_top)) + .on_action(cx.listener(Self::scroll_output_to_bottom)) + .on_action(cx.listener(Self::scroll_output_to_previous_message)) + .on_action(cx.listener(Self::scroll_output_to_next_message)) .on_action(cx.listener(|this, _: &ToggleFastMode, _window, cx| { this.toggle_fast_mode(cx); })) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index ed441e3b40534690d02b31109e719c60dd5802e0..b4c8e7ca9015190fb8bb1698f79f1b025bfa4829 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -427,6 +427,13 @@ impl ListState { self.0.borrow().follow_tail } + /// Returns whether the list is scrolled to the bottom (within 1px). + pub fn is_at_bottom(&self) -> bool { + let current_offset = self.scroll_px_offset_for_scrollbar().y.abs(); + let max_offset = self.max_offset_for_scrollbar().y; + current_offset >= max_offset - px(1.0) + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 510d218df050455d0df0f9c2b7b782a651694cd7..6f15450aa3f70593c6877c293fecb765978e065d 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -109,12 +109,12 @@ impl VimTestContext { } cx.bind_keys(default_key_bindings); if enabled { - let vim_key_bindings = settings::KeymapFile::load_asset( - "keymaps/vim.json", - Some(settings::KeybindSource::Vim), - cx, - ) - .unwrap(); + 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); } } diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 2da2f37a67edea48e0c34b14cab1ec0fc81a522b..89b0126c55a12b08d4f21a01fea38758c4d509b7 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -67,7 +67,9 @@ Right-click on any agent response in the thread view to access a context menu wi ### Navigating the Thread {#navigating-the-thread} -In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread. +In long conversations, use the scroll arrow buttons at the bottom of the panel to jump to your most recent prompt or to the very beginning of the thread. You can also scroll the thread using arrow keys, Page Up/Down, Home/End, and Shift+Page Up/Down to jump between messages, when the thread pane is focused. + +When focus is in the message editor, you can also use {#kb agent::ScrollOutputPageUp}, {#kb agent::ScrollOutputPageDown}, {#kb agent::ScrollOutputToTop}, {#kb agent::ScrollOutputToBottom}, {#kb agent::ScrollOutputLineUp}, and {#kb agent::ScrollOutputLineDown} to navigate the thread, or {#kb agent::ScrollOutputToPreviousMessage} and {#kb agent::ScrollOutputToNextMessage} to jump between your prompts. ### Navigating History {#navigating-history}