feat: add configurable right-click passthrough

refs #148
This commit is contained in:
Ogulcan Celik 2026-06-01 03:56:48 +03:00
parent 3ce4212e5e
commit 2fcf9b678d
9 changed files with 442 additions and 5 deletions

View File

@ -2,6 +2,9 @@
## Unreleased
### Added
- Added `ui.right_click_passthrough_modifier` so a configured modifier such as `ctrl` can forward right-click hold and drag gestures to mouse-reporting pane apps while normal right-click still opens Herdr's pane menu. (#148)
## [0.6.6] - 2026-05-31
### Added

View File

@ -242,6 +242,7 @@ sidebar_min_width = 18
sidebar_max_width = 36
mobile_width_threshold = 64
mouse_capture = true
right_click_passthrough_modifier = ""
redraw_on_focus_gained = true
mouse_scroll_lines = 3
confirm_close = true
@ -261,6 +262,8 @@ accent = "cyan"
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 `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.
Set `redraw_on_focus_gained = false` to avoid the visible full-screen refresh when switching back to Herdr. The default is `true` because a full redraw recovers from rare stale or dirty host terminal surfaces.
Set `mouse_scroll_lines` to change how many pane scrollback lines each mouse wheel notch scrolls. The default is 3. Pane apps that request mouse reporting still receive wheel events directly.

View File

@ -51,6 +51,8 @@ After detaching, run `herdr` again to reattach to the same session.
Herdr is mouse-native. You can click panes, tabs, workspaces, and agents; drag borders; drag-select text to copy it to your clipboard; double-click a token to copy it directly; and use right-click menus. Copying does not require Ctrl+C.
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.
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.
## Copy from the keyboard

View File

@ -1290,11 +1290,16 @@ mod tests {
},
x: 0,
y: 0,
list: MenuListState::new(4),
list: MenuListState::new(0),
};
let idx = menu
.items()
.iter()
.position(|item| *item == "Close pane")
.expect("close pane item");
let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 4);
apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, idx);
assert_eq!(state.selected, 0);
assert_eq!(state.mode, Mode::ConfirmClose);

View File

@ -6,7 +6,8 @@ use tracing::warn;
use crate::{
app::state::{
AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
MenuListState, Mode, TabPressState, ViewLayout, WorkspacePressState,
MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout,
WorkspacePressState,
},
layout::{PaneInfo, SplitBorder},
selection::Selection,
@ -141,6 +142,10 @@ impl AppState {
&& mouse.row >= sidebar.y
&& mouse.row < sidebar.y + sidebar.height;
if self.handle_right_click_passthrough(terminal_runtimes, mouse, in_sidebar) {
return None;
}
if self.mode == Mode::OpenExistingWorktree {
match mouse.kind {
MouseEventKind::ScrollUp => {
@ -1335,6 +1340,82 @@ impl AppState {
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
}
fn handle_right_click_passthrough(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
mouse: MouseEvent,
in_sidebar: bool,
) -> bool {
if let Some(gesture) = self.right_click_passthrough.clone() {
match mouse.kind {
MouseEventKind::Drag(MouseButton::Right)
| MouseEventKind::Up(MouseButton::Right) => {
let forwarded_mouse =
self.strip_right_click_passthrough_modifiers(mouse, gesture.modifiers);
let _ = self.forward_pane_mouse_button(
terminal_runtimes,
&gesture.pane_info,
forwarded_mouse,
);
if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Right)) {
self.right_click_passthrough = None;
}
return true;
}
_ => {
self.right_click_passthrough = None;
}
}
}
if self.mode != Mode::Terminal
|| in_sidebar
|| !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
{
return false;
}
let Some(modifiers) = self.right_click_passthrough_modifiers else {
return false;
};
if mouse.modifiers != modifiers {
return false;
}
let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else {
return false;
};
self.focus_pane(info.id);
let forwarded_mouse = self.strip_right_click_passthrough_modifiers(mouse, modifiers);
if !self.forward_pane_mouse_button(terminal_runtimes, &info, forwarded_mouse) {
return false;
}
self.selection = None;
self.selection_autoscroll = None;
self.workspace_press = None;
self.tab_press = None;
self.drag = None;
self.context_menu = None;
self.right_click_passthrough = Some(RightClickPassthroughGesture {
pane_info: info,
modifiers,
});
true
}
fn strip_right_click_passthrough_modifiers(
&self,
mouse: MouseEvent,
modifiers: crossterm::event::KeyModifiers,
) -> MouseEvent {
MouseEvent {
modifiers: mouse.modifiers.difference(modifiers),
..mouse
}
}
pub(super) fn handle_terminal_wheel(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
@ -1640,6 +1721,194 @@ mod tests {
assert_eq!(metrics.offset_from_bottom, 7);
}
#[tokio::test]
async fn configured_right_click_passthrough_forwards_full_gesture_to_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
let (runtime, mut input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
0,
b"\x1b[?1002h\x1b[?1006h",
4,
);
ws.insert_test_runtime(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL);
let col = info.inner_rect.x + 2;
let row = info.inner_rect.y + 3;
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL,
..mouse(MouseEventKind::Down(MouseButton::Right), col, row)
});
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL,
..mouse(MouseEventKind::Drag(MouseButton::Right), col + 1, row + 1)
});
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL,
..mouse(MouseEventKind::Up(MouseButton::Right), col + 1, row + 1)
});
assert_eq!(app.state.mode, Mode::Terminal);
assert!(app.state.context_menu.is_none());
assert!(app.state.right_click_passthrough.is_none());
assert_eq!(
input_rx.try_recv().expect("forwarded right mouse down"),
Bytes::from_static(b"\x1b[<2;3;4M")
);
assert_eq!(
input_rx.try_recv().expect("forwarded right mouse drag"),
Bytes::from_static(b"\x1b[<34;4;5M")
);
assert_eq!(
input_rx.try_recv().expect("forwarded right mouse up"),
Bytes::from_static(b"\x1b[<2;4;5m")
);
assert!(input_rx.try_recv().is_err());
}
#[tokio::test]
async fn unset_right_click_passthrough_keeps_modified_right_click_as_herdr_menu() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
let (runtime, mut input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
0,
b"\x1b[?1002h\x1b[?1006h",
4,
);
ws.insert_test_runtime(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
app.state.right_click_passthrough_modifiers = None;
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL,
..mouse(
MouseEventKind::Down(MouseButton::Right),
info.inner_rect.x + 2,
info.inner_rect.y + 3,
)
});
assert_eq!(app.state.mode, Mode::ContextMenu);
assert!(app.state.context_menu.is_some());
assert!(app.state.right_click_passthrough.is_none());
assert!(input_rx.try_recv().is_err());
}
#[tokio::test]
async fn right_click_passthrough_requires_exact_modifier_match() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
let (runtime, mut input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
0,
b"\x1b[?1002h\x1b[?1006h",
4,
);
ws.insert_test_runtime(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL);
let col = info.inner_rect.x + 2;
let row = info.inner_rect.y + 3;
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
..mouse(MouseEventKind::Down(MouseButton::Right), col, row)
});
assert_eq!(app.state.mode, Mode::ContextMenu);
assert!(app.state.context_menu.is_some());
assert!(app.state.right_click_passthrough.is_none());
assert!(input_rx.try_recv().is_err());
}
#[tokio::test]
async fn right_click_passthrough_does_not_forward_pane_frame_clicks() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let other_pane = ws.test_split(Direction::Vertical);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL);
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let info = app
.state
.view
.pane_infos
.iter()
.find(|info| info.id == pane_id)
.expect("pane info")
.clone();
let (runtime, mut input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
0,
b"\x1b[?1002h\x1b[?1006h",
4,
);
app.state.insert_test_runtime(pane_id, runtime);
app.state.insert_test_runtime(
other_pane,
crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""),
);
assert!(app.state.pane_at(info.rect.x, info.rect.y).is_none());
assert!(app
.state
.pane_mouse_target(info.rect.x, info.rect.y)
.is_some());
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL,
..mouse(
MouseEventKind::Down(MouseButton::Right),
info.rect.x,
info.rect.y,
)
});
assert_eq!(app.state.mode, Mode::ContextMenu);
assert!(app.state.context_menu.is_some());
assert!(app.state.right_click_passthrough.is_none());
assert!(input_rx.try_recv().is_err());
}
fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState {
crate::app::state::WorktreeOpenState {
source_workspace_id: "source".into(),

View File

@ -490,6 +490,8 @@ impl App {
sidebar_section_split,
agent_panel_scope,
mouse_capture: config.ui.mouse_capture,
right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(),
right_click_passthrough: None,
redraw_on_focus_gained: config.ui.redraw_on_focus_gained,
mouse_scroll_lines: config.ui.mouse_scroll_lines(),
confirm_close: config.ui.confirm_close,
@ -1107,6 +1109,8 @@ impl App {
}
self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained;
self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines();
self.state.right_click_passthrough_modifiers =
config.ui.right_click_passthrough_modifiers();
self.state.confirm_close = config.ui.confirm_close;
self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
self.state.show_agent_labels_on_pane_borders =
@ -1717,7 +1721,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n",
"[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@ -1742,6 +1746,10 @@ mod tests {
state::AgentPanelScope::CurrentWorkspace
);
assert!(!app.state.redraw_on_focus_gained);
assert_eq!(
app.state.right_click_passthrough_modifiers,
Some(KeyModifiers::CONTROL)
);
assert!(app.state.request_client_config_reload);
assert_eq!(app.state.default_shell, "nu");
assert_eq!(

View File

@ -31,6 +31,12 @@ pub(crate) struct SelectionAutoscroll {
pub last_mouse_screen_row: u16,
pub inner_rect: Rect,
}
#[derive(Clone)]
pub(crate) struct RightClickPassthroughGesture {
pub pane_info: PaneInfo,
pub modifiers: KeyModifiers,
}
use crate::terminal_theme::TerminalTheme;
use crate::workspace::Workspace;
@ -1238,6 +1244,8 @@ pub struct AppState {
/// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
/// captures mouse while the focused pane app requests mouse reporting.
pub mouse_capture: bool,
pub right_click_passthrough_modifiers: Option<KeyModifiers>,
pub right_click_passthrough: Option<RightClickPassthroughGesture>,
pub redraw_on_focus_gained: bool,
pub mouse_scroll_lines: usize,
pub confirm_close: bool,
@ -1548,6 +1556,8 @@ impl AppState {
sidebar_section_split: 0.5,
agent_panel_scope: AgentPanelScope::AllWorkspaces,
mouse_capture: true,
right_click_passthrough_modifiers: None,
right_click_passthrough: None,
redraw_on_focus_gained: true,
mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES,
confirm_close: true,

View File

@ -1,6 +1,7 @@
use std::num::NonZeroUsize;
use serde::{Deserialize, Deserializer, Serialize};
use crossterm::event::KeyModifiers;
use serde::{de, Deserialize, Deserializer, Serialize};
use super::{
BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD,
@ -34,6 +35,59 @@ impl AgentPanelScopeConfig {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RightClickPassthroughModifierConfig(Option<KeyModifiers>);
impl RightClickPassthroughModifierConfig {
pub fn modifiers(self) -> Option<KeyModifiers> {
self.0
}
}
impl<'de> Deserialize<'de> for RightClickPassthroughModifierConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
parse_right_click_passthrough_modifier(&value)
.map(Self)
.ok_or_else(|| {
de::Error::custom(
"right_click_passthrough_modifier must be empty, off, none, disabled, ctrl/control, alt/option, cmd/command/super, meta, hyper, or a + separated combination without shift",
)
})
}
}
fn parse_right_click_passthrough_modifier(value: &str) -> Option<Option<KeyModifiers>> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("off")
|| trimmed.eq_ignore_ascii_case("none")
|| trimmed.eq_ignore_ascii_case("disabled")
{
return Some(None);
}
let mut modifiers = KeyModifiers::empty();
for token in trimmed.split('+') {
let token = token.trim().to_ascii_lowercase();
let modifier = match token.as_str() {
"ctrl" | "control" => KeyModifiers::CONTROL,
"alt" | "option" => KeyModifiers::ALT,
"cmd" | "command" | "super" => KeyModifiers::SUPER,
"meta" => KeyModifiers::META,
"hyper" => KeyModifiers::HYPER,
"shift" => return None,
_ => return None,
};
modifiers |= modifier;
}
(!modifiers.is_empty()).then_some(Some(modifiers))
}
#[derive(Debug, Clone)]
pub struct ToastConfig {
pub delivery: ToastDelivery,
@ -276,6 +330,8 @@ pub struct UiConfig {
pub mobile_width_threshold: u16,
/// Capture mouse input for Herdr's mouse UI. Default: true.
pub mouse_capture: bool,
/// Modifier that lets right-click gestures pass through to pane apps. Empty disables it.
pub right_click_passthrough_modifier: RightClickPassthroughModifierConfig,
/// Force a full host-terminal redraw when the outer terminal regains focus. Default: true.
pub redraw_on_focus_gained: bool,
/// Lines to scroll per mouse wheel notch. Default: 3.
@ -438,6 +494,7 @@ impl Default for UiConfig {
sidebar_max_width: 36,
mobile_width_threshold: DEFAULT_MOBILE_WIDTH_THRESHOLD,
mouse_capture: true,
right_click_passthrough_modifier: RightClickPassthroughModifierConfig::default(),
redraw_on_focus_gained: true,
mouse_scroll_lines: None,
confirm_close: true,
@ -457,6 +514,10 @@ impl UiConfig {
.map(NonZeroUsize::get)
.unwrap_or(DEFAULT_MOUSE_SCROLL_LINES)
}
pub fn right_click_passthrough_modifiers(&self) -> Option<KeyModifiers> {
self.right_click_passthrough_modifier.modifiers()
}
}
impl Default for ToastConfig {
@ -701,6 +762,78 @@ mouse_capture = false
assert!(!config.ui.mouse_capture);
}
#[test]
fn right_click_passthrough_modifier_defaults_off_and_parses() {
let default_config = Config::default();
assert_eq!(default_config.ui.right_click_passthrough_modifiers(), None);
for value in ["", "off", "none", "disabled"] {
let toml = format!(
r#"
[ui]
right_click_passthrough_modifier = "{value}"
"#
);
let config: Config = toml::from_str(&toml).unwrap();
assert_eq!(
config.ui.right_click_passthrough_modifiers(),
None,
"value {value:?} should disable passthrough"
);
}
for (value, expected) in [
("ctrl", KeyModifiers::CONTROL),
("control", KeyModifiers::CONTROL),
("alt", KeyModifiers::ALT),
("option", KeyModifiers::ALT),
("cmd", KeyModifiers::SUPER),
("command", KeyModifiers::SUPER),
("super", KeyModifiers::SUPER),
("meta", KeyModifiers::META),
("hyper", KeyModifiers::HYPER),
] {
let toml = format!(
r#"
[ui]
right_click_passthrough_modifier = "{value}"
"#
);
let config: Config = toml::from_str(&toml).unwrap();
assert_eq!(
config.ui.right_click_passthrough_modifiers(),
Some(expected),
"value {value:?} should parse"
);
}
let toml = r#"
[ui]
right_click_passthrough_modifier = "cmd+alt"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(
config.ui.right_click_passthrough_modifiers(),
Some(KeyModifiers::SUPER | KeyModifiers::ALT)
);
}
#[test]
fn right_click_passthrough_modifier_rejects_shift() {
for value in ["shift", "shift+ctrl", "ctrl+", "ctrl++alt", "banana"] {
let toml = format!(
r#"
[ui]
right_click_passthrough_modifier = "{value}"
"#
);
assert!(
toml::from_str::<Config>(&toml).is_err(),
"value {value:?} should be rejected"
);
}
}
#[test]
fn redraw_on_focus_gained_default_on_and_parse() {
let default_config = Config::default();

View File

@ -193,6 +193,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Pane apps like lazygit and btop can still receive mouse when they request it.
# mouse_capture = true
# Optional modifier that forwards right-click hold/drag gestures to pane apps instead of opening Herdr's pane menu.
# Empty/off disables this. Shift is intentionally unsupported because terminals commonly reserve Shift+mouse.
# right_click_passthrough_modifier = ""
# Force a full redraw when the outer terminal regains focus.
# Set false to reduce visible flashing when switching back to Herdr.
# Trade-off: rare host terminal surface corruption may persist until the next full redraw.