aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-15 17:29:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-15 17:45:44 +0300
commit0099c10881fd05ddecd3b535700d41eb3f42b633 (patch)
tree0c05211953394414b35fe6930b5b45594294833a
parent07261e09be60ac0c145685fd11f94351cc0e5ba6 (diff)
fix: use ctrl-click for captured pane links on macos
-rw-r--r--docs/next/CHANGELOG.md2
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx2
-rw-r--r--docs/next/website/src/content/docs/quick-start.mdx2
-rw-r--r--src/app/input/mod.rs12
-rw-r--r--src/app/input/terminal.rs74
-rw-r--r--src/raw_input.rs21
6 files changed, 99 insertions, 14 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 927e8d63..6dd0e9e2 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -57,7 +57,7 @@ This is a hotfix release for v0.6.9. See the v0.6.9 notes for the full feature r
- Numeric keypad keys that send VT100 application-keypad escape sequences now enter their digits and operators instead of being dropped. (#493)
- Codex panes now stay marked working when the live status header uses reasoning-summary text such as `Investigating code output` instead of the literal `Working` label. (#501)
- Codex blocker detection now ignores stale prompt text outside the live prompt region, reducing false blocked states from old scrollback.
-- Native pane URL clicks now use Cmd-click on macOS and Ctrl-click on other platforms. (#307)
+- Pane URL clicks, including plugin link handlers and native URL opening, now use Ctrl-click on macOS too because captured terminal mouse reports do not expose Cmd-click separately from plain click. (#307)
- Worktree open, create, and remove actions now work from bare repositories instead of assuming a normal checkout. (#497)
- Pane mouse handling no longer sends empty PTY writes for mouse events that produce no terminal input. (#496)
- Pane output now renders flag emoji and other multi-codepoint grapheme clusters as complete symbols instead of blank cells. (#243)
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index fbabb8ba..188d1237 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -299,7 +299,7 @@ accent = "cyan"
`confirm_close` controls whether closing a workspace asks for confirmation. `prompt_new_tab_name` controls whether new tabs ask for a label first.
-Set `mouse_capture = false` if you want your terminal to handle normal clicks, such as command-clicking URLs. With mouse capture enabled, Ctrl-click opens pane links when your terminal sends that modified click to Herdr; use Shift-Ctrl-click on Linux or Shift-Cmd-click on macOS for the terminal-native bypass path.
+Set `mouse_capture = false` if you want your terminal to handle normal clicks, such as command-clicking URLs. With mouse capture enabled, Ctrl-click opens pane links when your terminal sends that modified click to Herdr. This includes macOS; Cmd-click is not reported separately from plain click while Herdr captures mouse input. Use Shift-Ctrl-click on Linux or Shift-Cmd-click on macOS for the terminal-native bypass path.
Set `right_click_passthrough_modifier = "ctrl"` if you want Ctrl-right-click, hold, and drag gestures inside mouse-reporting pane apps to reach the app instead of opening Herdr's pane menu. The default is empty, which disables this passthrough. Supported modifiers are `ctrl`, `alt`, `cmd`, `super`, `meta`, and `hyper`; `shift` is rejected because many terminals reserve Shift+mouse for their own mouse bypass.
diff --git a/docs/next/website/src/content/docs/quick-start.mdx b/docs/next/website/src/content/docs/quick-start.mdx
index 26eaa9aa..a8f95e7c 100644
--- a/docs/next/website/src/content/docs/quick-start.mdx
+++ b/docs/next/website/src/content/docs/quick-start.mdx
@@ -19,7 +19,7 @@ When a session has no workspaces, Herdr opens one automatically. A workspace is
Herdr is mouse-native, so start by clicking. Click panes, tabs, workspaces, and agents to focus them. Drag split borders to resize. Right-click for context menus, including splitting panes and creating tabs. Drag-select text to copy it to your clipboard; double-click a token to copy it directly. Copying does not require Ctrl+C.
-Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and visible `http://` or `https://` URLs. The portable terminal-native fallback is Shift-Ctrl-click on Linux or Shift-Cmd-click on macOS.
+Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and visible `http://` or `https://` URLs. On macOS, use Ctrl-click for Herdr-handled pane links while mouse capture is enabled; Cmd-click is only available through the terminal-native bypass path, such as Shift-Cmd-click or `ui.mouse_capture = false`.
If you configure `ui.right_click_passthrough_modifier`, that modifier plus right-click sends right-click, hold, and drag gestures to mouse-reporting pane apps.
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 339f7293..cb4b0d46 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -23,23 +23,13 @@ enum WheelRouting {
const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
const TAB_DRAG_THRESHOLD: u16 = 1;
-#[cfg(target_os = "macos")]
-fn modified_url_click_modifier() -> KeyModifiers {
- KeyModifiers::SUPER
-}
-
-#[cfg(not(target_os = "macos"))]
fn modified_url_click_modifier() -> KeyModifiers {
KeyModifiers::CONTROL
}
#[cfg(test)]
#[test]
-fn modified_url_click_modifier_matches_platform_primary_modifier() {
- #[cfg(target_os = "macos")]
- assert_eq!(modified_url_click_modifier(), KeyModifiers::SUPER);
-
- #[cfg(not(target_os = "macos"))]
+fn modified_url_click_modifier_matches_terminal_mouse_reporting() {
assert_eq!(modified_url_click_modifier(), KeyModifiers::CONTROL);
}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 60f2d4cc..e13412a2 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -261,6 +261,45 @@ mod tests {
.is_some_and(crate::selection::Selection::is_visible));
}
+ #[cfg(unix)]
+ fn install_test_link_handler(app: &mut App) {
+ let plugin_root = std::env::temp_dir();
+ app.state.installed_plugins = std::collections::HashMap::from([(
+ "example.links".to_string(),
+ crate::api::schema::InstalledPluginInfo {
+ plugin_id: "example.links".into(),
+ name: "Links".into(),
+ version: "0.1.0".into(),
+ min_herdr_version: "0.6.10".into(),
+ description: None,
+ manifest_path: plugin_root.join("herdr-plugin.toml").display().to_string(),
+ plugin_root: plugin_root.display().to_string(),
+ enabled: true,
+ platforms: None,
+ build: Vec::new(),
+ actions: vec![crate::api::schema::PluginManifestAction {
+ id: "open".into(),
+ title: "Open link".into(),
+ description: None,
+ contexts: Vec::new(),
+ platforms: None,
+ command: vec!["sh".into(), "-c".into(), ":".into()],
+ }],
+ events: Vec::new(),
+ panes: Vec::new(),
+ link_handlers: vec![crate::api::schema::PluginManifestLinkHandler {
+ id: "github-issue".into(),
+ title: "Open GitHub issue".into(),
+ pattern: "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(),
+ action: "open".into(),
+ platforms: None,
+ }],
+ source: crate::api::schema::PluginSourceInfo::default(),
+ warnings: Vec::new(),
+ },
+ )]);
+ }
+
#[tokio::test]
async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() {
let mut app = app_for_mouse_test();
@@ -465,6 +504,41 @@ mod tests {
assert!(app.selection_highlight_clear_deadline.is_none());
}
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() {
+ let line = "see https://github.com/ogulcancelik/herdr/issues/398";
+ let col = line.find("github").expect("url host") as u16;
+
+ let (mut ctrl_app, ctrl_info) = app_with_screen_bytes(line.as_bytes());
+ install_test_link_handler(&mut ctrl_app);
+ ctrl_app.handle_mouse(modified_mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ ctrl_info.inner_rect.x + col,
+ ctrl_info.inner_rect.y,
+ KeyModifiers::CONTROL,
+ ));
+
+ let ctrl_log = ctrl_app
+ .state
+ .plugin_command_logs
+ .last()
+ .expect("ctrl-click should start plugin link handler");
+ assert_eq!(ctrl_log.plugin_id, "example.links");
+ assert_eq!(ctrl_log.action_id.as_deref(), Some("open"));
+
+ let (mut super_app, super_info) = app_with_screen_bytes(line.as_bytes());
+ install_test_link_handler(&mut super_app);
+ super_app.handle_mouse(modified_mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ super_info.inner_rect.x + col,
+ super_info.inner_rect.y,
+ KeyModifiers::SUPER,
+ ));
+
+ assert!(super_app.state.plugin_command_logs.is_empty());
+ }
+
#[tokio::test]
async fn pane_cell_url_resolver_finds_visible_url() {
let line = "see https://example.com/pr/307.";
diff --git a/src/raw_input.rs b/src/raw_input.rs
index fddcb3fd..346da476 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -818,6 +818,27 @@ mod tests {
assert_eq!(mouse.kind, MouseEventKind::Down(MouseButton::Left));
assert_eq!(mouse.column, 19);
assert_eq!(mouse.row, 9);
+ assert_eq!(mouse.modifiers, KeyModifiers::empty());
+ }
+
+ #[test]
+ fn parses_sgr_mouse_observable_modifiers() {
+ let cases = [
+ (b"\x1b[<8;20;10M".as_slice(), KeyModifiers::ALT),
+ (b"\x1b[<16;20;10M".as_slice(), KeyModifiers::CONTROL),
+ (
+ b"\x1b[<24;20;10M".as_slice(),
+ KeyModifiers::ALT | KeyModifiers::CONTROL,
+ ),
+ ];
+
+ for (input, expected) in cases {
+ let (RawInputEvent::Mouse(mouse), _) = extract_one_event(input).unwrap() else {
+ panic!("expected mouse");
+ };
+ assert_eq!(mouse.modifiers, expected);
+ assert!(!mouse.modifiers.contains(KeyModifiers::SUPER));
+ }
}
#[test]