diff --git a/src/app/input.rs b/src/app/input.rs index 05667ab9..4822a2e0 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -16,6 +16,13 @@ enum ScrollbarClickTarget { Track { offset_from_bottom: usize }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WheelRouting { + HostScroll, + MouseReport, + AlternateScroll, +} + use super::state::{ key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, Mode, }; @@ -1071,33 +1078,9 @@ impl AppState { } } - MouseEventKind::ScrollUp if !in_sidebar => { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - self.scroll_pane_up(info.id, 3); - } else if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - self.scroll_pane_up(info.id, 3); - } else if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.focused_runtime() { - rt.scroll_up(3); - } - } - } - MouseEventKind::ScrollDown if !in_sidebar => { - self.selection = None; - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - self.scroll_pane_down(info.id, 3); - } else if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); - self.scroll_pane_down(info.id, 3); - } else if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { - if let Some(rt) = ws.focused_runtime() { - rt.scroll_down(3); - } - } + self.handle_terminal_wheel(mouse); } MouseEventKind::ScrollUp if in_sidebar => { @@ -1343,6 +1326,91 @@ impl AppState { } } + fn handle_terminal_wheel(&mut self, mouse: MouseEvent) { + const LINES_PER_NOTCH: usize = 3; + + if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + if self.forward_pane_wheel(&info, mouse) { + return; + } + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + _ => {} + } + return; + } + + if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + match mouse.kind { + MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH), + MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH), + _ => {} + } + return; + } + + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.focused_runtime() { + match mouse.kind { + MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH), + MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH), + _ => {} + } + } + } + } + + fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool { + let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else { + return false; + }; + let Some(rt) = ws.runtimes.get(&info.id) else { + return false; + }; + let Some(input_state) = rt.input_state() else { + return false; + }; + + match wheel_routing(input_state) { + WheelRouting::HostScroll => false, + WheelRouting::MouseReport => { + rt.scroll_reset(); + let column = mouse.column.saturating_sub(info.inner_rect.x); + let row = mouse.row.saturating_sub(info.inner_rect.y); + let Some(bytes) = crate::input::encode_mouse_scroll( + mouse.kind, + column, + row, + mouse.modifiers, + input_state.mouse_protocol_encoding, + ) else { + warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); + return true; + }; + if let Err(err) = rt.sender.try_send(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); + } + true + } + WheelRouting::AlternateScroll => { + rt.scroll_reset(); + let key = match mouse.kind { + MouseEventKind::ScrollUp => KeyCode::Up, + MouseEventKind::ScrollDown => KeyCode::Down, + _ => return true, + }; + let bytes = crate::input::encode_cursor_key(key, input_state.application_cursor); + if let Err(err) = rt.sender.try_send(Bytes::from(bytes)) { + warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); + } + true + } + } + } + fn set_pane_scroll_offset(&self, pane_id: crate::layout::PaneId, offset_from_bottom: usize) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.runtimes.get(&pane_id) { @@ -1410,6 +1478,16 @@ impl AppState { } } +fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { + if input_state.mouse_protocol_mode != vt100::MouseProtocolMode::None { + WheelRouting::MouseReport + } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { + WheelRouting::AlternateScroll + } else { + WheelRouting::HostScroll + } +} + // Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App impl AppState { pub(crate) fn split_pane(&mut self, direction: Direction) { @@ -1656,4 +1734,43 @@ mod tests { assert!(app.state.sidebar_width_auto); assert!(app.state.drag.is_none()); } + + #[test] + fn wheel_routing_prefers_mouse_reporting() { + let input_state = crate::pane::InputState { + alternate_screen: true, + application_cursor: false, + mouse_protocol_mode: vt100::MouseProtocolMode::ButtonMotion, + mouse_protocol_encoding: vt100::MouseProtocolEncoding::Sgr, + mouse_alternate_scroll: true, + }; + + assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); + } + + #[test] + fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { + let input_state = crate::pane::InputState { + alternate_screen: true, + application_cursor: false, + mouse_protocol_mode: vt100::MouseProtocolMode::None, + mouse_protocol_encoding: vt100::MouseProtocolEncoding::Default, + mouse_alternate_scroll: true, + }; + + assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); + } + + #[test] + fn wheel_routing_falls_back_to_host_scrollback() { + let input_state = crate::pane::InputState { + alternate_screen: false, + application_cursor: false, + mouse_protocol_mode: vt100::MouseProtocolMode::None, + mouse_protocol_encoding: vt100::MouseProtocolEncoding::Default, + mouse_alternate_scroll: true, + }; + + assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); + } } diff --git a/src/input/encode.rs b/src/input/encode.rs index b114935a..ee466f42 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; use super::{KeyboardProtocol, TerminalKey}; @@ -23,6 +23,77 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec< encode_legacy(key.as_key_event()) } +pub fn encode_cursor_key(code: KeyCode, application_cursor: bool) -> Vec { + match (code, application_cursor) { + (KeyCode::Up, true) => b"\x1bOA".to_vec(), + (KeyCode::Down, true) => b"\x1bOB".to_vec(), + (KeyCode::Right, true) => b"\x1bOC".to_vec(), + (KeyCode::Left, true) => b"\x1bOD".to_vec(), + (KeyCode::Up, false) => b"\x1b[A".to_vec(), + (KeyCode::Down, false) => b"\x1b[B".to_vec(), + (KeyCode::Right, false) => b"\x1b[C".to_vec(), + (KeyCode::Left, false) => b"\x1b[D".to_vec(), + _ => encode_legacy(KeyEvent::new(code, KeyModifiers::empty())), + } +} + +pub fn encode_mouse_scroll( + kind: MouseEventKind, + column: u16, + row: u16, + modifiers: KeyModifiers, + encoding: vt100::MouseProtocolEncoding, +) -> Option> { + let button = match kind { + MouseEventKind::ScrollUp => 64u16, + MouseEventKind::ScrollDown => 65u16, + MouseEventKind::ScrollLeft => 66u16, + MouseEventKind::ScrollRight => 67u16, + _ => return None, + }; + + let mut cb = button; + if modifiers.contains(KeyModifiers::SHIFT) { + cb += 4; + } + if modifiers.contains(KeyModifiers::ALT) { + cb += 8; + } + if modifiers.contains(KeyModifiers::CONTROL) { + cb += 16; + } + + let column = column as u32 + 1; + let row = row as u32 + 1; + + match encoding { + vt100::MouseProtocolEncoding::Sgr => { + Some(format!("\x1b[<{cb};{column};{row}M").into_bytes()) + } + vt100::MouseProtocolEncoding::Default => { + let cb = u8::try_from(cb + 32).ok()?; + let column = u8::try_from(column + 32).ok()?; + let row = u8::try_from(row + 32).ok()?; + Some(vec![0x1b, b'[', b'M', cb, column, row]) + } + vt100::MouseProtocolEncoding::Utf8 => { + let mut bytes = Vec::with_capacity(16); + bytes.extend_from_slice(b"\x1b[M"); + push_mouse_codepoint(&mut bytes, cb as u32 + 32)?; + push_mouse_codepoint(&mut bytes, column + 32)?; + push_mouse_codepoint(&mut bytes, row + 32)?; + Some(bytes) + } + } +} + +fn push_mouse_codepoint(bytes: &mut Vec, value: u32) -> Option<()> { + let ch = char::from_u32(value)?; + let mut buf = [0u8; 4]; + bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + Some(()) +} + /// CSI u encoding: \e[{codepoint};{modifiers}u /// Used when the child has pushed Kitty keyboard enhancement. /// Returns None if the key doesn't need CSI u (unmodified basic keys). diff --git a/src/input/mod.rs b/src/input/mod.rs index de4330a4..b5ed99b4 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -2,7 +2,7 @@ mod encode; mod model; mod parse; -pub use encode::{encode_key, encode_terminal_key}; +pub use encode::{encode_cursor_key, encode_key, encode_mouse_scroll, encode_terminal_key}; pub use model::{KeyboardProtocol, TerminalKey}; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/tests.rs b/src/input/tests.rs index 0d9d2c74..e6900df7 100644 --- a/src/input/tests.rs +++ b/src/input/tests.rs @@ -1,7 +1,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, ModifierKeyCode}; use super::{ - encode_key, encode_terminal_key, parse_terminal_key_sequence, KeyboardProtocol, TerminalKey, + encode_cursor_key, encode_key, encode_mouse_scroll, encode_terminal_key, + parse_terminal_key_sequence, KeyboardProtocol, TerminalKey, }; fn assert_terminal_key_eq( @@ -142,6 +143,32 @@ fn legacy_alt_char_still_esc_prefix() { assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba"); } +#[test] +fn application_cursor_keys_use_ss3_sequences() { + assert_eq!(encode_cursor_key(KeyCode::Up, true), b"\x1bOA"); + assert_eq!(encode_cursor_key(KeyCode::Down, true), b"\x1bOB"); +} + +#[test] +fn normal_cursor_keys_use_csi_sequences() { + assert_eq!(encode_cursor_key(KeyCode::Up, false), b"\x1b[A"); + assert_eq!(encode_cursor_key(KeyCode::Down, false), b"\x1b[B"); +} + +#[test] +fn sgr_mouse_scroll_encodes_wheel_button_and_coordinates() { + let encoded = encode_mouse_scroll( + crossterm::event::MouseEventKind::ScrollDown, + 4, + 6, + KeyModifiers::SHIFT, + vt100::MouseProtocolEncoding::Sgr, + ) + .expect("mouse scroll should encode"); + + assert_eq!(encoded, b"\x1b[<69;5;7M"); +} + #[test] fn kitty_shift_enter() { let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); diff --git a/src/pane.rs b/src/pane.rs index f25c6a42..01d872a6 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -84,6 +84,7 @@ pub struct PaneRuntime { current_size: Cell<(u16, u16)>, child_pid: Arc, pub kitty_keyboard_flags: Arc, + mouse_alternate_scroll: Arc, /// Live screen content snapshot — updated by reader, read by detector. /// Decouples detection from parser viewport state (scrollback). /// Kept alive here so the Arc isn't dropped; tasks hold their own clones. @@ -101,9 +102,12 @@ pub struct ScrollMetrics { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScrollState { - pub metrics: ScrollMetrics, +pub struct InputState { pub alternate_screen: bool, + pub application_cursor: bool, + pub mouse_protocol_mode: vt100::MouseProtocolMode, + pub mouse_protocol_encoding: vt100::MouseProtocolEncoding, + pub mouse_alternate_scroll: bool, } impl Drop for PaneRuntime { @@ -263,6 +267,7 @@ impl PaneRuntime { let responses = PtyResponses::new(); let kitty_keyboard_flags = responses.kitty_keyboard_flags.clone(); + let mouse_alternate_scroll = responses.mouse_alternate_scroll.clone(); let parser = Arc::new(RwLock::new(vt100::Parser::new_with_callbacks( rows, cols, @@ -523,6 +528,7 @@ impl PaneRuntime { current_size: Cell::new((rows, cols)), child_pid, kitty_keyboard_flags, + mouse_alternate_scroll, screen_content, detect_handle, }) @@ -572,25 +578,32 @@ impl PaneRuntime { } } - pub fn scroll_state(&self) -> Option { + pub fn scroll_metrics(&self) -> Option { let Ok(mut parser) = self.parser.write() else { return None; }; let max_offset_from_bottom = max_scrollback(&mut parser); let screen = parser.screen(); let (viewport_rows, _) = screen.size(); - Some(ScrollState { - metrics: ScrollMetrics { - offset_from_bottom: screen.scrollback(), - max_offset_from_bottom, - viewport_rows: viewport_rows as usize, - }, - alternate_screen: screen.alternate_screen(), + Some(ScrollMetrics { + offset_from_bottom: screen.scrollback(), + max_offset_from_bottom, + viewport_rows: viewport_rows as usize, }) } - pub fn scroll_metrics(&self) -> Option { - self.scroll_state().map(|state| state.metrics) + pub fn input_state(&self) -> Option { + let Ok(parser) = self.parser.read() else { + return None; + }; + let screen = parser.screen(); + Some(InputState { + alternate_screen: screen.alternate_screen(), + application_cursor: screen.application_cursor(), + mouse_protocol_mode: screen.mouse_protocol_mode(), + mouse_protocol_encoding: screen.mouse_protocol_encoding(), + mouse_alternate_scroll: self.mouse_alternate_scroll.load(Ordering::Relaxed), + }) } pub fn visible_text(&self) -> String { @@ -659,25 +672,45 @@ mod tests { } #[test] - fn alternate_screen_accumulates_its_own_scrollback() { + fn alternate_screen_does_not_accumulate_host_scrollback() { let responses = PtyResponses::new(); - let mut parser = vt100::Parser::new_with_callbacks(2, 10, 100, responses); - parser.process(b"\x1b[?1049h1\r\n2\r\n3"); + let mut parser = vt100::Parser::new_with_callbacks(3, 10, 100, responses); + parser.process(b"\x1b[?1049h1\r\n2\r\n3\r\n4"); assert!(parser.screen().alternate_screen()); - assert_eq!(max_scrollback(&mut parser), 1); - assert_eq!(recent_text_from_parser(&mut parser, 3), "1\n2\n3\n"); + assert_eq!(max_scrollback(&mut parser), 0); + assert_eq!(recent_text_from_parser(&mut parser, 4), "2\n3\n4\n"); } #[test] - fn top_anchored_scroll_regions_feed_scrollback() { + fn normal_screen_top_anchored_scroll_regions_feed_scrollback() { + let responses = PtyResponses::new(); + let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses); + parser.process(b"1\r\n2\r\n3\r\n4\r\n5"); + parser.process(b"\x1b[1;3r\x1b[3;1H\r\nX"); + + assert_eq!(max_scrollback(&mut parser), 1); + } + + #[test] + fn normal_screen_non_top_anchored_scroll_regions_do_not_feed_scrollback() { + let responses = PtyResponses::new(); + let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses); + parser.process(b"1\r\n2\r\n3\r\n4\r\n5"); + parser.process(b"\x1b[2;4r\x1b[4;1H\r\nX"); + + assert_eq!(max_scrollback(&mut parser), 0); + } + + #[test] + fn alternate_screen_scroll_regions_do_not_create_host_scrollback() { let responses = PtyResponses::new(); let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses); parser.process(b"\x1b[?1049h1\r\n2\r\n3\r\n4\r\n5"); parser.process(b"\x1b[1;3r\x1b[3;1H\r\nX"); assert!(parser.screen().alternate_screen()); - assert_eq!(max_scrollback(&mut parser), 1); + assert_eq!(max_scrollback(&mut parser), 0); } #[test] diff --git a/src/pty_callbacks.rs b/src/pty_callbacks.rs index 44000701..f4df562a 100644 --- a/src/pty_callbacks.rs +++ b/src/pty_callbacks.rs @@ -14,7 +14,7 @@ use std::sync::{ /// Collects response bytes that need to be written back to the PTY. /// Also tracks whether the child has requested the Kitty keyboard protocol. -#[derive(Clone, Default)] +#[derive(Clone)] pub struct PtyResponses { pending: Arc>>, /// Stack of kitty keyboard enhancement flags pushed by child programs. @@ -27,6 +27,21 @@ pub struct PtyResponses { pub kitty_keyboard: Arc, /// Exact active kitty keyboard flags from the top of the stack. pub kitty_keyboard_flags: Arc, + /// Tracks DECSET 1007 (alternate scroll mode). + /// Default on, matching Ghostty/xterm-style behavior for fullscreen apps. + pub mouse_alternate_scroll: Arc, +} + +impl Default for PtyResponses { + fn default() -> Self { + Self { + pending: Arc::default(), + kitty_stack: Arc::default(), + kitty_keyboard: Arc::new(AtomicBool::new(false)), + kitty_keyboard_flags: Arc::new(AtomicU16::new(0)), + mouse_alternate_scroll: Arc::new(AtomicBool::new(true)), + } + } } impl PtyResponses { @@ -43,6 +58,12 @@ impl PtyResponses { fn push(&self, bytes: &[u8]) { self.pending.lock().unwrap().extend_from_slice(bytes); } + + fn has_private_mode(params: &[&[u16]], mode: u16) -> bool { + params + .iter() + .any(|param| param.len() == 1 && param[0] == mode) + } } impl vt100::Callbacks for PtyResponses { @@ -100,8 +121,17 @@ impl vt100::Callbacks for PtyResponses { // DECRQM: \e[?Np → "is DEC private mode N set?" // Response: \e[?N;Ps$y where Ps = 1 (set), 2 (reset), 0 (unknown) (Some(b'?'), 'p') => { - // Report all queried modes as "reset" (2) — safe default - let response = format!("\x1b[?{param0};2$y"); + let state = match param0 { + 1007 => { + if self.mouse_alternate_scroll.load(Ordering::Relaxed) { + 1 + } else { + 2 + } + } + _ => 2, + }; + let response = format!("\x1b[?{param0};{state}$y"); self.push(response.as_bytes()); } @@ -113,6 +143,14 @@ impl vt100::Callbacks for PtyResponses { // === Keyboard Protocol === + // DECSET/DECRST 1007: alternate scroll mode. + (Some(b'?'), 'h') if Self::has_private_mode(params, 1007) => { + self.mouse_alternate_scroll.store(true, Ordering::Relaxed); + } + (Some(b'?'), 'l') if Self::has_private_mode(params, 1007) => { + self.mouse_alternate_scroll.store(false, Ordering::Relaxed); + } + // Kitty keyboard query: \e[?u → "what keyboard flags are active?" (Some(b'?'), 'u') => { let stack = self.kitty_stack.lock().unwrap(); @@ -251,6 +289,32 @@ mod tests { assert_eq!(r.take(), b"\x1b[?25;2$y"); // "reset" (2) } + #[test] + fn alternate_scroll_defaults_on_and_reports_set() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + + assert!(r.mouse_alternate_scroll.load(Ordering::Relaxed)); + p.process(b"\x1b[?1007p"); + assert_eq!(r.take(), b"\x1b[?1007;1$y"); + } + + #[test] + fn decset_decrst_1007_updates_alternate_scroll_mode() { + let r = PtyResponses::new(); + let mut p = make_parser(r.clone()); + + p.process(b"\x1b[?1007l"); + assert!(!r.mouse_alternate_scroll.load(Ordering::Relaxed)); + p.process(b"\x1b[?1007p"); + assert_eq!(r.take(), b"\x1b[?1007;2$y"); + + p.process(b"\x1b[?1007h"); + assert!(r.mouse_alternate_scroll.load(Ordering::Relaxed)); + p.process(b"\x1b[?1007p"); + assert_eq!(r.take(), b"\x1b[?1007;1$y"); + } + #[test] fn responds_to_kitty_keyboard_query() { let r = PtyResponses::new(); diff --git a/src/ui.rs b/src/ui.rs index df9216a1..b3f0152e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -121,14 +121,10 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec { let mut inner_rect = area; let mut scrollbar_rect = None; if let Some(rt) = ws.runtimes.get(&focused_id) { - let detected_agent = ws - .panes - .get(&focused_id) - .and_then(|pane| pane.detected_agent); - if rt.scroll_state().is_some_and(|state| { - should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent) - && area.width > 1 - }) { + if rt + .scroll_metrics() + .is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 1) + { inner_rect.width = inner_rect.width.saturating_sub(1); scrollbar_rect = Some(Rect::new( area.x + area.width.saturating_sub(1), @@ -168,11 +164,10 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec { let mut inner_rect = pane_inner; let mut scrollbar_rect = None; if let Some(rt) = ws.runtimes.get(&info.id) { - let detected_agent = ws.panes.get(&info.id).and_then(|pane| pane.detected_agent); - if rt.scroll_state().is_some_and(|state| { - should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent) - && pane_inner.width > 1 - }) { + if rt + .scroll_metrics() + .is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 1) + { inner_rect.width = inner_rect.width.saturating_sub(1); scrollbar_rect = Some(Rect::new( pane_inner.x + pane_inner.width.saturating_sub(1), @@ -699,20 +694,8 @@ pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option { info.scrollbar_rect } -fn should_show_scrollbar( - metrics: crate::pane::ScrollMetrics, - alternate_screen: bool, - detected_agent: Option, -) -> bool { - if metrics.max_offset_from_bottom == 0 { - return false; - } - - if !alternate_screen { - return true; - } - - metrics.offset_from_bottom > 0 || detected_agent.is_some() +fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool { + metrics.max_offset_from_bottom > 0 } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1845,51 +1828,25 @@ mod tests { } #[test] - fn alternate_screen_scrollbar_stays_hidden_for_unidentified_live_bottom() { + fn scrollbar_stays_hidden_without_scrollback() { + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 5, + }; + + assert!(!should_show_scrollbar(metrics)); + } + + #[test] + fn scrollbar_shows_with_scrollback() { let metrics = crate::pane::ScrollMetrics { offset_from_bottom: 0, max_offset_from_bottom: 20, viewport_rows: 5, }; - assert!(!should_show_scrollbar(metrics, true, None)); - } - - #[test] - fn alternate_screen_scrollbar_shows_for_agents_at_live_bottom() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - - assert!(should_show_scrollbar( - metrics, - true, - Some(crate::detect::Agent::Codex) - )); - } - - #[test] - fn alternate_screen_scrollbar_shows_when_user_scrolled_up() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 3, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - - assert!(should_show_scrollbar(metrics, true, None)); - } - - #[test] - fn normal_screen_scrollbar_shows_with_scrollback() { - let metrics = crate::pane::ScrollMetrics { - offset_from_bottom: 0, - max_offset_from_bottom: 20, - viewport_rows: 5, - }; - - assert!(should_show_scrollbar(metrics, false, None)); + assert!(should_show_scrollbar(metrics)); } #[test] diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 043d9931..42e5ac0f 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -1,5 +1,5 @@ use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -18,6 +18,7 @@ fn unique_test_dir() -> PathBuf { struct SpawnedHerdr { _master: Box, + _drain_thread: thread::JoinHandle<()>, child: Box, } @@ -76,10 +77,22 @@ fn spawn_herdr_with_path( cmd.env("PATH", path); } + let mut reader = pair.master.try_clone_reader().unwrap(); + let drain_thread = thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + }); + let child = pair.slave.spawn_command(cmd).unwrap(); SpawnedHerdr { _master: pair.master, + _drain_thread: drain_thread, child, } } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index dd52c082..5621b60b 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -1,5 +1,5 @@ use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::Command; @@ -18,6 +18,7 @@ fn unique_test_dir() -> PathBuf { struct SpawnedHerdr { _master: Box, + _drain_thread: thread::JoinHandle<()>, child: Box, } @@ -57,9 +58,21 @@ fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> Sp cmd.env("HERDR_SOCKET_PATH", socket_path); cmd.env_remove("HERDR_ENV"); + let mut reader = pair.master.try_clone_reader().unwrap(); + let drain_thread = thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + }); + let child = pair.slave.spawn_command(cmd).unwrap(); SpawnedHerdr { _master: pair.master, + _drain_thread: drain_thread, child, } } @@ -515,9 +528,20 @@ fn wait_agent_state_exits_when_state_matches() { std::env::var("PATH").unwrap_or_default() ), ); + let mut reader = pair.master.try_clone_reader().unwrap(); + let drain_thread = thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + }); let child = pair.slave.spawn_command(cmd).unwrap(); let mut herdr = SpawnedHerdr { _master: pair.master, + _drain_thread: drain_thread, child, }; diff --git a/vendor/vt100/src/grid.rs b/vendor/vt100/src/grid.rs index b1c5d341..f8958579 100644 --- a/vendor/vt100/src/grid.rs +++ b/vendor/vt100/src/grid.rs @@ -59,11 +59,6 @@ impl Grid { self.saved_origin_mode = false; } - pub fn clear_scrollback(&mut self) { - self.scrollback.clear(); - self.scrollback_offset = 0; - } - pub fn size(&self) -> Size { self.size } @@ -568,6 +563,8 @@ impl Grid { self.rows .insert(usize::from(self.scroll_bottom) + 1, self.new_row()); let removed = self.rows.remove(usize::from(self.scroll_top)); + // Top-anchored scroll regions still represent terminal output moving + // off the visible transcript and should feed host scrollback. if self.scrollback_len > 0 && self.scroll_top == 0 { self.scrollback.push_back(removed); while self.scrollback.len() > self.scrollback_len { diff --git a/vendor/vt100/src/screen.rs b/vendor/vt100/src/screen.rs index a11ed776..7dfec97d 100644 --- a/vendor/vt100/src/screen.rs +++ b/vendor/vt100/src/screen.rs @@ -73,7 +73,7 @@ impl Screen { grid.allocate_rows(); Self { grid, - alternate_grid: crate::grid::Grid::new(size, scrollback_len), + alternate_grid: crate::grid::Grid::new(size, 0), attrs: crate::attrs::Attrs::default(), saved_attrs: crate::attrs::Attrs::default(), @@ -652,7 +652,6 @@ impl Screen { self.grid_mut().set_scrollback(0); self.set_mode(MODE_ALTERNATE_SCREEN); self.alternate_grid.allocate_rows(); - self.grid_mut().set_scrollback(0); } fn exit_alternate_grid(&mut self) { @@ -1165,7 +1164,6 @@ impl Screen { [1049] => { self.decsc(); self.alternate_grid.clear(); - self.alternate_grid.clear_scrollback(); self.enter_alternate_grid(); } [2004] => self.set_mode(MODE_BRACKETED_PASTE),