fix: align pane scrolling with tmux and ghostty

This commit is contained in:
Ogulcan Celik 2026-03-31 23:03:11 +03:00
parent 5f97b0c26d
commit e857710d73
11 changed files with 428 additions and 127 deletions

View File

@ -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);
}
}

View File

@ -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<u8> {
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<Vec<u8>> {
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<u8>, 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).

View File

@ -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;

View File

@ -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);

View File

@ -84,6 +84,7 @@ pub struct PaneRuntime {
current_size: Cell<(u16, u16)>,
child_pid: Arc<AtomicU32>,
pub kitty_keyboard_flags: Arc<AtomicU16>,
mouse_alternate_scroll: Arc<AtomicBool>,
/// 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<ScrollState> {
pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
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<ScrollMetrics> {
self.scroll_state().map(|state| state.metrics)
pub fn input_state(&self) -> Option<InputState> {
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]

View File

@ -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<Mutex<Vec<u8>>>,
/// Stack of kitty keyboard enhancement flags pushed by child programs.
@ -27,6 +27,21 @@ pub struct PtyResponses {
pub kitty_keyboard: Arc<AtomicBool>,
/// Exact active kitty keyboard flags from the top of the stack.
pub kitty_keyboard_flags: Arc<AtomicU16>,
/// Tracks DECSET 1007 (alternate scroll mode).
/// Default on, matching Ghostty/xterm-style behavior for fullscreen apps.
pub mouse_alternate_scroll: Arc<AtomicBool>,
}
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();

View File

@ -121,14 +121,10 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
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<PaneInfo> {
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<Rect> {
info.scrollbar_rect
}
fn should_show_scrollbar(
metrics: crate::pane::ScrollMetrics,
alternate_screen: bool,
detected_agent: Option<crate::detect::Agent>,
) -> 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]

View File

@ -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<dyn MasterPty + Send>,
_drain_thread: thread::JoinHandle<()>,
child: Box<dyn Child + Send + Sync>,
}
@ -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,
}
}

View File

@ -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<dyn MasterPty + Send>,
_drain_thread: thread::JoinHandle<()>,
child: Box<dyn Child + Send + Sync>,
}
@ -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,
};

View File

@ -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 {

View File

@ -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),