From eb9b4b4ae83019b038143ee192cd56086068e8d9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 31 Aug 2026 22:07:22 +0100 Subject: [PATCH] fix(maestro): replay pane mouse modes on attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full-screen app enables mouse reporting once, at startup. A client attaching later never saw those sequences, so the web terminal read the pane as a plain alternate screen with mouseTrackingMode "none" and its touch and wheel gestures had nowhere to go — scrolling did nothing in a browser opened after the session started. Read the pane's mouse flags in the same display-message call and emit the matching DECSETs ahead of the screen content. tmux expands an unknown format name to the empty string, so the parser only trusts the mouse tail when every field is present. Closes #128 --- crates/maestro-daemon/src/tmux.rs | 143 +++++++++++++++++- .../maestro-daemon/tests/session_manager.rs | 53 +++++++ 2 files changed, 188 insertions(+), 8 deletions(-) diff --git a/crates/maestro-daemon/src/tmux.rs b/crates/maestro-daemon/src/tmux.rs index f2bfc3b..e216bd4 100644 --- a/crates/maestro-daemon/src/tmux.rs +++ b/crates/maestro-daemon/src/tmux.rs @@ -273,7 +273,7 @@ impl Tmux { "-t", &name, "-F", - "#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag}", + SCREEN_GEOMETRY_FORMAT, ";", "capture-pane", "-p", @@ -515,6 +515,52 @@ fn new_session_args( /// frame huge. const CAPTURE_HISTORY_LINES: usize = 300; +/// `display-message -F` request behind [`parse_screen_geometry`]. tmux expands +/// a name it does not know to the empty string rather than failing, so a field +/// missing on an older tmux shifts everything after it — hence the parser keys +/// the optional tail off the field count. +const SCREEN_GEOMETRY_FORMAT: &str = concat!( + "#{cursor_x} #{cursor_y} #{pane_height} #{alternate_on} #{cursor_flag} ", + "#{mouse_standard_flag} #{mouse_button_flag} #{mouse_all_flag} ", + "#{mouse_utf8_flag} #{mouse_sgr_flag}", +); + +/// Field count of [`SCREEN_GEOMETRY_FORMAT`]; fewer means an older tmux did not +/// know one of the names, and the mouse tail cannot be trusted by position. +const SCREEN_GEOMETRY_FIELDS: usize = 10; + +/// Mouse-reporting modes a pane has turned on. A full-screen program sets these +/// once, at startup, so a client attaching later never sees the sequences — +/// [`render_screen_snapshot`] replays them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct MouseModes { + /// `?1000` — report button press and release. + standard: bool, + /// `?1002` — also report motion while a button is held. + button: bool, + /// `?1003` — report all motion. + all: bool, + /// `?1005` — UTF-8 coordinates (superseded by SGR, still replayed if on). + utf8: bool, + /// `?1006` — SGR-encoded coordinates. + sgr: bool, +} + +impl MouseModes { + /// DECSET parameters to re-enable these modes, private-mode order. + fn decset_params(self) -> impl Iterator { + [ + (self.standard, 1000), + (self.button, 1002), + (self.all, 1003), + (self.utf8, 1005), + (self.sgr, 1006), + ] + .into_iter() + .filter_map(|(on, param)| on.then_some(param)) + } +} + /// Cursor position and screen geometry of a pane, from `display-message`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ScreenGeometry { @@ -523,17 +569,31 @@ struct ScreenGeometry { pane_height: usize, alternate: bool, cursor_visible: bool, + mouse: MouseModes, } fn parse_screen_geometry(s: &str) -> Option { let line = s.lines().next()?; - let mut it = line.split_whitespace(); - let cursor_x = it.next()?.parse().ok()?; - let cursor_y = it.next()?.parse().ok()?; - let pane_height: usize = it.next()?.parse().ok()?; - let alternate = it.next()? == "1"; + let fields: Vec<&str> = line.split_whitespace().collect(); + let cursor_x = fields.first()?.parse().ok()?; + let cursor_y = fields.get(1)?.parse().ok()?; + let pane_height: usize = fields.get(2)?.parse().ok()?; + let alternate = *fields.get(3)? == "1"; // Older tmux without `cursor_flag` just means "assume visible". - let cursor_visible = it.next().map(|f| f == "1").unwrap_or(true); + let cursor_visible = fields.get(4).map(|f| *f == "1").unwrap_or(true); + // Only trust the mouse tail when every field expanded; a short line means + // some name is unknown here and the remaining ones sit at other offsets. + let mouse = if fields.len() >= SCREEN_GEOMETRY_FIELDS { + MouseModes { + standard: fields[5] == "1", + button: fields[6] == "1", + all: fields[7] == "1", + utf8: fields[8] == "1", + sgr: fields[9] == "1", + } + } else { + MouseModes::default() + }; if pane_height == 0 { return None; } @@ -543,6 +603,7 @@ fn parse_screen_geometry(s: &str) -> Option { pane_height, alternate, cursor_visible, + mouse, }) } @@ -572,6 +633,12 @@ fn render_screen_snapshot(lines: &[&str], geom: ScreenGeometry) -> Vec { if geom.alternate { out.extend_from_slice(b"\x1b[?1049h"); } + // Mouse reporting is set up once, before this client existed. Without it + // the client cannot tell a mouse-driven app from a plain alternate screen, + // and its wheel and touch gestures have nowhere to go. + for param in geom.mouse.decset_params() { + out.extend_from_slice(format!("\x1b[?{param}h").as_bytes()); + } out.extend_from_slice(b"\x1b[H\x1b[2J\x1b[m"); for (i, line) in lines[..=last].iter().enumerate() { if i > 0 { @@ -645,7 +712,7 @@ mod tests { #[test] fn parse_screen_geometry_reads_cursor_and_alternate_flag() { - let g = parse_screen_geometry("12 3 40 1 0\n").unwrap(); + let g = parse_screen_geometry("12 3 40 1 0 0 1 0 0 1\n").unwrap(); assert_eq!( g, ScreenGeometry { @@ -654,6 +721,11 @@ mod tests { pane_height: 40, alternate: true, cursor_visible: false, + mouse: MouseModes { + button: true, + sgr: true, + ..MouseModes::default() + }, } ); let g = parse_screen_geometry("0 0 24 0").unwrap(); @@ -663,6 +735,31 @@ mod tests { assert!(parse_screen_geometry("garbage").is_none()); } + /// A tmux that does not know one of the mouse names expands it to nothing, + /// so the tail is one field short and every flag in it has slid. Reading it + /// by position would claim modes the pane never set. + #[test] + fn parse_screen_geometry_drops_a_short_mouse_tail() { + let g = parse_screen_geometry("0 0 24 1 1 0 0 1 1").unwrap(); + assert_eq!(g.mouse, MouseModes::default()); + } + + #[test] + fn mouse_modes_map_to_private_mode_parameters() { + let all = MouseModes { + standard: true, + button: true, + all: true, + utf8: true, + sgr: true, + }; + assert_eq!( + all.decset_params().collect::>(), + vec![1000, 1002, 1003, 1005, 1006] + ); + assert_eq!(MouseModes::default().decset_params().count(), 0); + } + #[test] fn snapshot_clears_prints_lines_and_parks_the_cursor() { // Two scrollback lines, then a 3-row screen with the cursor on row 1. @@ -675,6 +772,7 @@ mod tests { pane_height: 3, alternate: false, cursor_visible: true, + mouse: MouseModes::default(), }, ); let out = String::from_utf8(out).unwrap(); @@ -695,6 +793,7 @@ mod tests { pane_height: 3, alternate: true, cursor_visible: false, + mouse: MouseModes::default(), }, ); let out = String::from_utf8(out).unwrap(); @@ -716,12 +815,40 @@ mod tests { pane_height: 3, alternate: false, cursor_visible: true, + mouse: MouseModes::default(), }, )) .unwrap(); assert_eq!(out, "\x1b[H\x1b[2J\x1b[mtext\x1b[m\r\x1b[4C"); } + /// A client attaching to a running full-screen app never saw it turn mouse + /// reporting on, so the snapshot has to. Without this the client reads the + /// pane as a plain alternate screen and its scroll gestures go nowhere. + #[test] + fn snapshot_replays_the_panes_mouse_modes() { + let out = String::from_utf8(render_screen_snapshot( + &["app"], + ScreenGeometry { + cursor_x: 0, + cursor_y: 0, + pane_height: 1, + alternate: true, + cursor_visible: true, + mouse: MouseModes { + button: true, + sgr: true, + ..MouseModes::default() + }, + }, + )) + .unwrap(); + assert!( + out.starts_with("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"), + "{out:?}" + ); + } + /// Every phrasing must be recognised: an unmatched one makes /// `list_sessions` fail, and a failed pass reaps nothing. #[test] diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index a07114a..49c6fd3 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -246,6 +246,59 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() { fx.manager.kill(s.id, false).await.unwrap(); } +/// A full-screen app turns mouse reporting on once, at startup. A client that +/// attaches later never sees those sequences, so it reads the pane as a plain +/// alternate screen and its wheel and touch gestures have nowhere to go. The +/// snapshot must carry the pane's mouse modes with it. +#[tokio::test] +async fn attach_replay_carries_the_panes_mouse_modes() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + // Alternate screen, then button-event tracking (?1002) with SGR + // coordinates (?1006) — what tmux and agent TUIs actually request. + command: "sh -c 'printf \"\\\\033[?1049h\\\\033[?1002h\\\\033[?1006h\\\\033[2J\\\\033[HMOUSE_MARKER\"; exec sleep 30'" + .into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + + let want = b"MOUSE_MARKER"; + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut replay = Vec::new(); + while std::time::Instant::now() < deadline { + let (bytes, _rx) = fx + .manager + .subscribe_pty_with_replay(s.id) + .await + .expect("subscribe"); + replay = bytes; + if replay.windows(want.len()).any(|w| w == want) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let text = String::from_utf8_lossy(&replay).into_owned(); + assert!( + text.contains("MOUSE_MARKER"), + "replay never reached the live screen: {text:?}" + ); + assert!( + replay.starts_with(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"), + "replay dropped the pane's mouse modes: {text:?}" + ); + + fx.manager.kill(s.id, false).await.unwrap(); +} + /// Regression for NAP-228: after `recover`, PTY output must reach new /// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently /// kept feeding the old FIFO and the revived manager's subscribers saw