terminal: Forward Shift navigation keys to alternate-screen programs (#57479)

I ran into this while using lazygit: the Shift navigation keys were
being captured by Zed to scroll the terminal buffer instead of being
passed through to the program.

Capturing them makes sense in the normal terminal case, where the user
wants to scroll and Shift has no other meaning. But in alternate-screen
TUIs like lazygit, less, or neovim, terminal scrollback isn't relevant,
so we can forward these keys to the program while it's open.

Release Notes:
- Fixed Shift+Up, Shift+Down, Shift+Home, and Shift+End in terminal TUIs
like lazygit, less, and neovim.

---------

Co-authored-by: John Tur <john-tur@outlook.com>
This commit is contained in:
Smit Barmase 2026-05-28 15:04:57 +05:30 committed by GitHub
parent bedfe32fdc
commit 6726b15fce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 202 additions and 41 deletions

View file

@ -303,8 +303,18 @@ mod test {
assert_eq!(to_esc_str(&home, &app_cursor, false), Some("\x1bOH".into()));
assert_eq!(to_esc_str(&end, &app_cursor, false), Some("\x1bOF".into()));
let shift_up = Keystroke::parse("shift-up").unwrap();
let shift_down = Keystroke::parse("shift-down").unwrap();
let shift_home = Keystroke::parse("shift-home").unwrap();
let shift_end = Keystroke::parse("shift-end").unwrap();
assert_eq!(
to_esc_str(&shift_up, &none, false),
Some("\x1b[1;2A".into())
);
assert_eq!(
to_esc_str(&shift_down, &none, false),
Some("\x1b[1;2B".into())
);
assert_eq!(
to_esc_str(&shift_home, &none, false),
Some("\x1b[1;2H".into())

View file

@ -673,7 +673,20 @@ impl TerminalView {
});
}
fn is_alt_screen(&self, cx: &App) -> bool {
self.terminal
.read(cx)
.last_content
.mode
.contains(TermMode::ALT_SCREEN)
}
fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context<Self>) {
if self.is_alt_screen(cx) {
cx.propagate();
return;
}
let terminal_content = self.terminal.read(cx).last_content();
if self.block_below_cursor.is_some()
&& terminal_content.display_offset == 0
@ -689,6 +702,11 @@ impl TerminalView {
}
fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context<Self>) {
if self.is_alt_screen(cx) {
cx.propagate();
return;
}
let terminal_content = self.terminal.read(cx).last_content();
if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
let max_scroll_top = self.max_scroll_top(cx);
@ -704,6 +722,11 @@ impl TerminalView {
}
fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context<Self>) {
if self.is_alt_screen(cx) {
cx.propagate();
return;
}
if self.scroll_top == Pixels::ZERO {
self.terminal.update(cx, |term, _| term.scroll_page_up());
} else {
@ -729,6 +752,11 @@ impl TerminalView {
}
fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context<Self>) {
if self.is_alt_screen(cx) {
cx.propagate();
return;
}
self.terminal.update(cx, |term, _| term.scroll_page_down());
let terminal = self.terminal.read(cx);
if terminal.last_content().display_offset < terminal.viewport_lines() {
@ -738,11 +766,21 @@ impl TerminalView {
}
fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context<Self>) {
if self.is_alt_screen(cx) {
cx.propagate();
return;
}
self.terminal.update(cx, |term, _| term.scroll_to_top());
cx.notify();
}
fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context<Self>) {
if self.is_alt_screen(cx) {
cx.propagate();
return;
}
self.terminal.update(cx, |term, _| term.scroll_to_bottom());
if self.block_below_cursor.is_some() {
self.scroll_top = self.max_scroll_top(cx);
@ -2060,7 +2098,7 @@ fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use gpui::{TestAppContext, VisualTestContext};
use project::{Entry, Project, ProjectPath, Worktree};
use remote::RemoteClient;
use std::path::{Path, PathBuf};
@ -2104,6 +2142,88 @@ mod tests {
assert_eq!(written, expected_text);
}
// DEC private mode 1049: a program writes this to enter the alternate screen buffer.
const ENTER_ALT_SCREEN: &[u8] = b"\x1b[?1049h";
// CSI `1;2A` = cursor-up with the xterm Shift modifier (`1 + 1` for Shift).
const SHIFT_UP_ESCAPE: &[u8] = b"\x1b[1;2A";
#[gpui::test]
async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) {
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
cx.update(load_default_keymap);
let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| {
let _ = window.draw(cx);
});
cx.run_until_parked();
let output = (0..200)
.map(|line| format!("line {line}\n"))
.collect::<String>();
cx.update(|window, cx| {
terminal.update(cx, |terminal, cx| {
terminal.write_output(output.as_bytes(), cx);
terminal.sync(window, cx);
});
});
terminal.read_with(&cx, |terminal, _| {
assert!(!terminal.last_content.mode.contains(TermMode::ALT_SCREEN));
assert_eq!(terminal.last_content.display_offset, 0);
});
cx.simulate_keystrokes("shift-up");
cx.update(|window, cx| {
terminal.update(cx, |terminal, cx| terminal.sync(window, cx));
});
assert_eq!(
terminal.read_with(&cx, |terminal, _| terminal.last_content.display_offset),
1,
"shift-up should scroll terminal history in the normal screen",
);
assert!(
terminal
.update(&mut cx, |terminal, _| terminal.take_input_log())
.is_empty(),
"shift-up in the normal screen should not be forwarded to the shell",
);
}
#[gpui::test]
async fn shift_up_is_forwarded_to_program_in_alt_screen(cx: &mut TestAppContext) {
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
cx.update(load_default_keymap);
let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| {
let _ = window.draw(cx);
});
cx.run_until_parked();
cx.update(|window, cx| {
terminal.update(cx, |terminal, cx| {
terminal.write_output(ENTER_ALT_SCREEN, cx);
terminal.sync(window, cx);
});
});
terminal.read_with(&cx, |terminal, _| {
assert!(terminal.last_content.mode.contains(TermMode::ALT_SCREEN));
});
cx.simulate_keystrokes("shift-up");
assert_eq!(
terminal.update(&mut cx, |terminal, _| terminal.take_input_log()),
vec![SHIFT_UP_ESCAPE.to_vec()],
"shift-up should be forwarded to the program in the alternate screen",
);
}
// Working directory calculation tests
// No Worktrees in project -> home_dir()
@ -2276,6 +2396,72 @@ mod tests {
(project, workspace)
}
fn load_default_keymap(cx: &mut App) {
cx.bind_keys(
settings::KeymapFile::load_asset_allow_partial_failure(
settings::DEFAULT_KEYMAP_PATH,
cx,
)
.unwrap(),
);
}
fn add_display_only_terminal(
project: &Entity<Project>,
window_handle: gpui::WindowHandle<MultiWorkspace>,
focus: bool,
cx: &mut TestAppContext,
) -> (Entity<Pane>, Entity<Terminal>, Entity<TerminalView>) {
let project = project.clone();
window_handle
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().clone();
let active_pane = workspace.read(cx).active_pane().clone();
let terminal = cx.new(|cx| {
terminal::TerminalBuilder::new_display_only(
CursorShape::default(),
terminal::terminal_settings::AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.unwrap()
.subscribe(cx)
});
let terminal_view = cx.new(|cx| {
TerminalView::new(
terminal.clone(),
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
});
active_pane.update(cx, |pane, cx| {
pane.add_item(
Box::new(terminal_view.clone()),
true,
false,
None,
window,
cx,
);
});
if focus {
let focus_handle = terminal_view.read(cx).focus_handle.clone();
focus_handle.focus(window, cx);
}
(active_pane, terminal, terminal_view)
})
.unwrap()
}
/// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle.
async fn init_test_with_window(
cx: &mut TestAppContext,
@ -2476,45 +2662,11 @@ mod tests {
})
.unwrap();
let (active_pane, terminal, terminal_view, tab_item) = window_handle
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspace().clone();
let active_pane = workspace.read(cx).active_pane().clone();
let terminal = cx.new(|cx| {
terminal::TerminalBuilder::new_display_only(
CursorShape::default(),
terminal::terminal_settings::AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.unwrap()
.subscribe(cx)
});
let terminal_view = cx.new(|cx| {
TerminalView::new(
terminal.clone(),
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
});
active_pane.update(cx, |pane, cx| {
pane.add_item(
Box::new(terminal_view.clone()),
true,
false,
None,
window,
cx,
);
});
let (active_pane, terminal, terminal_view) =
add_display_only_terminal(&project, window_handle, false, cx);
let tab_item = window_handle
.update(cx, |_, window, cx| {
let tab_project_item = cx.new(|_| TestProjectItem {
entry_id: Some(second_entry.id),
project_path: Some(ProjectPath {
@ -2528,8 +2680,7 @@ mod tests {
active_pane.update(cx, |pane, cx| {
pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx);
});
(active_pane, terminal, terminal_view, tab_item)
tab_item
})
.unwrap();