aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-03 02:03:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-03 02:03:44 +0300
commitb695d7cbc0d29540993642c23b3d5798cbf9f7dc (patch)
treed7243c891dca6c8991583edd7666ec9fe8cc6f94
parentdaf636fef7fd68bcb1e30cfb155883c3b831513c (diff)
fix: forward any-motion pane mouse events
refs #419
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--src/app/input/mouse.rs181
-rw-r--r--src/ghostty/mod.rs13
-rw-r--r--src/pane.rs11
-rw-r--r--src/pane/input.rs26
-rw-r--r--src/pane/terminal.rs67
-rw-r--r--src/terminal/runtime.rs10
7 files changed, 306 insertions, 3 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index bc895693..e8b7d038 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -13,6 +13,7 @@
- Claude Code, Codex, and OpenCode integrations now report session identity only. Native state for those agents comes from Herdr's screen detection, while Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom socket integrations can still report state.
### Fixed
+- Pane apps that request any-motion mouse tracking now receive hover/move events, making Textual-style TUI mouse interaction more reliable inside Herdr. (#419)
- Kiro sub-agent tool approval prompts are now detected as blocked instead of working. (#388)
- Restored native agent sessions now resume across background workspaces and tabs after the first client provides terminal context instead of waiting until each pane is focused.
- Pane input no longer waits behind the PTY actor's idle read poll, restoring responsive typing at quiet shell prompts. (#379)
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 7bd68330..51a9763e 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -49,7 +49,9 @@ impl AppState {
MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
self.forward_pane_mouse_button(terminal_runtimes, &info, mouse);
}
- MouseEventKind::Moved => {}
+ MouseEventKind::Moved => {
+ self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse);
+ }
}
}
@@ -883,6 +885,12 @@ impl AppState {
}
}
+ MouseEventKind::Moved if self.mode == Mode::Terminal && !in_sidebar => {
+ if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse);
+ }
+ }
+
MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
self.workspace_press = None;
self.tab_press = None;
@@ -1490,6 +1498,30 @@ impl AppState {
true
}
+ pub(super) fn forward_pane_mouse_motion(
+ &self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ info: &PaneInfo,
+ mouse: MouseEvent,
+ ) -> bool {
+ let Some(ws_idx) = self.active else {
+ return false;
+ };
+ let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
+ else {
+ return false;
+ };
+ let column = mouse.column.saturating_sub(info.inner_rect.x);
+ let row = mouse.row.saturating_sub(info.inner_rect.y);
+ let Some(bytes) = rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers) else {
+ return false;
+ };
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse motion event");
+ }
+ true
+ }
+
fn forward_pane_reported_wheel(
&self,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -1779,6 +1811,153 @@ mod tests {
}
#[tokio::test]
+ async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() {
+ 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[?1003h\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.handle_pane_mouse_only(
+ &app.terminal_runtimes,
+ mouse(
+ MouseEventKind::Moved,
+ info.inner_rect.x + 2,
+ info.inner_rect.y + 3,
+ ),
+ );
+
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded mouse motion"),
+ Bytes::from_static(b"\x1b[<35;3;4M")
+ );
+ assert!(input_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn pane_mouse_motion_uses_computed_inner_rect_offsets() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 18,
+ 0,
+ b"\x1b[?1003h\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;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let info = app.state.view.pane_infos[0].clone();
+ assert!(info.inner_rect.x > 0, "sidebar offset should be present");
+ assert!(info.inner_rect.y > 0, "tab bar offset should be present");
+
+ app.state.handle_pane_mouse_only(
+ &app.terminal_runtimes,
+ mouse(
+ MouseEventKind::Moved,
+ info.inner_rect.x + 2,
+ info.inner_rect.y + 3,
+ ),
+ );
+
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded mouse motion"),
+ Bytes::from_static(b"\x1b[<35;3;4M")
+ );
+ assert!(input_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn mouse_dispatcher_downgrades_sgr_pixel_motion_to_cell_coordinates() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 18,
+ 0,
+ b"\x1b[?1003h\x1b[?1006h\x1b[?1016h",
+ 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;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let info = app.state.view.pane_infos[0].clone();
+ assert!(info.inner_rect.x > 0, "sidebar offset should be present");
+ assert!(info.inner_rect.y > 0, "tab bar offset should be present");
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Moved,
+ info.inner_rect.x + 2,
+ info.inner_rect.y + 3,
+ ));
+
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded mouse motion"),
+ Bytes::from_static(b"\x1b[<35;3;4M")
+ );
+ assert!(input_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn mouse_dispatcher_does_not_forward_motion_behind_herdr_modes() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 18,
+ 0,
+ b"\x1b[?1003h\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::Navigate;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let info = app.state.view.pane_infos[0].clone();
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Moved,
+ info.inner_rect.x + 2,
+ info.inner_rect.y + 3,
+ ));
+
+ 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");
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index 0576f340..20161d80 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -150,12 +150,15 @@ pub const MOUSE_BUTTON_WHEEL_LEFT: ffi::GhosttyMouseButton =
ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_SIX;
pub const MOUSE_BUTTON_WHEEL_RIGHT: ffi::GhosttyMouseButton =
ffi::GhosttyMouseButton_GHOSTTY_MOUSE_BUTTON_SEVEN;
+pub const MOUSE_FORMAT_SGR: ffi::GhosttyMouseFormat =
+ ffi::GhosttyMouseFormat_GHOSTTY_MOUSE_FORMAT_SGR;
pub const MODE_APPLICATION_CURSOR_KEYS: u16 = 1;
pub const MODE_FOCUS_EVENT: u16 = 1004;
pub const MODE_MOUSE_UTF8: u16 = 1005;
pub const MODE_MOUSE_SGR: u16 = 1006;
pub const MODE_MOUSE_ALTERNATE_SCROLL: u16 = 1007;
+pub const MODE_MOUSE_SGR_PIXELS: u16 = 1016;
pub const MODE_BRACKETED_PASTE: u16 = 2004;
pub const MODE_SYNCHRONIZED_OUTPUT: u16 = 2026;
@@ -2114,6 +2117,16 @@ impl MouseEncoder {
}
}
+ pub fn set_format(&mut self, format: ffi::GhosttyMouseFormat) {
+ unsafe {
+ ffi::ghostty_mouse_encoder_setopt(
+ self.raw,
+ ffi::GhosttyMouseEncoderOption_GHOSTTY_MOUSE_ENCODER_OPT_FORMAT,
+ (&format as *const ffi::GhosttyMouseFormat).cast(),
+ )
+ }
+ }
+
pub fn encode(&mut self, event: &MouseEvent) -> Result<Vec<u8>, Error> {
encode_with_retry(|buf, len, out_len| unsafe {
ffi::ghostty_mouse_encoder_encode(self.raw, event.raw, buf, len, out_len)
diff --git a/src/pane.rs b/src/pane.rs
index 7ab069bc..4611b315 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1900,6 +1900,17 @@ impl PaneRuntime {
.encode_mouse_button(kind, column, row, modifiers)
}
+ pub fn encode_mouse_motion(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ self.terminal
+ .encode_mouse_motion(kind, column, row, modifiers)
+ }
+
pub fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
diff --git a/src/pane/input.rs b/src/pane/input.rs
index 262e036d..6906e77d 100644
--- a/src/pane/input.rs
+++ b/src/pane/input.rs
@@ -58,6 +58,14 @@ pub(super) fn ghostty_mouse_encoder_for_terminal(
) -> Option<crate::ghostty::MouseEncoder> {
let mut encoder = crate::ghostty::MouseEncoder::new().ok()?;
encoder.set_from_terminal(terminal);
+ if terminal
+ .mode_get(crate::ghostty::MODE_MOUSE_SGR_PIXELS)
+ .ok()?
+ {
+ // Herdr receives host mouse positions in terminal cells. Downgrade
+ // SGR-pixels to normal SGR so forwarded coordinates stay cell-local.
+ encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR);
+ }
let cols = terminal.cols().ok()? as u32;
let rows = terminal.rows().ok()? as u32;
encoder.set_size(cols, rows, 1, 1);
@@ -121,6 +129,24 @@ pub(super) fn ghostty_mouse_event_from_button_kind(
Some(event)
}
+pub(super) fn ghostty_mouse_event_from_motion_kind(
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+) -> Option<crate::ghostty::MouseEvent> {
+ if kind != crossterm::event::MouseEventKind::Moved {
+ return None;
+ }
+
+ let mut event = crate::ghostty::MouseEvent::new().ok()?;
+ event.set_action(crate::ghostty::MOUSE_ACTION_MOTION);
+ event.clear_button();
+ event.set_mods(ghostty_mods_from_key_modifiers(modifiers));
+ event.set_position(column as f32, row as f32);
+ Some(event)
+}
+
pub(super) fn ghostty_mouse_event_from_wheel_kind(
kind: crossterm::event::MouseEventKind,
column: u16,
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index be1200e6..0a67f11a 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -16,8 +16,8 @@ use crate::protocol::CellData;
use super::{
input::{
ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal,
- ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_wheel_kind,
- ghostty_prefers_herdr_text_encoding,
+ ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_motion_kind,
+ ghostty_mouse_event_from_wheel_kind, ghostty_prefers_herdr_text_encoding,
},
kitty_keyboard::KittyKeyboardTracker,
osc::{
@@ -283,6 +283,17 @@ impl PaneTerminal {
.encode_mouse_button(kind, column, row, modifiers)
}
+ pub fn encode_mouse_motion(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ self.ghostty
+ .encode_mouse_motion(kind, column, row, modifiers)
+ }
+
pub fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
@@ -887,6 +898,24 @@ impl GhosttyPaneTerminal {
encoder.encode(&event).ok()
}
+ pub fn encode_mouse_motion(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ let Ok(core) = self.core.lock() else {
+ return None;
+ };
+ if !core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? {
+ return None;
+ }
+ let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
+ let event = ghostty_mouse_event_from_motion_kind(kind, column, row, modifiers)?;
+ encoder.encode(&event).ok()
+ }
+
pub fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
@@ -2351,6 +2380,40 @@ mod tests {
}
#[test]
+ fn ghostty_mouse_moved_encoding_uses_any_motion_state() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal.write(b"\x1b[?1003h\x1b[?1006h");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let encoded = pane.encode_mouse_motion(
+ crossterm::event::MouseEventKind::Moved,
+ 4,
+ 6,
+ crossterm::event::KeyModifiers::empty(),
+ );
+
+ assert_eq!(encoded.as_deref(), Some(&b"\x1b[<35;5;7M"[..]));
+ }
+
+ #[test]
+ fn ghostty_mouse_sgr_pixels_downgrades_to_cell_coordinates() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal.write(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let encoded = pane.encode_mouse_motion(
+ crossterm::event::MouseEventKind::Moved,
+ 4,
+ 6,
+ crossterm::event::KeyModifiers::empty(),
+ );
+
+ assert_eq!(encoded.as_deref(), Some(&b"\x1b[<35;5;7M"[..]));
+ }
+
+ #[test]
fn ghostty_normalize_buffer_symbol_prefers_grapheme_width_when_metadata_disagrees() {
const WIDE_GRAPHEME: &str = "🙂";
const VS16_GRAPHEME: &str = "⚠️";
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index bfc8f3c6..6d761b11 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -333,6 +333,16 @@ impl TerminalRuntime {
self.0.encode_mouse_button(kind, column, row, modifiers)
}
+ pub fn encode_mouse_motion(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ self.0.encode_mouse_motion(kind, column, row, modifiers)
+ }
+
pub fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,