From 1997b88b3fa45f838d44e69dcebde8acf33899fc Mon Sep 17 00:00:00 2001 From: Kataoka Katsuki <49934462+kataokatsuki@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:46:43 +0900 Subject: [PATCH] fix(input): keep pending url clicks across host focus loss (#2291) Opening a URL raises the browser, which takes focus away from the host terminal before the mouse release arrives. release_input_source(_headless) cleared pending_url_click_sources on that focus loss, so the release was forwarded to the pane and the agent opened the same URL again. Clear the set in clear_input_source instead, whose only production caller is remove_client. refs #2290 Co-authored-by: kataokatsuki --- src/app/input/terminal.rs | 59 +++++++++++++++++++++++++++++++++++++-- src/app/mod.rs | 5 ++++ src/server/headless.rs | 3 +- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 1ba2789c..2442353e 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -350,7 +350,7 @@ impl App { } pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) { - self.pending_url_click_sources.remove(&source_id); + // Pending URL clicks survive this call; see clear_input_source. for pressed in self.take_pressed_keys_for_source(source_id) { let release = pressed .key @@ -360,7 +360,7 @@ impl App { } pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) { - self.pending_url_click_sources.remove(&source_id); + // Pending URL clicks survive this call; see clear_input_source. for pressed in self.take_pressed_keys_for_source(source_id) { let release = pressed .key @@ -924,6 +924,61 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn outer_focus_loss_does_not_forward_pending_url_click_release_to_pane() { + let line = "see https://github.com/herdrdev/herdr/issues/1761"; + let col = line.find("github").expect("url host") as u16; + let (mut app, info) = app_with_screen_bytes(b""); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}"); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 0, + screen.as_bytes(), + 4, + ); + app.state.insert_test_runtime(pane_id, runtime); + install_test_link_handler(&mut app); + let url_x = info.inner_rect.x + col; + + app.handle_mouse_from_input_source( + 41, + modified_mouse( + MouseEventKind::Down(MouseButton::Left), + url_x, + info.inner_rect.y, + KeyModifiers::CONTROL, + ), + ); + assert_eq!(app.state.plugin_command_logs.len(), 1); + + // Opening the URL raises the browser, so the host terminal loses focus + // while the button is still down. + app.route_client_events_from( + 41, + vec![crate::raw_input::RawInputEvent::OuterFocusLost], + false, + ); + + app.handle_mouse_from_input_source( + 41, + modified_mouse( + MouseEventKind::Up(MouseButton::Left), + url_x, + info.inner_rect.y, + KeyModifiers::empty(), + ), + ); + + assert!( + input_rx.try_recv().is_err(), + "focus loss must not clear a pending URL click, so its release must stay out of the pane" + ); + } + #[cfg(unix)] #[tokio::test] async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() { diff --git a/src/app/mod.rs b/src/app/mod.rs index 82ac1a60..c27c249e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1778,6 +1778,11 @@ impl App { } pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) { + // Call this only when the input source is gone for good. A pending URL + // click has to outlive a plain focus change, because opening the URL + // raises the browser and costs the host terminal its focus before the + // mouse release arrives. + self.pending_url_click_sources.remove(&source_id); self.release_input_source_headless(source_id); } diff --git a/src/server/headless.rs b/src/server/headless.rs index 752f8e8c..b4f433f7 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2710,7 +2710,8 @@ impl HeadlessServer { .iter() .any(|event| matches!(event, crate::raw_input::RawInputEvent::OuterFocusLost)) { - self.app.clear_input_source(client_id); + // Focus loss is not a teardown, so the pending URL click stays. + self.app.release_input_source_headless(client_id); } } let events = events_for_app_routing(events, source_was_foreground, source_is_full_app);