From c9d089534b3e0ddc1456377b6569112753b37edb Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 18:46:26 +0100 Subject: [PATCH] enable session scrolling across clients --- crates/maestro-tui/src/action.rs | 9 ++ crates/maestro-tui/src/app.rs | 157 ++++++++++++++++++++++++- crates/maestro-tui/src/config.rs | 3 + crates/maestro-tui/src/input.rs | 10 ++ crates/maestro-tui/src/pane.rs | 2 +- crates/maestro-tui/src/session_task.rs | 6 +- docs/INSTALL.md | 2 + web/src/attach.ts | 3 +- web/src/styles.css | 8 +- 9 files changed, 193 insertions(+), 7 deletions(-) diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index b96f7b2..97016b2 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -31,6 +31,10 @@ pub enum Action { FocusSidebar, FocusPane(Direction), + // Session scrollback + ScrollPanePageUp, + ScrollPanePageDown, + // Sidebar highlight movement SidebarNext, SidebarPrev, @@ -78,6 +82,8 @@ impl Action { format!("{direction:?}").to_lowercase() ) } + Action::ScrollPanePageUp => "Scroll session up one page".into(), + Action::ScrollPanePageDown => "Scroll session down one page".into(), Action::SidebarNext => "Select next sidebar item".into(), Action::SidebarPrev => "Select previous sidebar item".into(), Action::ToggleSidebarGroup => "Expand or collapse directory".into(), @@ -102,6 +108,8 @@ impl Action { "EnterInsert" => return Ok(Action::EnterInsert), "LeaveInsert" => return Ok(Action::LeaveInsert), "FocusSidebar" => return Ok(Action::FocusSidebar), + "ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp), + "ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown), "SidebarNext" => return Ok(Action::SidebarNext), "SidebarPrev" => return Ok(Action::SidebarPrev), "ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup), @@ -221,6 +229,7 @@ mod tests { Action::EnterInsert, Action::Quit, Action::NewDraftPane, + Action::ScrollPanePageDown, Action::FocusPane(Direction::Right), ] { let s = a.to_string(); diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 87d299e..fc6011a 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -207,6 +207,11 @@ impl App { return Ok(false); } + if let Some(effect) = self.handle_pane_scroll(&key) { + self.perform(effect.into_iter().collect()).await; + return Ok(false); + } + let known = self.cfg.keymap.known_bindings(self.state.mode); match self.chord.feed(&key, &known) { ChordResult::Pending => Ok(false), @@ -370,6 +375,10 @@ impl App { // either in the sidebar or back in the pane. self.state.focus = Focus::Pane; } + Action::ScrollPanePageUp => self.scroll_active_pane(self.pane_page_rows() as isize), + Action::ScrollPanePageDown => { + self.scroll_active_pane(-(self.pane_page_rows() as isize)) + } Action::SidebarNext => { let order = visible_sidebar_order( &self.catalog.snapshot(), @@ -501,11 +510,79 @@ impl App { /// attached to send it to. fn forward_to_session(&mut self, key: &InputKey) -> Option { let sid = self.state.active_session()?; - self.emulators.get(sid)?; + let handle = self.emulators.get(sid)?; let bytes = encode_key(key)?; + handle + .screen + .lock() + .expect("vt100 parser mutex") + .set_scrollback(0); Some(Effect::Forward { id: sid, bytes }) } + fn pane_page_rows(&self) -> usize { + self.region + .pane + .map_or(1, |area| usize::from(area.height.saturating_sub(1).max(1))) + } + + /// Positive rows move into older history; negative rows return toward the + /// live bottom. vt100 clamps both ends to the retained buffer. + fn scroll_active_pane(&mut self, rows: isize) { + let Some(sid) = self.state.active_session() else { + return; + }; + let Some(handle) = self.emulators.get(sid) else { + return; + }; + let mut parser = handle.screen.lock().expect("vt100 parser mutex"); + let current = parser.screen().scrollback(); + let next = if rows >= 0 { + current.saturating_add(rows as usize) + } else { + current.saturating_sub(rows.unsigned_abs()) + }; + parser.set_scrollback(next); + } + + /// Wheel input belongs to the terminal surface in every mode. Normal + /// shells move through Maestro's retained history; full-screen programs + /// that enabled terminal mouse reporting receive a real wheel event and + /// scroll their own alternate-screen content. + fn handle_pane_scroll(&mut self, key: &InputKey) -> Option> { + let InputKey::Mouse { + kind, + region: Region::Pane, + col, + row, + } = key + else { + return None; + }; + let lines = match kind { + MouseKind::ScrollUp => 3, + MouseKind::ScrollDown => -3, + _ => return None, + }; + let sid = self.state.active_session()?; + let handle = self.emulators.get(sid)?; + let pane = self.region.pane?; + let parser = handle.screen.lock().expect("vt100 parser mutex"); + if parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None { + let bytes = encode_mouse_wheel( + *kind, + col.saturating_sub(pane.x).saturating_add(1), + row.saturating_sub(pane.y).saturating_add(1), + parser.screen().mouse_protocol_encoding(), + ); + drop(parser); + return Some(Some(Effect::Forward { id: sid, bytes })); + } + drop(parser); + self.scroll_active_pane(lines); + Some(None) + } + /// Feed one keystroke into the active tab's draft form. Returns the spawn /// effect when the user accepted a valid form; an invalid one names the /// missing field inside the form (#64) instead of posting. @@ -1016,6 +1093,8 @@ fn encode_key(key: &InputKey) -> Option> { InputKey::Left => Some(b"\x1b[D".to_vec()), InputKey::Home => Some(b"\x1b[H".to_vec()), InputKey::End => Some(b"\x1b[F".to_vec()), + InputKey::PageUp => Some(b"\x1b[5~".to_vec()), + InputKey::PageDown => Some(b"\x1b[6~".to_vec()), InputKey::Delete => Some(b"\x1b[3~".to_vec()), InputKey::Paste(text) => { // Wrap in the DECSET 2004 markers. Raw bytes look like typed keys @@ -1031,6 +1110,39 @@ fn encode_key(key: &InputKey) -> Option> { } } +fn encode_mouse_wheel( + kind: MouseKind, + col: u16, + row: u16, + encoding: vt100::MouseProtocolEncoding, +) -> Vec { + let button = match kind { + MouseKind::ScrollUp => 64, + MouseKind::ScrollDown => 65, + _ => return Vec::new(), + }; + match encoding { + vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(), + vt100::MouseProtocolEncoding::Default => vec![ + 0x1b, + b'[', + b'M', + (button + 32) as u8, + col.min(223).saturating_add(32) as u8, + row.min(223).saturating_add(32) as u8, + ], + vt100::MouseProtocolEncoding::Utf8 => { + let mut out = b"\x1b[M".to_vec(); + for value in [button + 32, u32::from(col) + 32, u32::from(row) + 32] { + let ch = char::from_u32(value).expect("mouse coordinate is a valid codepoint"); + let mut bytes = [0; 4]; + out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes()); + } + out + } + } +} + /// Build the lines shown by the help overlay from the live keymap, so the /// user always sees the bindings that are actually active (defaults + their /// own `[tui.keymap.*]` overrides). @@ -1825,6 +1937,49 @@ mod tests { assert!(app.forward_to_session(&InputKey::Enter).is_none()); } + #[test] + fn pane_scrollback_moves_and_user_input_returns_to_live_output() { + use crate::session_task::{SessionHandle, SessionState}; + use std::sync::atomic::AtomicU8; + use std::sync::{Arc, Mutex}; + use tokio::sync::mpsc; + + let sid = Uuid::from_u128(91); + let mut parser = vt100::Parser::new(2, 20, 20); + parser.process(b"one\r\ntwo\r\nthree\r\nfour\r\nfive"); + let screen = Arc::new(Mutex::new(parser)); + let handle = SessionHandle { + tx: mpsc::channel(1).0, + screen: Arc::clone(&screen), + state: Arc::new(AtomicU8::new(SessionState::Live as u8)), + }; + let mut app = make_app(); + app.state.active_tab_mut().pane = Some(PaneRef::Session(sid)); + app.emulators.insert(sid, handle); + + app.scroll_active_pane(3); + assert!(screen.lock().unwrap().screen().scrollback() > 0); + assert!(app.forward_to_session(&InputKey::Enter).is_some()); + assert_eq!(screen.lock().unwrap().screen().scrollback(), 0); + } + + #[test] + fn mouse_wheel_encoding_supports_sgr_terminals() { + assert_eq!( + encode_mouse_wheel(MouseKind::ScrollUp, 7, 4, vt100::MouseProtocolEncoding::Sgr,), + b"\x1b[<64;7;4M" + ); + assert_eq!( + encode_mouse_wheel( + MouseKind::ScrollDown, + 7, + 4, + vt100::MouseProtocolEncoding::Sgr, + ), + b"\x1b[<65;7;4M" + ); + } + /// Regression review fix: `Paste(_)` used to be silently dropped, so a /// paste into an attached session vanished once bracketed paste was /// enabled. It also has to keep its DECSET 2004 markers — without them a diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 00e4678..8e05612 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -56,6 +56,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("w", "ShowWorktrees"), // Focus ("ctrl-w h", "FocusSidebar"), + // Session scrollback + ("pageup", "ScrollPanePageUp"), + ("pagedown", "ScrollPanePageDown"), // Chrome ("ctrl-w b", "ToggleSidebar"), ("?", "ShowHelp"), diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index b59d250..f0f1af2 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -36,6 +36,8 @@ pub enum InputKey { Right, Home, End, + PageUp, + PageDown, Delete, /// Bracketed paste: crossterm delivers the whole payload as one event, so /// text fields can insert it as a single operation. @@ -135,6 +137,8 @@ impl InputKey { InputKey::Right => "right".into(), InputKey::Home => "home".into(), InputKey::End => "end".into(), + InputKey::PageUp => "pageup".into(), + InputKey::PageDown => "pagedown".into(), InputKey::Delete => "delete".into(), // Pastes are not bindable; the canonical form exists only to // satisfy exhaustiveness and never round-trips through `parse`. @@ -204,6 +208,8 @@ impl InputKey { "right" => return Ok(InputKey::Right), "home" => return Ok(InputKey::Home), "end" => return Ok(InputKey::End), + "pageup" => return Ok(InputKey::PageUp), + "pagedown" => return Ok(InputKey::PageDown), "delete" => return Ok(InputKey::Delete), _ => {} } @@ -278,6 +284,8 @@ where KeyCode::Right => InputKey::Right, KeyCode::Home => InputKey::Home, KeyCode::End => InputKey::End, + KeyCode::PageUp => InputKey::PageUp, + KeyCode::PageDown => InputKey::PageDown, KeyCode::Delete => InputKey::Delete, _ => return None, }) @@ -614,6 +622,8 @@ mod tests { "right", "home", "end", + "pageup", + "pagedown", "delete", ] { let k = InputKey::parse(s).unwrap(); diff --git a/crates/maestro-tui/src/pane.rs b/crates/maestro-tui/src/pane.rs index 552ae1c..fcf0462 100644 --- a/crates/maestro-tui/src/pane.rs +++ b/crates/maestro-tui/src/pane.rs @@ -74,7 +74,7 @@ impl Pane { // Place the terminal cursor at vt100's cursor position when we own // focus and the cursor is on-screen. Terminals blink it on their own. - if self.focused && !screen.hide_cursor() { + if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() { let (cr, cc) = screen.cursor_position(); if cr < rows && cc < cols { f.set_cursor_position((area.x + cc, area.y + cr)); diff --git a/crates/maestro-tui/src/session_task.rs b/crates/maestro-tui/src/session_task.rs index 847bec9..df0db2a 100644 --- a/crates/maestro-tui/src/session_task.rs +++ b/crates/maestro-tui/src/session_task.rs @@ -23,6 +23,10 @@ use maestro_protocol::ClientControl; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::Message; +/// Match the browser terminal's retained history without letting long-running +/// sessions grow the client without bound. +pub const SCROLLBACK_ROWS: usize = 5_000; + /// Input the dispatcher (or the resize tracker) pushes to a session task. #[derive(Debug, Clone)] pub enum SessionInput { @@ -103,7 +107,7 @@ impl SessionTask { /// up with `SessionInput::Resize` on every frame whose allocation changes. pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle { let (tx, mut rx) = mpsc::channel::(64); - let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0))); + let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS))); let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8)); let parser_bg = parser.clone(); diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 68822b0..d6680f2 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -145,6 +145,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` "?" = "ShowHelp" "ctrl-w h" = "FocusSidebar" "enter" = "AttachHighlighted" +"pageup" = "ScrollPanePageUp" +"pagedown" = "ScrollPanePageDown" [tui.keymap.insert] # (leader is added automatically as LeaveInsert; only override here when you diff --git a/web/src/attach.ts b/web/src/attach.ts index 52ada32..0ec5594 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -123,7 +123,8 @@ export function createAttachConnection( // wheel events so xterm encodes them as mouse-wheel reports the app reads, // scrolling *its* content (the only scroll that exists on the alt screen). // • alt screen with no mouse tracking → nothing to do; the app owns input. - // `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture. + // `touch-action: none` (CSS) keeps the browser from cancelling the custom + // gesture stream; only the terminal surface opts out of native page panning. { const vp = el.querySelector(".xterm-viewport") as HTMLElement | null; const screen = el.querySelector(".xterm-screen") as HTMLElement | null; diff --git a/web/src/styles.css b/web/src/styles.css index 55b0124..07dbd41 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1142,9 +1142,11 @@ textarea { .attach-body .xterm .xterm-viewport { overflow-y: auto; scrollbar-width: none; - /* We drive scrollback from touchmove (see attach.ts); claim vertical pans so - the browser doesn't fight the handler or trigger gestures mid-scroll. */ - touch-action: pan-y; + /* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands + the gesture to the browser, which may cancel the event stream before the + terminal sees it; `none` keeps a drag inside the terminal under our + control while the rest of the page retains native scrolling. */ + touch-action: none; } .attach-body .xterm .xterm-viewport::-webkit-scrollbar {