From 5e711fb7d3b6039afa7d3683d9ef0504f2de9cb2 Mon Sep 17 00:00:00 2001 From: Othavio Quiliao Date: Thu, 9 Apr 2026 16:04:06 -0300 Subject: [PATCH 1/2] fix: focus pane on left-click even when mouse reporting is active The Down(Left) handler called forward_pane_mouse_button before focus_pane and early-returned when the forward succeeded, which happens for any pane running a TUI that has enabled mouse reporting (e.g. Claude Code). As a result, clicking such a pane forwarded the click to the inner TUI but never updated herdr's focus state, leaving the previous pane highlighted. Reorder the handler to focus first and forward after, matching the pattern already used by handle_terminal_wheel. Shells and TUIs without mouse reporting (e.g. Codex) were unaffected because their forward returned false and the focus update happened in the fallthrough branch. Add a regression test that installs a pane runtime with mouse reporting enabled via the '\x1b[?1002h' DECSET sequence and asserts that a Down(Left) event retargets focus. --- src/app/input.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index cf9c9de9..6a770d06 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -2155,6 +2155,11 @@ impl AppState { return None; } } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + if self.forward_pane_mouse_button(&info, mouse) { self.selection = None; return None; @@ -2170,11 +2175,6 @@ impl AppState { col, self.pane_scroll_metrics(info.id), )); - - self.focus_pane(info.id); - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; - } } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x && mouse.column < p.rect.x + p.rect.width @@ -4573,4 +4573,70 @@ mod tests { assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); } + + #[tokio::test] + async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); + + let terminal_area = Rect::new(26, 2, 80, 18); + let pane_infos = ws.tabs[0].layout.panes(terminal_area); + let first_info = pane_infos + .iter() + .find(|p| p.id == first_pane) + .expect("first pane info") + .clone(); + let second_info = pane_infos + .iter() + .find(|p| p.id == second_pane) + .expect("second pane info") + .clone(); + + ws.tabs[0].runtimes.insert( + first_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + first_info.inner_rect.width.max(1), + first_info.inner_rect.height.max(1), + b"", + ), + ); + ws.tabs[0].runtimes.insert( + second_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + second_info.inner_rect.width.max(1), + second_info.inner_rect.height.max(1), + b"\x1b[?1002h", + ), + ); + + ws.tabs[0].layout.focus_pane(first_pane); + + 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; + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + first_pane, + "first pane should be focused before click" + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + second_info.inner_rect.x + 2, + second_info.inner_rect.y + 2, + )); + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane, + "left-clicking a pane with mouse reporting should move focus to it" + ); + assert_eq!(app.state.mode, Mode::Terminal); + } + } From 42fa2648ee4b98775eee5df3ccf8ecdcbec88fb3 Mon Sep 17 00:00:00 2001 From: Othavio Quiliao Date: Thu, 9 Apr 2026 16:06:27 -0300 Subject: [PATCH 2/2] fix: always open pane context menu on right-click The Down(Right) handler forwarded the click to the inner pane runtime when mouse reporting was active, then early-returned before creating the context menu state. For panes running a TUI that enables mouse reporting (e.g. Claude Code), right-clicking focused the pane but never opened the herdr context menu, even though right-click on plain shells or Codex still worked. Stop forwarding Down(Right) to the pane runtime altogether. Right-click on a pane now always focuses it and opens the herdr context menu, matching how terminal emulators like Ghostty, Alacritty, Kitty and iTerm2 treat right-click. This means the previous context menu actions (close pane, split, fullscreen, etc.) now also reach the intended pane via focus_pane before the menu is displayed. Remove Up(Right) and Drag(Right) from the middle/right forward branch so the inner TUI never sees an Up or Drag without a matching Down. Middle button forwarding is preserved because it is still used for paste in many terminal protocols. Tighten the existing right-click regression test with assertions that Mode::ContextMenu is entered and context_menu is populated. Without these extra checks the earlier version of the test only verified focus and failed to catch this exact bug. --- src/app/input.rs | 81 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index 6a770d06..b2412046 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -2336,10 +2336,7 @@ impl AppState { } } - MouseEventKind::Up(MouseButton::Middle) - | MouseEventKind::Up(MouseButton::Right) - | MouseEventKind::Drag(MouseButton::Middle) - | MouseEventKind::Drag(MouseButton::Right) + MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { @@ -2440,9 +2437,7 @@ impl AppState { MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - if self.forward_pane_mouse_button(&info, mouse) { - return None; - } + self.focus_pane(info.id); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane, x: mouse.column, @@ -4639,4 +4634,76 @@ mod tests { assert_eq!(app.state.mode, Mode::Terminal); } + #[tokio::test] + async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Vertical); + + let terminal_area = Rect::new(26, 2, 80, 18); + let pane_infos = ws.tabs[0].layout.panes(terminal_area); + let first_info = pane_infos + .iter() + .find(|p| p.id == first_pane) + .expect("first pane info") + .clone(); + let second_info = pane_infos + .iter() + .find(|p| p.id == second_pane) + .expect("second pane info") + .clone(); + + ws.tabs[0].runtimes.insert( + first_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + first_info.inner_rect.width.max(1), + first_info.inner_rect.height.max(1), + b"", + ), + ); + ws.tabs[0].runtimes.insert( + second_pane, + crate::pane::PaneRuntime::test_with_screen_bytes( + second_info.inner_rect.width.max(1), + second_info.inner_rect.height.max(1), + b"\x1b[?1002h", + ), + ); + + ws.tabs[0].layout.focus_pane(first_pane); + + 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; + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + first_pane, + "first pane should be focused before click" + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + second_info.inner_rect.x + 2, + second_info.inner_rect.y + 2, + )); + + assert_eq!( + app.state.workspaces[0].tabs[0].layout.focused(), + second_pane, + "right-clicking a pane with mouse reporting should move focus to it" + ); + assert_eq!( + app.state.mode, + Mode::ContextMenu, + "right-click should enter ContextMenu mode" + ); + assert!( + app.state.context_menu.is_some(), + "right-click should populate context_menu state" + ); + } }