terminal: Open links with Cmd/Ctrl-click when mouse mode is enabled (#60067)

This fixes #56956.

The terminal link-open gesture was split between `mouse_down` and
`mouse_up`, but both halves only ran in the non-mouse-reporting path.
When a foreground app enabled terminal mouse reporting, a secondary
click on a URL or file path was forwarded to the PTY instead of opening
the link.

This changes secondary-click link handling so it checks for a hyperlink
before mouse reporting on press, and completes the matching hyperlink
open before mouse reporting on release. The event is only consumed when
the click actually lands on the same link, so normal clicks in
mouse-reporting TUIs continue to be forwarded as before.

Validation:

- `CARGO_BUILD_JOBS=1 cargo test -j1 -p terminal
test_hyperlink_ctrl_click_same_position_in_mouse_mode`
- `CARGO_BUILD_JOBS=1 cargo check -j1 -p terminal`

Release Notes:

- Improved terminal links: Cmd/Ctrl-click now opens links even when the
application has mouse reporting enabled (e.g. vim, opencode, claude).
Disable `terminal.open_links_in_mouse_mode` to forward these clicks to
the application instead.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Tianze Zhao 2026-07-08 20:41:48 +08:00 committed by GitHub
parent 739f23926d
commit f5c975162c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 314 additions and 37 deletions

View file

@ -1897,6 +1897,12 @@
"copy_on_select": false,
// Whether to keep the text selection after copying it to the clipboard.
"keep_selection_on_copy": true,
// Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even
// when the terminal application has enabled mouse reporting (e.g. vim with
// mouse=a, htop). When false, these clicks are forwarded to the application
// instead, and hyperlinks can still be opened with shift-cmd-click
// (shift-ctrl-click).
"open_links_in_mouse_mode": true,
// Whether to show the terminal button in the status bar
"button": true,
// Any key-value pairs added to this list will be added to the terminal's

View file

@ -899,6 +899,7 @@ impl VsCodeSettings {
.map(FontSize::from),
font_weight: None,
keep_selection_on_copy: None,
open_links_in_mouse_mode: None,
line_height: self
.read_f32("terminal.integrated.lineHeight")
.map(|lh| TerminalLineHeight::Custom(lh)),

View file

@ -124,6 +124,14 @@ pub struct TerminalSettingsContent {
///
/// Default: true
pub keep_selection_on_copy: Option<bool>,
/// Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even
/// when the terminal application has enabled mouse reporting (e.g. vim with
/// mouse=a, htop). When false, these clicks are forwarded to the application
/// instead, and hyperlinks can still be opened with shift-cmd-click
/// (shift-ctrl-click).
///
/// Default: true
pub open_links_in_mouse_mode: Option<bool>,
/// Whether to show the terminal button in the status bar.
///
/// Default: true

View file

@ -7115,7 +7115,7 @@ fn terminal_page() -> SettingsPage {
]
}
fn behavior_settings_section() -> [SettingsPageItem; 5] {
fn behavior_settings_section() -> [SettingsPageItem; 6] {
[
SettingsPageItem::SectionHeader("Behavior Settings"),
SettingsPageItem::SettingItem(SettingItem {
@ -7179,6 +7179,29 @@ fn terminal_page() -> SettingsPage {
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Open Links In Mouse Mode",
description: "Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even when the terminal application has enabled mouse reporting. When disabled, these clicks are forwarded to the application; links can still be opened with shift-cmd-click.",
field: Box::new(SettingField {
organization_override: None,
json_path: Some("terminal.open_links_in_mouse_mode"),
pick: |settings_content| {
settings_content
.terminal
.as_ref()?
.open_links_in_mouse_mode
.as_ref()
},
write: |settings_content, value, _| {
settings_content
.terminal
.get_or_insert_default()
.open_links_in_mouse_mode = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Audible Bell",
description: "Whether to play a sound when the BEL character (`\\a`, `0x07`) is printed",

View file

@ -1004,6 +1004,8 @@ impl TerminalBuilder {
path_style,
#[cfg(any(test, feature = "test-support"))]
input_log: Vec::new(),
#[cfg(any(test, feature = "test-support"))]
pty_write_log: Default::default(),
};
TerminalBuilder {
@ -1275,6 +1277,8 @@ impl TerminalBuilder {
path_style,
#[cfg(any(test, feature = "test-support"))]
input_log: Vec::new(),
#[cfg(any(test, feature = "test-support"))]
pty_write_log: Default::default(),
};
if !activation_script.is_empty() && no_task {
@ -1442,6 +1446,8 @@ pub struct Terminal {
path_style: PathStyle,
#[cfg(any(test, feature = "test-support"))]
input_log: Vec<Vec<u8>>,
#[cfg(any(test, feature = "test-support"))]
pty_write_log: std::cell::RefCell<Vec<Vec<u8>>>,
}
struct CopyTemplate {
@ -1964,8 +1970,10 @@ impl Terminal {
/// Write the Input payload to the PTY, if applicable.
/// (This is a no-op for display-only terminals.)
fn write_to_pty(&self, input: impl Into<Cow<'static, [u8]>>) {
let input = input.into();
#[cfg(any(test, feature = "test-support"))]
self.pty_write_log.borrow_mut().push(input.to_vec());
if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
let input = input.into();
if log::log_enabled!(log::Level::Debug) {
if let Ok(str) = str::from_utf8(&input) {
log::debug!("Writing to PTY: {:?}", str);
@ -2097,6 +2105,11 @@ impl Terminal {
std::mem::take(&mut self.input_log)
}
#[cfg(any(test, feature = "test-support"))]
pub fn take_pty_write_log(&mut self) -> Vec<Vec<u8>> {
std::mem::take(self.pty_write_log.get_mut())
}
#[cfg(any(test, feature = "test-support"))]
pub fn keyboard_input_sent(&self) -> bool {
self.keyboard_input_sent
@ -2307,22 +2320,30 @@ impl Terminal {
pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context<Self>) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
if self.mouse_mode(e.modifiers.shift) {
let (point, side) = grid_point_and_side(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if self.mouse_changed(point, side) {
let bytes = mouse_moved_report(
point,
e.pressed_button,
e.modifiers,
self.last_content.mode,
// A ctrl/cmd press on a link suppressed its button-press report in
// `mouse_down`. Since the app never saw the press, we must swallow
// the whole gesture rather than forward later motion/release
// reports, which would be a press-less (malformed) sequence.
// `mouse_up` resolves it: release on the same link opens it,
// otherwise the gesture is dropped.
if self.mouse_down_hyperlink.is_none() {
let (point, side) = grid_point_and_side(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if let Some(bytes) = bytes {
self.write_to_pty(bytes);
if self.mouse_changed(point, side) {
let bytes = mouse_moved_report(
point,
e.pressed_button,
e.modifiers,
self.last_content.mode,
);
if let Some(bytes) = bytes {
self.write_to_pty(bytes);
}
}
}
} else {
@ -2444,7 +2465,7 @@ impl Terminal {
Some(scroll_lines.clamp(-3, 3))
}
pub fn mouse_down(&mut self, e: &MouseDownEvent, _cx: &mut Context<Self>) {
pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context<Self>) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
let point = grid_point(
position,
@ -2454,7 +2475,8 @@ impl Terminal {
if e.button == MouseButton::Left
&& e.modifiers.secondary()
&& !self.mouse_mode(e.modifiers.shift)
&& (TerminalSettings::get_global(cx).open_links_in_mouse_mode
|| !self.mouse_mode(e.modifiers.shift))
{
self.mouse_down_hyperlink = self.find_hyperlink_at_point(point);
@ -2504,7 +2526,7 @@ impl Terminal {
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
MouseButton::Middle => {
if let Some(item) = _cx.read_from_primary() {
if let Some(item) = cx.read_from_primary() {
let text = item.text().unwrap_or_default();
self.paste(&text);
}
@ -2518,6 +2540,33 @@ impl Terminal {
let setting = TerminalSettings::get_global(cx);
let position = e.position - self.last_content.terminal_bounds.bounds.origin;
if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
let point = grid_point(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if self
.find_hyperlink_at_point(point)
.is_some_and(|mouse_up_hyperlink| mouse_up_hyperlink == mouse_down_hyperlink)
{
self.events
.push_back(InternalEvent::ProcessHyperlink(mouse_down_hyperlink, true));
self.selection_phase = SelectionPhase::Ended;
self.last_mouse = None;
self.mouse_down_position = None;
return;
}
if self.mouse_mode(e.modifiers.shift) {
self.selection_phase = SelectionPhase::Ended;
self.last_mouse = None;
self.mouse_down_position = None;
return;
}
}
if self.mouse_mode(e.modifiers.shift) {
let point = grid_point(
position,
@ -2536,24 +2585,6 @@ impl Terminal {
self.copy(Some(true));
}
if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
let point = grid_point(
position,
self.last_content.terminal_bounds,
self.last_content.display_offset,
);
if let Some(mouse_up_hyperlink) = self.find_hyperlink_at_point(point) {
if mouse_down_hyperlink == mouse_up_hyperlink {
self.events
.push_back(InternalEvent::ProcessHyperlink(mouse_up_hyperlink, true));
self.selection_phase = SelectionPhase::Ended;
self.last_mouse = None;
return;
}
}
}
//Hyperlinks
if self.selection_phase == SelectionPhase::Ended {
let mouse_cell_index =
@ -3586,6 +3617,7 @@ mod tests {
);
terminal.last_content.terminal_bounds = terminal_bounds;
terminal.events.clear();
terminal.take_pty_write_log();
});
terminal
@ -3649,6 +3681,20 @@ mod tests {
terminal.mouse_down(&mouse_down, cx);
}
fn left_mouse_up_at(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
cx: &mut Context<Terminal>,
) {
let mouse_up = MouseUpEvent {
button: MouseButton::Left,
position,
modifiers: Modifiers::none(),
click_count: 1,
};
terminal.mouse_up(&mouse_up, cx);
}
fn left_mouse_drag_to(
terminal: &mut Terminal,
position: GpuiPoint<Pixels>,
@ -4408,6 +4454,166 @@ mod tests {
});
}
#[gpui::test]
async fn test_hyperlink_ctrl_click_same_position_in_mouse_mode(cx: &mut TestAppContext) {
let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
let click_position = point(px(80.0), px(10.0));
ctrl_mouse_down_at(terminal, click_position, cx);
ctrl_mouse_up_at(terminal, click_position, cx);
assert!(
terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
"Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position in mouse mode"
);
assert!(
terminal.take_pty_write_log().is_empty(),
"a consumed link click must not be reported to the PTY"
);
});
}
#[gpui::test]
async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture(
cx: &mut TestAppContext,
) {
let terminal = init_ctrl_click_hyperlink_test(
cx,
b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
);
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
terminal.take_pty_write_log();
let down_position = point(px(80.0), px(10.0));
let up_position = point(px(10.0), px(30.0));
ctrl_mouse_down_at(terminal, down_position, cx);
terminal.mouse_move(
&MouseMoveEvent {
position: up_position,
pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::secondary_key(),
},
cx,
);
ctrl_mouse_up_at(terminal, up_position, cx);
assert!(
!terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
"Should NOT open a link when press and release land on different hyperlinks"
);
let pty_writes = terminal.take_pty_write_log();
assert!(
pty_writes.is_empty(),
"a captured press must consume the whole gesture, but reports leaked to the PTY: {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_plain_click_on_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) {
let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
terminal.take_pty_write_log();
let click_position = point(px(80.0), px(10.0));
left_mouse_down_at(terminal, click_position, cx);
left_mouse_up_at(terminal, click_position, cx);
assert!(
!terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
"a plain click must not open a link"
);
let pty_writes = terminal.take_pty_write_log();
assert_eq!(
pty_writes.len(),
2,
"expected press and release reports, got {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_ctrl_click_on_non_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) {
let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
terminal.take_pty_write_log();
// Past the end of the line: nothing link-like under the cursor.
let click_position = point(px(370.0), px(10.0));
ctrl_mouse_down_at(terminal, click_position, cx);
ctrl_mouse_up_at(terminal, click_position, cx);
assert!(
!terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
"a secondary click off a link must not open anything"
);
let pty_writes = terminal.take_pty_write_log();
assert_eq!(
pty_writes.len(),
2,
"expected press and release reports, got {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_ctrl_click_in_mouse_mode_forwards_when_setting_disabled(cx: &mut TestAppContext) {
let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
cx.update_global(|store: &mut settings::SettingsStore, cx| {
store.update_user_settings(cx, |settings| {
settings
.terminal
.get_or_insert_default()
.open_links_in_mouse_mode = Some(false);
});
});
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
let click_position = point(px(80.0), px(10.0));
ctrl_mouse_down_at(terminal, click_position, cx);
ctrl_mouse_up_at(terminal, click_position, cx);
assert!(
!terminal
.events
.iter()
.any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
"with the setting disabled, ctrl+click must not open links in mouse mode"
);
let pty_writes = terminal.take_pty_write_log();
assert_eq!(
pty_writes.len(),
2,
"expected press and release reports, got {pty_writes:?}"
);
});
}
#[gpui::test]
async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) {
let terminal = init_ctrl_click_hyperlink_test(

View file

@ -35,6 +35,7 @@ pub struct TerminalSettings {
pub option_as_meta: bool,
pub copy_on_select: bool,
pub keep_selection_on_copy: bool,
pub open_links_in_mouse_mode: bool,
pub button: bool,
pub dock: TerminalDockPosition,
pub flexible: bool,
@ -105,6 +106,7 @@ impl settings::Settings for TerminalSettings {
option_as_meta: user_content.option_as_meta.unwrap(),
copy_on_select: user_content.copy_on_select.unwrap(),
keep_selection_on_copy: user_content.keep_selection_on_copy.unwrap(),
open_links_in_mouse_mode: user_content.open_links_in_mouse_mode.unwrap(),
button: user_content.button.unwrap(),
dock: user_content.dock.unwrap(),
default_width: px(user_content.default_width.unwrap()),

View file

@ -4158,6 +4158,7 @@ List of `integer` column numbers
"blinking": "terminal_controlled",
"copy_on_select": false,
"keep_selection_on_copy": true,
"open_links_in_mouse_mode": true,
"dock": "bottom",
"default_width": 640,
"default_height": 320,
@ -4352,6 +4353,26 @@ List of `integer` column numbers
}
```
### Terminal: Open Links In Mouse Mode
- Description: Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even when the terminal application has enabled mouse reporting (e.g. vim with `mouse=a`, htop). When `false`, these clicks are forwarded to the application instead, and hyperlinks can still be opened with shift-cmd-click (shift-ctrl-click).
- Setting: `open_links_in_mouse_mode`
- Default: `true`
**Options**
`boolean` values
**Example**
```json [settings]
{
"terminal": {
"open_links_in_mouse_mode": false
}
}
```
### Terminal: Env
- Description: Any key-value pairs added to this object will be added to the terminal's environment. Keys must be unique, use `:` to separate multiple values in a single variable

View file

@ -282,6 +282,16 @@ Common formats recognized:
- `src/main.rs:42:10` — Opens at line 42, column 10
- `File "script.py", line 10` — Python tracebacks
By default, `Cmd+Click`/`Ctrl+Click` opens links even when the running application has enabled mouse reporting (e.g. vim with `mouse=a`, htop). If you prefer those clicks to be forwarded to the application instead, disable `open_links_in_mouse_mode`; links can then still be opened with `Shift+Cmd+Click` (`Shift+Ctrl+Click`):
```json
{
"terminal": {
"open_links_in_mouse_mode": false
}
}
```
## Panel Configuration
### Dock Position