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